Java 제네릭 와일드카드의 핵심인 extends/super 차이와 PECS 원칙을 실무 예제로 한 번에 정리합니다.
제네릭을 쓰다 보면 List<Number> 자리에 List<Integer>를 넣고 싶은데 컴파일 에러가 나서 당황하실 때가 있어요. 반대로 “읽기만 할 건데 왜 타입이 이렇게 까다롭지?” 같은 생각도 들고요. 이럴 때 문제를 풀어주는 도구가 바로 와일드카드(?)입니다.
핵심 개념 — Java 와일드카드와 PECS 원칙이 중요한 이유

와일드카드는 “정확한 타입을 지금은 모르지만, 어떤 범위인지는 안다”를 타입 시스템에 표현하는 문법입니다. 핵심은 두 가지예요.
<? extends T>: T의 하위 타입 중 하나 (Upper bounded wildcard)<? super T>: T의 상위 타입 중 하나 (Lower bounded wildcard)
여기서 중요한 포인트는 “상속”이 아니라 “제네릭의 불공변성(invariance)”입니다.Integer extends Number는 맞지만, List<Integer> extends List<Number>는 아니에요. 그래서 컬렉션을 API로 받을 때는, 딱 떨어지는 List<T>보다 “범위”를 받는 와일드카드가 더 유연하고 안전합니다.
<? extends T>는 “꺼내 읽기”에 강합니다
List<? extends Number>는 List<Integer>, List<Long> 등을 모두 받을 수 있어요. 대신 이 리스트에 뭔가를 add하려고 하면 대부분 막힙니다.
왜냐하면 컴파일러 입장에서는 이 리스트가 List<Integer>일 수도, List<Double>일 수도 있는데, 거기에 Number를 넣는 순간 타입 안전성이 깨질 수 있거든요.
즉, extends는 Producer(생산자): 값을 “내보내는” 쪽에 적합합니다.
<? super T>는 “집어넣기”에 강합니다
List<? super Integer>는 List<Integer>, List<Number>, List<Object>를 받을 수 있어요. 여기에 Integer는 안전하게 넣을 수 있습니다.
대신 꺼낼 때는 타입을 Object로만 확신할 수 있어요(정확히는 컴파일러가 보장 가능한 최소 타입이 Object).
즉, super는 Consumer(소비자): 값을 “받아먹는” 쪽에 적합합니다.
PECS 원칙: Producer Extends, Consumer Super
실무에서 와일드카드를 빠르게 결정하는 공식이 PECS입니다.
- **Producer(읽기/제공)**면
extends - **Consumer(쓰기/수집)**면
super
아래 표처럼 정리해 두면, API 설계할 때 흔들릴 일이 확 줄어듭니다.
| 목적 | 추천 시그니처 | 할 수 있는 일 | 제한 |
|---|---|---|---|
| 읽기 중심(생산) | List<? extends T> |
T로 안전하게 get() |
add() 거의 불가(null만 가능) |
| 쓰기 중심(소비) | List<? super T> |
T를 안전하게 add() |
get()은 Object 수준 |
| 읽기+쓰기 둘 다 | List<T> |
get/add 모두 타입 안정 |
호출부 타입 유연성 낮음 |
흐름으로 이해하기: “읽는 쪽” vs “쓰는 쪽”
flowchart LR
A["Source: Producer"] --> B["List<? extends T>"]
C["Destination: Consumer"] --> D["List<? super T>"]
B --> E["Safe: read as T"]
D --> F["Safe: write T"]
한 줄 설명: extends는 읽기 안전, super는 쓰기 안전이라는 방향성을 보여줍니다.
코드 예제 — extends/super 실무 적용을 한 번에 실행해보기
아래 코드는 “읽기 전용 집계(extends)”와 “복사/수집(super)”를 한 파일에서 보여줍니다. 그대로 복붙해서 실행하실 수 있어요.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class WildcardDemo {
// Producer Extends: numbers는 "Number를 생산(읽기)"하는 역할
static double sum(List<? extends Number> numbers) {
double total = 0.0;
for (Number n : numbers) { // 안전하게 Number로 읽을 수 있음
total += n.doubleValue();
}
// numbers.add(1); // 컴파일 에러: 어떤 하위 타입 리스트인지 몰라서 안전하지 않음
return total;
}
// Consumer Super: dest는 "T를 소비(쓰기)"하는 역할
static <T> void copy(List<? extends T> src, List<? super T> dest) {
for (T t : src) {
dest.add(t); // 안전하게 T를 넣을 수 있음
}
}
// 실무에서 자주 나오는 형태: "입력은 유연하게, 출력은 구체적으로"
static List<Number> normalizeToNumberList(List<? extends Number> input) {
List<Number> out = new ArrayList<>();
// out은 우리가 소유한 구체 타입(List<Number>)이라 add가 자유롭습니다.
for (Number n : input) {
out.add(n);
}
return out;
}
public static void main(String[] args) {
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(0.5, 1.5);
System.out.println("sum(ints) = " + sum(ints));
System.out.println("sum(doubles) = " + sum(doubles));
List<Number> numbers = new ArrayList<>();
copy(ints, numbers); // src: List<Integer> -> dest: List<Number>
copy(doubles, numbers); // src: List<Double> -> dest: List<Number>
System.out.println("numbers after copy = " + numbers);
List<Object> objects = new ArrayList<>();
copy(ints, objects); // dest를 더 상위 타입으로도 받을 수 있음 (super의 장점)
System.out.println("objects after copy = " + objects);
List<Number> normalized = normalizeToNumberList(ints);
System.out.println("normalized = " + normalized);
}
}
실무 팁
💡 실무에서는: “API 입력 파라미터는 와일드카드, 내부 구현은 구체 타입”으로 잡아보세요
- 외부에서 받는 값(입력)은
List<? extends T>처럼 유연하게 받으면 호출부의 제약이 줄어듭니다. - 내부에서 새로 만드는 컬렉션(내가 소유하는 컬렉션)은
List<T>처럼 구체 타입으로 두는 게 유지보수에 좋아요. - 특히 “받아서 가공 후 새 리스트로 반환” 패턴은
<? extends T>+ 내부List<T>조합이 가장 깔끔합니다.
💡 실무에서는: <? super T>는 “배치 적재/수집” 코드에서 빛납니다
- 예: 여러 타입 소스에서 읽어
List<Number>나Collection<Object>에 누적 적재하는 로직 copy(src, dest)처럼 src는 extends, dest는 super로 두면 제네릭 메서드의 재사용성이 확 올라갑니다.- 단,
super컬렉션에서 꺼내 쓸 때는Object로만 보장되므로, “넣기 전용”이라는 의도를 코드로 드러내는 용도로 쓰는 게 안전합니다.
핵심 요약
<? extends T>는 읽기(Producer)에 강하고,<? super T>는 쓰기(Consumer)에 강합니다.- PECS: Producer Extends, Consumer Super만 기억해도 와일드카드 선택이 빨라집니다.
- 실무 API는 입력을 유연하게(와일드카드), 내부는 구체적으로(명시 타입) 설계해 보세요.
다음 글: [#18 제네릭 실전 패턴]
'JAVA' 카테고리의 다른 글
| Java 람다식 — 콜백 지옥 탈출 (함수형 인터페이스부터 메서드 레퍼런스까지) (0) | 2026.02.21 |
|---|---|
| Java 제네릭 실전 패턴: 제네릭 메서드·인터페이스 설계와 자주 만나는 에러 해결법 (0) | 2026.02.21 |
| Java 제네릭 기초 — 타입 안전성의 시작 (0) | 2026.02.20 |
| Java Collections 유틸 활용 팁 — 정렬부터 불변 컬렉션까지 한 번에 정리 (0) | 2026.02.19 |
| Java HashMap 동작 원리와 함정 — 해시 충돌부터 equals/hashCode 계약까지 (0) | 2026.02.19 |