자바 제네릭 컬렉션에서의 와일드카드 상하한

Jaehyeon Han·2025년 4월 17일

이 글은 자바 제네릭 컬렉션에서 와일드카드의 역할을 값을 꺼낼 때와 넣을 때로 구분하여 설명한다.

먼저 자바 제네릭의 특징인 공변/불공변과 타입 소거를 간단하게 알고 넘어가자.

공변과 불공변

제네릭 와일드카드 설명글은 대부분 공변과 불공변으로 시작한다. 내 생각에 이 부분에서 알아야 할 것은 제네릭 타입은 서로 호환되지 않는다는 것뿐인 것 같다. 예를 들어 IntegerNumber의 하위 타입이지만 List<Number>List<Integer>는 전혀 연관성이 없다는 것이다. 따라서 다음 코드는 타입 오류를 일으킨다.

void processNumberList(List<Number> list) {...}

void callProcessIntegerList() {
  List<Integer> integerList = List.of(1, 2, 3);
  processNumberList(integerList); // 컴파일 오류
}

따라서 상속관계를 적용할 수 없고, Object로 "아무 타입" or "모든 타입"을 나타낼 수 없으므로 별도의 문법 → 와일드카드(?)가 필요하다.

타입 소거

간단하게 말하면 제네릭은 컴파일 시에만 존재하고, 컴파일 후에는 전부 Object 또는 타입 경계가 있는 경우(<T extends Number>) 해당 경계 타입으로 바뀐다. 그리고 값을 꺼낼 때는 컴파일러가 자동으로 형변환을 하는 코드를 삽입해준다.

더 자세한 내용은 타입 소거(type erasure)로 검색하자.

상한 경계 (? extends T)

자바에서 클래스 구조는 부모가 위, 자식이 아래로 표현된다. 그런 의미에서 상한 경계는 "적어도 이 클래스의 부모로 T가 올 수 있다" 라는 의미이다. 따라서 상한을 사용하면 해당 제네릭 타입이 적어도 T임을 보장함으로써, T 타입 메소드와 필드 사용이 가능하다.

값을 꺼내기

망나니개발자 님의 제네릭 관련 글에 있는 다음 클래스 구조를 보자.

class MyGrandParent {}

class MyParent extends MyGrandParent {}

class MyChild extends MyParent {}

다음으로 MyParent 타입을 상한으로 갖는 경우(<? extends MyParent>)를 생각해보자.

void method(List<? extends MyParent> list) {...}

여기에는 MyParentMyChild가 올 수 있다. 그러면 List에서 꺼낸 객체는 MyParent 타입임을 보장할 수 있고, 따라서 내부에서 MyParent로 다뤄도 무방하다.

MyParent parent = list.get(0);

따라서 상한을 지정하면 제네릭을 사용하면서도 List에 있는 값을 모두 꺼내어 MyParent 타입의 필드나 메소드에 접근할 수 있다.

값을 넣기

반면 List에 값을 넣어줘야 하는 경우는 상황이 다르다. 올 수 있는 타입은 MyParentMyChild이다. 하지만 호출 시 타입은 하나로 고정된다. 즉, 이 method의 매개변수는 List<MyParent>거나 List<MyChild> 등이 될 수 있지만, 동시에 모든 타입이 될 수는 없다. 다음 코드를 보자.

void method(List<? extends MyParent> list) {
  MyParent parent = new MyParent();
  list.add(parent); // 컴파일 오류
}

List에 담을 수 있는 타입은 무엇일까? 답은 "알 수 없다"이다. 물론 List<MyParent> 라면 하위 타입을 다 넣을 수 있을 것이다. 하지만 List<MyChild>라면? MyParent를 상속한 MyChild2 클래스가 있다면? MyChild에는 MyParent 를 담을 수 없다. MyChild2MyChild를 담을 수 없다. 즉, 해당 컬렉션에 담을 수 있는 게 무엇인지 한정할 수 없다. 따라서 컴파일러는 이를 허용하지 않는다.

하한 경계 (? super T)

하한 경계는 반대이다.

값을 꺼내기

void method(List<? super MyParent> list) {...}

이 메소드 안에서 list.get(0)를 했을 때, 어떤 타입이 반환될까? 이 표현이 보장하는 것은 MyParent 또는 그 상위 타입이라는 점이다. 즉 모든 걸 담을 수 있는 타입은 Object뿐이다. 그래서 값을 꺼낼 때는 Object로만 꺼낼 수 있다.

나는 처음에는 매개변수로 List를 받았다면 List에서 값을 꺼낼 거라고 생각해서 하한이 왜 필요한지 이해하지 못했다. 어차피 Object로 꺼낼 거면 그냥 List<Object>를 쓰면 되니까 말이다. 하지만 List에 추가하는 다음 경우를 보자.

값을 넣기

void method(List<? super MyParent> list) {
  MyParent parent = new MyParent();
  list.add(parent);
}

하한과 달리 위 코드는 정상 작동한다. List에 올 수 있는 타입은 List<MyParent>, List<MyGrandParent>, List<Object> 등이다. 그래서 이 타입은 모두 MyParent해당 타입(MyGrandParent, Object 등)으로 참조할 수 있다. 비록 MyParent라는 타입 정보는 사라지지만 말이다.

Producer-Extends, Consumer-Super

위 내용을 정리한 것이 매개변수인 제네릭 컬렉션이 정보의 생산자인 경우 extends를 사용하고, 정보의 소비자인 경우 super를 사용한다는 PECS (Producer-Extends, Consumer-Super)이다.

<? extends T>는 컬렉션을 읽기 전용으로 만들고, <? super T>는 쓰기 전용으로 만든다는 주장도 동일한 의미다.

정리

<? extends T>는 컬렉션에서 값을 꺼낼 때, 해당 객체가 T임을 보장한다. 반면 <? super T>는 컬렉션에 값을 넣을 때, 해당 컬렉션이 그 값을 받을 수 있음을 보장한다.

참고자료

Oracle Docs - Guidelines for Wildcard Use: in 변수와 out 변수로 나누어, extendssuper의 사용은 데이터를 빼올 것인가(in)와 데이터를 넣을 것인가(out)으로 나누어 다루고 있다.

[3편] 제네릭이란? — 왜 모르는가?: 상하한의 범위를 직관적으로 파악할 수 있다.

java - What is PECS (Producer Extends Consumer Super)? - Stack Overflow: Collection<? extends Thing>Collection<? super Thing>를 다루는 기본적인 관점을 설명한다.

[Java] 제네릭과 와일드카드 타입에 대해 쉽고 완벽하게 이해하기(공변과 불공변, 상한 타입과 하한 타입) - MangKyu's Diary: 직관적인 와일드카드 사용 예시를 설명한다.

0개의 댓글