가장 괴랄한 Java 문법(feat. <?>)

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

WTH
WTH

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>

자바의 제너릭은 기본적으로 불변이에요. 다음 코드는 IntegerNumber의 하위 타입이라 괜찮을 것 같지만, 사실 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

  1. 자바 제너릭은 불변이고, 와일드카드를 사용해 이 제약을 완화할 수 있다.
  2. 타입깨지면 안전하지 않다 == 타입만 맞으면 뭔짓을해도 OK
  3. 다음글에 이어서