본문 바로가기

래퍼 클래스와 박싱·언박싱

기본형을 객체로 감싸는 래퍼 클래스가 왜 필요한지, 박싱과 언박싱이 언제 일어나는지, null 언박싱과 == 비교에서 생기는 버그를 어떻게 피하는지 익힙니다.

게시판에서 게시글의 조회 수를 int 필드에 담는다고 해 봅시다. 그런데 조회 수 집계가 아직 끝나지 않은 게시글은 무엇으로 표시해야 할까요? 0을 넣으면 '아무도 읽지 않았다'와 '아직 세지 않았다'를 구별할 수 없습니다. -1 같은 특별한 값을 정해 두면, 그 약속을 모르는 코드가 -1을 그대로 합계에 더해 버립니다. int에는 '값이 없다'를 나타낼 방법이 없기 때문입니다.

int는 메모리를 적게 쓰고 계산도 빠르지만 객체가 아닙니다. 그래서 메서드를 가질 수 없고, '아직 값이 없다'는 상태를 null로 나타낼 수도 없으며, List<Integer>처럼 객체만 담는 컬렉션에 그대로 넣을 수도 없습니다. 자바는 이 빈자리를 채우려고 기본형마다 짝이 되는 클래스를 두었는데, 이를 래퍼 클래스(wrapper class)라고 부릅니다. Integer는 int 값 하나를 객체로 감싼 것입니다.

래퍼는 편리하지만 공짜는 아닙니다. 객체가 된 만큼 null일 수 있고, ==가 값이 아니라 참조를 비교합니다. 이 두 가지가 래퍼를 쓸 때 가장 흔한 버그의 원인입니다. 이 글에서는 래퍼를 직접 만들어 보며 그 구조를 확인한 뒤, 기본형과 래퍼 사이의 변환이 언제 일어나는지 살펴보고, 두 함정을 피하는 방법을 차례로 익힙니다.

값 하나를 감싸는 클래스 직접 만들기

래퍼는 특별한 문법이 아니라 평범한 클래스입니다. 이 점을 확인하려고 조회 수를 감싸는 클래스를 직접 만들어 보겠습니다. 다음 MyViewCount는 int 하나를 필드로 감싸고, 생성자에서 음수를 거부하며, 다른 수와 크기를 비교하는 메서드를 함께 제공합니다.

src/MyViewCountWrapper.java
public final class MyViewCountWrapper {
    public static void main(String[] args) {
        MyViewCount viewCount = new MyViewCount(40);
        System.out.println("vs30=" + viewCount.compareTo(30));
        System.out.println("vs40=" + viewCount.compareTo(40));
        System.out.println("vs50=" + viewCount.compareTo(50));
    }

    private static final class MyViewCount {
        private final int value;

        MyViewCount(int value) {
            if (value < 0) throw new IllegalArgumentException("viewCount");
            this.value = value;
        }

        int compareTo(int other) {
            return Integer.compare(value, other);
        }
    }
}
vs30=1
vs40=0
vs50=-1

compareTo는 감싼 값이 인수보다 크면 양수, 같으면 0, 작으면 음수를 돌려줍니다. 40은 30보다 크므로 첫 줄이 양수이고, 40과 같은 둘째 줄은 0, 50보다 작은 셋째 줄은 음수입니다. 계산은 Integer.compare에 맡겼는데, 이 메서드의 API 문서는 결과의 부호만 정해 두고 구체적인 숫자는 정하지 않습니다. 지금의 JDK가 1과 -1을 돌려줄 뿐이므로, 결과를 쓸 때는 > 0, == 0, < 0처럼 부호로 판단해야 합니다.

이렇게 값을 객체로 감싸면 값과 그 값에 관련된 기능을 한곳에 모을 수 있습니다. MyViewCount를 받는 메서드는 조회 수가 음수일까 걱정하지 않아도 되고, 비교가 필요하면 객체에게 맡기면 됩니다. 다만 객체이므로 MyViewCount 변수에 null을 넣을 수 있다는 성질도 함께 따라옵니다. 자바가 제공하는 표준 래퍼도 이와 같은 구조입니다.

자바가 준비해 둔 여덟 가지 래퍼

자바의 기본형은 여덟 가지이고, java.lang 패키지에 각각 짝이 되는 래퍼 클래스가 있습니다. java.lang은 import 없이 쓸 수 있는 패키지라서 Integer나 Double도 String처럼 바로 쓸 수 있습니다.

기본형래퍼 클래스
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter
booleanBoolean

대부분은 기본형 이름의 첫 글자를 대문자로 바꾼 이름이지만, int와 char의 래퍼만 Integer와 Character라는 온전한 단어를 씁니다. 숫자를 감싸는 여섯 래퍼는 모두 Number라는 공통 부모 클래스를 상속합니다. 그래서 intValue(), longValue(), doubleValue()처럼 감싼 값을 원하는 숫자 형식으로 꺼내는 메서드를 똑같이 가지고 있습니다.

래퍼 객체는 한 번 만들면 안에 든 값을 바꿀 수 없는 불변 객체입니다. Integer에는 값을 바꾸는 메서드가 없으므로, 다른 값이 필요하면 새 객체를 써야 합니다. 불변 객체와 안전한 공유에서 본 것처럼, 덕분에 여러 곳에서 같은 Integer 객체를 함께 써도 누군가 값을 바꿀 걱정이 없습니다.

래퍼 클래스에는 감싼 값을 다루는 인스턴스 메서드 말고도 그 기본형에 관련된 static 메서드와 상수가 모여 있습니다. 앞에서 쓴 Integer.compare, 뒤에서 볼 Integer.parseInt, int의 최댓값인 Integer.MAX_VALUE가 그 예입니다. 이런 멤버는 객체를 만들지 않고 클래스 이름으로 바로 씁니다.

박싱과 언박싱

