가장 괴랄한 Java 문법(feat. <?>)
자바에서 가장 기괴한 제네릭 문법 ? — 와일드카드 Java’s weirdest generic syntax: the wildcard ?

Intro
Java에서 가장 괴랄한 문법을 꼽으면, LLM 3개 모두 제네릭 와일드카드 ?
라고 대답해요.
사실 공부하면서 봤었지만, 쓸 일 없을 것 같아 넘겼는데…
이벤트 처리 로직 만들다가 결국 마주처버렸어요.
이왕 마주친 김에, 확실히 정리할게요.
제네릭 전체를 설명하기엔 이 글이 너무 짧아서,?(와일드카드)의 개념만 간단히 짚고 실제로 마주쳤던 문제로 바로 넘어갈게요.
라고 하고싶었는데, 벌써 글이 너무 길어졌어요.
와일드카드란❓
공변&불변&반공변
- 공변(covariant) : A가 B의 하위 타입일 때,
T<A>
가T<B>
의 하위 타입이면 T는 공변
A ⊂ B → T<A> ⊂ T<B>
- 반공변(Contravariant): A가 B의 하위 타입일 때,
T<B>
가T<A>
의 하위 타입이면 T는 반공변A ⊂ B → T<B> ⊂ T<A>
- 불변(invariant) : A가 B의 하위 타입이더라도,
T<A>
가T<B>
간에 서브타입 관계가 없으면 T는 불변A ⊂ B → T<A> ⊄ T<B>, T<B> ⊄ T<A>
자바의 제너릭은 기본적으로 불변이에요. 다음 코드는 Integer
가 Number
의 하위 타입이라 괜찮을 것 같지만, 사실 List<Object>와 List<Integer>는 아무 관계가 없기 때문에 컴파일되지 않아요.
List<Integer> integers = List.of(1,2,3);
List<Number> numbers = integers;❌컴파일에러
그럼 다음과 같은 메서드를 작성하려고 하면, Number를 상속받는 타입을 모두 작성해야할까요?
void m(List<Integer> list) {System.out.println(list.get(0));
void m(List<Double> list) {System.out.println(list.get(0));
void m(List<AutomicLong> list) {System.out.println(list.get(0));
....
이런 반복을 방지하기 위해 ?
(와일드카드) 가 등장했어요.
대표적으로 List<?>
는 타입에 상관없는 리스트를 의미하는데, List<String>,List<MyType> 등 어떤 타입도 범용으로 받을 수 있죠.
하지만 위 예제에서는,최소한 Number
의 하위 타입이라는 건 보장되어야 의미가 있겠죠?
이럴 때 타입 경계를 지정한 와일드카드 - (bounded wildcard)를 사용할 수 있어요,
상한 와일드카드(Upper Bounded Wildcard)
<? extends T>
처럼, 특정 타입의 하위 타입만 허용하는 방식을 상한 와일드카드라고 부르는데,
이건 해당 제네릭 요소가 T
의 하위 타입임을 보장해줘요.
void m(List<? extends Number> list){
System.out.println(list.get(0).longvalue());
}
m(List.of(1,2,3)) OK
m(List.of(1L,2L,3L)) OK
m(List.of("a","b","c")) ❌ // 컴파일에러
Number
의 하위 타입임이 보장되므로, Number
의 longvalue()를 사용할 수 있어요.
하지만 해당 리스트의 “정확한” 제네릭을 알 수 없기 때문에, 명시적 null
외에는 추가가 불가능해요.
같은Number
하위 타입이어도,List<Integer>
에AutomicLong
을 추가하면 안되겠죠?
만약 추가해야 한다면…
하한 와일드카드(Lower Bounded Wildcard)
<? super T>
처럼, 특정 타입의 상위 타입만 허용하는 방식을 하한 와일드카드라고 부르는데, 이건 해당 제네릭 요소가 T
의 상위 타입임을 보장해줘요.
void m(List<? super Number> list) {
list.add(1L);
}
m(new ArrayList<Object>()); OK
m(new ArrayList<Number>()); OK
m(new ArrayList<Integer>()); ❌ // 컴파일에러
Number의 상위 타입임이 보장되므로, Number의 하위 타입들을 마음대로 삽입할 수 있어요.
하지만, 이번에도 해당 리스트의 “정확한” 제네릭을 알 수 없는 것은 마찬가지여서, 요소를 꺼낼 때는 Object
로만 꺼낼 수 있어요.
List<Object>
에서Number
를 꺼낼 수 있으면 안되겠죠?
PECS
저를 비롯해서 많은 개발자분들이 extends
,super
를 언제 써야 할지 헷갈려 하실 텐데요, 이를 위해 EFFECTIVE JAVA에서 PECS 공식을 소개하고 있어요.
PECS는 Producer-Extends, Consumer- Super의 약자예요.사실, 단어뜻은 가슴근육이래요.
정리하면, 제네릭 T
가
- 생산자라면 (값을 꺼낸다면) ⇒
extends
를 사용하고 - 소비자라면 (값을 넣는다면) ⇒
super
를 사용하면 돼요.
void transfer(List<? super Integer> dst, List<? extends Integer> src) {
dst.add(src.get(0));
}
extends
에서 꺼내고, super
에 넣어요. 근데 실전에선...
강제 캐스팅
제네릭의 불변성에 따라, 컴파일러는 List<Integer>를 List<Number>로 바꾸는 걸 절대 용납하지 않아요.
List<Integer> integers = List.of(1, 2, 3);
List<Number> numbers = integers; ❌ //컴파일에러
List<Number> numbers = (List<Number>) integers; ❌
하지만 와일드카드를 사용하면,
컴파일러는 개발자를 믿고(?) unchecked 경고만 날린 채, 강제 캐스팅을 허용하죠.
그래서 아래 코드도 컴파일은 되는데..
List<Integer> list;
List<?> superList = new ArrayList<Object>(List.of(1, "aaaaa", "bbbb"));
list = (List<Integer>) superList; // unchecked warning
System.out.printf("list = %s\\n",list); // list = [1, aaaaa, bbbb]
Integer i = list.get(0); // 첫번째원소는 Integer여서 문제없음
//Integer j = list.get(1); ❗️이걸 호출한다면? ClassCastException💀💀
놀랍게도 런타임에도 깨지지 않아요.
list = [1, aaaaa, bbbb] (제네릭의 타입 소거 덕분에 가능해요) 딱 봐도 위험해보이죠? list의 첫 원소가 Integer
여서 정말 운좋게 문제가 생기지 않았지만, 다른 요소에 접근하는 순간 ClassCastException
이 기다리고 있어요.
그런데… 어떻게 생각하면 타입만 맞으면 문제없다? ← 이걸 기억해주세요.
다음글에서 계속
사실, 제가 마주쳤던 문제를 여기서 다루려 했지만… 글이 너무 길어졌어요.
2편에서 실제 코드와 함께 이어서 설명할게요.🙇🙇🏽♂️
3-Point
- 자바 제너릭은 불변이고, 와일드카드를 사용해 이 제약을 완화할 수 있다.
- 타입깨지면 안전하지 않다 == 타입만 맞으면 뭔짓을해도 OK
- 다음글에 이어서…