Java 경계 와일드카드와 PECS — extends와 super는 언제 쓰나
removeIf 구현을 열어보다 만난 Predicate<? super E>에서 출발해, Java 제네릭의 상한 경계(? extends T)와 하한 경계(? super T) 와일드카드를 정리합니다. 제네릭의 불공변성에서 경계 와일드카드가 왜 필요한지, 컴파일러가 읽기·쓰기를 어떤 근거로 허용/차단하는지, 그리고 PECS(Producer-Extends, Consumer-Super) 원칙을 Collections.copy·Stream·removeIf 사례로 다룹니다.


이 글을 쓰게 된 계기 — removeIf를 열어보다
평소 List에서 조건에 맞는 원소를 안전하게 지울 때 removeIf를 즐겨 썼습니다.
for 루프를 돌며 remove를 호출하면 ConcurrentModificationException이 나기 십상인데, removeIf는 그런 걱정 없이 한 줄로 끝나기 때문입니다.
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4));
numbers.removeIf(n -> n % 2 == 0); // 짝수 제거 → [1, 3]그러다 문득 "이건 내부적으로 어떻게 구현했지?" 싶어 Collection.removeIf의 기본 구현을 열어봤습니다.

Collection.removeIf의 기본 구현. 시그니처의 Predicate<? super E>에 눈이 갔습니다.filter가 null인지 확인한 뒤, iterator()로 순회하며 조건에 맞는 원소를 Iterator.remove()로 지우는 코드입니다.
눈길이 간 건 구현부가 아니라 시그니처였습니다.
default boolean removeIf(Predicate<? super E> filter)늘 람다만 넘겼을 뿐, 정작 이 <? super E>가 왜 붙어 있는지 생각해본 적이 없더군요.
그게 궁금해서 찾아본 내용을 정리한 글입니다. Predicate<? super E>가 왜 저렇게 생겼는지는 마지막 실전 사례에서 다시 이야기합니다.
무엇이 헷갈리는가
List<? extends Number>와 List<? super Integer>. 처음엔 둘 다 그냥 "Number 언저리의 리스트"로 보여서 뭐가 다른지 감이 안 옵니다.
심지어 한쪽은 add가 안 되고, 다른 쪽은 get을 하면 Object만 나옵니다. 규칙만 외우면 며칠 지나 또 헷갈립니다.
그래서 규칙을 외우는 대신, 컴파일러가 왜 이렇게 동작하는지를 따라가 보려 합니다.
제네릭의 불공변성 때문에 경계 와일드카드가 왜 필요해졌는지 보고, extends와 super가 읽기·쓰기를 어떻게 나누는지, 마지막으로 이걸 한 줄로 줄인 PECS까지 순서대로 다룹니다.
시작점: 제네릭은 왜 불편함을 남겼나
제네릭은 Java 5에서 "캐스팅 지옥"과 런타임 ClassCastException을 없애려고 도입됐습니다.
// 제네릭 이전 — 컴파일러는 타입을 모른다
List list = new ArrayList();
list.add("hello");
Integer n = (Integer) list.get(0); // 컴파일 OK, 런타임 ClassCastException 💥
// 제네릭 이후 — 컴파일 시점에 걸린다
List<String> list = new ArrayList<>();
list.add("hello");
Integer n = list.get(0); // ❌ 컴파일 에러 (실행 전에 발견)타입을 타입 파라미터로 고정한 덕분에 안전해졌습니다. 그런데 이 고정이 다른 불편을 불러옵니다.
void printAll(List<Number> numbers) {
for (Number n : numbers) System.out.println(n);
}
List<Integer> ints = List.of(1, 2, 3);
printAll(ints); // ❌ 컴파일 에러Integer는 Number의 하위 타입이 맞는데도, List<Integer>를 List<Number> 자리에 넘길 수 없습니다.
이 어긋남을 메우려고 나온 게 경계 와일드카드입니다.
불공변성 — List<Integer>는 List<Number>가 아니다
개별 값과 컬렉션은 상속 관계가 다르게 흘러갑니다.


