List<String>List<Integer>가 런타임에 같은 타입인 이유 — 제네릭과 타입 소거

new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() — 이 표현식은 true다. List<String>List<Integer>는 런타임에 같은 클래스(ArrayList)다. 제네릭 타입 정보가 컴파일 후 사라지기 때문이다 — 이것이 타입 소거(type erasure)다. (JLS §4.6)

타입 소거를 모르면, 리플렉션으로 제네릭 타입을 검사하려다 실패하고, 제네릭 배열을 만들려다 컴파일 에러를 만나고, 힙 오염(heap pollution)에 빠진다. 왜 Java는 이렇게 설계했을까? 이 글은 제네릭이 컴파일 후 어떻게 사라지는지, 그리고 와일드카드(PECS)로 무엇을 하는지 풀어간다.

제네릭이 해결하는 문제 — 타입 안전성

제네릭 이전(Java 5 이전)에는 컬렉션이 Object를 저장했다:

// Java 1.4 — 제네릭 없음
List list = new ArrayList();
list.add("Hello");
// list.add(42);   // String만 넣으려 했지만 컴파일러가 못 막음
String s = (String) list.get(0);   // 캐스트 필수 — 런타임 ClassCastException 위험

제네릭으로 타입을 명시하면, 컴파일러가 타입 안전성을 보장한다:

// Java 25
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(42);   // 컴파일 에러! Integer는 넣을 수 없음
String s = list.get(0);   // 캐스트 불필요 — 컴파일러가 보장

타입 소거(Type Erasure) — 제네릭의 본질

제네릭은 컴파일 타임에만 타입을 검사하고, 런타임에는 타입 정보를 제거한다. (JLS §4.6)

flowchart LR
    SRC["소스 코드<br/>List<String>"] -->|javac| COMPILE["컴파일 타임<br/>타입 검사 수행"]
    COMPILE --> ERASE["타입 소거<br/>List<String> → List"]
    ERASE --> BYTECODE["바이트코드<br/>List (raw type)"]

컴파일러가 하는 일:

  1. 제네릭 타입으로 타입 안전성 검사 (컴파일 타임)
  2. 타입 매개변수를 상한 경계(bound) 또는 Object로 교체
  3. 필요한 곳에 캐스트 코드 자동 삽입
// 소스 코드
List<String> list = new ArrayList<>();
list.add("Hello");
String s = list.get(0);

// 컴파일 후 (개념적 — 타입 소거 결과)
List list = new ArrayList();
list.add("Hello");
String s = (String) list.get(0);   // 캐스트 자동 삽입

타입 소거로 인해 불가능한 것들

// Java 25 — 타입 소거로 인해 컴파일 에러나는 것들
// new T();                    // 에러: T의 타입을 런타임에 모름
// T[] array = new T[10];      // 에러: 제네릭 배열 생성 불가
// if (obj instanceof T) { }   // 에러: T로 instanceof 불가
// class Foo<T> extends T { }  // 에러: T를 상속 불가

// 런타임에 제네릭 타입 인자를 알 수 없음
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
System.out.println(strings.getClass() == ints.getClass());   // true! (둘 다 ArrayList.class)

타입 소거는 하위 호환성을 위해 선택됐다. Java 5에서 제네릭을 도입할 때, 기존 Java 1.4 코드와 바이트코드 수준에서 호환되어야 했다. C#의 reified generics(런타임에 타입 정보 보존)와의 핵심적 차이다.

와일드카드 — PECS 패턴

제네릭은 불공변(invariant)이다 — List<String>List<Object>의 하위 타입이 아니다. (JLS §4.10)

// Java 25 — 불공변
// List<Object> objects = new ArrayList<String>();   // 컴파일 에러!
// 이유: String → Object 대입은 가능하지만, List<String>에 Integer를 넣을 수 있게 되면 타입 안전성이 깨짐

불공변을 완화하기 위해 와일드카드를 사용한다.

? extends T — 공변(covariant), 읽기 전용

// Java 25
List<? extends Number> numbers = new ArrayList<Integer>();   // OK
Number n = numbers.get(0);   // OK: 읽기 가능 (Number로 안전)
// numbers.add(42);           // 컴파일 에러! 쓰기 불가

? extends Number는 "Number 또는 그 하위 타입"을 의미한다. 읽을 수는 있지만(add 제외), 쓸 수는 없다 — 구체적 하위 타입이 뭔지 런타임에 알 수 없으므로 안전한 쓰기가 불가능하다.