기본형 값을 래퍼 객체로 감싸는 일을 박싱(boxing), 래퍼 객체에서 기본형 값을 꺼내는 일을 언박싱(unboxing)이라고 합니다. 값을 상자에 넣었다 꺼내는 모습에서 온 이름입니다. 박싱은 Integer.valueOf(40)처럼, 언박싱은 intValue()처럼 메서드를 호출해서 할 수 있습니다. 그런데 Java 5부터는 컴파일러가 필요한 곳에 이 호출을 알아서 넣어 주므로 Integer autoBox = 50;처럼 기본형 값을 바로 대입해도 됩니다. 이렇게 자동으로 일어나는 변환을 오토박싱(autoboxing)과 오토언박싱(auto-unboxing)이라고 합니다.

다음 프로그램은 두 방식을 나란히 보여 주고, 래퍼를 담은 배열에서 값을 꺼내 합계도 구합니다.

src/BoxingUnboxingBoard.java
public final class BoxingUnboxingBoard {
    public static void main(String[] args) {
        int primitive = 40;
        Integer explicitBox = Integer.valueOf(primitive);
        int explicitUnbox = explicitBox.intValue();

        Integer autoBox = 50;
        int autoUnbox = autoBox;
        Integer[] viewCounts = {explicitBox, autoBox};

        int sum = 0;
        for (Integer viewCount : viewCounts) {
            sum += viewCount.intValue();
        }

        System.out.println("explicit=" + explicitUnbox);
        System.out.println("auto=" + autoUnbox);
        System.out.println("sum=" + sum);
    }
}
explicit=40
auto=50
sum=90

위쪽의 valueOf와 intValue()는 명시적인 변환입니다. Integer.valueOf(primitive)가 40을 감싼 Integer 객체의 참조를 돌려주고, explicitBox.intValue()가 그 객체에서 40을 다시 꺼내므로 첫 줄은 40입니다. 그다음 두 줄은 같은 일을 컴파일러에게 맡긴 것입니다. 컴파일러는 Integer autoBox = 50;을 Integer autoBox = Integer.valueOf(50);으로, int autoUnbox = autoBox;를 int autoUnbox = autoBox.intValue();로 바꿔서 컴파일합니다. 그래서 둘째 줄은 50입니다.

Integer[]는 Integer 객체의 참조를 담는 배열입니다. 배열 초기화 {explicitBox, autoBox}는 두 변수에 든 참조를 각 칸에 복사합니다. 반복문은 칸마다 객체에서 intValue()로 값을 꺼내 더하므로 합계는 40과 50을 더한 90입니다. 반복문 안을 sum += viewCount;로 바꿔 써도 결과는 같은데, 이때는 컴파일러가 viewCount.intValue()를 대신 넣어 줍니다. 값이 반드시 있고 계산만 한다면 처음부터 int[]를 쓰는 편이 더 단순합니다.

오토박싱과 오토언박싱은 대입에서만 일어나지 않습니다. int 값을 Integer 매개변수에 넘길 때, Integer끼리 +로 계산할 때, Integer를 int와 비교할 때처럼 기본형과 래퍼가 만나는 곳이면 어디서든 컴파일러가 변환을 끼워 넣습니다. 변수 하나를 1 늘리는 count++도 마찬가지입니다. Integer count에 대해 이 식은 값을 꺼내 1을 더한 뒤 다시 박싱해서 count에 대입하므로, 원래 객체는 그대로 두고 count가 다른 객체를 가리키게 됩니다. 편리한 대신 코드에 보이지 않는 메서드 호출이 생기는 셈이고, 다음 절의 문제가 바로 여기서 나옵니다.

new Integer(40)처럼 생성자로 래퍼를 만드는 옛 코드를 볼 수도 있습니다. 이 생성자는 Java 9부터 사용하지 말라는 표시(deprecated)가 붙었고, 지금은 앞으로 제거될 예정이라는 표시까지 붙어 있습니다. 그래서 JDK 21로 컴파일하면 [removal] Integer(int) in Integer has been deprecated and marked for removal이라는 경고가 나옵니다. 박싱에는 valueOf나 오토박싱을 쓰면 됩니다.

null을 언박싱하면 NullPointerException이 납니다

이제 처음의 조회 수 문제로 돌아가 보겠습니다. 집계가 끝나지 않은 조회 수를 Integer 변수에 null로 넣어 두었는데, 다른 코드가 그 값에 10을 더하려고 합니다.

lab/NullAutoUnboxingFailure.java
public final class NullAutoUnboxingFailure {
    public static void main(String[] args) {
        Integer measuredViewCount = null;
        int total = measuredViewCount + 10;
        System.out.println(total);
    }
}

이 코드는 아무 경고 없이 컴파일됩니다. Integer와 int를 더하는 식은 문법상 올바르기 때문입니다. 하지만 실행하면 덧셈을 하는 4번 줄에서 예외가 나고, 마지막 println까지 가지 못합니다.

실행 결과
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local1>" is null
	at NullAutoUnboxingFailure.main(NullAutoUnboxingFailure.java:4)

메시지를 자세히 보면 원인이 그대로 드러납니다. 소스 코드 어디에도 intValue()를 쓰지 않았는데, 메시지는 Integer.intValue()를 호출할 수 없었다고 말합니다. measuredViewCount + 10을 계산하려면 먼저 int 값이 필요하므로 컴파일러가 이 식을 measuredViewCount.intValue() + 10으로 바꿔 두었기 때문입니다. 그런데 measuredViewCount에는 객체가 없으니 메서드를 호출할 대상도 없습니다. null과 NullPointerException에서 본, null에 점 연산을 한 상황과 똑같습니다. 메시지의 <local1>은 1번 지역 변수인 measuredViewCount를 가리킵니다.

null은 0이 아닙니다. 오토언박싱은 null을 0으로 바꿔 주지 않고, 객체가 있다고 믿고 intValue()를 호출할 뿐입니다. 산술 연산 말고도 Integer 값을 int 변수에 대입하거나 int 매개변수에 넘길 때, count == 5처럼 int와 비교할 때도 언박싱이 일어나므로 null이면 같은 예외가 납니다.

