본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 제네릭

타입 소거와 런타임 한계

Java 제네릭의 타입 인자는 대부분 컴파일 뒤 소거되어 런타임에는 원래 타입 정보가 직접 남지 않습니다.

이 설계는 제네릭 도입 전 바이트코드와의 호환을 도왔지만 배열 생성, instanceof, 오버로딩에 제한을 만듭니다.

컴파일러는 필요한 캐스트와 브리지 메서드를 삽입해 소스 수준 계약을 유지합니다.

런타임에 타입이 정말 필요하면 Class 같은 타입 토큰을 명시적으로 전달해야 합니다.

이 절에서는 컴파일 시점 보장과 런타임 정보의 경계를 확인합니다.

배열은 런타임에도 원소 타입을 기억해 잘못된 저장을 ArrayStoreException으로 막지만, 제네릭 컬렉션의 타입 인자는 소거됩니다. 이 차이 때문에 공변 배열과 불공변 제네릭을 섞어 쓰면 안전성을 설명하기 어려워지고, new T[] 같은 생성이 금지됩니다.


T는 보통 첫 경계 타입으로 소거된다

경계가 없으면 Object, T extends Number면 Number로 바뀝니다.

호출자의 구체 타입 인자가 별도 클래스 버전을 만들지는 않습니다.


제네릭 배열은 직접 만들 수 없다

new T[10]이나 new List<String>[10]은 런타임 배열 검사가 소거된 원소 타입을 확인할 수 없어 금지됩니다.

첫 비교는 서로 다른 타입 인자를 쓴 두 List가 런타임에는 같은 클래스임을 확인합니다. 두 번째 부분은 소거된 T만으로 만들 수 없는 배열을 String.class라는 런타임 증거로 생성합니다.

ErasureLimits.java
import java.lang.reflect.Array;
import java.util.List;

public class ErasureLimits {
    static <T> T[] arrayOf(Class<T> type, int length) {
        @SuppressWarnings("unchecked")
        T[] result = (T[]) Array.newInstance(type, length);
        return result;
    }

    public static void main(String[] args) {
        List<String> strings = List.of("A");
        List<Integer> integers = List.of(1);
        System.out.println(strings.getClass() == integers.getClass());

        String[] array = arrayOf(String.class, 2);
        array[0] = "Java";
        System.out.println(array[0]);
    }
}

true는 두 목록의 원소가 같다는 뜻이 아니라 getClass()가 타입 인자를 구분하지 못한다는 뜻입니다. 반면 만들어진 배열은 실제 String 배열이므로 JVM도 그 원소 타입을 알고 있습니다.

true
Java

두 List의 구체 타입 인자는 달라도 런타임 클래스는 같은 구현이며 배열 생성에는 Class 토큰을 전달했습니다.


instanceof는 재구체화 가능한 타입만 검사한다

value instanceof List<?>는 가능하지만 List<String>인지 직접 검사할 수 없습니다.

원소를 별도로 검증해야 합니다.

다음 제한은 모두 “실행 중에 구체 T를 확인할 정보가 없다”는 한 원인에서 나옵니다.

비교 1
static <T> boolean isList(Object value) {
    return value instanceof List<?>;
}
비교 2
// value instanceof List<String> // 컴파일 오류: 소거된 타입 인자 검사 불가
// T.class                       // 컴파일 오류: T의 Class 리터럴 없음
// new T[10]                     // 컴파일 오류: 제네릭 배열 직접 생성 불가
비교 3
Class<String> token = String.class; // 런타임 타입을 명시적으로 전달

타입 토큰으로 런타임 정보를 전달한다

Class<T>를 매개변수로 받으면 cast, 생성자 조회, 배열 생성 등에 실제 클래스 정보를 사용할 수 있습니다.

arrayOf(Number.class, 2)로 Number 배열을 만들고 Integer와 Double을 각각 저장해 보세요. 이어서 반환 배열을 Object로 받은 뒤 String을 저장하려 하면 JVM이 실제 배열 타입을 검사해 실패합니다. 컬렉션의 소거와 배열의 재구체화된 타입 정보가 대비됩니다.

Class<T> 하나로 List<String> 같은 중첩 타입 전체를 표현할 수는 없습니다. JSON 역직렬화처럼 중첩 제네릭 정보가 필요한 API는 별도의 타입 토큰 객체를 받는 이유가 여기에 있습니다. 런타임 검사가 필요하지 않은 일반 업무 코드에는 이런 메타데이터를 퍼뜨리지 않는 편이 단순합니다.


제네릭은 강한 컴파일 시점 계약을 제공하지만 런타임 구체 타입이 필요하면 별도 메타데이터를 전달해야 합니다.