자바 제네릭을 어느 정도 쓰다 보면, “왜 List<Integer>는 List<Number>의 하위 타입이 아니지?”, “? extends랑 ? super는 대충 알겠는데 막상 쓰려니 헷갈린다” 같은 지점에서 자주 막힌다. 이 글에서는 변성(공변/불공변)을 짚고, PECS 원칙과 함께 실무에서의 사용 기준까지 한 번에 정리해 보려고 한다.
먼저 “변성(variance)” 개념부터 아주 가볍게 짚고 가자.
Integer는 Number의 하위 타입이다.List<Integer>도 List<Number>의 하위 타입이 된다. List<Integer>와 List<Number>는 서로 상속 관계가 없다. ? super T 와일드카드가 “부분적으로” 반공변처럼 동작한다고 볼 수 있다.요약하면:
List<Integer>는 List<Number>가 아니다(불공변). List<? extends Number> 같은 와일드카드가 등장하면서 공변/반공변을 흉내내는 식으로 사용 범위를 넓힌다.PECS는 다음의 머리글자다.
Producer – extends
Consumer – super
? extends T 사용. ? super T 사용. 진짜 한 줄로만 기억하려면:
값을 꺼내 쓰는 쪽이면 extends,
값을 집어넣는 쪽이면 super.
다음 네 개의 설명을 예제로 하나씩 살펴보자.
A. 데이터를 읽어오는 용도(Producer)라면
<? extends T>를 사용하여 유연성을 높일 수 있다 ?
B. 데이터를 저장하는 용도(Consumer)라면<? extends T>를 사용해야 안전하다 ?
C.List<? extends Number>는 새로운 요소를 추가(add)하는 데 적합한 구조다 ?
D.List<? super Integer>에서 요소를 꺼낼(get) 때 결과값은 항상 Integer 타입임이 보장된다 ?
? extends T 사용void printNumbers(List<? extends Number> list) {
for (Number n : list) {
System.out.println(n);
}
}
list에서 읽기만 한다. List<Integer>, List<Double>, List<Long> 등 Number의 하위 타입 리스트들을 모두 받을 수 있다. ? extends Number 덕분에 호출 측의 타입 자유도가 올라가면서, 메서드 안에서는 안전하게 Number로 처리할 수 있다.핵심 포인트:
? extends T는 “T의 자식들이 들어 있는 어떤 리스트”라고 보면 된다. extends를 쓰면 왜 안 되는가B. 데이터를 저장하는 용도(Consumer)라면
<? extends T>를 사용해야 안전합니다.
이 문장은 틀린 설명이다. Consumer에는 ? super T를 사용해야 한다.
? extends가 Consumer에 안 맞는 이유를 코드로 보자.
void addNumbers(List<? extends Number> list) {
list.add(1); // 컴파일 에러
list.add(1.0); // 컴파일 에러
list.add(null); // 이건 허용
}
왜 문제가 될까?
list는 실제로 List<Integer>일 수도 있고 List<Double>일 수도 있고 List<Long>일 수도 있다. Number의 어떤 하위 타입 리스트인지 정확히 모르는 상태”다.List<Integer>인데 메서드 안에서 Double을 add하게 허용하면 타입 안정성이 깨진다. ? extends T에 대해 null 외의 add를 금지한다.즉:
? extends를 쓰면 ? super T를 사용해야 한다.List<? extends Number>는 add에 적합한가?C.
List<? extends Number>는 새로운 요소를 추가(add)하는 데 적합한 구조입니다.
위에서 본 것처럼, 이 설명도 틀렸다.
List<? extends Number> list = new ArrayList<Integer>();
list.add(1); // 컴파일 에러
list.add(1.0); // 컴파일 에러
list.add(null); // 컴파일 OK
? extends Number지만, 실제 구현체는 ArrayList<Integer>일 수도 있다. Double을 add하게 허용하면, 실제 타입인 ArrayList<Integer>에 Double이 들어가는 모순이 생긴다. 그래서 List<? extends Number>는:
List<? super Integer>의 get 타입은?D.
List<? super Integer>에서 요소를 꺼낼(get) 때 결과값은 항상 Integer 타입임이 보장됩니다.
이 설명도 틀렸다. ? super Integer는 “Integer의 상위 타입 리스트”라는 뜻이다.
List<? super Integer> list1 = new ArrayList<Integer>();
List<? super Integer> list2 = new ArrayList<Number>();
List<? super Integer> list3 = new ArrayList<Object>();
List<? super Integer>에 대입 가능하다. get을 해보면?Object o = list1.get(0); // OK
Integer i = list1.get(0); // 컴파일 에러
list1.get(0)의 실제 타입이 즉:
? super T는 T를 넣는 것은 안전하다. ? super T 감 잡기: “넣을 때 넓게, 읽을 때 좁게”? super T는 처음 보면 직관이 잘 안 오는데, 이렇게 정리하면 좀 더 편하다.
void addIntegers(List<? super Integer> list) {
list.add(1); // OK
list.add(2); // OK
// list.add(1.0); // 컴파일 에러 (Double은 Integer가 아님)
}
List<Integer> List<Number> List<Object> Integer를 add하는 것”은 안전하다. Object뿐이므로 이렇게 된다.List<? super Integer> list = new ArrayList<Number>();
Object x = list.get(0); // OK
// Integer y = list.get(0); // 컴파일 에러
머릿속 이미지:
? extends T ? super T <T> 타입 파라미터글로벌하게 보면, “이 메서드는 ‘유틸리티 함수’에 가깝다 vs 타입 파라미터를 돌려 쓰는 구조다”로 나눠서 생각하면 편하다.
? extends / ? super)를 쓸까void printAll(List<? extends Number> list) { ... }void addAllIntegers(List<? super Integer> list) { ... }List<? extends Shape>를 받아서 draw()만 호출한다든지.한마디로, “한 방향 흐름(읽기나 쓰기)만 있고, 타입을 재사용해 반환할 필요가 없을 때”는 와일드카드가 깔끔하다.
<T> 타입 파라미터를 쓸까<T> T copyFirst(List<T> from, List<T> to) {
T element = from.get(0);
to.add(element);
return element;
}? extends / ? super로는 표현하기 힘들다.<T extends Number> T max(List<T> list) { ... }class Box<T> {
private T value;
T get() { return value; }
void set(T value) { this.value = value; }
}정리하면:
? extends / ? super). <T>).마지막으로, 실제 코드 짤 때 떠올리기 좋은 질문들만 정리해 보자.
? extends T 고려. ? super T 고려. <T> 타입 파라미터로 가는 게 낫다.<T>. from과 to가 같은 타입 리스트여야 하는 경우 → <T>. ? extends Number.이 정도만 몸에 붙으면,
<T>”가 꽤 명확하게 갈릴 것이다.