자바 제네릭 정리: 타입 제한과 와일드카드는 서로 다른 문제를 푼다
세 편을 관통하는 하나의 질문
지금까지 제네릭 시리즈를 이렇게 써왔다.
- 154편: 제네릭이 왜 필요한가, 기본 문법
- 155편:
T extends Number로 타입 매개변수의 범위를 제한하는 법 - 156편:
List<? extends T>/List<? super T>와일드카드와 PECS
세 편을 순서대로 읽다 보면 자연스럽게 이런 의문이 생길 수 있다.
"155편에서
T extends Number로 범위를 제한했으니까,Box<Integer>는 이제Box<Number>처럼 취급해도 되는 거 아닌가? 상한을 걸어놨는데 왜 아직도 불공변이지?"
결론부터 말하면 이건 서로 완전히 다른 축의 문제를 섞어서 생각한 것이다. 이번 글에서는 이 두 축을 분리해서 정리한다.
실험: 타입 제한이 불공변성을 풀어줄까?
직접 코드로 확인해보자.
class Box<T extends Number> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
T에 extends Number라는 상한을 걸어뒀다. 이 상태에서 Box<Integer>를 Box<Number> 자리에 대입해보면 어떻게 될까?
Box<Integer> intBox = new Box<>();
Box<Number> numBox = intBox; // ❌ 컴파일 에러
여전히 에러가 난다. T extends Number라는 제약이 걸려있어도 Box<Integer>와 Box<Number> 사이에는 아무 상속 관계가 생기지 않는다. 156편에서 다룬 불공변성은 타입 제한과 전혀 무관하게 그대로 유지된다.
두 축을 분리해서 보기
여기서 헷갈리는 이유는, T extends Number와 List<? extends Number>가 겉보기에 똑같이 extends 키워드를 쓰기 때문이다. 하지만 이 둘은 완전히 다른 질문에 답한다.
축 1: 원소(element)에 대해 무엇을 할 수 있는가 — 타입 제한이 푸는 문제
class Box<T extends Number> {
private T value;
void print() {
System.out.println(value.doubleValue()); // ⭕ Number의 메서드 사용 가능
}
}
T extends Number는 "이 클래스/메서드 안에서 T 타입의 값을 다룰 때, Number가 제공하는 메서드까지는 써도 된다"는 뜻이다. 이건 타입 매개변수 T 하나에 대한 제약일 뿐, Box<Integer>와 Box<Number>라는 서로 다른 두 타입 사이의 관계와는 아무 상관이 없다. 155편에서 다룬 내용이 정확히 이 축이다.
축 2: 컨테이너(Box, List) 자체가 상속 관계를 갖는가 — 불공변성/와일드카드가 푸는 문제
이건 Box<Integer>라는 타입 자체와 Box<Number>라는 타입 자체 사이의 관계를 묻는 질문이다. 앞의 실험에서 봤듯, 타입 제한 여부와 무관하게 기본적으로 이 관계는 존재하지 않는다(불공변). 이 관계를 부분적으로나마 만들어주는 게 156편에서 다룬 와일드카드다.
정리하면:
| 무엇에 대한 질문인가 | 답을 주는 문법 | |
|---|---|---|
| 축 1 | T 자리에 들어온 원소로 뭘 할 수 있는가 | <T extends X> (타입 매개변수 선언) |
| 축 2 | Box<A>와 Box<B> 사이에 상속 관계가 있는가 |
<? extends X> / <? super X> (와일드카드) |
같은 <>와 같은 extends 키워드를 쓰지만, 전자는 "타입 매개변수의 상한"이고 후자는 "타입 인자의 알 수 없는 범위"라서 완전히 다른 층위의 문법이다.
문법적으로 쓰일 수 있는 자리도 다르다
개념만 다른 게 아니라, 실제로 코드에 쓸 수 있는 위치 자체도 다르다. 간단히 표로 정리하면 이렇다.
| 구분 | 타입 제한 <T extends X> |
와일드카드 <? extends X> / <? super X> |
|---|---|---|
| 선언 위치 | 클래스·인터페이스·메서드의 타입 매개변수 선언부 | 이미 존재하는 제네릭 타입의 타입 인자 자리 |
객체 생성(new) |
new Box<Integer>() 가능 |
new ArrayList<?>() 불가 |
클래스 상속(extends)의 부모 타입 |
class MyBox<T> extends Box<T> 가능 |
class MyList extends ArrayList<?> 불가 |
| 여러 자리에서 "같은 타입"임을 강제 | 이름(T)으로 재참조해서 강제 가능 | 불가능 — ?끼리는 같은 타입이라는 보장이 없음 |
| 주로 쓰이는 자리 | 클래스 정의, 제네릭 메서드 시그니처 | 필드, 지역 변수, 매개변수 타입 (타입을 한 번만 쓸 때) |
정리하면 타입 제한은 "타입을 선언하는" 문법이고, 와일드카드는 "이미 선언된 제네릭 타입을 사용하는" 문법이다. 그래서 와일드카드는 new나 상속처럼 새로운 타입 관계를 만드는 자리에는 쓸 수 없고, 이미 만들어진 타입을 참조하는 자리에서만 쓸 수 있다. (와일드카드를 쓸 수 없는 자리의 전체 목록은 156편에서 더 자세히 다뤘다.)
추가 요약:
제네릭/타입제한: 하나의 클래스/메서드(컨테이너)를 여러 타입에 대해 재사용 가능하게 만듦
와일드카드: 같은 컨테이너의 서로 다른 파라미터화(List<Integer> vs List<Number>) 사이에 존재하는 불공변을 해결
그런데, 컨테이너 제약을 굳이 풀어야 할까?
"타입 제한(T extends X)으로 원소 문제는 이미 풀었는데, 컨테이너 불공변까지 손댈 필요가 있나?"라는 의문이 들 수 있다. 예를 들어 여러 종류의 숫자 리스트를 모두 받아서 합을 구하는 sum 메서드를 만든다고 해보자.
시도 1: 타입별로 메서드를 다 만든다
static double sum(List<Integer> list) { ... }
static double sum(List<Double> list) { ... }
static double sum(List<Long> list) { ... }
타입이 늘어날 때마다 똑같은 로직의 메서드가 계속 늘어난다. 유지보수 지옥이다.
시도 2: raw type을 쓴다
static double sum(List list) {
double total = 0;
for (Object o : list) {
total += ((Number) o).doubleValue(); // 캐스팅 필요, 런타임 에러 위험
}
return total;
}
컴파일러의 타입 체크를 통째로 포기하는 것이다. 제네릭이 등장하기 이전 시대(154편 참고)로 되돌아가는 셈이다.
시도 3: List<Object>를 쓴다
static double sum(List<Object> list) { ... }
sum(List.of(1, 2, 3)); // ❌ 컴파일 에러
이건 계속 봐온 불공변성 문제 그 자체다. List<Integer>는 List<Object>가 아니므로 여전히 넘길 수 없다.
그렇다면 타입 매개변수를 쓰면 되지 않나?
static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T t : list) total += t.doubleValue();
return total;
}
사실 이 방법은 실제로 동작한다. sum(List<Integer>)를 호출하면 컴파일러가 T를 Integer로 추론해서 처리하기 때문에, 컨테이너 사이의 상속 관계(List<Integer>가 List<Number>인지 아닌지)와는 아예 무관하게 문제가 풀린다. 제네릭 메서드의 타입 추론은 대입이 아니라 "호출마다 새로 타입을 맞추는" 방식이라 애초에 불공변성의 영향을 받지 않는다.
그럼 왜 굳이 와일드카드가 필요할까? 두 가지 이유가 남는다.
- T가 메서드 안에서 딱 한 번만 쓰이는데 이름을 붙일 이유가 없다. T를 다른 매개변수나 반환 타입과 엮어 쓰는 게 아니라면, 이름 있는 타입 매개변수를 선언하는 건 불필요한 군더더기다.
- 애초에 메서드가 아니면 타입 매개변수를 선언할 자리가 없다. 필드나 지역 변수는 "호출마다 타입을 추론"해주는 메커니즘이 없다.
class Statistics {
private List<? extends Number> data; // 필드에는 이 방법 외엔 대안이 없다
}
Statistics 클래스 자체를 제네릭으로 만들지 않는 이상, data 필드가 List<Integer>든 List<Double>든 유연하게 받으려면 와일드카드 말고는 방법이 없다. 즉 컨테이너 제약을 풀어야 하는 이유는 단순히 메서드 하나를 유연하게 만들기 위해서가 아니라, 타입 매개변수를 선언할 수 없는 자리(필드, 변수 선언, 여러 컨테이너가 얽힌 시그니처)에서도 여러 타입을 유연하게 받아들이면서 타입 안전성을 지키기 위해서다.
그래서 와일드카드가 실제로 주는 건 "완전한 상속"이 아니다
여기서 자연스럽게 드는 의문이 있다. "그럼 와일드카드는 축 2의 문제를 완전히 풀어주는 건가?" 답은 "아니다, 절반만 푼다"이다.
일반적인 클래스 상속에서는 리스코프 치환 원칙(LSP)에 따라 하위 타입이 상위 타입의 모든 역할을 대신할 수 있어야 한다. Integer는 Number 자리에 들어가서 읽기, 쓰기, 메서드 호출 등 뭘 하든 안전하다.
하지만 List<Integer>를 List<Number> 자리에 완전히 대입해버리면 어떻게 될까? 156편에서 봤듯, List<Number>로 취급된 리스트에 Double을 넣는 순간 실제 List<Integer>의 타입 안전성이 깨진다. 그래서 자바는 "완전한 치환"을 포기하고, 방향을 하나로 제한한 절반짜리 치환만 허용했다. 그게 바로 extends(읽기 전용 치환)와 super(쓰기 전용 치환)다.
| 관계 | 얻는 것 | 잃는 것 |
|---|---|---|
List<? extends Number> |
List<Integer>, List<Double> 등을 대입 가능 |
add() 불가 (읽기만 가능) |
List<? super Integer> |
List<Number>, List<Object> 등을 대입 가능 |
get()이 Object로만 반환 (쓰기만 안전) |
일반 클래스 상속 (Integer extends Number) |
완전한 치환, 모든 연산 가능 | 잃는 것 없음 (LSP 그대로 성립) |
정리하면, 와일드카드가 주는 "상속처럼 보이는 관계"는 진짜 상속이 아니라 타입 안전성이 깨지지 않는 방향으로만 제한된 부분적 치환이다.
컨테이너 제약이 풀리면 원소 제약도 자동으로 풀릴까?
여기서 하나 더 확인할 게 있다. 컨테이너 축이 풀렸을 때, 축 1(원소에 대한 제약)도 자동으로 같이 풀릴까? extends냐 super냐에 따라 답이 갈린다.
? extends X는 같이 풀린다.
List<? extends Number> list = new ArrayList<Integer>();
Number n = list.get(0); // ⭕ Number의 메서드까지 바로 사용 가능
컴파일러는 List<? extends Number>를 내부적으로 이름 없는 임시 타입 변수로 처리한다. 이를 와일드카드 캡처(wildcard capture) 라고 부르는데, 개념적으로는 이렇게 취급하는 것과 같다.
// 컴파일러 머릿속 (실제 문법은 아님)
<CAP extends Number> List<CAP> list = ...;
이 CAP extends Number는 155편에서 본 T extends Number와 완전히 동일한 메커니즘이다. ? extends X의 상한(X) 하나가 "컨테이너 공변 허용"과 "원소 읽기 타입 보장"이라는 두 역할을 동시에 하기 때문에, 컨테이너 제약이 풀리면 원소 제약도 따라온다.
? super X는 안 풀린다.
List<? super Integer> list = new ArrayList<Number>();
Object o = list.get(0); // ❌ Number가 아니라 Object로만 나옴
여기서 캡처된 타입은 CAP super Integer, 즉 "Integer 또는 그 조상 중 뭔가"라는 하한만 알려준다. 상한이 없으니 컴파일러는 "이 타입이 확실히 가진 메서드"를 특정할 수 없고, 결국 모든 타입의 최소 공통분모인 Object로만 취급한다.
| 와일드카드 | 컨테이너 축 | 원소 축 | 왜 갈리는가 |
|---|---|---|---|
? extends X |
풀림 | 같이 풀림 (X로 접근 가능) | 상한(X)이 공변 허용과 읽기 타입 보장을 동시에 담당 |
? super X |
풀림 | 안 풀림 (Object만 가능) | 하한(X)만 있고 상한이 없어 보장 가능한 메서드가 Object뿐 |
즉 "컨테이너 제약이 풀리면 원소 제약도 자동으로 풀린다"는 extends에 한정해서만 참이다. 오히려 이 비대칭이 PECS에서 extends는 읽기, super는 쓰기에 쓰이는 이유를 한 번 더 뒷받침해준다.
정리
세 편의 글은 사실 서로 다른 질문에 답한 것이었다.
- 154편: 제네릭 자체가 왜 필요한가 (타입 안전성, 캐스팅 제거)
- 155편 (
T extends X): 타입 매개변수 T의 원소를 다룰 때 어떤 API까지 쓸 수 있는가 → 원소에 대한 제약 - 156편 (
? extends X/? super X):Container<A>와Container<B>사이에 상속과 비슷한 관계를 만들 수 있는가, 만든다면 어디까지 안전한가 → 컨테이너에 대한 제약
타입 제한은 원소 제약을 푸는 도구이고, 그 자체로는 컨테이너 간의 불공변성에 전혀 손대지 못한다. 컨테이너 제약은 제네릭 메서드의 타입 추론으로 우회할 수 있는 경우도 있지만, 타입 매개변수를 선언할 수 없는 자리(필드, 변수, 여러 컨테이너가 얽힌 시그니처)에서는 와일드카드가 사실상 유일한 해법이다. 그리고 와일드카드가 여는 건 완전한 상속이 아니라 방향이 제한된 절반짜리 치환이며, extends는 이 과정에서 원소 제약까지 함께 풀어주지만 super는 그렇지 않다는 비대칭까지 알아두면, 154~156편의 흐름이 하나로 이어진다.
'Language > Java' 카테고리의 다른 글
| 자바 제네릭, 와일드카드(?)와 PECS 원칙 (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 |
