제네릭 심화 — 와일드카드, 타입 소거, PECS를 넘어서
List<String>은List<Object>의 하위 타입이 아니라는 거, 공부하다 보니 처음에 정말 헷갈렸습니다. 제네릭의 더 깊은 곳을 파보겠습니다.
이게 뭔가요?
자바 제네릭의 기본(와일드카드, PECS)을 넘어서, 타입 시스템의 내부 동작과 고급 패턴을 다루는 심화 주제입니다. 라이브러리 설계나 프레임워크 코드를 읽을 때 반드시 만나게 됩니다.
재귀 타입 바운드 (Recursive Type Bound)
Comparable<T>를 보면 자기 자신을 타입 파라미터로 사용합니다.
// T는 자기 자신과 비교 가능한 타입이어야 함
public static <T extends Comparable<T>> T max(List<T> list) {
T result = list.get(0);
for (T item : list) {
if (item.compareTo(result) > 0) result = item;
}
return result;
}
빌더 패턴에서도 사용됩니다:
// 상속 가능한 빌더
public abstract class Builder<T extends Builder<T>> {
private String name;
@SuppressWarnings("unchecked")
protected T self() { return (T) this; }
public T name(String name) {
this.name = name;
return self(); // 하위 빌더 타입 반환
}
}
public class UserBuilder extends Builder<UserBuilder> {
private int age;
public UserBuilder age(int age) {
this.age = age;
return this;
}
}
// new UserBuilder().name("홍길동").age(25) — 체이닝이 끊기지 않음
타입 토큰 (Type Token)
제네릭 타입 정보는 런타임에 소거되지만, 클래스 리터럴을 "타입 토큰"으로 활용할 수 있습니다.
// 타입 안전한 이종 컨테이너 (Effective Java Item 33)
public class TypeSafeMap {
private final Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> type, T value) {
map.put(type, value);
}
public <T> T get(Class<T> type) {
return type.cast(map.get(type)); // 타입 안전한 캐스팅
}
}
TypeSafeMap favorites = new TypeSafeMap();
favorites.put(String.class, "자바");
favorites.put(Integer.class, 42);
String s = favorites.get(String.class); // 캐스팅 불필요
Integer i = favorites.get(Integer.class);
슈퍼 타입 토큰 (Super Type Token)
Class<T>는 List<String>.class 같은 매개변수화 타입을 표현할 수 없습니다. 이를 해결하는 패턴입니다.
// 익명 클래스로 제네릭 타입 정보 보존
public abstract class TypeReference<T> {
private final Type type;
protected TypeReference() {
Type superClass = getClass().getGenericSuperclass();
this.type = ((ParameterizedType) superClass)
.getActualTypeArguments()[0];
}
public Type getType() { return type; }
}
// 사용 — 익명 클래스의 수퍼타입에 제네릭 정보가 남음
TypeReference<List<String>> ref = new TypeReference<>() {};
ref.getType(); // java.util.List<java.lang.String>
Jackson의 TypeReference, Spring의 ParameterizedTypeReference가 이 패턴을 사용합니다.
제네릭 배열은 왜 만들 수 없나?
// 컴파일 에러: 제네릭 배열 생성 불가
List<String>[] array = new List<String>[10]; // 불가
만약 허용된다면:
Object[] objArray = array; // 배열은 공변이므로 허용
objArray[0] = List.of(42); // Integer 리스트 삽입 (런타임에 체크 불가)
String s = array[0].get(0); // ClassCastException!
배열은 공변(covariant)이고 런타임에 타입을 체크하지만, 제네릭은 불변(invariant)이고 컴파일 타임에만 체크합니다. 이 불일치 때문에 제네릭 배열 생성을 금지합니다.
교차 타입 (Intersection Type)
하나의 타입 파라미터에 여러 제약을 걸 수 있습니다.
// T는 Serializable이면서 Comparable이어야 함
public <T extends Serializable & Comparable<T>> void process(T item) {
// Serializable 메서드도, Comparable 메서드도 사용 가능
}
람다에서 교차 타입 캐스팅:
// Serializable한 Runnable 만들기
Runnable r = (Runnable & Serializable) () -> System.out.println("hi");
타입 소거의 함정
// 컴파일 에러: 같은 소거 타입
public void process(List<String> list) { } // 소거 → process(List)
public void process(List<Integer> list) { } // 소거 → process(List) — 충돌!
// instanceof에서 제네릭 사용 불가
if (obj instanceof List<String>) { } // 컴파일 에러
if (obj instanceof List<?>) { } // 가능 (와일드카드)
힙 오염 (Heap Pollution)
가변 인자(varargs)와 제네릭을 함께 쓸 때 발생합니다.
@SafeVarargs // 안전하다고 보장하는 어노테이션
public static <T> List<T> listOf(T... elements) {
return List.of(elements);
}
@SafeVarargs는 "이 메서드는 가변 인자 배열에 타입 안전하지 않은 작업을 하지 않는다"는 의미입니다. 배열에 값을 쓰지 않고 읽기만 한다면 안전합니다.
자주 헷갈리는 포인트
List<String>은List<Object>의 하위 타입이 아닙니다: 제네릭은 불변입니다. 하지만List<String>은List<? extends Object>의 하위 타입입니다.Class<T>vsType:Class<T>는 구체적인 클래스만 표현합니다.List<String>같은 매개변수화 타입은ParameterizedType(Type의 하위 인터페이스)으로 표현합니다.- 브릿지 메서드: 제네릭을 상속하면 컴파일러가 브릿지 메서드를 자동 생성합니다. 리플렉션에서 예상치 못한 메서드가 보일 수 있습니다.
정리
| 항목 | 설명 |
|---|---|
| 재귀 타입 바운드 | T extends Comparable<T> — 자기 참조 타입 제약 |
| 타입 토큰 | Class<T>로 런타임 타입 정보 보존 |
| 슈퍼 타입 토큰 | 익명 클래스로 매개변수화 타입 정보 보존 |
| 제네릭 배열 금지 | 배열 공변성과 타입 소거 불일치 |
| 교차 타입 | T extends A & B — 다중 제약 |
| 힙 오염 | varargs + 제네릭 시 @SafeVarargs |