지난 포스팅에서 제네릭의 불공변성(Invariance) 때문에 발생하는 불편함에 대해 다뤘습니다.
List<Object>는 List<Integer>의 부모가 아니기 때문에 유연하게 사용할 수 없었죠.
이를 해결하기 위해 등장한 것이 바로 **와일드카드(?)**입니다. 이번 글에서는 와일드카드의 핵심 개념과 3가지 종류, 그리고 이를 뒷받침하는 공변과 반공변의 원리까지 정리합니다.
1. 와일드카드란? (Unknown Type Argument)
와일드카드(?)는 말 그대로 **"알 수 없는 타입(Unknown Type)"**을 의미합니다. 정해지지 않은 타입을 나타내기 때문에 제네릭을 더 유연하게 사용할 수 있게 해줍니다.
핵심 제약 사항
- Type Argument 위치에서만 사용 가능
- 클래스나 메서드를 정의할 때(Box<?>)는 쓸 수 없습니다. (T를 써야 함)
- 변수 선언이나 파라미터 등 타입을 사용하는 자리(List<?> list)에서만 쓸 수 있습니다.
- 단독 사용 불가
- ?만 따로 타입으로 쓸 수 없습니다. (예: ? value; ❌)
- 제네릭 타입의 객체를 '참조'할 목적으로만 사용
- 생성자에서는 사용 불가!
- new ArrayList<?>() ❌ ➡ 객체를 생성할 때는 구체적인 타입이 필요합니다.
- 실제 타입을 모른다
- 와일드카드는 "여기에 뭐가 오긴 오는데, 정확히 뭔지는 몰라"라는 상태입니다.
2. 와일드카드의 3가지 종류
자바는 와일드카드에 경계(Bound)를 두어 타입 안정성을 보장합니다.
① Unbounded Wildcard (비한정 와일드카드) : <?>
제약 없이 모든 타입의 제네릭 객체를 참조할 수 있습니다. List<Object>와 비슷해 보이지만 다릅니다.
- 특징: 실제 내부가 String인지 Integer인지 전혀 모르는 상태.
- 제약 사항:
- 데이터 추가(Write) 불가: 타입을 모르기 때문에 null 외에는 아무것도 넣을 수 없습니다.
- 데이터 읽기(Read): 무엇이 들어있든 최상위 타입인 **Object**로만 읽어올 수 있습니다.
- 삭제(Remove)는 가능: remove(Object o) 메서드는 타입을 타지 않기 때문입니다.
public void printList(List<?> list) {
// list.add(10); // ❌ 컴파일 에러 (타입을 모르니 추가 불가)
Object item = list.get(0); // ⭕ Object로 꺼내는 건 가능
System.out.println(item);
}
② Upper Bounded Wildcard (상한 경계) : <? extends T>
특정 타입 T와 그 **자식 타입(Subclass)**들만 참조할 수 있도록 제한합니다.
- 의미: "T 혹은 T의 자식들은 다 들어와." (상한선이 T)
- 특징:
- 데이터 읽기(Read): 안전합니다. 무엇이 들어오든 최소한 T 타입임이 보장되므로, T 타입으로 꺼내 쓸 수 있습니다.
- 데이터 추가(Write) 불가: 실제 구현체가 List<Son>인지 List<Daughter>인지 확신할 수 없으므로 추가는 불가능합니다.
// Number와 그 자식들(Integer, Double 등)만 가능
public void sum(List<? extends Number> list) {
// Number num = list.get(0); // ⭕ 안전하게 Number로 꺼낼 수 있음
// list.add(10); // ❌ Integer인지 Double인지 확신할 수 없어 추가 불가
}
③ Lower Bounded Wildcard (하한 경계) : <? super T>
특정 타입 T와 그 **부모 타입(Superclass)**들만 참조할 수 있습니다.
- 의미: "T 혹은 T의 조상들은 다 들어와." (하한선이 T)
- 특징:
- 데이터 읽기(Read): 꺼냈을 때 T의 부모가 누구일지(Object일지 Number일지) 알 수 없으므로, **Object**로만 받아야 합니다.
- 데이터 추가(Write) 가능: 이것이 핵심입니다! 최소한 이 리스트는 T 타입을 담을 수 있는 그릇임이 보장됩니다. 따라서 T와 T의 자식 타입(Subclass)은 안전하게 추가할 수 있습니다.
// Integer와 그 부모들(Number, Object)만 가능
public void addNumbers(List<? super Integer> list) {
list.add(10); // ⭕ Integer는 안전하게 추가 가능
list.add(20); // ⭕ Integer는 어떤 부모 리스트에도 들어갈 수 있음
// list.add(3.14); // ❌ Double은 Integer의 가족이 아님
// Integer i = list.get(0); // ❌ 꺼낼 땐 Object로만 나옴 (타입 보장 X)
}
3. 공변과 반공변 (Covariance & Contravariance)
와일드카드의 extends와 super는 단순히 범위만 제한하는 것이 아니라, **타입의 상속 방향(계층 구조)**을 결정짓습니다. 이를 전문 용어로 공변과 반공변이라 합니다.
1. 기본 개념: 방향성(Direction)
먼저, 우리가 알고 있는 기본 상속 관계를 정의해 봅시다.
- 자식: Integer
- 부모: Number
- 방향: Integer ➡ Number (Integer는 Number의 하위 타입)
이 화살표 방향이 제네릭 와일드카드를 만났을 때 유지되느냐, 뒤집히느냐가 핵심입니다.

