백엔드 면접 용어 사전
Java·객체지향type erasure

타입 소거

제네릭 타입 정보가 컴파일 후 지워지는 특성. 런타임에 타입 파라미터를 알 수 없다.

제네릭 타입 정보가 컴파일 후 사라지는 자바의 구현 방식(type erasure).

무슨 일이 일어나는가

// 우리가 쓴 코드
List<String> list = new ArrayList<>();
list.add("hello");
String s = list.get(0);

// 컴파일 후 (바이트코드 수준)
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);   // 컴파일러가 형변환을 삽입

제네릭은 컴파일 타임에만 존재하고, 런타임에는 사라진다. List<String>List<Integer>는 런타임에 똑같은 List 다.

new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()  // true

왜 이렇게 만들었나 — 하위 호환성

제네릭은 자바 5에 도입됐다. 그전 코드가 산더미처럼 있었다.

선택지 ① 런타임에도 타입을 유지 (C#의 방식)

  • 기존 라이브러리와 호환되지 않는다. 생태계가 갈라진다

선택지 ② 컴파일 후 지운다

  • 기존 List 코드와 그대로 섞여 동작한다 ← 자바의 선택

"기존 코드를 깨지 않는다" 를 최우선으로 한 결과다. 편의를 얻고 런타임 타입 정보를 잃은 거래였다.

그래서 생기는 제약들

① 런타임에 타입을 알 수 없다

if (list instanceof List<String>) { }   // ✗ 컴파일 에러

② 제네릭 타입으로 객체 생성 불가

class Box<T> {
    T create() { return new T(); }        // ✗ T가 뭔지 런타임에 모른다
    T[] array = new T[10];                // ✗
}

Class<T>를 받아 clazz.getDeclaredConstructor().newInstance() 로 우회한다.

③ static 컨텍스트에서 사용 불가

class Box<T> {
    static T value;                       // ✗ 인스턴스마다 다른 T인데 static은 하나
}

④ 오버로딩 불가

void f(List<String> a) { }
void f(List<Integer> a) { }   // ✗ 소거 후 시그니처가 같아진다

⑤ 제네릭 예외 불가catch는 런타임에 타입을 비교해야 하기 때문이다.

힙 오염과 @SafeVarargs

List<String>[] arrays = new List<String>[10];   // ✗ 배열은 런타임 타입을 유지

배열은 소거되지 않고 제네릭은 소거되므로 둘의 성질이 어긋난다. 그래서 제네릭 배열 생성이 금지된다.

static <T> void unsafe(List<T>... lists) {   // 가변인자는 내부적으로 배열
    Object[] objs = lists;
    objs[0] = List.of(1, 2);                 // 컴파일러가 못 막는다
    String s = lists[0].get(0);              // ClassCastException!
}

경고가 뜨며, 안전하다고 확신할 때만 @SafeVarargs를 붙인다.

소거되지 않는 곳 — 리플렉션으로 읽을 수 있다

변수의 타입은 지워지지만, 클래스·필드·메서드 선언의 제네릭 정보는 클래스 파일에 메타데이터로 남는다.

class StringList extends ArrayList<String> { }
// 부모의 타입 인자 String 은 남아 있다

이 성질을 이용한 것이 슈퍼 타입 토큰이다.

// Jackson, Spring 에서 흔히 보는 패턴
new TypeReference<List<User>>() {}     // 익명 클래스를 만들어 타입을 남긴다
new ParameterizedTypeReference<List<User>>() {}

{}를 붙이나 — 익명 하위 클래스를 만들면 "이 클래스는 TypeReference<List<User>>를 상속한다"는 정보가 클래스 파일에 기록된다. 런타임에 그것을 읽어 List<User>를 복원하는 것이다.

이 트릭을 설명할 수 있으면 타입 소거를 제대로 이해한 것이다.

실무 영향

// JSON 역직렬화에서 타입을 명시해야 하는 이유
objectMapper.readValue(json, new TypeReference<List<User>>() {});
// readValue(json, List.class) 는 List<LinkedHashMap> 을 준다

List<User>로 받았다고 생각했는데 원소가 LinkedHashMap이라 ClassCastException이 나는 것이 대표적인 사고다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — Java·객체지향 — OOP·JVM·GC·컬렉션