타입 소거와 런타임 한계
instanceof T와 new T가 컴파일되지 않는 이유, 비매개변수화 타입의 힙 오염과 지연된 ClassCastException, 상한 치환과 컴파일러 캐스팅을 관찰하고 Class 토큰·팩터리로 런타임 타입 작업을 설계합니다.
자바 제네릭은 주로 컴파일 시점에 타입 안전성을 검사합니다. 타입 매개변수는 바이트코드에서 상한 또는 Object 관점으로 소거되고, 필요한 곳에 컴파일러가 캐스팅을 삽입합니다. 그래서 같은 제네릭 클래스 한 벌로 여러 타입을 사용할 수 있지만 런타임에 T 자체를 검사하거나 생성하는 데는 제한이 있습니다.
타입 소거와 생성 제한
T가 소거된 뒤에는 어떤 타입을 검사하고 어떤 생성자를 호출해야 하는지 알 수 없습니다.
T.class, new T[], static T 필드도 같은 이유로 제한됩니다.
public final class ErasedTypeOperationFailure<T> {
public boolean matches(Object value) {
return value instanceof T;
}
public T create() {
return new T();
}
}error: Object cannot be safely cast to T
error: unexpected type
required: class
found: type parameter T만약 제한 없는 T가 Object로 바뀐 뒤 instanceof Object로 실행되면 거의 항상 참이라 원래 의도를 지킬 수 없습니다.
컴파일러는 의미가 사라지는 연산을 허용하지 않습니다.
소거 후의 런타임 구조
Box<String>과 Box<Integer>는 서로 다른 소스 타입이지만 런타임 클래스 객체는 같습니다.
get 결과를 구체 타입으로 받는 지점에는 컴파일러가 안전하다고 검증한 캐스팅을 넣습니다.
public final class ErasureRuntimeObservation {
public static void main(String[] args) {
Box<String> strings = new Box<>("generic");
Box<Integer> numbers = new Box<>(13);
System.out.println("same-class=" + (strings.getClass() == numbers.getClass()));
String topic = strings.get();
Integer chapter = numbers.get();
System.out.println(topic + "=" + chapter);
}
private record Box<T>(T value) {
T get() { return value; }
}
}same-class=true
generic=13제한 없는 T는 기본적으로 Object로 치환됩니다.
<T extends Animal>은 Animal 상한으로 치환되므로 구현은 Animal 메서드를 호출할 수 있습니다.
반환 지점에서 Dog가 필요하면 호출 코드에 Dog 캐스팅이 삽입됩니다.
정상적인 제네릭 사용에서는 컴파일러가 입력까지 검사했기 때문에 이 캐스팅이 안전합니다.
리플렉션 API가 필드·메서드 선언에 남은 generic signature 메타데이터를 일부 보여 주기도 하지만, 객체 하나가 Box<String>인지 Box<Integer>인지 완전히 재구성하는 실행 타입 표지는 아닙니다.
런타임 검증을 그 메타데이터에 막연히 의존하지 않습니다.
비매개변수화 타입과 힙 오염
소거가 안전하지 않은 것이 아니라 비매개변수화 타입과 unchecked 연산이 컴파일 검사를 우회하는 것이 문제입니다.
Box<String> 객체에 raw 참조로 Integer를 넣으면 힙의 실제 값과 정적 타입 약속이 달라집니다.
public final class RawGenericHeapPollutionFailure {
public static void main(String[] args) {
Box<String> strings = new Box<>();
strings.set("safe");
Box raw = strings;
raw.set(13);
String topic = strings.get();
System.out.println(topic);
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
}java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String실패는 컴파일러가 get 뒤 삽입한 String 캐스팅에서 나타납니다.
root cause는 raw set입니다.
-Xlint:unchecked 경고를 실패로 취급하면 이런 경계를 일찍 발견할 수 있습니다.
외부 레거시 API가 raw 값을 돌려주면 한 어댑터에서 원소를 검사해 새 타입 안전 컬렉션으로 복사합니다.
런타임 타입 정보의 전달
T의 클래스 검사나 객체 생성을 해야 한다면 Class<T>, Supplier<T>, 전용 팩터리를 생성자 인수로 받습니다.
소거된 정보를 마법처럼 복원하는 것이 아니라 호출자가 알고 있는 런타임 전략을 명시적으로 전달합니다.
import java.util.function.Supplier;
public final class ClassTokenAndFactory {
public static void main(String[] args) {
TypedFactory<StudyEntry> factory = new TypedFactory<>(
StudyEntry.class,
() -> new StudyEntry("untitled", 1));
System.out.println("matches=" + factory.matches(new StudyEntry("java", 40)));
System.out.println("created=" + factory.create());
}
private record TypedFactory<T>(Class<T> type, Supplier<T> supplier) {
boolean matches(Object value) { return type.isInstance(value); }
T cast(Object value) { return type.cast(value); }
T create() { return supplier.get(); }
}
private record StudyEntry(String topic, int minutes) { }
}matches=true
created=StudyEntry[topic=untitled, minutes=1]Class 토큰은 검사와 캐스팅, Supplier는 생성 방법을 담당합니다. 기본 생성자를 리플렉션으로 강제하지 않으므로 검증된 생성자·팩터리도 전달할 수 있습니다. 중첩 제네릭 타입 전체가 필요하면 단순 Class 하나로 부족하므로 JSON 라이브러리의 TypeToken 같은 별도 타입 표현이 필요할 수 있습니다.
배열과 가변 인자의 소거 경계
배열은 런타임에 원소 타입을 검사하지만 제네릭은 소거됩니다.
그래서 new T[10]을 직접 만들 수 없고 List<String>[] 같은 제네릭 배열 생성도 금지됩니다.
두 모델을 섞으면 런타임 배열 검사와 컴파일 제네릭 검사가 서로 다른 정보를 가지게 됩니다.
제네릭 varargs는 내부적으로 배열을 만들기 때문에 힙 오염 위험이 있습니다.
메서드가 배열을 외부에 노출하거나 원소를 바꾸지 않고 안전하다는 것을 구현자가 증명한 경우에만 @SafeVarargs를 사용합니다.
경고를 숨기는 용도로 붙이지 않습니다.
가능하면 List<T>를 인수로 받는 API가 더 명확합니다.
오버로딩도 소거 뒤 시그니처가 같아질 수 있습니다.
process(List<String>)와 process(List<Integer>)는 둘 다 런타임에 process(List)가 되어 함께 선언할 수 없습니다.
메서드 이름을 역할별로 바꾸거나 하나의 제네릭 메서드로 관계를 표현합니다.
제네릭 설계 선택 기준
- 컴파일 단계의 입력·반환 관계가 목적: 타입 매개변수
- 실행 중 타입 검사: Class 토큰 또는 명시 식별자
- 실행 중 생성 전략: Supplier나 팩터리
- 읽기/쓰기 범위 확장: 와일드카드와 PECS
- raw 경계: 한곳에서 검증·복사하고 unchecked 범위 최소화
- 타입별 행동이 본질적으로 다름: 다형성·전략·도메인 타입
제네릭은 타입 관계가 분명할수록 강력합니다.
<A,B,C,D>가 서로 어떻게 연결되는지 설명하기 어렵다면 더 작은 객체나 이름 있는 중간 타입으로 나눕니다.
캐스팅을 숨기기 위해 unchecked helper를 만들기보다 공개 계약이 실제로 보장할 수 있는지 먼저 검토합니다.
연습 문제
체력이 큰 같은 유닛 타입을 반환하는 제네릭 메서드, BioUnit 하위 한 종류를 싣는 Shuttle, 어떤 Shuttle이든 출력하는 와일드카드 메서드를 작성하세요.
해설 보기
public final class GenericUnitToolkitExercise {
public static void main(String[] args) {
Marine first = new Marine("M1", 40, 1);
Marine second = new Marine("M2", 55, 2);
Marine stronger = maxHp(first, second);
Shuttle<Marine> shuttle = new Shuttle<>();
shuttle.in(stronger);
printUnit(shuttle);
System.out.println("squad=" + stronger.squad());
}
private static <T extends BioUnit> T maxHp(T left, T right) {
return left.hp() >= right.hp() ? left : right;
}
private static void printUnit(Shuttle<? extends BioUnit> shuttle) {
BioUnit unit = shuttle.out();
System.out.println(unit.name() + ":" + unit.hp());
}
private static final class Shuttle<T extends BioUnit> {
private T unit;
void in(T unit) { this.unit = unit; }
T out() { return unit; }
}
private interface BioUnit { String name(); int hp(); }
private record Marine(String name, int hp, int squad) implements BioUnit { }
}M2:55
squad=2maxHp의 T는 Marine 반환을 보존하고 Shuttle의 T는 적재 수명 동안 같은 타입을 유지합니다.
printer는 부모 기능만 읽으므로 ? extends BioUnit이면 충분합니다.