JDK 5(2004년) 이전의 ArrayList 는 모든 요소를 Object 로 저장했습니다.
// JDK 1.4 스타일
List orders = new ArrayList();
orders.add("ORD-001"); // String
orders.add(Integer.valueOf(42)); // Integer — 컴파일러는 막지 못함
String first = (String) orders.get(0); // 캐스팅 필수
String second = (String) orders.get(1); // 컴파일 OK, 런타임에 ClassCastException!문제는 세 가지였습니다.
| 문제 | 설명 |
|---|---|
| 캐스팅 지옥 | 꺼낼 때마다 (String) 캐스팅. 코드가 지저분하고 실수하기 쉬움 |
| 런타임 폭발 | 잘못된 타입을 넣어도 컴파일은 통과. 운영 환경에서 ClassCastException |
| 의도 불명 | List 만 보고는 무엇이 들어 있는지 알 수 없음. 문서나 변수명에 의존 |
제네릭은 이 세 문제를 컴파일러가 타입을 추적하게 만드는 것 으로 해결합니다. List<String> 이라고 선언하면 add(Integer) 는 컴파일 에러이고, get() 은 캐스팅 없이 String 을 돌려줍니다.
가장 중요한 원리입니다. JVM 은 제네릭을 모릅니다. 자바 제네릭은 컴파일러가 타입을 검사한 뒤, 바이트코드를 만들 때 타입 정보를 지워버립니다(erasure, 소거). 그래서 List<String> 과 List<Integer> 는 런타임에 완전히 동일한 List 클래스입니다.
왜 이렇게 설계했을까요? 하위 호환성 때문입니다. JDK 5 에서 제네릭을 도입할 때, 기존에 컴파일된 수백만 개의 .class 파일과 라이브러리가 그대로 동작해야 했습니다. C# 처럼 런타임이 제네릭 타입을 구분하는 방식(reified generics, 구체화된 제네릭)을 택했다면 JVM 자체와 모든 기존 컬렉션 클래스를 바꿔야 했습니다.
소거 방식은 List<String> 을 그냥 List 로 컴파일하므로 옛날 코드와 새 코드가 섞여도 문제가 없습니다.
소거 규칙은 단순합니다.
| 선언 | 소거 후 |
|---|---|
T (바운드 없음) |
Object |
T extends Comparable<T> |
Comparable (첫 번째 바운드) |
T extends Number & Runnable |
Number (첫 번째 바운드) |
List<String> |
List |
Map<K, V> |
Map |
컴파일러는 소거로 사라진 타입 정보를 보완하기 위해 캐스팅을 자동 삽입합니다.
// 우리가 쓴 코드
List<String> names = new ArrayList<>();
names.add("kim");
String n = names.get(0);
// 컴파일러가 실제로 만든 바이트코드에 가까운 코드
List names = new ArrayList();
names.add("kim");
String n = (String) names.get(0); // 컴파일러가 캐스팅을 삽입즉 제네릭은 "캐스팅을 없애준다"가 아니라 "안전함이 증명된 캐스팅을 컴파일러가 대신 써준다"입니다.
소거 원리를 알면 제네릭의 이상한 제약들이 전부 설명됩니다.
| 제약 | 왜 안 되는가 |
|---|---|
new T() 불가 |
런타임에 T 가 Object 로 지워져서 어떤 생성자를 호출할지 알 수 없음 |
new T[10] 불가 |
배열은 런타임에 요소 타입을 검사함(공변). T 가 지워지면 Object[] 가 되어 타입 안전성 붕괴 |
obj instanceof T 불가 |
런타임에 T 정보가 없음. instanceof List<?> 만 가능 |
static T field 불가 |
Box<String> 과 Box<Integer> 가 같은 클래스라 필드 하나를 공유. 타입 모순 |
기본형 불가 (List<int>) |
T 는 Object 로 지워지는데 int 는 Object 가 아님. List<Integer> + 오토박싱 |
| 오버로드 불가 | f(List<String>) 과 f(List<Integer>) 는 소거 후 시그니처가 같음 |
catch (T e) 불가 |
예외 처리는 런타임 타입 매칭인데 T 가 없음 |
new T[] 문제를 우회하는 관용구는 두 가지입니다.
// 방법 1: Object[] 로 만들고 꺼낼 때 캐스팅 (ArrayList 내부 구현 방식)
private Object[] elements = new Object[16];
@SuppressWarnings("unchecked")
public T get(int i) { return (T) elements[i]; }
// 방법 2: Class<T> 토큰을 받아 Array.newInstance 사용
public static <T> T[] newArray(Class<T> type, int size) {
@SuppressWarnings("unchecked")
T[] arr = (T[]) java.lang.reflect.Array.newInstance(type, size);
return arr;
}Class<T> 를 넘겨서 런타임 타입 정보를 살리는 방식을 타입 토큰(type token) 이라고 합니다. Jackson 의 objectMapper.readValue(json, Order.class) 가 바로 이것입니다.
제네릭 클래스는 클래스 이름 뒤에 타입 파라미터를 선언합니다.
public class Box<T> { // T: 타입 파라미터(type parameter)
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
Box<String> b = new Box<>(); // String: 타입 인자(type argument), <> 는 다이아몬드 연산자제네릭 메서드는 반환 타입 앞에 타입 파라미터를 선언합니다. 클래스가 제네릭이 아니어도 메서드만 제네릭일 수 있고, 타입 인자는 대부분 호출 인자에서 추론(inference) 됩니다.
public static <T> List<T> listOf(T a, T b) { // <T> 가 "이 메서드는 제네릭이다" 선언
List<T> list = new ArrayList<>();
list.add(a); list.add(b);
return list;
}
List<String> s = listOf("a", "b"); // T = String 추론
List<Integer> i = listOf(1, 2); // T = Integer 추론제네릭 인터페이스는 Comparable<T>, Iterable<T>, Function<T, R> 처럼 계약에 타입을 끼워 넣습니다. 구현할 때 구체 타입을 채워 넣습니다.
public record Money(long amount) implements Comparable<Money> {
@Override public int compareTo(Money o) { return Long.compare(amount, o.amount); }
}타입 파라미터 이름은 관례입니다.
| 이름 | 의미 |
|---|---|
T |
Type (일반 타입) |
E |
Element (컬렉션 요소) |
K, V |
Key, Value |
R |
Return (반환 타입) |
N |
Number |
<T> 는 "아무 타입"이라 T 에 대해 Object 메서드밖에 못 씁니다. T 가 특정 능력을 가졌다고 보장하려면 상한 경계(upper bound) 를 겁니다.
// T 는 반드시 Comparable<T> 를 구현해야 한다 → compareTo 호출 가능
public static <T extends Comparable<T>> T max(List<T> list) {
T best = list.get(0);
for (T t : list) if (t.compareTo(best) > 0) best = t;
return best;
}extends 는 클래스든 인터페이스든 동일하게 씁니다(제네릭에는 implements 키워드가 없음). 여러 바운드는 & 로 잇습니다: <T extends Number & Comparable<T>>. 클래스는 하나만, 그것도 맨 앞에 와야 합니다.
바운드는 두 가지 이득을 줍니다.
T 에 대해 바운드 타입의 메서드를 호출할 수 있다.max(List<Object>) 는 에러).List<Dog> 는 List<Animal> 이 아닌가Dog extends Animal 일 때, Dog[] 는 Animal[] 에 대입할 수 있습니다(배열은 공변, covariant). 하지만 List<Dog> 는 List<Animal> 에 대입할 수 없습니다(제네릭은 불공변, invariant). 왜일까요?
만약 허용된다면 이런 일이 벌어집니다.
List<Dog> dogs = new ArrayList<>();
List<Animal> animals = dogs; // 가정: 허용된다면
animals.add(new Cat()); // Animal 리스트니까 Cat 추가 가능
Dog d = dogs.get(0); // 실제로는 Cat → ClassCastException배열은 이 상황을 런타임에 ArrayStoreException 으로 잡습니다(배열은 요소 타입을 런타임에 기억하니까). 제네릭은 타입 소거로 런타임 정보가 없으므로, 런타임 검사가 불가능합니다. 그래서 컴파일러가 아예 대입 자체를 금지하는 불공변을 택했습니다. 배열의 공변성은 자바 초기 설계의 실수로 평가되며, 제네릭은 그 실수를 반복하지 않은 것입니다.
| 배열 | 제네릭 | |
|---|---|---|
| 변성 | 공변 (Dog[] → Animal[] OK) |
불공변 (List<Dog> → List<Animal> 에러) |
| 타입 검사 시점 | 런타임 (ArrayStoreException) |
컴파일 타임 |
| 런타임 타입 정보 | 유지 | 소거 |
불공변은 안전하지만 불편합니다. printAll(List<Animal>) 메서드에 List<Dog> 를 넘길 수 없으니까요. 이 불편을 해결하는 것이 와일드카드입니다.
?, ? extends, ? super와일드카드는 "정확한 타입은 모르지만 이 범위 안"이라는 뜻입니다.
| 형태 | 의미 | 읽기(get) | 쓰기(add) |
|---|---|---|---|
List<?> |
무언가의 리스트 (unbounded) | Object 로만 |
불가 (null 제외) |
List<? extends Animal> |
Animal 또는 그 하위 타입의 리스트 (upper bounded) | Animal 로 가능 |
불가 (null 제외) |
List<? super Dog> |
Dog 또는 그 상위 타입의 리스트 (lower bounded) | Object 로만 |
Dog (와 하위) 가능 |
? extends Animal 에 왜 add 가 안 되는가? List<? extends Animal> 은 실제로 List<Dog> 일 수도 List<Cat> 일 수도 있습니다. 컴파일러는 어느 쪽인지 모르므로, add(new Dog()) 를 허용하면 List<Cat> 에 Dog 가 들어갈 수 있습니다. 그래서 금지. 반대로 꺼내는 것은 "무엇이든 최소한 Animal" 이므로 안전합니다.
? super Dog 에 왜 get 이 Object 인가? List<? super Dog> 는 List<Animal> 일 수도 List<Object> 일 수도 있습니다. 꺼낸 것이 Dog 라는 보장이 없으니 Object 로만 받을 수 있습니다. 반대로 Dog 를 넣는 것은 "Dog 의 상위 타입 리스트"이므로 항상 안전합니다.
이펙티브 자바의 유명한 원칙입니다. 파라미터가 값을 생산(제공)하면 extends, 소비(받아들이면)하면 super 를 쓰라는 뜻입니다.
Collections.copy 의 시그니처가 완벽한 예입니다.
public static <T> void copy(List<? super T> dest, List<? extends T> src)src 는 값을 읽어서 제공하는 쪽(producer) → ? extends T. List<Dog> 를 T = Animal 로 읽을 수 있음.dest 는 값을 받아서 저장하는 쪽(consumer) → ? super T. List<Object> 에 Animal 을 넣을 수 있음.List<Dog> dogs = List.of(new Dog("a"), new Dog("b"));
List<Animal> animals = new ArrayList<>(List.of(new Cat(), new Cat()));
Collections.copy(animals, dogs); // dest: List<? super Dog>, src: List<? extends Dog>PECS 를 지키지 않고 copy(List<T> dest, List<T> src) 로 선언했다면, 위 호출은 T 가 Dog 이면서 Animal 이어야 하므로 컴파일 에러가 납니다.
또 다른 예: Comparator<? super T>. sort(List<T> list, Comparator<? super T> c) 는 List<Dog> 를 Comparator<Animal> 로 정렬할 수 있게 해줍니다. Comparator 는 T 를 소비(비교 대상으로 받아들임)하니까 super 입니다.
PECS 적용 판단표:
| 파라미터 용도 | 와일드카드 | 예 |
|---|---|---|
| 읽기만 | ? extends T |
sum(List<? extends Number>) |
| 쓰기만 | ? super T |
fill(List<? super Integer>, int) |
| 읽고 쓰기 | T (와일드카드 없음) |
swap(List<T>, int, int) |
| 타입 무관 (size, clear) | ? |
printSize(List<?>) |
<T extends Comparable<T>> 처럼 타입 파라미터가 자기 자신을 참조하는 바운드입니다. "T 는 T 끼리 비교할 수 있어야 한다"는 뜻입니다.
더 유연한 형태는 <T extends Comparable<? super T>> 입니다. Money extends Amount 이고 Amount implements Comparable<Amount> 일 때, Money 는 Comparable<Money> 는 아니지만 Comparable<? super Money> 는 만족합니다. JDK 의 Collections.max 가 이 형태입니다.
재귀 바운드의 또 다른 실무 용도는 자기 타입을 반환하는 빌더(self-type builder) 입니다.
abstract class Builder<T extends Builder<T>> {
protected String name;
@SuppressWarnings("unchecked")
public T name(String n) { this.name = n; return (T) this; } // 하위 타입을 반환
}
class OrderBuilder extends Builder<OrderBuilder> {
private int qty;
public OrderBuilder qty(int q) { this.qty = q; return this; }
}
// new OrderBuilder().name("x").qty(3) ← name() 이 OrderBuilder 를 반환하니 qty() 체인 가능| 관계 | 허용 여부 |
|---|---|
ArrayList<String> → List<String> |
O (구현 클래스 → 인터페이스, 타입 인자 동일) |
List<String> → List<Object> |
X (불공변) |
List<String> → List<?> |
O |
List<String> → List<? extends Object> |
O |
List<Integer> → List<? extends Number> |
O |
List<Number> → List<? super Integer> |
O |
List<String> → Collection<String> |
O |