? super T — 반공변(contravariant), 쓰기 전용

// Java 25
List<? super Integer> list = new ArrayList<Number>();   // OK
list.add(42);   // OK: Integer 쓰기 가능
// Object o = list.get(0);   // 읽기는 Object로만 가능 (구체적 타입 모름)

? super Integer는 "Integer 또는 그 상위 타입"을 의미한다. Integer를 안전하게 쓸 수 있지만, 읽을 때는 정확한 타입을 모른다.

PECS — Producer Extends, Consumer Super

"매개변수화 타입 T가 생산자(producer)? extends T를 쓰고, 소비자(consumer)? super T를 쓴다." (Effective Java Item 31)

// Java 25 — PECS 적용 예
// src에서 읽어서(producer) dest에 쓰는(consumer) 메서드
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) {    // src는 생산자 → extends
        dest.add(item);     // dest는 소비자 → super
    }
}

// 실제 Java 표준 라이브러리의 Collections.copy 시그니처
// public static <T> void copy(List<? super T> dest, List<? extends T> src)

제네릭 메서드

메서드 수준에서도 타입 매개변수를 정의할 수 있다:

// Java 25
public static <T> List<T> listOf(T... elements) {
    return new ArrayList<>(Arrays.asList(elements));
}

// 타입 추론
List<String> strings = listOf("a", "b", "c");
List<Integer> ints = listOf(1, 2, 3);

bounded type parameter — 타입 경계

// Java 25 — T는 Comparable<T>를 구현해야 함
public static <T extends Comparable<T>> T max(List<T> list) {
    if (list.isEmpty()) throw new IllegalArgumentException("빈 리스트");
    T result = list.get(0);
    for (T item : list) {
        if (item.compareTo(result) > 0) {
            result = item;
        }
    }
    return result;
}

<T extends Bound>는 T가 Bound의 하위 타입이어야 한다는 제약이다. 이 제약 덕분에 Bound의 메서드를 T에 대해 호출할 수 있다.

힙 오염(Heap Pollution) — 타입 소거의 어두운 면

// Java 25 — 힙 오염
List<String> strings = new ArrayList<>();
List rawList = strings;       // raw type으로 경고만 (컴파일 가능)
rawList.add(42);              // 런타임에 Integer가 List<String>에 들어감!

// 나중에 꺼낼 때 폭발
for (String s : strings) {     // ClassCastException! (Integer → String 캐스트)
    System.out.println(s);
}

히프 오염은 raw type을 섞어서 제네릭 타입 안전성이 런타임에 깨지는 현상이다. (JLS §4.12.2) 컴파일러는 경고(unchecked)를 내지만, 에러가 아니다 — 레거시 코드와의 호환을 위해 허용된다.

-Xlint:unchecked 또는 -Werror 옵션으로 unchecked 경고를 에러로 처리하면 힙 오염을 예방할 수 있다. 프로덕션 빌드에서는 unchecked 경고를 0으로 유지하는 것이 권장된다.

브리지 메서드 — 타입 소거를 보정하는 컴파일러의 트릭

타입 소거로 인해 다형성이 깨질 위험이 있다. 컴파일러는 이를 브리지 메서드(bridge method)로 해결한다.

// Java 25 — 소스 코드
class Node<T> {
    T value;
    void setValue(T value) { this.value = value; }
}

class StringNode extends Node<String> {
    @Override
    void setValue(String value) {   // String으로 override
        super.setValue(value);
    }
}
// 타입 소거 후 (javap로 확인 가능)
// Node 클래스:
void setValue(Object)        // T가 Object로 소거
// StringNode 클래스:
void setValue(String)        // 우리가 작성한 override
void setValue(Object)        // 브리지 메서드 (컴파일러 생성)
                             // → 내부적으로 setValue(String)을 호출

브리지 메서드는 setValue(Object)를 받아 setValue(String)으로 캐스트 후 호출하는 합성(synthetic) 메서드다. 이것이 있어야 Node n = new StringNode(); n.setValue(obj);가 다형성을 유지한다.

브리지 메서드는 javap -v StringNode.class로 확인할 수 있다. ACC_SYNTHETIC 플래그가 붙어 있으며, 내부적으로 캐스트를 수행한다. 리플렉션으로 메서드를 열거하면 브리지 메서드도 보인다.

제네릭 클래스 상속 — 주의점