그렇다면 어떻게 해야 할까요? 먼저 이 변수에 null이 들어오는 것이 정상인지부터 정해야 합니다. '아직 집계하지 않음'처럼 null이 실제로 의미 있는 상태라면, 계산하기 전에 if (measuredViewCount == null)로 그 경우를 따로 처리합니다. 반대로 null이 들어오면 안 되는 값이라면, 객체를 만들거나 값을 받는 시점에 바로 거부해서 계산하는 코드까지 null이 흘러가지 않게 합니다. 그러면 문제가 생겨도 엉뚱한 계산 줄이 아니라 잘못된 값이 들어온 곳에서 예외가 나므로 원인을 찾기 쉽습니다. 값이 없을 수도 있다는 사실을 null 대신 타입으로 드러내는 방법은 33장에서 Optional과 함께 다룹니다.

아래 그림은 앞의 두 프로그램을 나란히 놓고, 객체가 들어 있는 Integer와 null인 Integer에서 값을 꺼낼 때 무엇이 달라지는지 단계별로 보여 줍니다. 그림의 화살표는 변환 단계를 나타낼 뿐, 박싱할 때마다 새 객체가 만들어진다는 뜻은 아닙니다. 객체를 재사용하는 경우는 다음 절에서 봅니다.

박싱의 정상 값과 null 언박싱 실패

40/50의 박싱·언박싱 및 별도 null 언박싱 실패를 보여 줍니다. 두 정상 값의 변환 위치와 null이 실패하는 소비 지점을 순차 강조하되 별도 실행을 합치지 않아야 합니다.

박싱의 정상 값과 null 언박싱 실패

정상 박싱 예제는 명시적 변환으로 40, 자동 변환으로 50, 합계 90을 얻는다. 별도 null 예제는 measuredViewCount에서 int를 꺼내려다 NullPointerException으로 중단된다.

박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다

BoxingUnboxingBoard의 정상 변환 두 가지와, 앞서 본 NullAutoUnboxingFailure의 실패를 구별합니다.

박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 원 독립 프로그램: 명시40/자동50의 박싱과 언박싱. 별도 null예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다. 명시적 변환 기본형 값 int 40 Integer 참조 숫자 값 40 꺼낸 int 40 박싱 언박싱 자동 변환 기본형 값 int 50 Integer 참조 숫자 값 50 꺼낸 int 50 박싱 언박싱 별도 null 실패 · 정상 합계90과 독립 참조: null 숫자 객체 없음 언박싱 실패 NullPointerException 화살표: 변환 단계 · 박싱이 매번 새 객체라는 뜻은 아님
박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 원 독립 프로그램: 명시40/자동50의 박싱과 언박싱. 별도 null예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다. 명시적 변환 기본형 값 int 40 Integer 참조 숫자 값 40 꺼낸 int 40 박싱 언박싱 자동 변환 기본형 값 int 50 Integer 참조 숫자 값 50 꺼낸 int 50 박싱 언박싱 별도 null 실패 참조: null 숫자 객체 없음 언박싱 실패 NullPointer Exception 정상 두 값의 합계는90 null 실패는 출력 전에 종료
Integer에 객체가 있을 때와 null일 때의 변환
원문 사례값을 꺼내는 지점실제 결과
명시적 변환Integer.valueOf(40)으로 받은 참조에서 explicitBox.intValue()를 호출합니다.explicit=40
자동 변환Integer autoBox = 50 뒤 int autoUnbox = autoBox에서 언박싱합니다.auto=50
별도 null 실패measuredViewCount + 10에서 null인 참조를 언박싱하려 합니다.NullPointerException · 숫자 출력에 도달하지 않습니다.
명시적 변환
값을 꺼내는 지점: Integer.valueOf(40)으로 받은 참조에서 explicitBox.intValue()를 호출합니다.
실제 결과: explicit=40
자동 변환
값을 꺼내는 지점: Integer autoBox = 50 뒤 int autoUnbox = autoBox에서 언박싱합니다.
실제 결과: auto=50
별도 null 실패
값을 꺼내는 지점: measuredViewCount + 10에서 null인 참조를 언박싱하려 합니다.
실제 결과: NullPointerException · 숫자 출력에 도달하지 않습니다.

정상 프로그램의 배열에는 두 참조가 모두 있어 합계가 sum=90입니다. 박싱이 매번 새 객체를 만든다는 뜻은 아닙니다.

null은 숫자 0이 아닙니다. 값 없음이 허용된 경우에는 계산 전에 분기하고, 허용되지 않으면 생성 경계에서 거부합니다.

이 예의 조건

  • 명시적 변환 40/자동 변환 50 예제와 null 실패는 독립 실행이다.
  • 박싱 화살표는 매번 새 객체를 할당한다는 보장이 아니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원 독립 프로그램: 명시적 변환 40/자동 변환 50의 박싱과 언박싱. 별도 null 예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다.
21별도 null 실패 · 정상 합계 90과 독립
원 도표의 설명과 주석
구분내용
설명원 독립 프로그램: 명시적 변환 40/자동 변환 50의 박싱과 언박싱. 별도 null 예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다.
전체 읽기 정보박싱의 정상 값과 null 언박싱 실패 박싱의 정상 값과 null 언박싱 실패 정상 박싱 예제는 명시적 변환으로 40, 자동 변환으로 50, 합계 90을 얻는다. 별도 null 예제는 measuredViewCount에서 int를 꺼내려다 NullPointerException으로 중단된다. 박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 BoxingUnboxingBoard 의 정상 변환 두 가지와, 앞서 본 NullAutoUnboxingFailure 의 실패를 구별합니다. Integer 참조가 객체를 가리킬 때와 null일 때의 변환 원문 사례 값을 꺼내는 지점 실제 결과 명시적 변환 Integer.valueOf(40) 으로 받은 참조에서 explicitBox.intValue() 를 호출합니다. explicit=40 자동 변환 Integer autoBox = 50 뒤 int autoUnbox = autoBox 에서 언박싱합니다. auto=50 별도 null 실패 measuredViewCount + 10 에서 null 인 참조를 언박싱하려 합니다. NullPointerException · 숫자 출력에 도달하지 않습니다. 명시적 변환 값을 꺼내는 지점: Integer.valueOf(40) 으로 받은 참조에서 explicitBox.intValue() 를 호출합니다. 실제 결과: explicit=40 자동 변환 값을 꺼내는 지점: Integer autoBox = 50 뒤 int autoUnbox = autoBox 에서 언박싱합니다. 실제 결과: auto=50 별도 null 실패 값을 꺼내는 지점: measuredViewCount + 10 에서 null 인 참조를 언박싱하려 합니다. 실제 결과: NullPointerException · 숫자 출력에 도달하지 않습니다. 정상 프로그램의 배열에는 두 참조가 모두 있어 합계가 sum=90 입니다. 박싱이 매번 새 객체를 만든다는 뜻은 아닙니다. null 은 숫자 0 이 아닙니다. 값 없음이 허용된 경우에는 계산 전에 분기하고, 허용되지 않으면 생성 경계에서 거부합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
변환 입력

