Java Boxing 완전 정복 - 원시 타입부터 바이트코드까지

2024년 12월 04일

java

# Java# Boxing# Unboxing# Wrapper Class# JVM

들어가며

Java를 사용하다 보면 intInteger, booleanBoolean 같은 타입들을 자연스럽게 혼용하게 된다. 하지만 이 둘의 차이점과 변환 과정에서 발생하는 일들을 정확히 이해하지 못하면 예상치 못한 버그나 성능 문제를 만날 수 있다.

이 글에서는 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를 선택한 이유:

  1. 하위 호환성: Java 1.4 이하 코드와 호환
  2. 런타임 오버헤드 제거: 타입 체크는 컴파일 시점에만

하지만 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);  // 자동 unboxing

JSR 201 (Autoboxing/Unboxing) 스펙이 Java 5에 포함되면서, 컴파일러가 자동으로 변환 코드를 삽입하게 되었다.

1.5 다른 언어들은 어떻게 해결했나?

C# (.NET)

C#은 Value TypeReference 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 + b

Kotlin에서는 IntInt?만 있고, 컴파일러가 알아서 최적화한다.

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는 여전히 10

2.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

intInteger, charCharacter만 이름이 다르고 나머지는 첫 글자만 대문자로 바뀐다.

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);  // 8

4. 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-unboxing

5. 바이트코드로 보는 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);  // false

6.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) deprecated
  • Integer.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 덕분에 편리하게 사용할 수 있지만, 내부 동작을 이해하지 못하면 다음과 같은 문제에 빠질 수 있다:

  1. == 비교 오류 - 캐시 범위에 따라 결과가 달라짐
  2. NullPointerException - auto-unboxing 시 null 처리 누락
  3. 성능 저하 - 루프 내 불필요한 boxing/unboxing

핵심 정리:

  • Wrapper 비교는 equals() 사용
  • nullable이 아니면 primitive 사용
  • 성능 중요한 코드에서는 boxing 최소화
  • Integer.valueOf()캐시를 사용한다

참고자료

© 2025, 미나리와 함께 만들었음