들어가며
Java를 사용하다 보면 int와 Integer, boolean과 Boolean 같은 타입들을 자연스럽게 혼용하게 된다. 하지만 이 둘의 차이점과 변환 과정에서 발생하는 일들을 정확히 이해하지 못하면 예상치 못한 버그나 성능 문제를 만날 수 있다.
이 글에서는 Boxing의 기초 개념부터 시작해서 JVM 바이트코드 레벨까지 깊이 파고들어 본다.
1. Boxing이 탄생한 배경
1.1 Java의 설계 철학: 두 세계의 공존
Java가 1995년에 처음 등장했을 때, 언어 설계자들은 중요한 딜레마에 직면했다:
“순수 객체지향 언어로 갈 것인가, 성능을 위해 타협할 것인가?”
순수 객체지향의 길 (Smalltalk 방식)
Smalltalk 같은 순수 객체지향 언어에서는 모든 것이 객체다:
" Smalltalk에서는 숫자도 객체 "
3 + 4 " 3이라는 객체에 +4 메시지를 보냄 "
5 timesRepeat: [ Transcript show: 'Hello' ]장점: 일관성 있는 설계, 모든 값에 메서드 호출 가능 단점: 극심한 성능 저하 (단순 덧셈도 객체 생성과 메서드 호출 필요)
성능을 위한 타협 (C/C++ 방식)
C/C++에서는 기본 타입이 메모리에 직접 저장된다:
int a = 10; // 스택에 4바이트로 직접 저장
int b = a + 5; // CPU 레지스터에서 직접 연산장점: 하드웨어 수준의 빠른 연산 단점: 객체가 아니므로 다형성, 제네릭 등에서 사용 불가
Java의 선택: 이원화 (Duality)
James Gosling과 Java 설계팀은 두 세계를 모두 가져가기로 결정했다:
// Primitive: 성능이 필요한 곳
int count = 0;
for (int i = 0; i < 1_000_000; i++) {
count += i; // 빠른 CPU 연산
}
// Object: 객체지향이 필요한 곳
Object obj = new Integer(42); // 다형성 활용이 결정의 결과:
- 8개의 primitive type (int, long, double 등)
- 그 외 모든 것은 Object (String, Array, 사용자 정의 클래스 등)
1.2 문제의 시작: 두 세계의 단절
primitive와 Object는 완전히 다른 세계였다:
// 1990년대 후반 Java 코드
Vector numbers = new Vector(); // 제네릭 없던 시절
numbers.add(10); // ❌ 컴파일 에러! int는 Object가 아님
numbers.add(new Integer(10)); // ✅ 이렇게 해야 함
// 꺼낼 때도 번거로움
Integer wrapped = (Integer) numbers.get(0);
int value = wrapped.intValue();이 불편함은 특히 컬렉션 프레임워크에서 두드러졌다.
1.3 제네릭의 등장과 Type Erasure
Java 5 (2004년)에서 제네릭이 도입되었다:
List<Integer> numbers = new ArrayList<Integer>();하지만 여기서 또 다른 제약이 생겼다. Java의 제네릭은 Type Erasure 방식을 사용한다:
// 우리가 작성한 코드
List<Integer> list = new ArrayList<Integer>();
// 컴파일 후 바이트코드 (Type Erasure)
List list = new ArrayList(); // 타입 정보가 지워짐Type Erasure를 선택한 이유:
- 하위 호환성: Java 1.4 이하 코드와 호환
- 런타임 오버헤드 제거: 타입 체크는 컴파일 시점에만
하지만 Type Erasure의 결과로:
List<int> numbers; // ❌ 불가능!
// 컴파일 후 List가 되는데, int는 Object를 상속받지 않음Type Erasure로 인해 제네릭에서 primitive를 직접 사용할 수 없게 되었고, 이것이 Wrapper Class가 필수적인 이유다.
1.4 Autoboxing의 탄생 (Java 5)
매번 수동으로 변환하는 것은 너무 번거로웠다:
// Before Java 5: 지옥의 수동 Boxing
List<Integer> list = new ArrayList<Integer>();
list.add(new Integer(1));
list.add(new Integer(2));
list.add(new Integer(3));
int sum = list.get(0).intValue() + list.get(1).intValue();
// After Java 5: Autoboxing의 축복
List<Integer> list = new ArrayList<>();
list.add(1); // 컴파일러가 알아서 변환
list.add(2);
list.add(3);
int sum = list.get(0) + list.get(1); // 자동 unboxingJSR 201 (Autoboxing/Unboxing) 스펙이 Java 5에 포함되면서, 컴파일러가 자동으로 변환 코드를 삽입하게 되었다.
1.5 다른 언어들은 어떻게 해결했나?
C# (.NET)
C#은 Value Type과 Reference Type을 구분하되, 둘 다 공통 조상 System.Object를 가진다:
int x = 10; // Value Type (스택)
object obj = x; // Boxing 발생 (힙에 복사)
int y = (int)obj; // Unboxing
// 제네릭에서 primitive 직접 사용 가능!
List<int> numbers = new List<int>(); // ✅ 가능C#은 Reified Generics (실체화 제네릭)을 사용하여 런타임에도 타입 정보가 유지된다.
Kotlin
Kotlin은 컴파일러가 상황에 따라 최적화한다:
val x: Int = 10 // 컴파일 시 primitive int로 최적화
val y: Int? = 10 // nullable이면 Integer로 boxing
val list: List<Int> // 컬렉션 내부에서는 Integer
// 코드상으로는 구분 없이 사용
fun sum(a: Int, b: Int): Int = a + bKotlin에서는 Int와 Int?만 있고, 컴파일러가 알아서 최적화한다.
Scala
val x: Int = 10 // primitive로 컴파일
val list: List[Int] // 내부적으로 boxing 발생
// Value Class로 boxing 없이 래핑 가능
class Meter(val value: Double) extends AnyVal비교 정리
| 언어 | 접근 방식 | primitive 제네릭 |
|---|---|---|
| Java | 명시적 이원화 + Autoboxing | ❌ 불가 |
| C# | Value/Reference + Reified Generics | ✅ 가능 |
| Kotlin | 컴파일러 자동 최적화 | 부분적 |
| Scala | Value Class | 부분적 |
1.6 Java의 미래: Project Valhalla
Java도 이 문제를 인식하고 Project Valhalla를 진행 중이다:
// 현재 Java
record Point(int x, int y) {} // 항상 힙에 할당
// Valhalla 이후 (예상)
value record Point(int x, int y) {} // 스택/인라인 가능
// Primitive class (Universal Generics)
List<int> numbers; // ✅ 드디어 가능해질 예정!핵심 개념:
- Value Types: 객체지만 primitive처럼 효율적
- Universal Generics: 제네릭에서 primitive 직접 사용
2. Primitive Type vs Reference Type
2.1 Primitive Type (원시 타입)
Java에는 8가지 원시 타입이 존재한다:
| 타입 | 크기 | 기본값 | 범위 |
|---|---|---|---|
byte |
1 byte | 0 | -128 ~ 127 |
short |
2 bytes | 0 | -32,768 ~ 32,767 |
int |
4 bytes | 0 | -2^31 ~ 2^31-1 |
long |
8 bytes | 0L | -2^63 ~ 2^63-1 |
float |
4 bytes | 0.0f | IEEE 754 |
double |
8 bytes | 0.0d | IEEE 754 |
char |
2 bytes | ‘\u0000’ | 0 ~ 65,535 |
boolean |
JVM 의존 | false | true/false |
원시 타입의 특징:
- Stack 메모리에 직접 값이 저장된다
- null을 가질 수 없다
- 객체가 아니므로 메서드를 호출할 수 없다
- 제네릭 타입 파라미터로 사용할 수 없다
int a = 10; // Stack에 직접 10이라는 값 저장
int b = a; // b에도 10이라는 값이 복사됨 (독립적)
a = 20; // a만 변경됨, b는 여전히 102.2 Reference Type (참조 타입)
참조 타입은 객체의 주소를 저장한다:
Integer x = new Integer(10); // Heap에 객체 생성, Stack에는 주소만 저장
Integer y = x; // y도 같은 객체를 참조메모리 구조:
Stack Heap
+-------+ +----------------+
| x | ─────────────>| Integer 객체 |
+-------+ | value = 10 |
| y | ─────────────>| |
+-------+ +----------------+3. Wrapper Class
3.1 Wrapper Class란?
각 원시 타입에 대응하는 참조 타입 클래스를 Wrapper Class라고 한다:
| Primitive | Wrapper Class |
|---|---|
| byte | Byte |
| short | Short |
| int | Integer |
| long | Long |
| float | Float |
| double | Double |
| char | Character |
| boolean | Boolean |
int→Integer,char→Character만 이름이 다르고 나머지는 첫 글자만 대문자로 바뀐다.
3.2 왜 Wrapper Class가 필요한가?
(1) 컬렉션에서 사용
// 컴파일 에러! 제네릭은 참조 타입만 가능
List<int> numbers = new ArrayList<>(); // ❌
// Wrapper Class 사용
List<Integer> numbers = new ArrayList<>(); // ✅(2) null 표현
// 원시 타입은 null 불가
int value = null; // ❌ 컴파일 에러
// Wrapper는 null 가능 - "값이 없음"을 표현할 수 있다
Integer value = null; // ✅데이터베이스에서 nullable 컬럼을 매핑할 때 특히 중요하다.
(3) 유틸리티 메서드 제공
// 문자열 → 정수 변환
int parsed = Integer.parseInt("123");
// 진법 변환
String binary = Integer.toBinaryString(10); // "1010"
String hex = Integer.toHexString(255); // "ff"
// 비트 연산
int bitCount = Integer.bitCount(15); // 4 (1111)
int highestBit = Integer.highestOneBit(10); // 84. Boxing과 Unboxing
4.1 개념
Boxing: Primitive → Wrapper (포장)
Unboxing: Wrapper → Primitive (개봉)수동 Boxing/Unboxing (Java 5 이전)
// Boxing: int → Integer
int primitiveValue = 10;
Integer boxedValue = new Integer(primitiveValue); // deprecated
Integer boxedValue2 = Integer.valueOf(primitiveValue); // 권장
// Unboxing: Integer → int
Integer wrapped = Integer.valueOf(20);
int unwrapped = wrapped.intValue();4.2 Autoboxing과 Auto-unboxing (Java 5+)
Java 5부터 컴파일러가 자동으로 변환 코드를 삽입해준다:
// Autoboxing
Integer num = 10; // 컴파일러가 Integer.valueOf(10)으로 변환
// Auto-unboxing
int value = num; // 컴파일러가 num.intValue()로 변환실제 컴파일러 변환 예시
// 우리가 작성한 코드
List<Integer> list = new ArrayList<>();
list.add(1);
list.add(2);
int sum = list.get(0) + list.get(1);
// 컴파일러가 변환한 코드 (개념적)
List<Integer> list = new ArrayList<>();
list.add(Integer.valueOf(1)); // Autoboxing
list.add(Integer.valueOf(2)); // Autoboxing
int sum = list.get(0).intValue() // Auto-unboxing
+ list.get(1).intValue(); // Auto-unboxing5. 바이트코드로 보는 Boxing
실제로 컴파일러가 어떻게 변환하는지 바이트코드를 통해 확인해보자.
5.1 테스트 코드
public class BoxingExample {
public static void main(String[] args) {
// Autoboxing
Integer a = 100;
// Auto-unboxing
int b = a;
// 연산 시 unboxing
int c = a + 10;
}
}5.2 바이트코드 확인 (javap -c)
public static void main(java.lang.String[]);
Code:
0: bipush 100
2: invokestatic #2 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
5: astore_1
6: aload_1
7: invokevirtual #3 // Method java/lang/Integer.intValue:()I
10: istore_2
11: aload_1
12: invokevirtual #3 // Method java/lang/Integer.intValue:()I
15: bipush 10
17: iadd
18: istore_3
19: return바이트코드 분석:
- 2번 라인:
Integer.valueOf()호출 → Autoboxing - 7번 라인:
intValue()호출 → Auto-unboxing - 12번 라인: 연산 전에
intValue()로 unboxing
중요: Autoboxing은
new Integer()가 아닌Integer.valueOf()를 사용한다!
6. Integer Cache - 성능 최적화의 비밀
6.1 이상한 동작
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true (?!)
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false왜 127까지는 ==가 true이고, 128부터는 false일까?
6.2 IntegerCache 내부 구현
Integer.valueOf() 메서드의 소스코드:
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}IntegerCache 클래스:
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer[] cache;
static {
// high 값은 JVM 옵션으로 조절 가능
int h = 127;
String integerCacheHighPropValue =
VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
h = Math.min(i, Integer.MAX_VALUE - (-low) - 1);
}
high = h;
// -128 ~ high 범위의 Integer 객체를 미리 생성
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
}
}6.3 캐시 동작 원리
Integer.valueOf(100)
│
▼
┌──────────────────────┐
│ -128 <= 100 <= 127? │
└──────────────────────┘
│ Yes
▼
┌─────────────────────────────┐
│ IntegerCache.cache[228] │
│ (100 + 128 = 228번 인덱스) │
│ 미리 생성된 객체 반환 │
└─────────────────────────────┘ Integer.valueOf(1000)
│
▼
┌──────────────────────┐
│ -128 <= 1000 <= 127?│
└──────────────────────┘
│ No
▼
┌─────────────────────────────┐
│ new Integer(1000) │
│ 새로운 객체 생성 │
└─────────────────────────────┘6.4 다른 Wrapper Class의 캐시
| 클래스 | 캐시 범위 | 조절 가능 |
|---|---|---|
| Byte | -128 ~ 127 (전체) | No |
| Short | -128 ~ 127 | No |
| Integer | -128 ~ 127 (기본) | Yes (-XX:AutoBoxCacheMax) |
| Long | -128 ~ 127 | No |
| Character | 0 ~ 127 | No |
| Boolean | TRUE, FALSE (2개) | No |
| Float | 없음 | - |
| Double | 없음 | - |
// Long도 캐시 사용
Long x = 127L;
Long y = 127L;
System.out.println(x == y); // true
Long p = 128L;
Long q = 128L;
System.out.println(p == q); // false6.5 Integer 캐시 범위 조절
# JVM 옵션으로 캐시 상한 조절
java -XX:AutoBoxCacheMax=1000 MyApp// 캐시 상한을 1000으로 설정한 경우
Integer a = 1000;
Integer b = 1000;
System.out.println(a == b); // true (캐시 범위 내)7. 주의해야 할 함정들
7.1 == vs equals()
Integer a = 1000;
Integer b = 1000;
// 객체 주소 비교 - 다른 객체이므로 false
System.out.println(a == b); // false
// 값 비교 - 같은 값이므로 true
System.out.println(a.equals(b)); // true
// 권장: Objects.equals() 사용 (null-safe)
System.out.println(Objects.equals(a, b)); // true핵심 규칙: Wrapper 클래스 비교는 항상 equals() 사용!
7.2 NullPointerException
Auto-unboxing 시 null이면 NPE 발생:
Integer value = null;
// 컴파일은 되지만 런타임에 NPE!
int primitive = value; // value.intValue() → NPE
// 안전한 처리
int safe = (value != null) ? value : 0;
// Java 9+ Optional 활용
int safe2 = Optional.ofNullable(value).orElse(0);실제 발생하기 쉬운 상황
public class Order {
private Integer quantity; // DB nullable 컬럼
public int getQuantity() {
return quantity; // quantity가 null이면 NPE!
}
}
// 안전한 버전
public int getQuantity() {
return quantity != null ? quantity : 0;
}7.3 불필요한 Boxing이 발생하는 경우
삼항 연산자
Integer a = true ? 1 : new Integer(2);
// 1이 Integer.valueOf(1)로 boxing됨
// 더 나쁜 케이스
Integer result = condition ? null : 0; // 0이 boxing됨메서드 오버로딩 혼란
public void process(int value) {
System.out.println("primitive");
}
public void process(Integer value) {
System.out.println("wrapper");
}
process(10); // "primitive" - 정확히 매칭
process(Integer.valueOf(10)); // "wrapper"
process(null); // "wrapper" - primitive는 null 불가8. 성능 영향
8.1 메모리 오버헤드
int primitive = 10; // 4 bytes
Integer wrapper = 10; // 16+ bytes (64bit JVM 기준)
// - 객체 헤더: 12 bytes
// - int value: 4 bytes
// - 패딩: 가변배열 비교:
int[] primitives = new int[1000]; // ~4KB
Integer[] wrappers = new Integer[1000]; // ~20KB+ (객체 배열 + 각 Integer 객체)8.2 연산 성능 비교
// 비효율적인 코드
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i; // 매 반복마다 unboxing → 연산 → boxing
}
// 효율적인 코드
long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i; // primitive 연산만
}실제 벤치마크 결과 (대략적):
- Wrapper 사용: ~6000ms
- Primitive 사용: ~600ms
- 약 10배 차이!
8.3 GC 압박
// 나쁜 예: 불필요한 객체 생성
for (int i = 0; i < 1_000_000; i++) {
Integer temp = i * 2; // 매번 새 객체 생성 (캐시 범위 벗어나면)
// ...
}9. Best Practices
9.1 타입 선택 가이드라인
| 상황 | 권장 타입 |
|---|---|
| 지역 변수, 연산 | int, long 등 primitive |
| 컬렉션 요소 | Integer, Long 등 wrapper (필수) |
| 클래스 필드 (non-null 보장) | primitive |
| 클래스 필드 (null 가능) | wrapper |
| DB 매핑 (nullable) | wrapper |
| 메서드 파라미터 | 상황에 따라 |
9.2 코드 예시
// Good: primitive 사용
public int calculateSum(int[] numbers) {
int sum = 0; // primitive
for (int num : numbers) {
sum += num;
}
return sum;
}
// Good: 필요한 경우에만 Wrapper
public class User {
private long id; // not null, primitive
private Integer age; // nullable, wrapper
private boolean active; // not null, primitive
}
// Good: 비교는 equals() 사용
public boolean isSameValue(Integer a, Integer b) {
return Objects.equals(a, b); // null-safe
}9.3 정적 분석 도구 활용
IntelliJ IDEA, SonarQube 등에서 불필요한 boxing 감지:
- “Unnecessary boxing”
- “Unnecessary unboxing”
- “Boxing inside loop”
10. Java 버전별 변화
Java 5
- Autoboxing/Auto-unboxing 도입
- 제네릭 도입
Java 9
new Integer(int)deprecatedInteger.valueOf(int)권장
Java 16+ (Project Valhalla 진행 중)
- Primitive class (value type) 도입 예정
- Wrapper 클래스의 성능 오버헤드 해결 기대
- Universal Generics로
List<int>가능해질 전망
// 미래의 Java (예상)
primitive class Point {
int x;
int y;
}
// 객체처럼 사용하지만 primitive처럼 효율적마치며
Boxing은 Java의 “원시 타입과 객체 타입의 이원화”라는 설계 결정에서 비롯된 개념이다. Autoboxing 덕분에 편리하게 사용할 수 있지만, 내부 동작을 이해하지 못하면 다음과 같은 문제에 빠질 수 있다:
- == 비교 오류 - 캐시 범위에 따라 결과가 달라짐
- NullPointerException - auto-unboxing 시 null 처리 누락
- 성능 저하 - 루프 내 불필요한 boxing/unboxing
핵심 정리:
- Wrapper 비교는 equals() 사용
- nullable이 아니면 primitive 사용
- 성능 중요한 코드에서는 boxing 최소화
Integer.valueOf()는 캐시를 사용한다