List<int> numbers = new ArrayList<>(); // 컴파일 에러
List<Integer> numbers = new ArrayList<>(); // 이건 됨
이 차이는 어디서 오는 걸까.
Java의 generics는 컴파일 타임에만 존재한다. List<String>이라고 써도 컴파일러가 타입 검증을 마치면, 런타임에는 그냥 List로 바뀐다. <String>이라는 정보는 지워진다. 이것이 타입 소거(type erasure)다.
타입 소거가 일어나면 List<T>의 T는 Object로 치환된다. List<String>이든 List<Integer>든 런타임에는 List<Object>와 다를 게 없다. 컨테이너가 담을 수 있는 건 Object뿐이다.
List<int>는 안 되는가Object는 힙(heap)에 있는 객체를 가리키는 참조다. 변수에 담기는 건 실제 값이 아니라 그 객체가 있는 메모리 주소다.
int는 다르다. 값 자체가 스택(stack)에 직접 올라간다. 주소가 없고 값만 있다. int를 Object 자리에 넣으려면 주소가 필요한데, int에는 주소가 없다.
List<int>가 안 되는 이유가 여기 있다. 타입 소거로 List<Object>가 되는데, int는 Object가 기대하는 메모리 주소를 줄 수 없기 때문이다.
Integer 같은 래퍼 클래스가 이 문제를 해결한다. int 값을 힙에 올린 객체로 만들어주기 때문이다. 힙에 올라가면 주소가 생기고, 그 주소를 Object 자리에 넣을 수 있다.
컴파일러는 int와 Integer 사이의 변환을 자동으로 처리해준다. list.add(3)이라고 써도 컴파일러가 list.add(Integer.valueOf(3))으로 바꿔준다. 이것이 autoboxing이다.
Java 5 이전에는 generics가 없었다. 당시에는 타입 없이 List를 쓰고 꺼낼 때 직접 캐스팅하는 방식이 일반적이었다.
List list = new ArrayList();
list.add("hello");
list.add(123);
String s = (String) list.get(0); // 직접 캐스팅
Java 5에서 generics를 도입할 때, 이미 이 방식으로 작성된 코드가 방대했다. 런타임에 List<String>을 List와 완전히 다른 타입으로 만들어버리면 기존 코드가 전부 깨진다. 타입 소거를 선택한 이유가 여기 있다. 런타임에는 기존 방식 그대로 List로 동작하게 하면서, 컴파일 타임에만 타입을 검증한다. 새 generics 문법과 옛날 코드가 같은 런타임 위에서 돌아갈 수 있는 건 이 선택 덕분이다.