2. 공변 (Covariance) : <? extends T>
"방향이 같이(Co) 변한다."
상속 방향이 원래 타입의 방향과 일치합니다.
- 원래 관계: Integer ➡ Number
- 제네릭 관계: List<? extends Integer> ➡ List<? extends Number>
위 그림의 왼쪽을 보면, 화살표가 아래에서 위로 올라갑니다.
- 가장 아래 List<Integer>가 있고,
- 그 위에 List<? extends Integer>가 있고,
- 그 위에 List<? extends Number>가 있습니다.
즉, **"Integer가 Number의 자식이듯이, List<? extends Integer>도 List<? extends Number>의 자식이다"**라는 관계가 성립합니다. 이를 공변이라고 합니다.
- 특징:
- 우리가 아는 상식적인 상속 관계와 같습니다.
- Read 전용: 상위 타입으로 안전하게 꺼낼 수 있습니다.
3. 반공변 (Contravariance) : <? super T>
"방향이 반대(Contra)로 변한다."
상속 방향이 원래 타입의 방향과 정반대로 뒤집힙니다. (여기가 제일 헷갈리면서도 재미있는 부분입니다!)
- 원래 관계: Integer ➡ Number (Integer가 자식)
- 제네릭 관계: List<? super Number> ➡ List<? super Integer>
- 어? Number 쪽이 자식이 되고, Integer 쪽이 부모가 됐네?
위 그림의 오른쪽 화살표를 자세히 보세요.
- List<? super Number>에서 출발한 화살표가
- List<? super Integer>로 향하고 있습니다.
즉, **"List<? super Number>가 List<? super Integer>의 하위 타입(Subtype)이 된다"**는 뜻입니다.
왜 뒤집힐까? (논리적 증명)
논리적으로 생각해보면 당연합니다. **"범위가 더 좁은 것이 하위 타입"**이기 때문입니다.
- List<? super Integer>의 범위: Integer, Number, Object (더 넓음 ➡ 상위 타입)
- List<? super Number>의 범위: Number, Object (더 좁음 ➡ 하위 타입)
Number가 Integer보다 상위 타입이지만, super 와일드카드를 씌우면 조건이 더 까다로운(범위가 좁은) super Number 쪽이 하위 타입이 되는 현상, 이것이 바로 반공변입니다.
- 특징:
- 타입 계층 구조가 역전됩니다.
- Write 전용: 하위 타입 객체를 안전하게 add 할 수 있습니다.
- Read 시 주의: 상위 타입이 어디까지 올라갈지 모르므로(Object일 수도 있음), 꺼낼 때는 무조건 **Object**로만 받아야 합니다.
4. 요약 정리
| 구분 | 와일드카드 문법 | 방향성 (vs 원래 타입) | 서브타입 관계 예시 | 특징 |
| 공변 (Covariance) | <? extends T> | 일치 (유지) | List<? extends Integer> ⬇ (자식) List<? extends Number> |
- Producer (생산) - get() 안전 - add() 불가 |
| 반공변 (Contravariance) | <? super T> | 반대 (역전) | List<? super Number> ⬇ (자식) List<? super Integer> |
- Consumer (소비) - add() 안전 - get()은 Object |
그림으로 한 번에 이해하기
첨부한 이미지의 화살표 흐름을 다시 한 번 봅시다.
- 가운데 최상위: List<?> (모든 것의 부모)
- 왼쪽 (extends): Integer에서 Number로 올라가는 순서 그대로 리스트도 올라갑니다. (공변)
- 오른쪽 (super): Number 리스트가 Integer 리스트보다 **아래(자식)**에 위치합니다. (반공변)
- 화살표가 List<Number> ➡ List<? super Number> ➡ List<? super Integer> 순서로 올라가는 것을 꼭 확인하세요!
[결론]
- extends는 "우리가 아는 상속 순서 그대로" (공변)
- super는 "상속 순서가 반대로 뒤집힘" (반공변)
- 이 원리 덕분에 우리는 PECS (Producer-Extends, Consumer-Super) 원칙을 유연하게 적용할 수 있습니다.
5. 최종 요약 정리
| 종류 | 문법 | 의미 | 데이터 추가(Write) | 데이터 읽기(Read) | 변성(Variance) |
| Unbounded | <?> | 타입 모름 | ❌ 불가 | Object | - |
| Upper Bound | <? extends T> | T와 그 자식들 | ❌ 불가 | T | 공변 (방향 유지) |
| Lower Bound | <? super T> | T와 그 부모들 | ⭕ T(자식) 가능 | Object | 반공변 (방향 역전) |
💡 PECS 원칙 (Producer-Extends, Consumer-Super)
- 데이터를 제공(Produce)/조회만 한다면? ➡ extends (공변)
- 데이터를 소비(Consume)/추가해야 한다면? ➡ super (반공변)
'자바 > 자바' 카테고리의 다른 글
| [Java] 제네릭은 왜 불변(Invariant)일까? (feat. 공변과 서브타입) (1) | 2026.01.21 |
|---|---|
| (java) 패턴 매칭 instanceof 캐스팅 제거 (Flow Scoping 완벽 이해) (0) | 2026.01.20 |
| (자바) Apache Kafka 핵심 개념 (RabbitMQ <->Redis (Pub/Sub) <->Apache Kafka 차이 ) (0) | 2025.12.15 |
| [Java] I/O 스트림과 안전한 자원 관리: Try-with-Resources 완벽 분석 (Try-Catch-Finally 비교) (0) | 2025.12.04 |
| (JAVA) 함수형 인터페이스 완벽 정리: 람다부터 스트림까지의 연결고리 (0) | 2025.12.03 |