기본형 40/50과 별도 null 참조를 둡니다.

  • 명시 입력: int 40
  • 자동 입력: int 50
  • 실패 입력: null
  • 값을 가진 기본형과 객체 없는 참조는 다른 입력이다.
박싱

두 정상 값은 Integer 참조의 값으로 다룹니다.

  • 명시 박싱 값: 40
  • 자동 박싱 값: 50
  • null: 숫자 객체 없음
  • 정상 참조에는 꺼낼 숫자 값이 있다.
언박싱

두 값은 int로 나오지만 null의 소비는 실패합니다.

  • 정상 합계: 90
  • null 결과: NullPointerException, 출력 없음
  • 객체 없는 참조에서 숫자를 꺼낼 수 없어 실패한다.

박싱의 정상 값과 null 언박싱 실패

40/50의 박싱·언박싱 및 별도 null 언박싱 실패를 보여 줍니다. 두 정상 값의 변환 위치와 null이 실패하는 소비 지점을 순차 강조하되 별도 실행을 합치지 않아야 합니다.

박싱의 정상 값과 null 언박싱 실패

정상 박싱 예제는 명시적 변환으로 40, 자동 변환으로 50, 합계 90을 얻는다. 별도 null 예제는 measuredViewCount에서 int를 꺼내려다 NullPointerException으로 중단된다.

박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다

BoxingUnboxingBoard의 정상 변환 두 가지와, 앞서 본 NullAutoUnboxingFailure의 실패를 구별합니다.

박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 원 독립 프로그램: 명시40/자동50의 박싱과 언박싱. 별도 null예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다. 명시적 변환 기본형 값 int 40 Integer 참조 숫자 값 40 꺼낸 int 40 박싱 언박싱 자동 변환 기본형 값 int 50 Integer 참조 숫자 값 50 꺼낸 int 50 박싱 언박싱 별도 null 실패 · 정상 합계90과 독립 참조: null 숫자 객체 없음 언박싱 실패 NullPointerException 화살표: 변환 단계 · 박싱이 매번 새 객체라는 뜻은 아님
박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 원 독립 프로그램: 명시40/자동50의 박싱과 언박싱. 별도 null예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다. 명시적 변환 기본형 값 int 40 Integer 참조 숫자 값 40 꺼낸 int 40 박싱 언박싱 자동 변환 기본형 값 int 50 Integer 참조 숫자 값 50 꺼낸 int 50 박싱 언박싱 별도 null 실패 참조: null 숫자 객체 없음 언박싱 실패 NullPointer Exception 정상 두 값의 합계는90 null 실패는 출력 전에 종료
Integer에 객체가 있을 때와 null일 때의 변환
원문 사례값을 꺼내는 지점실제 결과
명시적 변환Integer.valueOf(40)으로 받은 참조에서 explicitBox.intValue()를 호출합니다.explicit=40
자동 변환Integer autoBox = 50 뒤 int autoUnbox = autoBox에서 언박싱합니다.auto=50
별도 null 실패measuredViewCount + 10에서 null인 참조를 언박싱하려 합니다.NullPointerException · 숫자 출력에 도달하지 않습니다.
명시적 변환
값을 꺼내는 지점: Integer.valueOf(40)으로 받은 참조에서 explicitBox.intValue()를 호출합니다.
실제 결과: explicit=40
자동 변환
값을 꺼내는 지점: Integer autoBox = 50 뒤 int autoUnbox = autoBox에서 언박싱합니다.
실제 결과: auto=50
별도 null 실패
값을 꺼내는 지점: measuredViewCount + 10에서 null인 참조를 언박싱하려 합니다.
실제 결과: NullPointerException · 숫자 출력에 도달하지 않습니다.

정상 프로그램의 배열에는 두 참조가 모두 있어 합계가 sum=90입니다. 박싱이 매번 새 객체를 만든다는 뜻은 아닙니다.

null은 숫자 0이 아닙니다. 값 없음이 허용된 경우에는 계산 전에 분기하고, 허용되지 않으면 생성 경계에서 거부합니다.

이 예의 조건

  • 명시적 변환 40/자동 변환 50 예제와 null 실패는 독립 실행이다.
  • 박싱 화살표는 매번 새 객체를 할당한다는 보장이 아니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원 독립 프로그램: 명시적 변환 40/자동 변환 50의 박싱과 언박싱. 별도 null 예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다.