이 성질을 불공변성이라고 부릅니다. 왜 굳이 막을까요? 반대로 허용된다고 치고 코드를 따라가 보면 이유가 보입니다.
List<Integer> ints = new ArrayList<>();
List<Number> nums = ints; // (가정) 이게 허용된다면 — nums와 ints는 '같은 리스트'다
nums.add(3.14); // Number 자리니까 Double을 넣을 수 있다
Integer i = ints.get(0); // 💥 같은 리스트라 그 Double이 그대로 나온다 — 타입 안전성 붕괴nums는 새 리스트가 아니라 ints와 같은 객체를 가리키는 다른 이름입니다(대입은 참조만 복사하니까요). 그래서 nums로 넣은 값이 ints에도 그대로 보입니다.
List<Integer>를 List<Number>로 받는 순간 Double을 Integer 리스트에 몰래 넣는 길이 열리는 셈입니다.
컴파일러는 이런 일이 벌어지지 못하게 두 타입의 상속 관계를 끊어 둡니다.
정리하면
값 하나는 Integer를 Number로 대입할 수 있습니다(공변).
하지만 List<Integer>는 List<Number>와 남남입니다(불공변).
안전을 위한 제약이지만, 앞의 printAll처럼 멀쩡한 코드까지 막아 버립니다.
와일드카드 ? — "어떤 타입인지 특정하지 않는다"
그렇다고 매번 타입을 딱 맞춰 쓰기엔 불편합니다. "정확한 타입은 몰라도 된다"고 말할 방법이 필요한데, 그게 와일드카드 ?입니다.
void printAll(List<?> list) { // '무언가의 List'
for (Object o : list) System.out.println(o);
}
printAll(List.of(1, 2, 3)); // ✅ List<Integer> OK
printAll(List.of("a", "b")); // ✅ List<String> OKList<?>는 어떤 타입의 리스트든 받습니다. 대신 타입을 특정할 수 없으니, 값을 넣는 건 안 됩니다.
void addOne(List<?> list) {
list.add(1); // ❌ 컴파일 에러 — 이게 무슨 타입의 리스트인지 모른다
}여기에 경계(bound)를 붙이면, "아무 타입"이 아니라 "이 범위 안의 타입"으로 좁힐 수 있습니다. 이게 상한/하한 경계 와일드카드입니다.
상한 경계 ? extends T — 읽기 전용(Producer)
앞으로의 예시는 아래 타입 계층을 기준으로 합니다. extends는 여기서 아래쪽(하위 타입)으로, 뒤이어 볼 super는 위쪽(상위 타입)으로 범위를 엽니다.


? extends Number는 Number이거나 그 하위 타입을 뜻합니다.
List<? extends Number> list = new ArrayList<Integer>(); // ✅
list = new ArrayList<Double>(); // ✅ 재대입도 OK
Number n = list.get(0); // ✅ 꺼내기 — 최소한 Number임은 보장된다
list.add(1); // ❌ 넣기 — 컴파일 에러- 읽기가 되는 이유:
list가List<Integer>든List<Double>이든 꺼낸 원소는Number의 하위 타입입니다. 그래서Number로 받는 건 항상 안전합니다. - 쓰기가 막히는 이유: 컴파일러는 이게
List<Integer>인지List<Double>인지 모릅니다.Integer를 넣었는데 실제로는List<Double>이었다면 타입이 깨지죠. 확신할 수 없으니 넣기를 막습니다.
그래서 상한 경계 와일드카드는 값을 꺼내 주기만 하는 자리, 곧 생산자(Producer)에 어울립니다.
하한 경계 ? super T — 쓰기 전용(Consumer)
? super Integer는 Integer이거나 그 상위 타입을 뜻합니다.
List<? super Integer> list = new ArrayList<Number>(); // ✅ 실제 객체는 Number 리스트
list = new ArrayList<Object>(); // ✅ 재대입도 OK
list.add(1); // ✅ 넣기 — Integer는 어떤 경우든 하위 타입이다
Integer i = list.get(0); // ❌ 꺼내기 — 실제로 Number를 담아도 막힌다
Object o = list.get(0); // ✅ Object로만 받을 수 있다- 쓰기가 되는 이유:
list가List<Integer>든List<Number>든List<Object>든,Integer는 이들 모두의 하위 타입입니다. 그래서Integer를 넣는 건 어느 쪽이든 안전합니다. - 읽기가 막히는 이유: 컴파일러는 실제 객체가
ArrayList<Number>라는 걸 일부러 잊고 선언 타입? super Integer만 봅니다. 원소가Integer인지Number인지Object인지 알 수 없으니, 이들의 공통 상한인Object로만 꺼내 줍니다.
"Number로 초기화했는데 왜 Object로만 나오죠?"
읽기가 막히는 건 대입한 객체가 아니라 변수의 선언 타입(? super Integer) 때문입니다.
위에서 list = new ArrayList<Object>()로 다시 대입할 수 있었듯, 이 변수는 언제든 다른 상위 타입 리스트를 가리킬 수 있습니다. 그래서 컴파일러는 초기값 Number를 믿지 않고 늘 Object로만 꺼내게 합니다.
재대입 줄을 지워도 결과는 같습니다 — 원인은 ? super 선언 그 자체입니다.
그래서 하한 경계 와일드카드는 값을 받아 담기만 하는 자리, 곧 소비자(Consumer)에 어울립니다.
두 경계의 허용 범위 비교