// Java 25
// 제네릭 클래스를 상속할 때 타입 인자를 고정
class NumberList extends ArrayList<Number> {
    // 이 클래스는 ArrayList<Number>로 고정됨
    // ArrayList<Integer>나 ArrayList<String>이 아님
}

// 또는 타입 매개변수를 그대로 유지
class CustomList<T> extends ArrayList<T> {
    // T를 그대로 전달
}

// 여러 제네릭 인터페이스 구현
class StringComparator implements Comparator<String>, Serializable {
    @Override
    public int compare(String a, String b) { return a.compareTo(b); }
}

제네릭 클래스를 상속할 때 같은 제네릭 인터페이스를 서로 다른 타입 인자로 두 번 구현할 수 없다:

// class Foo implements Comparable<String>, Comparable<Integer> { }  // 컴파일 에러!

타입 소거로 인해 두 인터페이스가 같은 Comparable이 되기 때문이다.

실습 — 타입 소거 직접 확인

// Java 25 — ErasureDemo.java
import java.util.*;

public class ErasureDemo {
    public static void main(String[] args) {
        List<String> strings = new ArrayList<>();
        List<Integer> integers = new ArrayList<>();

        // 런타임에 같은 클래스
        System.out.println(strings.getClass());    // class java.util.ArrayList
        System.out.println(integers.getClass());   // class java.util.ArrayList
        System.out.println(strings.getClass() == integers.getClass());  // true

        // PECS 실습
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(1.1, 2.2, 3.3);
        List<Number> target = new ArrayList<>();

        // ? extends Number: Integer, Double 모두 받음 (생산자)
        copyNumbers(target, ints);
        copyNumbers(target, doubles);
        System.out.println(target);   // [1, 2, 3, 1.1, 2.2, 3.3]
    }

    // PECS: src는 생산자(extends), dest는 소비자(super)
    static void copyNumbers(List<? super Number> dest, List<? extends Number> src) {
        for (Number n : src) {
            dest.add(n);
        }
    }
}
java ErasureDemo.java
class java.util.ArrayList
class java.util.ArrayList
true
[1, 2, 3, 1.1, 2.2, 3.3]

확인할 것: List<String>List<Integer>getClass()가 동일하다 — 타입 소거의 직접적 증거. PECS 패턴으로 IntegerDouble을 모두 Number 리스트에 안전하게 복사한다.

요약 — 이 글의 결론

  • 제네릭은 컴파일 타임 타입 안전성을 보장하고, 런타임에는 타입 정보가 소거된다. List<String>List<Integer>는 런타임에 같은 ArrayList다. 타입 소거를 이해하지 못하면 리플렉션, 배열 생성, instanceof에서 문제에 부딪힌다.
  • 제네릭은 불공변이다. List<String>List<Object>의 하위 타입이 아니다. 유연성이 필요하면 와일드카드(? extends T, ? super T)를 쓴다.
  • PECS: Producer Extends, Consumer Super. 값을 읽어오는(생산) 매개변수는 ? extends, 값을 쓰는(소비) 매개변수는 ? super. Collections.copy()가 표준 예시다.
  • 힙 오염은 raw type 사용으로 발생한다. unchecked 경고를 무시하지 말고, -Werror로 강제한다.
  • 제네릭 배열 생성은 불가하다. new T[10]은 타입 소거로 인해 허용되지 않는다. 대신 Array.newInstance() 또는 List<T>를 사용한다.

생각해 볼 문제

  1. List<String>[] 배열은 컴파일 에러지만 List<?>[]는 가능하다. 그 이유를 타입 소거 관점에서 설명하라.
  2. Class<T>를 매개변수로 받으면 new T()를 간접적으로 수행할 수 있다. 이 패턴을 팩토리 메서드로 구현해 보자.
  3. <T extends Comparable<? super T>>의 의미를 풀어서 설명하라. 단순히 <T extends Comparable<T>>와 어떻게 다른가?
  4. 타입 소거 때문에 @SuppressWarnings("unchecked") 없이 제네릭 캐스트를 해야 하는 상황을 예로 들어 보자.
  5. record Pair<T, U>(T first, U second)의 타입 소거 후 바이트코드를 javap로 확인해 보자. T와 U가 어떻게 표현되는가?

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 13. stream  (0) 2026.07.12
Java - 12. lambda functional  (0) 2026.07.12
Java - 10. collections  (0) 2026.07.12
Java - 09. exception  (0) 2026.07.12
Java - 08. record sealed enum  (0) 2026.07.12