21별도 null 실패 · 정상 합계 90과 독립
원 도표의 설명과 주석
구분내용
설명원 독립 프로그램: 명시적 변환 40/자동 변환 50의 박싱과 언박싱. 별도 null 예제는 언박싱 때 NPE로 출력 없음. 변환 화살표는 언제나 새 객체를 할당한다는 뜻이 아니다.
전체 읽기 정보박싱의 정상 값과 null 언박싱 실패 박싱의 정상 값과 null 언박싱 실패 정상 박싱 예제는 명시적 변환으로 40, 자동 변환으로 50, 합계 90을 얻는다. 별도 null 예제는 measuredViewCount에서 int를 꺼내려다 NullPointerException으로 중단된다. 박싱한 값은 꺼낼 수 있지만 null은 언박싱할 수 없습니다 BoxingUnboxingBoard 의 정상 변환 두 가지와, 앞서 본 NullAutoUnboxingFailure 의 실패를 구별합니다. Integer 참조가 객체를 가리킬 때와 null일 때의 변환 원문 사례 값을 꺼내는 지점 실제 결과 명시적 변환 Integer.valueOf(40) 으로 받은 참조에서 explicitBox.intValue() 를 호출합니다. explicit=40 자동 변환 Integer autoBox = 50 뒤 int autoUnbox = autoBox 에서 언박싱합니다. auto=50 별도 null 실패 measuredViewCount + 10 에서 null 인 참조를 언박싱하려 합니다. NullPointerException · 숫자 출력에 도달하지 않습니다. 명시적 변환 값을 꺼내는 지점: Integer.valueOf(40) 으로 받은 참조에서 explicitBox.intValue() 를 호출합니다. 실제 결과: explicit=40 자동 변환 값을 꺼내는 지점: Integer autoBox = 50 뒤 int autoUnbox = autoBox 에서 언박싱합니다. 실제 결과: auto=50 별도 null 실패 값을 꺼내는 지점: measuredViewCount + 10 에서 null 인 참조를 언박싱하려 합니다. 실제 결과: NullPointerException · 숫자 출력에 도달하지 않습니다. 정상 프로그램의 배열에는 두 참조가 모두 있어 합계가 sum=90 입니다. 박싱이 매번 새 객체를 만든다는 뜻은 아닙니다. null 은 숫자 0 이 아닙니다. 값 없음이 허용된 경우에는 계산 전에 분기하고, 허용되지 않으면 생성 경계에서 거부합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
변환 입력

기본형 40/50과 별도 null 참조를 둡니다.

  • 명시 입력: int 40
  • 자동 입력: int 50
  • 실패 입력: null
  • 값을 가진 기본형과 객체 없는 참조는 다른 입력이다.
박싱

두 정상 값은 Integer 참조의 값으로 다룹니다.

  • 명시 박싱 값: 40
  • 자동 박싱 값: 50
  • null: 숫자 객체 없음
  • 정상 참조에는 꺼낼 숫자 값이 있다.
언박싱

두 값은 int로 나오지만 null의 소비는 실패합니다.

  • 정상 합계: 90
  • null 결과: NullPointerException, 출력 없음
  • 객체 없는 참조에서 숫자를 꺼낼 수 없어 실패한다.

두 Integer를 ==로 비교하면 안 되는 이유

래퍼의 두 번째 함정은 비교입니다. Integer는 객체이므로 두 Integer 변수를 ==로 비교하면, String 비교에서와 마찬가지로 두 변수가 같은 객체를 가리키는지를 따집니다. 값이 같은지를 비교하지 않습니다. 그런데 Integer에서는 이 차이가 값에 따라 드러났다 숨었다 해서 더 찾기 어렵습니다.

src/IntegerComparisonBoundary.java
public final class IntegerComparisonBoundary {
    public static void main(String[] args) {
        Integer smallA = 100;
        Integer smallB = 100;
        Integer largeA = 1_000;
        Integer largeB = 1_000;

        System.out.println("small-identity=" + (smallA == smallB));
        System.out.println("large-identity=" + (largeA == largeB));
        System.out.println("large-value=" + largeA.equals(largeB));
        System.out.println("compare=" + Integer.compare(largeA, largeB));
    }
}
small-identity=true
large-identity=false
large-value=true
compare=0

같은 방식으로 만든 두 쌍인데 ==의 결과가 다릅니다. 원인은 오토박싱이 사용하는 Integer.valueOf에 있습니다. valueOf는 -128부터 127까지의 Integer 객체를 미리 만들어 두고, 이 범위의 값을 박싱할 때는 새 객체를 만드는 대신 미리 만든 객체를 돌려줍니다. 이 미리 만든 객체 모음을 흔히 Integer 캐시라고 부릅니다. 그래서 100을 박싱한 smallA와 smallB는 같은 객체를 가리키고 ==가 true입니다. 반면 1000은 범위 밖이라 이 실행에서는 박싱할 때마다 새 객체가 만들어졌고, 값은 같아도 서로 다른 객체이므로 ==가 false입니다.

-128부터 127까지는 Java 언어 명세(JLS 5.1.7)가 정한 규칙이어서 어느 JVM에서나 같은 객체가 나옵니다. 하지만 그 밖의 값은 명세가 정하지 않았고, 캐시를 얼마나 넓게 둘지는 구현에 달려 있습니다. 실제로 JVM 설정에 따라 1000도 같은 객체가 될 수 있습니다. 그러니 둘째 줄의 false는 기본 설정의 JDK 21에서 나온 결과일 뿐, 1000이면 언제나 false라는 뜻은 아닙니다.

이 함정이 위험한 이유는 테스트에서 잘 드러나지 않기 때문입니다. 작은 값으로만 시험하면 ==로 비교한 코드도 늘 맞는 답을 냅니다. 그러다 실제 서비스에서 조회 수가 127을 넘는 순간부터 같은 값을 다르다고 판단하기 시작합니다. 그러니 캐시 범위를 외워서 피하려 하지 말고, 래퍼끼리 비교할 때는 처음부터 ==를 쓰지 않아야 합니다.

래퍼를 비교할 때는 목적에 맞는 방법을 고릅니다.

  • 값이 같은지 알고 싶으면 equals를 씁니다. 출력의 large-value=true처럼 두 객체가 감싼 값을 비교합니다.
  • 크기 순서가 필요하면 Integer.compare(a, b)나 a.compareTo(b)를 씁니다. 결과는 앞에서 본 것처럼 부호로 읽으며, 같은 값이면 출력의 compare=0처럼 0입니다.
  • 한쪽이 null일 수 있으면 Objects.equals(a, b)를 씁니다. a.equals(b)는 a가 null이면 예외가 나지만, Objects.equals는 둘 다 null이면 true, 한쪽만 null이면 false를 돌려줍니다.

