제네릭 타입 정보가 컴파일 후 지워지는 것. 자바 제네릭의 거의 모든 제약이 여기서 나온다.
무엇이 지워지나
// 우리가 쓴 코드
List<String> list = new ArrayList<>();
String s = list.get(0);
// 컴파일 후 (개념적으로)
List list = new ArrayList();
String s = (String) list.get(0); // 컴파일러가 캐스팅을 넣어 준다
- <T> — → Object 로 대체된다 <T extends Number> → Number 로 대체된다 (경계 소거)
타입 검사는 컴파일 시점에 끝내고, 런타임에는 흔적이 없다
왜 이렇게 만들었나
하위 호환 때문이다. Java 5에서 제네릭을 도입하면서, 기존에 컴파일된 라이브러리와 섞여 돌아야 했다. 런타임 표현을 바꾸지 않는 소거 방식이 그 타협이었다.
따라오는 제약들
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true! 둘 다 그냥 ArrayList 다
if (obj instanceof List<String>) { } // 컴파일 에러 — 확인할 방법이 없다
new T[10]; // 컴파일 에러 — T 를 모른다
T t = new T(); // 컴파일 에러
// 오버로딩 불가 — 소거 후 시그니처가 같아진다
void f(List<String> s) { }
void f(List<Integer> i) { } // 컴파일 에러: name clash
// static 필드에 타입 파라미터를 못 쓴다 (인스턴스마다 다른 T 를 공유할 수 없다)
class Box<T> { static T value; } // 컴파일 에러
우회 — 타입 토큰
// Class<T> 를 함께 받아 런타임에 타입을 안다
<T> T parse(String json, Class<T> type) { return mapper.readValue(json, type); }
// 제네릭 타입 자체를 유지하려면 슈퍼 타입 토큰 (익명 클래스의 시그니처는 남는다)
new TypeReference<List<Member>>() {} // Jackson
new ParameterizedTypeReference<List<Member>>() {} // Spring
익명 클래스나 필드·메서드 시그니처에 쓰인 제네릭은 클래스 파일에 남는다 — 프레임워크가 이 틈을 이용한다.
힙 오염
@SafeVarargs // 이 경고를 억누를 때 쓴다
static <T> List<T> of(T... args) { return List.of(args); }
// 제네릭 varargs 는 내부적으로 T[] 를 만드는데, 배열은 공변이라 오염될 수 있다
다른 언어와 비교
- 자바 — 소거 — 런타임에 타입 없음. 하위 호환을 얻고 표현력을 잃었다
- C# — 구체화(reified) — 런타임에도 타입이 남는다. new T() 가 가능하다
- 코틀린 — 기본은 소거. inline + reified 로 인라인 시점에 타입을 심는다
면접 함정
- ❌ "런타임에
List<String>인지 알 수 있다" → 알 수 없다. - ❌ "소거는 성능 때문" → 하위 호환 때문이다.