자바를 배우다 보면 “왜 int는 null을 못 담고 Integer는 담을 수 있을까?”라는 질문이 생긴다.
둘 다 숫자를 담는 건데 뭐가 다른 걸까?
이런 궁금증이 있다면 이 글을 끝까지 읽어보자.
처음 자바를 배울 때 이런 코드를 써봤을 것이다.
int num1 = 10;
Integer num2 = 10;
System.out.println(num1 == num2); // true
int num3 = null; // 컴파일 에러!
Integer num4 = null; // 정상 동작
“왜 int는 null이 안 되고 Integer는 될까?”
많은 초보자들이 이 차이점을 헷갈려한다.
int는 원시 타입(primitive type)이다.
메모리에 직접 값을 저장한다.
원시 타입이란 자바에서 제공하는 가장 기본적인 데이터 타입이다.
총 8개가 있다. byte, short, int, long, float, double, char, boolean
이들은 객체가 아니라 단순한 값이다.
int number = 42; // 메모리에 42가 직접 저장됨
// number는 그냥 숫자 42 그 자체다
Integer는 래퍼 클래스(wrapper class)다.
객체로 감싸서 사용한다.
래퍼 클래스란 원시 타입을 객체로 감싸는 클래스다.
모든 원시 타입에는 대응하는 래퍼 클래스가 있다
int → Integer, long → Long, double → Double, boolean → Boolean 등
이들은 모두 java.lang 패키지에 있다.
Integer number = 42; // Integer 객체가 생성되고 그 안에 42가 저장됨
// number는 Integer 객체의 주소를 담고 있다
Stack:
┌─────────┐
│ 42 │ ← 값이 직접 저장
└─────────┘
int는 Stack 메모리에 값이 직접 저장된다.
변수명과 값이 1:1로 대응된다.
메모리 주소를 거치지 않고 바로 값에 접근한다.
Stack: Heap:
┌─────────┐ ┌─────────────┐
│ 100 │ ───→ │ Integer(42) │
└─────────┘ └─────────────┘
Integer는 두 단계를 거친다.
int num = null; // 컴파일 에러!
// int는 원시 타입이라 null을 가질 수 없음
왜 null이 안 될까?
int는 단순한 값이기 때문이다. null은 “아무것도 가리키지 않는다”는 의미인데,
int는 값을 직접 저장하는 공간이라 “아무것도 없다”는 상태를 표현할 수 없다.
0은 0이고, -1은 -1이다. null은 없다.
Integer num = null; // 정상 동작
// Integer는 객체라서 null 참조 가능
왜 null이 될까?
Integer는 객체의 주소를 저장하기 때문이다.
null은 “아무 객체도 가리키지 않는다”는 의미다.
주소 공간에 null이 들어가면 “어디도 가리키지 않는다”는 뜻이다.
Stack:
┌─────────┐
│ null │ ← 아무것도 가리키지 않음
└─────────┘
자바는 편의를 위해 자동으로 변환해준다.
오토박싱과 언박싱은 자바 5부터 도입된 기능이다.
이전에는 개발자가 직접 변환 코드를 써야 했지만, 이제는 자동으로 해준다.
// 오토박싱: int → Integer (값을 객체로 감싸기)
int primitive = 10;
Integer wrapper = primitive; // 자동으로 Integer 객체 생성
// 실제로는: Integer wrapper = Integer.valueOf(primitive);
// 언박싱: Integer → int (객체에서 값 추출하기)
Integer wrapper = 10;
int primitive = wrapper; // 자동으로 int 값 추출
// 실제로는: int primitive = wrapper.intValue();
Integer num = 10;
// 실제로는 이렇게 동작:
// Integer num = Integer.valueOf(10);
int value = num;
// 실제로는 이렇게 동작:
// int value = num.intValue();
int primitive = 42; // 4바이트
Integer wrapper = 42; // 4바이트(int) + 객체 헤더(8바이트) = 12바이트
왜 Integer가 더 많은 메모리를 사용할까?
객체는 단순히 값만 저장하는 게 아니라 여러 정보를 함께 저장한다.
// 빠른 연산
int a = 10;
int b = 20;
int result = a + b; // 직접 연산
// 느린 연산
Integer a = 10;
Integer b = 20;
Integer result = a + b; // 언박싱 → 연산 → 오토박싱
왜 Integer 연산이 느릴까?
Integer 연산은 3단계를 거친다.
public class IntVsInteger {
public static void main(String[] args) {
// 원시 타입
int num1 = 10;
int num2 = 10;
System.out.println(num1 == num2); // true - 값 비교
// 래퍼 클래스
Integer num3 = 10;
Integer num4 = 10;
System.out.println(num3 == num4); // true - 같은 객체 참조 (캐시 때문)
// 왜 true일까? JVM이 -128~127 범위의 Integer를 미리 생성해두기 때문
// 새로운 객체 생성
Integer num5 = new Integer(10);
Integer num6 = new Integer(10);
System.out.println(num5 == num6); // false - 다른 객체
// new를 사용하면 캐시를 무시하고 새 객체를 생성한다
System.out.println(num5.equals(num6)); // true - 값 비교
// equals()는 객체의 내용(값)을 비교한다
}
}
// 배열이나 반복문에서
int[] numbers = new int[1000000];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = i * 2; // 빠른 연산
}
// null 체크가 필요한 경우
Integer score = getScore(); // null을 반환할 수 있음
if (score != null && score > 80) {
System.out.println("합격");
}
Integer num = null;
int value = num; // NullPointerException 발생!
// num이 null인데 .intValue()를 호출하려고 해서 에러
// 안전한 방법
Integer num = null;
if (num != null) {
int value = num; // null 체크 후 안전하게 언박싱
}
이 에러가 발생하는 이유
Integer num = null;에서 num은 아무것도 가리키지 않는다.
int value = num;은 실제로 int value = num.intValue();로 변환된다.
null.intValue()를 호출하려고 하니 NullPointerException이 발생한다.
Integer a = 128;
Integer b = 128;
System.out.println(a == b); // false! (캐시 범위 벗어남)
System.out.println(a.equals(b)); // true
왜 128에서는 ==이 false일까?
JVM은 -128부터 127까지의 Integer만 미리 생성해둔다.
128은 캐시 범위를 벗어나므로 각각 새로운 객체를 생성한다.
==는 객체 주소를 비교하므로 false가 된다.
equals()는 객체의 내용(값)을 비교하므로 true가 된다.
Integer a = 127; // 캐시 범위 내
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128; // 캐시 범위 밖
Integer d = 128;
System.out.println(c == d); // false
JVM은 -128부터 127까지의 Integer 객체를 미리 생성해둔다.
이를 Integer Cache라고 한다.
자주 사용되는 작은 숫자들을 미리 만들어두어 메모리와 성능을 최적화한다.
Integer a = 100; // 캐시된 객체 사용
Integer b = 100; // 같은 캐시된 객체 사용
System.out.println(a == b); // true - 같은 객체를 참조
Integer c = 200; // 새로운 객체 생성
Integer d = 200; // 또 다른 새로운 객체 생성
System.out.println(c == d); // false - 서로 다른 객체
캐시 범위는 JVM 구현에 따라 다를 수 있지만,
대부분 -128부터 127까지다.
이는 byte 타입의 범위와 같다.
// 원시 타입은 제네릭에 사용 불가
List<int> list1; // 컴파일 에러!
// 래퍼 클래스만 제네릭에 사용 가능
List<Integer> list2; // 정상 동작
왜 원시 타입은 제네릭에 사용할 수 없을까?
제네릭은 타입 소거(Type Erasure) 방식을 사용한다.
컴파일 시점에 타입 정보가 사라지고 Object로 변환된다.
원시 타입은 Object의 하위 타입이 아니므로 사용할 수 없다.
래퍼 클래스는 Object의 하위 타입이므로 사용 가능하다.
// 비효율적
List<Integer> numbers = new ArrayList<>();
for (int i = 0; i < 1000000; i++) {
numbers.add(i); // 오토박싱 발생
}
// 100만 번의 오토박싱으로 인한 성능 저하
// 효율적 (특수한 경우)
int[] numbers = new int[1000000];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = i; // 직접 할당
}
// 오토박싱 없이 직접 할당
왜 List
매번 add(i)를 호출할 때마다 오토박싱이 발생한다.
int → Integer 변환 과정에서 객체 생성 비용이 발생한다.
100만 번의 오토박싱은 상당한 성능 저하를 일으킨다.
int[] 배열은 오토박싱 없이 직접 값을 저장한다.
int와 Integer의 차이를 이해하는 건 자바의 기본이다.
원시 타입과 래퍼 클래스의 특성을 알고, 상황에 맞게 선택하는 것이 중요하다.
기억하자:
이제 int와 Integer의 차이점을 완전히 이해했을 것이다.
상황에 맞는 올바른 선택을 하자.