Integer.compare는 매개변수가 int이므로 넘긴 Integer가 언박싱됩니다. 따라서 둘 중 하나가 null이면 앞 절과 같은 NullPointerException이 납니다. 같은 이유로 Integer와 int를 ==로 비교하면 Integer 쪽이 언박싱되어 값을 비교하게 됩니다. Integer에 담긴 1000과 int 1000을 비교하면 true가 나오는 것은 이 때문입니다. 하지만 이것도 Integer가 null이면 예외가 나므로, null 가능성이 없을 때만 기대어도 됩니다.

Integer 참조 동일성과 숫자 값 비교

두 Integer 참조의 ==와 equals 및 Integer.compare를 나눈다. 100의 박싱 동일성은 보장되지만 1000에서 false라는 원문 관찰은 모든 JVM 설정의 보장이 아니다.

Integer의 참조 동일성과 숫자 값 비교는 다릅니다

IntegerComparisonBoundary의 smallA/smallB는 각각 100, largeA/largeB는 각각 1_000을 박싱한 참조입니다.

Integer의 참조 동일성과 숫자 값 비교는 다릅니다 100의 두 참조는 같은 Integer를 가리킨다. 1000의 두 값은 equals true/compare0이며 ==false는 원 실행 관찰이다. 객체 공유를 고정하지 않는다. 100 · 같은 박싱 참조 smallA 참조 smallB 참조 같은 Integer 값100 1000 · 값 비교와 참조 비교 구별 largeA / largeB 둘 다 숫자 값1000 equals: true / compare: 0 == false는 원 실행의 관찰 1000의 객체 공유는 캐시 확장에 따라 달라짐 · 점선은 값 비교
Integer의 참조 동일성과 숫자 값 비교는 다릅니다 100의 두 참조는 같은 Integer를 가리킨다. 1000의 두 값은 equals true/compare0이며 ==false는 원 실행 관찰이다. 객체 공유를 고정하지 않는다. 100 · 박싱 참조 보장 smallA 참조 smallB 참조 같은 Integer 값100 1000 · 값 비교 largeA / largeB 둘 다 값1000 equals: true compare: 0 == false는 원 실행 관찰 캐시 확장 시 같을 수 있음 점선: 숫자 값 비교
같은 숫자를 비교해도 참조 비교와 값 비교의 계약은 다르다
원문 비교본문의 관찰값이 결과의 의미
smallA == smallBsmall-identity=true상수 100의 박싱은 같은 참조를 보장합니다.
largeA == largeBlarge-identity=false이 실행의 관찰입니다. 1_000은 캐시 확장에 따라 같은 참조가 될 수도 있습니다.
largeA.equals(largeB)large-value=true두 객체의 int 값이 같습니다.
Integer.compare(largeA, largeB)compare=0두 인수를 언박싱해 순서를 비교합니다. 값이 같으므로 0입니다.
smallA == smallB
본문의 관찰값: small-identity=true
이 결과의 의미: 상수 100의 박싱은 같은 참조를 보장합니다.
largeA == largeB
본문의 관찰값: large-identity=false
이 결과의 의미: 이 실행의 관찰입니다. 1_000은 캐시 확장에 따라 같은 참조가 될 수도 있습니다.
largeA.equals(largeB)
본문의 관찰값: large-value=true
이 결과의 의미: 두 객체의 int 값이 같습니다.
Integer.compare(largeA, largeB)
본문의 관찰값: compare=0
이 결과의 의미: 두 인수를 언박싱해 순서를 비교합니다. 값이 같으므로 0입니다.

이 ==는 양쪽이 Integer 참조인 비교입니다. 숫자 동등성을 참조 캐시에 맡기지 마세요. Integer.compare에 넘기는 참조가 null이면 언박싱부터 실패합니다.

더 깊이: 캐시 범위는 누가 정하고 왜 있을까

Integer.valueOf(int)의 API 문서는 -128부터 127까지의 값은 항상 캐시하고, 이 범위 밖의 값도 캐시할 수 있다고 적고 있습니다. 반드시 캐시해야 하는 범위만 정하고, 그보다 넓힐지는 구현에 맡긴 것입니다. 참고로 JLS 5.1.7의 문장은 100처럼 상수 식을 박싱하는 경우를 기준으로 쓰여 있습니다. 계산 결과를 박싱할 때도 범위 안이면 같은 객체가 나오는 것은, javac가 오토박싱을 Integer.valueOf 호출로 컴파일하고 이 메서드가 범위 안의 값을 항상 캐시하기 때문입니다.

HotSpot JVM에서는 실행 옵션 -XX:AutoBoxCacheMax로 캐시의 위쪽 끝을 넓힐 수 있습니다. 앞의 프로그램을 java -XX:AutoBoxCacheMax=1000 -cp out IntegerComparisonBoundary로 실행하면 둘째 줄이 large-identity=true로 바뀝니다. 코드 한 줄 바꾸지 않았는데 ==의 결과가 달라지는 것이므로, ==로 래퍼의 값을 비교하는 코드는 실행 환경에 따라 다르게 동작할 수 있습니다.

캐시가 있는 이유는 작은 정수가 프로그램에서 특히 자주 쓰이기 때문입니다. 반복문의 카운터나 작은 개수처럼 흔한 값을 박싱할 때마다 객체를 새로 만들면 메모리와 시간이 낭비됩니다. 이런 공유가 가능한 것은 래퍼가 불변이기 때문입니다. 누군가 공유 중인 Integer 객체의 값을 바꿀 수 있다면 캐시는 성립하지 않습니다.

API 문서는 Integer를 값 기반 클래스(value-based class)로 분류합니다. 값이 같은 인스턴스는 서로 바꿔 써도 되는 것으로 다루고, ==나 동기화처럼 객체의 정체성에 기대는 연산에 쓰지 말라는 뜻입니다. 생성자에 제거 예정 표시가 붙은 것도 같은 맥락입니다. new는 언제나 새 객체를 만들어 정체성을 드러내지만, valueOf는 같은 값에 대해 어떤 객체를 돌려줄지 구현이 자유롭게 정할 수 있습니다.

문자열을 숫자로 바꾸기