| 구분 | ? extends Number | ? super Integer |
|---|---|---|
| 허용 타입 | Number, Integer, Double, Long … | Integer, Number, Object |
| 방향 | 기준에서 아래(하위 타입) | 기준에서 위(상위 타입) |
| 읽기(get) | ✅ Number로 꺼냄 | ⚠️ Object로만 꺼냄 |
| 쓰기(add) | ❌ 불가 | ✅ Integer 넣기 |
| 역할 | Producer(생산) | Consumer(소비) |
범위가 겹쳐 보여도 서로 바꿔 쓸 수 없습니다
? extends Number와 ? super Integer는 허용 타입 목록이 일부 겹칩니다(둘 다 Number, Integer 포함).
하지만 허용하는 연산이 정반대입니다. 하나는 읽기 전용, 하나는 쓰기 전용이죠.
겹치는 건 '타입 집합'일 뿐, '할 수 있는 일'은 대칭입니다.
PECS — 하나의 문장으로 정리하기
지금까지의 내용은 Effective Java의 한 문구로 요약됩니다.
PECS: Producer-Extends, Consumer-Super 생산하면(꺼내면) extends, 소비하면(넣으면) super.
파라미터를 볼 때 이 컬렉션이 값을 주는 쪽인지 받는 쪽인지만 따지면 됩니다.


