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)"]
컴파일러가 하는 일:
- 제네릭 타입으로 타입 안전성 검사 (컴파일 타임)
- 타입 매개변수를 상한 경계(bound) 또는
Object로 교체 - 필요한 곳에 캐스트 코드 자동 삽입
// 소스 코드
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 패턴으로 Integer와 Double을 모두 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>를 사용한다.
생각해 볼 문제
List<String>[]배열은 컴파일 에러지만List<?>[]는 가능하다. 그 이유를 타입 소거 관점에서 설명하라.Class<T>를 매개변수로 받으면new T()를 간접적으로 수행할 수 있다. 이 패턴을 팩토리 메서드로 구현해 보자.<T extends Comparable<? super T>>의 의미를 풀어서 설명하라. 단순히<T extends Comparable<T>>와 어떻게 다른가?- 타입 소거 때문에
@SuppressWarnings("unchecked")없이 제네릭 캐스트를 해야 하는 상황을 예로 들어 보자. record Pair<T, U>(T first, U second)의 타입 소거 후 바이트코드를javap로 확인해 보자. T와 U가 어떻게 표현되는가?
참고
- JLS §4.6 - Type Erasure - 접근 2026-07-10
- JLS §4.10 - Subtyping - 접근 2026-07-10
- JLS §4.12.2 - Heap Pollution - 접근 2026-07-10
- JEP-101: Generics (Java 5 도입) - 접근 2026-07-10
- Collections.copy API - 접근 2026-07-10
'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 |