사용자가 입력한 값이나 파일에서 읽은 값은 숫자처럼 보여도 처음에는 문자열입니다. 래퍼 클래스에는 문자열을 기본형 값으로 바꾸는 static 메서드가 있습니다. Integer.parseInt는 int를, Integer.valueOf(String)은 Integer 객체를 돌려준다는 점만 다르고, 문자열을 읽는 규칙은 같습니다. Long.parseLong, Double.parseDouble처럼 다른 숫자 래퍼에도 같은 이름 규칙의 메서드가 있습니다.

다음 프로그램은 성공하는 변환 세 가지와 실패하는 입력 세 가지를 보여 줍니다.

src/WrapperParsingBoundary.java
public final class WrapperParsingBoundary {
    public static void main(String[] args) {
        int first = Integer.parseInt("40");
        Integer second = Integer.valueOf("50");
        double ratio = Double.parseDouble("1.25");

        System.out.println("total=" + (first + second));
        System.out.println("ratio=" + ratio);

        String[] inputs = {"forty", "3000000000", " 40"};
        for (String input : inputs) {
            try {
                Integer.parseInt(input);
            } catch (NumberFormatException error) {
                System.out.println("rejected=[" + input + "]");
            }
        }
    }
}
total=90
ratio=1.25
rejected=[forty]
rejected=[3000000000]
rejected=[ 40]

첫 줄의 first + second에서 second는 Integer이므로 언박싱된 뒤 int 덧셈이 되어 90이 나옵니다. 반복문의 세 입력은 모두 변환에 실패합니다. "forty"는 숫자가 아니고, "3000000000"은 숫자이지만 int의 최댓값 2147483647을 넘으며, " 40"은 앞에 공백이 하나 붙어 있습니다. 세 경우 모두 NumberFormatException이 발생해 catch 블록이 실행됩니다. 출력에서 입력을 대괄호로 감싼 것은 셋째 입력의 공백이 눈에 보이게 하려는 것입니다.

예외 객체의 메시지에는 For input string: "forty"처럼 변환하지 못한 문자열이 들어 있어서 원인을 찾을 때 도움이 됩니다. parseInt는 공백을 알아서 지워 주지 않으므로, 키보드나 파일에서 읽은 문자열은 strip()으로 양끝 공백을 지운 뒤 넘기는 것이 안전합니다. 부호는 허용되어서 "-40"과 "+40"은 각각 -40과 40으로 바뀝니다.

변환에 성공했다고 해서 쓸 수 있는 값인 것은 아닙니다. "-40"은 올바른 int이지만 조회 수로는 말이 되지 않습니다. 그래서 외부 입력은 '숫자 형식인가'와 '이 프로그램이 허용하는 값인가'를 두 단계로 나눠 검사하는 편이 좋습니다. 단계를 나누면 오류 메시지도 '숫자가 아닙니다'와 '0 이상이어야 합니다'처럼 구체적으로 쓸 수 있습니다.

조회 수 문자열을 검사하는 메서드
static int parseViewCount(String text) {
    int value = Integer.parseInt(text.strip()); // 1단계: 숫자 형식이 아니면 NumberFormatException
    if (value < 0) {                             // 2단계: 조회 수로 허용하는 값인지 검사
        throw new IllegalArgumentException("조회 수는 0 이상이어야 합니다: " + value);
    }
    return value;
}

NumberFormatException은 IllegalArgumentException의 하위 클래스입니다. 그래서 이 메서드를 호출한 쪽에서 IllegalArgumentException을 잡으면 두 단계의 실패를 한 번에 처리할 수 있고, 각 예외의 메시지로 어느 단계에서 실패했는지 구별할 수 있습니다.

기본형과 래퍼 중 무엇을 쓸까

기본은 기본형입니다. 값이 항상 있고 계산에 쓰는 지역 변수, 필드, 배열은 int, double, int[]로 충분하고, 그러면 이 글에서 본 두 함정이 처음부터 생기지 않습니다. 래퍼가 필요한 곳은 크게 두 가지입니다. 하나는 컬렉션처럼 객체만 받는 API에 값을 넘길 때이고, 다른 하나는 '값이 없음'이 실제로 의미 있는 상태라서 null을 써야 할 때입니다. 두 번째 경우라면 그 변수가 null일 수 있다는 사실과 그때 어떻게 처리하는지를 코드에서 분명히 드러내야 합니다.

조회 수처럼 '0 이상'이라는 업무 규칙까지 있는 값이라면, Integer보다 앞에서 만든 MyViewCount 같은 전용 클래스가 의도를 더 잘 드러냅니다. 규칙을 생성자 한곳에서 검사하므로, 그 타입의 값을 받은 코드는 같은 검사를 되풀이하지 않아도 됩니다.

성능에도 차이가 있습니다. 래퍼는 값마다 객체를 따로 두고 참조를 거쳐 값을 읽으므로, 같은 개수의 값을 다룰 때 대개 기본형보다 메모리를 더 쓰고 느립니다. 하지만 보통의 애플리케이션 코드에서 박싱 몇 번의 비용은 거의 드러나지 않고, null 언박싱이나 잘못된 == 비교가 훨씬 자주 문제를 일으킵니다. 수백만 번 도는 계산 반복문처럼 차이가 실제로 쌓이는 곳에서는 기본형을 쓰고, 그 밖의 곳에서 성능을 이유로 코드를 바꾸고 싶다면 먼저 측정해 봐야 합니다. 성능을 제대로 측정하는 방법은 38장의 벤치마크에서 다룹니다.

더 깊이: 보이지 않는 박싱이 반복문에서 쌓이는 경우

오토박싱은 코드에 드러나지 않기 때문에, 반복문 안에서 객체가 대량으로 만들어져도 알아차리기 어렵습니다. 다음은 자주 인용되는 예입니다.

Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
    sum += i; // sum = Long.valueOf(sum.longValue() + i)와 같다
}

