자바 제네릭, 와일드카드(?)와 PECS 원칙
지난 글 요약과 오늘 다룰 것
지난 글에서는 T extends SuperClass로 타입 매개변수의 상한을 제한하는 법을 다뤘다.Calculator<T extends Number>처럼 클래스나 메서드를 선언하는 시점에 T의 범위를 정하는 방식이었다.
그런데 이런 경우는 어떨까.
void printAll(List<Object> list) {
for (Object o : list) {
System.out.println(o);
}
}
List<Integer> numbers = List.of(1, 2, 3);
printAll(numbers); // ❌ 컴파일 에러
Integer는 Object의 자식인데, List<Integer>는 List<Object>의 자식이 아니다.
이 글에서는 이 현상이 왜 발생하는지부터 시작해서, 이를 해결하는 와일드카드(?), 그리고 실무에서 와일드카드를 쓸 때 따라야 하는 PECS 원칙까지 정리한다.
제네릭은 왜 상속 관계를 따르지 않을까? (불공변성)
배열은 공변(Covariant)이다
자바 배열은 상속 관계를 그대로 따른다.
Object[] objects = new Integer[3]; // ⭕ 컴파일 성공
Integer[]는 Object[]의 하위 타입으로 취급된다. 이걸 공변(covariant) 이라고 한다.
문제는 이게 런타임에 사고를 친다는 점이다.
Object[] objects = new Integer[3];
objects[0] = "hello"; // 컴파일은 통과하지만...
// ArrayStoreException 발생!
컴파일러는 objects가 Object[]이니 문자열을 넣어도 막지 못한다. 하지만 실제 배열은 Integer[]이기 때문에 런타임에 ArrayStoreException이 터진다. 즉 배열의 공변성은 타입 안전성을 희생한 설계다.
제네릭은 불공변(Invariant)이다
제네릭은 이 실수를 반복하지 않기 위해 정반대로 설계됐다.
List<Object> objects = new ArrayList<Integer>(); // ❌ 컴파일 에러
Integer가 Object의 자식이어도, List<Integer>와 List<Object> 사이에는 아무 상속 관계가 없다. 이걸 불공변(invariant) 이라고 한다.
왜 이렇게 만들었을까? 만약 이게 허용됐다면 배열과 똑같은 문제가 생긴다.
List<Integer> integers = new ArrayList<>();
List<Object> objects = integers; // 만약 이게 가능했다면
objects.add("hello"); // 컴파일 통과
Integer i = integers.get(0); // 런타임에 ClassCastException
제네릭의 존재 이유가 "런타임 오류를 컴파일 타임 오류로 앞당기는 것"이었다는 걸 떠올려보면(154편 참고), 제네릭이 배열과 같은 공변성을 허용하는 순간 그 존재 이유 자체가 무너진다. 그래서 컴파일러는 아예 List<Integer>를 List<Object> 자리에 대입하는 것 자체를 막아버린다.
그런데 이 불공변성이 너무 빡빡하다
문제는 안전한데 너무 융통성이 없다는 것이다. 앞서 본 printAll(List<Object> list)는 사실 내부적으로 list.add(...)를 하지 않고 읽기만 한다. 그렇다면 List<Integer>를 넘겨도 안전할 텐데, 불공변성 때문에 컴파일러는 무조건 막아버린다.
즉 우리에게 필요한 건 "정확히 List<Object>" 가 아니라 "Object로 취급해도 되는 무언가의 리스트" 를 표현할 방법이다. 이게 와일드카드가 등장하는 지점이다.
와일드카드(?)의 세 가지 형태
와일드카드는 "구체적으로 어떤 타입인지는 모르지만, 그 타입에 대한 제약 조건만 표현"하는 도구다. 세 가지 형태가 있다.
| 형태 | 의미 | 용도 |
|---|---|---|
List<?> |
알 수 없는 타입 | 읽기 전용, 타입 무관 처리 |
List<? extends T> |
T 또는 T의 자식 타입 | 데이터를 꺼내 쓸 때 (Producer) |
List<? super T> |
T 또는 T의 부모 타입 | 데이터를 넣을 때 (Consumer) |
1. 비한정 와일드카드 List<?>
154편에서 잠깐 언급했던 그 형태다. List는 원래 List<E>라는 제네릭 타입이고, List<?>는 "그 E 자리에 뭐가 들어갈지는 모른다"는 뜻이다. 그래서 List<?> 타입의 참조 변수에는 ArrayList<Integer>든 ArrayList<String>이든, List<E>를 구현하는 어떤 객체든 대입할 수 있다.
void printSize(List<?> list) {
System.out.println(list.size()); // ⭕ 타입과 무관한 동작은 가능
}
다만 타입 정보가 전혀 없기 때문에 꺼낼 때는 Object로만 받을 수 있고, null을 제외하고는 아무것도 넣을 수 없다.
List<?> list = new ArrayList<String>();
Object o = list.get(0); // ⭕ Object로만 가능
list.add("hi"); // ❌ 컴파일 에러 (null 제외)
2. 상한 와일드카드 List<? extends T>
"T 또는 T의 자식 타입의 리스트"를 의미한다. 아까 봤던 printAll 문제가 이걸로 해결된다.
void printAll(List<? extends Object> list) {
for (Object o : list) {
System.out.println(o);
}
}
printAll(List.of(1, 2, 3)); // ⭕
printAll(List.of("a", "b")); // ⭕
핵심은 꺼내는(get) 건 안전하지만, 넣는(add) 건 위험하다는 것이다.
List<? extends Number> list = new ArrayList<Integer>();
Number n = list.get(0); // ⭕ 뭐가 들어있든 최소한 Number인 건 보장됨
list.add(10); // ❌ 컴파일 에러
왜 "실제로 가리키는 객체"를 기준으로 판단하면 안 될까?
이쯤에서 이런 의문이 들 수 있다.
List는 원래List<E>라는 제네릭 형태이고,List<? extends Number>는 "E 자리에 Number거나 그 자식인 무언가가 들어간다"는 뜻이다. 그렇다면list에는List<E>를 구현하는 어떤 객체(ArrayList등)든 대입할 수 있는데, 위 코드에서는 이미list = new ArrayList<Integer>()로 실제 타입이 확정돼 있다. 그럼list.add(10)도 결국Integer가 들어가는 거니까 문제없지 않나?
이 추론 자체는 틀리지 않았다. 실제로 이 시점의 list는 ArrayList<Integer>를 가리키고 있으니, add(10)은 런타임 관점에서 전혀 위험하지 않다. 문제는 컴파일러가 "지금 실제로 어떤 객체를 가리키고 있는지"를 근거로 판단하지 않는다는 것이다. 컴파일러는 오직 변수에 선언된 정적 타입(static type) 만 보고 허용 여부를 결정한다. 왜 그래야만 하는지 아래 예시로 확인해보자.
List<? extends Number> list;
if (someCondition) {
list = new ArrayList<Integer>();
} else {
list = new ArrayList<Double>();
}
list.add(10); // ❌ 컴파일 에러
someCondition이 참인지 거짓인지는 프로그램을 실행해봐야 알 수 있다. 컴파일러는 이 조건문을 실행하지 않으므로, 컴파일 시점에 list가 최종적으로 ArrayList<Integer>를 가리킬지 ArrayList<Double>을 가리킬지 확정할 수 없다.
만약 컴파일러가 "코드에 마지막으로 보이는 대입문"만 보고 add(10)을 허용해줬다면, 런타임에 someCondition이 false가 되어 실제로는 ArrayList<Double>을 가리키는 상황에서 Integer가 섞여 들어가는 사고가 그대로 재현된다.
즉 List<? extends Number>라는 선언은 "Number의 하위 타입 중 컴파일러도 특정할 수 없는 어떤 타입 하나(T) 가 있고, list는 그 List<T>다"라는 뜻이다. 이 T는 코드 실행 중 실제 객체를 보고 매번 다시 판단되는 값이 아니라, 이 변수가 가질 수 있는 모든 실행 경로에 대해 항상 동일하게 성립해야 하는 하나의 미지수로 취급된다. add(10)이 안전하려면 "T가 Integer다"가 모든 경로에서 보장돼야 하는데, else 분기가 있는 한 그 보장은 성립하지 않는다.
결국 이건 앞서 본 불공변성과 같은 뿌리다. 제네릭은 "지금 개발자 눈에 보이는 실제 타입"이 아니라 "컴파일러가 모든 경로에 대해 증명 가능한 타입"만 신뢰한다. (참고로 이런 상황에서 정말 리스트에 값을 다시 써야 한다면, 제네릭 메서드로 타입을 포착하는 helper method 패턴을 쓰기도 한다. 이건 나중에 따로 다룰 만한 주제다.)
3. 하한 와일드카드 List<? super T>
"T 또는 T의 부모 타입의 리스트"를 의미한다. 상한과 반대로 넣는(add) 건 안전하지만, 꺼내는(get) 건 Object로만 가능하다.
void addNumbers(List<? super Integer> list) {
list.add(1); // ⭕ Integer는 어떤 상위 타입 리스트에 넣어도 안전
list.add(2);
}
addNumbers(new ArrayList<Integer>()); // ⭕
addNumbers(new ArrayList<Number>()); // ⭕
addNumbers(new ArrayList<Object>()); // ⭕
List<? super Integer> list = new ArrayList<Number>();
list.add(10); // ⭕ Integer는 확실히 넣을 수 있음
Object o = list.get(0); // 실제 타입이 Number인지 Object인지 몰라서 Object로만 받을 수 있음
같은 원리다. 컴파일러는 list가 실제로 ArrayList<Number>인지 ArrayList<Object>인지 모든 실행 경로에서 확정할 수 없기 때문에, get()의 결과를 가장 보수적인 Object로만 내어준다.
와일드카드를 쓸 수 있는 자리, 없는 자리
와일드카드는 아무 데나 쓸 수 있는 게 아니다. 원칙은 하나다. 와일드카드는 오직 "이미 존재하는 제네릭 타입의 타입 인자(type argument)" 자리에서만 쓸 수 있다. 이 원칙만 기억하면 아래 규칙은 자연스럽게 따라온다.
쓸 수 있는 자리
- 변수/필드 선언의 타입 인자:
List<?> list;,Map<String, ? extends Number> map; - 메서드 매개변수의 타입 인자:
void printAll(List<? extends Number> list) - 메서드 반환 타입의 타입 인자:
List<?> getList()(문법적으로는 가능하지만, 앞서 정리한 것처럼 실무에서는 지양하는 편) - 타입 매개변수 경계 내부:
<T extends Comparable<?>>처럼 bound 안의 타입 인자로도 사용 가능
공통점은 전부 이미 만들어진 제네릭 타입의 <> 안에서, 타입 인자로만 쓰였다는 것이다.
쓸 수 없는 자리
객체 생성(
new) 시 타입 인자List<?> list = new ArrayList<?>(); // ❌ 컴파일 에러 List<?> list = new ArrayList<>(); // ⭕ 다이아몬드 연산자나 구체 타입은 가능new는 힙에 실제 객체를 만드는 행위다. 객체를 만들려면 "무엇으로 만들지"가 확정돼야 하는데, 와일드카드는 애초에 "모르는 타입"을 표현하는 기호라서 인스턴스화 대상이 될 수 없다.제네릭 클래스/인터페이스/메서드의 타입 매개변수 선언 자체
class Box<?> { } // ❌ 타입 매개변수 선언에는 못 씀 <?> void method(...) { } // ❌ 이것도 마찬가지타입 매개변수는 클래스나 메서드 내부에서 이름으로 참조되며 쓰인다(
T value;,T get()처럼). 와일드카드는 이름이 없는 자리표시자라서 애초에 선언 대상이 될 수 없다. 반드시class Box<T>처럼 이름 있는 타입 매개변수를 선언해야 한다.상속/구현(
extends,implements)의 부모 타입class MyList extends ArrayList<?> { } // ❌ class MyList<T> extends ArrayList<T> { } // ⭕부모 타입을 지정하는 자리는 "이 클래스가 정확히 어떤 타입 관계를 갖는가"를 고정해야 하는 자리라서, 모르는 타입을 뜻하는 와일드카드가 들어올 수 없다.
제네릭 메서드 호출 시 명시적 타입 인자
List<Number> list = Collections.<?>emptyList(); // ❌ List<Number> list = Collections.<Number>emptyList(); // ⭕ (대부분 생략 가능)참고:
instanceof는 예외적으로 비한정 와일드카드(?)만 허용한다.list instanceof ArrayList<?>는 가능하지만,list instanceof ArrayList<? extends Number>처럼 상한/하한이 붙은 형태는 런타임에 검증 자체가 불가능해서 금지된다. (타입 소거와 맞물린 얘기라 다음에 따로 다룰 만하다.)
정리하면, 와일드카드는 "이미 결정된 제네릭 타입을 사용하는 자리"에서만 쓸 수 있고, "새로운 타입 관계를 만들어내는 자리"(생성, 선언, 상속)에서는 쓸 수 없다.
PECS: Producer Extends, Consumer Super
지금까지 본 규칙을 한 문장으로 정리한 게 PECS다.
Producer Extends, Consumer Super
데이터를 꺼내기만 하는(생산자) 역할이면extends, 데이터를 넣기만 하는(소비자) 역할이면super를 쓴다.
이 원칙이 왜 성립하는지는 사실 위에서 이미 증명한 셈이다.
? extends T: 실제 타입을 특정할 수 없으니 넣는 건 금지, 꺼내는 건 T로 보장 → Producer? super T: 실제 타입이 T의 조상 중 뭔지 모르니 꺼내는 건 Object로만 가능, 대신 T는 어떤 조상에 넣어도 안전하니 넣는 건 허용 → Consumer
JDK 표준 라이브러리에서 가장 좋은 예시가 Collections.copy()다.
public static <T> void copy(List<? super T> dest, List<? extends T> src)
src는 데이터를 꺼내기만 하니까? extends T(Producer)dest는 데이터를 넣기만 하니까? super T(Consumer)
Comparator<? super T>, Consumer<? super T>, Function<? super T, ? extends R> 같은 시그니처들도 전부 같은 원리다.
왜 이 원칙을 지켜야 할까?
PECS를 지키지 않으면 API의 유연성이 크게 떨어진다.
// PECS를 무시한 버전
void copyNumbers(List<Integer> src, List<Integer> dest) { ... }
// PECS를 적용한 버전
void copyNumbers(List<? extends Number> src, List<? super Integer> dest) { ... }
무시한 버전은 정확히 List<Integer>끼리만 동작한다. 반면 PECS를 적용하면 src에는 List<Integer>뿐 아니라 List<Double>도, dest에는 List<Number>나 List<Object>도 넘길 수 있다. 즉 호출자 입장에서 쓸 수 있는 타입의 폭이 넓어진다. 제네릭 API를 라이브러리 수준으로 설계할 때는 이 유연성의 차이가 크다.
실무에서는 이렇게 기억하면 된다
- 파라미터에서만 고민한다. PECS는 메서드 파라미터 설계 원칙이다. 반환 타입에 와일드카드를 쓰는 건 권장되지 않는다.
List<? extends Number> getNumbers()처럼 반환하면, 호출하는 쪽에서 결과를 어떤 구체 타입 변수로도 받기 애매해지고 그 리스트에 아무것도 추가할 수 없어 활용도가 떨어진다. - 읽기만 하면 extends, 쓰기만 하면 super, 둘 다 하면 와일드카드를 쓰지 않는다. 파라미터로 받은 컬렉션에 값을 넣기도 하고 꺼내기도 한다면, 애초에 와일드카드가 아니라
List<T>로 명확한 타입을 써야 한다. 와일드카드는 "이 메서드 안에서 이 컬렉션의 역할이 한쪽으로 고정돼 있을 때"만 의미가 있다. - 판단이 안 서면 "이 안에서 add를 부르나 get을 부르나"만 확인한다. add 호출이 있으면 super, get 호출(그리고 그 결과를 특정 타입으로 쓰는) 이 있으면 extends. 이 질문 하나로 대부분의 상황이 정리된다.
- 와일드카드를 너무 많이 겹치지 않는다.
List<? extends List<? extends T>>같은 건 코드 가독성을 크게 해친다. 이런 상황이면 와일드카드보다 제네릭 메서드로 타입 매개변수를 새로 선언하는 게 나을 때가 많다.
정리
- 제네릭은 배열과 달리 불공변이다.
List<Integer>는List<Object>의 하위 타입이 아니며, 이는 컴파일 타임 타입 안전성을 지키기 위한 의도적 설계다. - 불공변성의 경직성을 완화하기 위해 와일드카드(
?) 가 존재한다.List<?>: 완전히 알 수 없는 타입, 읽기 전용(Object로만)List<? extends T>: T 또는 그 자식, 꺼내기 전용List<? super T>: T 또는 그 부모, 넣기 전용
- 컴파일러는 실제로 가리키는 객체가 아니라 변수에 선언된 정적 타입만으로 안전성을 판단한다. 코드가 어떤 실행 경로를 타든 항상 성립해야 안전하다고 보기 때문에, 와일드카드가 가리키는 미지의 타입에 값을 넣는 연산은 원천적으로 막힌다.
- 와일드카드는 이미 존재하는 제네릭 타입의 타입 인자 자리에서만 쓸 수 있다. 객체 생성, 타입 매개변수 선언, 상속 관계처럼 새로운 타입 관계를 만드는 자리에는 쓸 수 없다.
- 이 규칙을 요약한 게 PECS(Producer Extends, Consumer Super) 이며, JDK 표준 API 곳곳에서 이 원칙을 따르고 있다.
- 실무에서는 메서드 파라미터가 "꺼내기 전용인지, 넣기 전용인지, 둘 다인지"만 판단하면 PECS를 자연스럽게 적용할 수 있다.
'Language > Java' 카테고리의 다른 글
| 자바 제네릭 정리: 타입 제한과 와일드카드는 서로 다른 문제를 푼다 (0) | 2026.09.20 |
|---|---|
| 자바 제네릭, <T>만으로는 부족하다 - 타입 제한(extends) (1) | 2026.09.14 |
| 자바 제네릭 클래스(Java Generic Class) 개념 (0) | 2026.09.14 |
| Java Stream API (0) | 2026.09.07 |
| [기술서적] 객체지향의 사실과 오해 (0) | 2025.10.20 |
