제네릭 — 컴파일러에게 타입을 맡기기
List하나에 문자열만 들어 있다고 누가 보장할까? 제네릭은 컴파일 타임에 타입을 못 박아, 런타임에서야 터질ClassCastException을 빌드 시점으로 끌어온다. 그 원리와, 가끔 우리를 헷갈리게 하는 변성·타입 소거를 차례로 따라가 본다.
1. 캐스팅 지옥에서 벗어나기
제네릭이 없던 시절엔 컬렉션이 Object를 담았다. 꺼낼 때마다 캐스팅해야 했고, 실수는 런타임에야 터졌다.
List raw = new ArrayList();
raw.add("hello");
Integer n = (Integer) raw.get(0); // 컴파일은 통과, 실행하면 💥 ClassCastException
제네릭은 이 위험을 컴파일러에게 떠넘긴다. List<String>이라 적으면, 컴파일러가 "여긴 String만"이라고 강제한다.
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(42); // ✗ 여기서 바로 컴파일 오류 — 실수를 빌드 때 잡는다
String s = list.get(0); // 캐스팅도 필요 없다
핵심은 "실수를 최대한 일찍 잡는다"이다. 런타임 사고를 빌드 단계로 당기는 것 — 이게 제네릭의 전부이고, 나머지는 이걸 정교하게 만드는 규칙들이다.
2. 왜 List<String>은 List<Object>가 아닌가 — 불공변
직관적으론 "String은 Object니까 List<String>도 List<Object>겠지" 싶지만, 자바는 이걸 막는다(불공변, invariant). 막아야 안전하기 때문이다.
List<String> strs = new ArrayList<>();
List<Object> objs = strs; // ✗ 만약 이게 됐다면…
objs.add(42); // …String 리스트에 Integer가 섞인다!
흥미롭게도 배열은 반대로 허용한다(공변). 그 대가가 런타임 폭발이다.
Object[] arr = new String[3]; // 공변: 컴파일 OK
arr[0] = 42; // 💥 런타임 ArrayStoreException
왜 제네릭은 불공변을 택했나? 배열은 실행 중에 타입을 검사해
ArrayStoreException을 던지지만, 제네릭은 소거(뒤에 나옴) 때문에 런타임에 타입을 모른다. 그러니 컴파일 타임에 아예 막는 길이 안전하다. "위험을 빌드 때로 당긴다"는 1번의 원칙이 여기서도 작동한다(Item 28: 배열보다 리스트).
3. 꺼내면 extends, 넣으면 super — PECS
불공변은 안전하지만 너무 뻣뻣하다. 그래서 유연성을 위한 와일드카드가 있다.
? extends T— T나 그 하위를 꺼낼 때(생산자, producer)? super T— T나 그 하위를 넣을 때(소비자, consumer)
// src에서 꺼내(produce) dest에 넣는다(consume)
static <T> void copy(List<? super T> dest, List<? extends T> src) {
for (T t : src) dest.add(t);
}
이걸 외우는 한 마디가 PECS — Producer-Extends, Consumer-Super. "꺼내면 extends, 넣으면 super." Collections.copy, Stream.map(Function<? super T, ? extends R>) 같은 표준 API가 전부 이 모양이다.
실무에서는 — 내가 만드는 메서드가 컬렉션을 받기만(읽기) 하면
? extends, 채우기만(쓰기) 하면? super로 받으면 호출자가 훨씬 유연해진다(copy(List<Object>, List<Integer>)도 통과). API의 수용성을 넓히는 기본기.
4. 런타임엔 타입이 사라진다 — 소거(erasure)
여기가 제네릭의 가장 헷갈리는 지점이자, 많은 제약의 근원이다. 컴파일이 끝나면 타입 파라미터는 지워진다.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true — 둘 다 그냥 ArrayList.class
바이트코드에서 무슨 일이 (CS 연결)
소거 후 List<String>은 그냥 List가 되고, 컴파일러가 꺼내는 자리마다 checkcast String 명령을 몰래 끼워 넣는다. 그래서 우리가 캐스팅을 안 써도 런타임에 타입이 검사된다. 또 공변 반환과 소거가 만나면, 오버라이딩을 잇기 위해 브리지 메서드(Object get() → 실제 String get() 호출)를 자동 생성한다.
왜 타입을 지웠나? 제네릭은 Java 5에 뒤늦게 들어왔다. 이미 깔린 라이브러리·바이트코드와 하위 호환을 지키려면 런타임 표현을 바꿀 수 없었다 → "컴파일 타임에만 검사하고 런타임엔 지운다"는 마이그레이션 호환성을 택했다.
그 대가가 소거의 제약들이다 — 런타임에 T가 무엇인지 모르니:
// new T[]; // ✗ 제네릭 배열 생성 불가
// if (o instanceof List<String>) // ✗ 런타임에 <String> 구분 불가
// void f(List<String>){} void f(List<Integer>){} // ✗ 소거 후 시그니처 충돌
실무에서는 — ① 로 타입(raw
List)을 쓰지 마라 — 컴파일러 검사를 통째로 잃는다(Item 26). ② 런타임 타입이 꼭 필요하면Class<T>를 인자로 받아 두는 타입 토큰으로 우회(type.cast(...), Item 33). ③ 제네릭 가변인자는 힙 오염 경고가 나는데, 안전하게 읽기만 한다면@SafeVarargs로 표시(단, 애너테이션이 안전을 만들어 주진 않는다 — 잘못 구현하면 여전히 오염).
한눈에 정리
- 제네릭 = 런타임 ClassCastException을 빌드 시점으로 당기기. 실수를 일찍 잡는다.
- 불공변:
List<String>은List<Object>가 아니다(안전). 배열은 공변이라 런타임에 터진다. - PECS: 꺼내면
? extends, 넣으면? super. API 수용성을 넓힌다. - 소거: 런타임엔 타입이 없다 → 컴파일러가
checkcast삽입·브리지 생성. 하위 호환 때문. - 실무: 로 타입 금지, 런타임 타입은
Class<T>토큰, 제네릭 varargs는@SafeVarargs.