sum의 타입이 Long이라서 sum += i는 매번 값을 꺼내 더한 뒤 다시 박싱합니다. 합계는 금방 127을 넘으므로, 그 뒤로는 거의 모든 반복에서 Long.valueOf가 새 객체를 만들고 이전 객체는 바로 버려집니다. JIT 컴파일러가 최적화 과정에서 이런 할당의 일부를 없애 주기도 하지만, 그것은 실행 환경이 판단할 일이라 코드를 짤 때 기댈 수는 없습니다. 결과는 맞지만 long sum = 0L;로 고치기만 하면 이 객체 생성이 처음부터 사라집니다. 이처럼 누적 변수나 반복문의 카운터에 래퍼 타입이 들어가 있지 않은지 살펴보는 것만으로도 불필요한 비용을 많이 줄일 수 있습니다.

정리

  • 래퍼 클래스는 기본형 값 하나를 객체로 감싼 불변 클래스입니다. int의 래퍼는 Integer, char의 래퍼는 Character이고, 나머지는 기본형 이름의 첫 글자를 대문자로 바꾼 이름입니다.
  • 박싱은 valueOf로, 언박싱은 intValue() 같은 메서드로 일어납니다. 기본형과 래퍼가 만나는 곳에서는 컴파일러가 이 호출을 자동으로 넣습니다.
  • null인 래퍼를 언박싱하면 NullPointerException이 납니다. null이 정상적인 상태라면 계산 전에 분기하고, 허용하지 않는 값이라면 들어오는 시점에 거부합니다.
  • 두 래퍼를 ==로 비교하면 참조를 비교합니다. -128부터 127까지만 같은 객체가 보장되고 그 밖은 구현에 따라 다르므로, 값은 equals, 순서는 compare, null 가능성이 있으면 Objects.equals로 비교합니다.
  • parseInt는 형식만 검사합니다. 공백 제거와 업무 규칙 검사는 따로 해야 합니다.

연습 문제

  1. Integer[] 배열에 측정한 조회 수가 담겨 있고, 아직 측정하지 않은 칸은 null입니다. null은 건너뛰고 나머지의 합계와 측정 개수를 출력하는 메서드를 작성하세요. 입력 {40, null, 50}과 {0, null, 70}에 대해 반복문이 칸마다 합계와 개수를 어떻게 바꾸는지 먼저 예상해 보세요. 미측정을 뜻하는 null과 실제로 측정한 0을 구별하는 것이 핵심입니다.

  2. 다음 코드의 출력을 예상하고, 각 줄이 왜 그렇게 나오는지 설명하세요.

    Integer a = 127, b = 127;
    Integer c = 128, d = 128;
    System.out.println(a == b);
    System.out.println(c == d);
    System.out.println(c.equals(d));
  3. 다음 코드는 첫 줄에 false를 출력합니다. 값은 분명 100인데 왜 그럴까요? 의도대로 비교하려면 어떻게 고쳐야 할까요?

    Long postId = 100L;
    System.out.println(postId.equals(100));
    System.out.println(postId.equals(100L));
해설 보기

1번. 언박싱이 일어나기 전에 null을 걸러 내면 됩니다. 두 입력을 같은 메서드로 처리하도록 작성했습니다.

src/NullableViewCountExercise.java
public final class NullableViewCountExercise {
    public static void main(String[] args) {
        printSummary(new Integer[] {40, null, 50});
        printSummary(new Integer[] {0, null, 70});
    }

    private static void printSummary(Integer[] measured) {
        int total = 0;
        int count = 0;
        for (Integer value : measured) {
            if (value == null) continue; // 미측정: 합계에도 개수에도 넣지 않는다
            total += value;              // 여기서 언박싱이 일어난다
            count++;
        }
        System.out.println("count=" + count + ", total=" + total);
    }
}
count=2, total=90
count=2, total=70

첫 입력 {40, null, 50}에서 반복문이 진행되는 과정은 다음과 같습니다.

칸값처리totalcount
040더함401
1null건너뜀401
250더함902

if 검사가 total += value보다 앞에 있어야 합니다. 순서가 바뀌면 둘째 칸에서 null을 언박싱하다가 NullPointerException이 납니다. 두 번째 입력 {0, null, 70}에서도 개수는 2입니다. 0은 합계를 늘리지 않지만 측정은 한 번 했으므로 개수에 들어가고, 건너뛰는 것은 null뿐입니다. 만약 null을 0으로 바꿔 더했다면 개수가 3이 되어 평균 같은 값이 틀어졌을 것입니다. 상태가 '측정함'과 '미측정' 말고도 더 늘어난다면, null 하나로 버티기보다 이 장 뒤에서 배우는 enum이나 전용 결과 타입으로 상태를 나타내는 편이 낫습니다.

2번. 출력은 true, false, true입니다. 127은 캐시 범위 안이므로 a와 b는 같은 객체를 가리키고, ==가 true입니다. 128은 범위 밖이라 기본 설정의 JDK 21에서는 박싱할 때마다 새 객체가 만들어지므로 c == d는 false입니다. 다만 앞에서 본 것처럼 128이 같은 객체가 되는지는 명세가 정하지 않았으므로, 실행 환경에 따라 true가 나올 수도 있습니다. 마지막 줄의 equals는 감싼 값을 비교하므로 어디서 실행하든 true입니다. 127과 128 사이에서 결과가 갈린다는 점이 == 버그가 작은 값의 테스트를 통과해 버리는 이유입니다.

3번. equals의 매개변수 타입은 Object이므로 인수 100은 객체로 박싱되어야 합니다. 그런데 100은 int 리터럴이라 Long이 아니라 Integer로 박싱됩니다. Long의 equals는 인수가 같은 값을 가진 Long일 때만 true를 돌려주므로, Integer 100과 비교한 첫 줄은 false입니다. 둘째 줄처럼 100L로 long 리터럴을 넘기면 Long으로 박싱되어 true가 나옵니다. postId == 100L로 써도 postId가 언박싱되어 값을 비교하지만, postId가 null일 수 있다면 예외가 나므로 Objects.equals(postId, 100L)이 더 안전합니다.

다음 글

System·Math·Random 클래스 사용하기에서는 래퍼처럼 java.lang과 java.util에 들어 있는 기본 도구를 살펴봅니다. 실행 환경의 정보를 읽는 System, 수학 계산을 하는 Math, 같은 순서를 다시 만들어 낼 수 있는 난수를 주는 Random을 차례로 다룹니다.