읽기와 쓰기를 다 해야 할 땐 와일드카드를 쓰지 않습니다. extends는 쓰기를 막고 super는 읽기를 제한하니, 둘 다 필요하면 어느 경계도 걸 수 없거든요. 이럴 땐 그냥 T를 씁니다.
실전 사례
1. Collections.copy — 한 시그니처에 둘 다
copy는 원본을 읽어 목적지에 씁니다. 읽는 쪽은 생산자, 쓰는 쪽은 소비자입니다.
public static <T> void copy(
List<? super T> dest, // 목적지: T를 넣는다 → Consumer → super
List<? extends T> src) // 원본: T를 꺼낸다 → Producer → extends이 시그니처 덕분에 아래가 모두 컴파일됩니다.
List<Object> dest = new ArrayList<>(List.of(0, 0, 0));
List<Integer> src = List.of(1, 2, 3);
Collections.copy(dest, src); // ✅ T=Integer, dest는 상위 타입(Object)이라도 OK만약 두 파라미터를 그냥 List<T>로 뒀다면, dest가 List<Object>이고 src가 List<Integer>인 이 자연스러운 호출이 막혔을 겁니다.
2. Stream API — 콜백의 유연성
Stream의 시그니처 곳곳에 PECS가 박혀 있습니다.
// forEach: 원소를 콜백에 넘겨 '소비'시킨다 → Consumer는 super
void forEach(Consumer<? super T> action)
// map: 입력을 받아(소비) 결과를 만든다(생산) → 입력 super, 출력 extends
<R> Stream<R> map(Function<? super T, ? extends R> mapper)super가 어떤 유연성을 주는지 보겠습니다.
Stream<Integer> stream = Stream.of(1, 2, 3);
Consumer<Number> printNumber = n -> System.out.println(n);
stream.forEach(printNumber); // ✅ Number를 받는 콜백에 Integer 스트림을 넘길 수 있다Number를 처리할 수 있는 콜백이면 Integer도 당연히 처리합니다.
forEach가 Consumer<? super T>로 열려 있으니, 이렇게 더 일반적인 콜백을 그대로 재사용할 수 있습니다.
Consumer<T>였다면 Consumer<Integer>만 받았겠죠.
한 번 더 뒤집어 보기
Consumer<? super T>가 되는 이유: 콜백은 스트림에서 나온 원소를 받아서 소비합니다.
소비하는 쪽이니 super. PECS의 C(Consumer-Super)가 그대로 적용된 자리입니다.
3. removeIf — 다시 출발점으로
이제 이 글을 시작하게 만든 그 시그니처를 봅니다.
default boolean removeIf(Predicate<? super E> filter)Predicate<T>는 값 하나를 받아 boolean을 돌려주는 함수형 인터페이스입니다. 메서드가 test(T) 하나뿐이죠.
그래서 글 첫머리의 removeIf(n -> n % 2 == 0)처럼 람다를 그대로 넘길 수 있고, 그 람다가 test의 구현이 됩니다.
이 test는 원소를 받아서 참/거짓을 판정합니다. 원소가 Predicate 쪽으로 들어가는 거죠.
원소를 소비하는 자리니 Consumer이고, 그래서 ? super E입니다. 앞서 본 Consumer<? super T>와 똑같은 이야기입니다.
super E로 열어 둔 덕분에 더 일반적인 Predicate를 재사용할 수 있습니다.
List<String> names = new ArrayList<>(Arrays.asList("a", null, "b", null));
Predicate<Object> isNull = Objects::isNull; // Object를 판별하는 범용 Predicate
names.removeIf(isNull); // ✅ String 컬렉션에 그대로 재사용 → [a, b]Object를 판별할 수 있는 Predicate라면 String 하나 판별하는 건 문제도 아니고요.
늘 아무 생각 없이 넘기던 람다였는데, 시그니처 하나에 이런 배려가 들어 있었습니다.
와일드카드가 없었다면
와일드카드의 값어치는 빼보면 확실해집니다. 앞 사례에서 와일드카드를 걷어내면, 그 불편이 고스란히 호출하는 쪽으로 넘어갑니다.
? extends Number를 빼면 — 읽는 쪽
리스트에서 값을 꺼내 더하기만 하는 메서드가 있다고 해보죠.
// 와일드카드 X — List<Number>로 못 박음
double sumAll(List<Number> list) {
double sum = 0;
for (Number n : list) sum += n.doubleValue();
return sum;
}읽기만 하는데도 List<Number>로 고정한 탓에, 흔하디흔한 List<Integer>조차 넘길 수 없습니다.
List<Integer> ints = List.of(1, 2, 3);
sumAll(ints); // ❌ 컴파일 에러 (List<Integer> ≠ List<Number>)
// 호출하려면 매번 List<Number>로 옮겨 담아야 한다
List<Number> copy = new ArrayList<>(ints);
sumAll(copy); // ✅ 대신 불필요한 복사 비용을 치른다파라미터를 List<? extends Number>로 열어 두면 이 복사가 필요 없어집니다.
? super E를 빼면 — 쓰는 쪽
removeIf가 Predicate<E>였다고 해봅시다.
// 와일드카드 X — 컬렉션 타입과 정확히 일치하는 Predicate만 받음
default boolean removeIf(Predicate<E> filter)이러면 범용 콜백을 재사용할 수 없습니다.
List<String> names = new ArrayList<>(Arrays.asList("a", null, "b"));
Predicate<Object> isNull = Objects::isNull;
names.removeIf(isNull); // ❌ Predicate<String>만 받으므로 거부
// 컬렉션 타입마다 똑같은 로직을 새로 만들어야 한다
Predicate<String> isNullStr = Objects::isNull;
names.removeIf(isNullStr); // ✅ 대신 String 전용으로 중복 생성파라미터를 Predicate<? super E>로 열어 두면 Predicate<Object> 하나를 모든 컬렉션에 재사용할 수 있습니다.
와일드카드를 빼면, 불공변성이 막아 둔 자연스러운 호출을 되살리는 비용을 결국 호출부가 치릅니다. 불필요한 복사거나, 타입마다 새로 찍어내는 중복 코드죠.
정리
| 구분 | ? extends T | ? super T | 와일드카드 없음 T |
|---|---|---|---|
| 의미 | T와 하위 타입 | T와 상위 타입 | T 고정 |
| 읽기(get) | ✅ T로 꺼냄 | ⚠️ Object로만 | ✅ |
| 쓰기(add) | ❌ | ✅ T 넣기 | ✅ |
| 역할 | Producer(생산) | Consumer(소비) | 생산·소비 둘 다 |
| 판단 | 값을 꺼내 쓰나? | 값을 넣나? | 둘 다 하나? |
경계 와일드카드는 그냥 외우는 문법이 아니라, 불공변성이 걸어 둔 빗장을 안전하게 푸는 도구입니다.
extends는 하위 타입이 뭔지는 몰라도 꺼내 읽는 건 안전하다고 보고, super는 상위 타입이 뭔지는 몰라도(꺼낼 땐 Object뿐이지만) 넣는 건 안전하다고 봅니다.
둘 중 뭘 쓸지는 결국 한 문장이면 됩니다. Producer면 Extends, Consumer면 Super.
- 제네릭(Generics)↑
- List<String>처럼 클래스나 메서드가 다룰 타입을 외부에서 지정받게 하는 기능이다. 캐스팅 없이 원소를 꺼낼 수 있고, 잘못된 타입을 넣으면 런타임이 아닌 컴파일 시점에 걸러진다. Java 5부터 도입되었다.
- 타입 파라미터(Type Parameter)↑
- class Box<T>의 T처럼, 실제 타입이 정해지기 전까지 자리를 대신하는 형식 매개변수다. 사용 시점에 Box<String>처럼 구체 타입으로 치환된다. 관례상 T(Type), E(Element), K/V(Key/Value)를 쓴다.
- 불공변성(Invariance)↑
- Java 제네릭은 기본적으로 불공변이다. 즉 A가 B의 하위 타입이라도 List<A>와 List<B> 사이에는 아무런 상속 관계가 없다. 이 제약이 타입 안전성을 지키지만 유연성을 떨어뜨리며, 이를 완화하기 위해 와일드카드가 등장했다.
- 와일드카드(Wildcard)↑
- List<?>처럼 타입 인자 자리에 ?를 쓰면 '무언가의 List이지만 그 무언가가 무엇인지는 특정하지 않는다'는 의미가 된다. 여기에 extends/super로 상한·하한 경계를 걸어 허용 범위를 좁힐 수 있다.
- 상한 경계 와일드카드(? extends T)↑
- ? extends Number는 Number, Integer, Double 등 Number 이하의 어떤 타입인지 특정할 수 없는 컬렉션을 뜻한다. 꺼낼 때는 최소한 Number임이 보장되므로 읽기가 가능하지만, 정확한 타입을 몰라 넣기는 막힌다. 값을 생산(Producer)하는 자리에 쓴다.
- 하한 경계 와일드카드(? super T)↑
- ? super Integer는 Integer, Number, Object 중 어떤 타입인지 특정할 수 없는 컬렉션을 뜻한다. Integer는 이들 모두의 하위 타입이므로 넣기가 안전하지만, 꺼낼 때는 타입을 특정할 수 없어 Object로만 받는다. 값을 소비(Consumer)하는 자리에 쓴다.
- PECS↑
- Effective Java에서 제시된 원칙으로, 파라미터가 값을 생산(꺼내 읽음)하면 ? extends T, 소비(넣음)하면 ? super T를 쓰라는 규칙이다. 둘 다 하는 파라미터에는 와일드카드를 쓰지 않고 T를 그대로 쓴다.
- 생산자(Producer)↑
- 데이터를 읽어서 내보내는 쪽이다. Collections.copy에서 원본(src)이나 forEach가 순회하는 스트림이 여기 해당한다. 생산자는 읽기만 하면 되므로 ? extends T로 열어 유연성을 얻는다.
- 소비자(Consumer)↑
- 데이터를 받아서 담는 쪽이다. Collections.copy에서 목적지(dest)나 Consumer<? super T> 콜백이 여기 해당한다. 소비자는 쓰기만 하면 되므로 ? super T로 열어 유연성을 얻는다.