자바/자바

[Java] 제네릭 와일드카드 완벽 정리 (feat. 공변과 반공변)

불광동 물주먹 2026. 1. 22. 11:55

지난 포스팅에서 제네릭의 불공변성(Invariance) 때문에 발생하는 불편함에 대해 다뤘습니다.

List<Object>는 List<Integer>의 부모가 아니기 때문에 유연하게 사용할 수 없었죠.

이를 해결하기 위해 등장한 것이 바로 **와일드카드(?)**입니다. 이번 글에서는 와일드카드의 핵심 개념과 3가지 종류, 그리고 이를 뒷받침하는 공변과 반공변의 원리까지 정리합니다.


1. 와일드카드란? (Unknown Type Argument)

와일드카드(?)는 말 그대로 **"알 수 없는 타입(Unknown Type)"**을 의미합니다. 정해지지 않은 타입을 나타내기 때문에 제네릭을 더 유연하게 사용할 수 있게 해줍니다.

핵심 제약 사항

  1. Type Argument 위치에서만 사용 가능
    • 클래스나 메서드를 정의할 때(Box<?>)는 쓸 수 없습니다. (T를 써야 함)
    • 변수 선언이나 파라미터 등 타입을 사용하는 자리(List<?> list)에서만 쓸 수 있습니다.
  2. 단독 사용 불가
    • ?만 따로 타입으로 쓸 수 없습니다. (예: ? value; ❌)
  3. 제네릭 타입의 객체를 '참조'할 목적으로만 사용
    • 생성자에서는 사용 불가!
    • new ArrayList<?>() ❌ ➡ 객체를 생성할 때는 구체적인 타입이 필요합니다.
  4. 실제 타입을 모른다
    • 와일드카드는 "여기에 뭐가 오긴 오는데, 정확히 뭔지는 몰라"라는 상태입니다.

2. 와일드카드의 3가지 종류

자바는 와일드카드에 경계(Bound)를 두어 타입 안정성을 보장합니다.

① Unbounded Wildcard (비한정 와일드카드) : <?>

제약 없이 모든 타입의 제네릭 객체를 참조할 수 있습니다. List<Object>와 비슷해 보이지만 다릅니다.

  • 특징: 실제 내부가 String인지 Integer인지 전혀 모르는 상태.
  • 제약 사항:
    • 데이터 추가(Write) 불가: 타입을 모르기 때문에 null 외에는 아무것도 넣을 수 없습니다.
    • 데이터 읽기(Read): 무엇이 들어있든 최상위 타입인 **Object**로만 읽어올 수 있습니다.
    • 삭제(Remove)는 가능: remove(Object o) 메서드는 타입을 타지 않기 때문입니다.
public void printList(List<?> list) {
    // list.add(10); // ❌ 컴파일 에러 (타입을 모르니 추가 불가)
    Object item = list.get(0); // ⭕ Object로 꺼내는 건 가능
    System.out.println(item);
}

② Upper Bounded Wildcard (상한 경계) : <? extends T>

특정 타입 T와 그 **자식 타입(Subclass)**들만 참조할 수 있도록 제한합니다.

  • 의미: "T 혹은 T의 자식들은 다 들어와." (상한선이 T)
  • 특징:
    • 데이터 읽기(Read): 안전합니다. 무엇이 들어오든 최소한 T 타입임이 보장되므로, T 타입으로 꺼내 쓸 수 있습니다.
    • 데이터 추가(Write) 불가: 실제 구현체가 List<Son>인지 List<Daughter>인지 확신할 수 없으므로 추가는 불가능합니다.
// Number와 그 자식들(Integer, Double 등)만 가능
public void sum(List<? extends Number> list) {
    // Number num = list.get(0); // ⭕ 안전하게 Number로 꺼낼 수 있음
    // list.add(10); // ❌ Integer인지 Double인지 확신할 수 없어 추가 불가
}

③ Lower Bounded Wildcard (하한 경계) : <? super T>

특정 타입 T와 그 **부모 타입(Superclass)**들만 참조할 수 있습니다.

  • 의미: "T 혹은 T의 조상들은 다 들어와." (하한선이 T)
  • 특징:
    • 데이터 읽기(Read): 꺼냈을 때 T의 부모가 누구일지(Object일지 Number일지) 알 수 없으므로, **Object**로만 받아야 합니다.
    • 데이터 추가(Write) 가능: 이것이 핵심입니다! 최소한 이 리스트는 T 타입을 담을 수 있는 그릇임이 보장됩니다. 따라서 T와 T의 자식 타입(Subclass)은 안전하게 추가할 수 있습니다.
// Integer와 그 부모들(Number, Object)만 가능
public void addNumbers(List<? super Integer> list) {
    list.add(10);  // ⭕ Integer는 안전하게 추가 가능
    list.add(20);  // ⭕ Integer는 어떤 부모 리스트에도 들어갈 수 있음
    // list.add(3.14); // ❌ Double은 Integer의 가족이 아님
    
    // Integer i = list.get(0); // ❌ 꺼낼 땐 Object로만 나옴 (타입 보장 X)
}

3. 공변과 반공변 (Covariance & Contravariance)

와일드카드의 extends와 super는 단순히 범위만 제한하는 것이 아니라, **타입의 상속 방향(계층 구조)**을 결정짓습니다. 이를 전문 용어로 공변과 반공변이라 합니다.

1. 기본 개념: 방향성(Direction)

먼저, 우리가 알고 있는 기본 상속 관계를 정의해 봅시다.

  • 자식: Integer
  • 부모: Number
  • 방향: Integer ➡ Number (Integer는 Number의 하위 타입)

이 화살표 방향이 제네릭 와일드카드를 만났을 때 유지되느냐, 뒤집히느냐가 핵심입니다.

 

출처 : 유튜브 쉬운코드

 


2. 공변 (Covariance) : <? extends T>

"방향이 같이(Co) 변한다."

상속 방향이 원래 타입의 방향과 일치합니다.

  • 원래 관계: Integer ➡ Number
  • 제네릭 관계: List<? extends Integer> ➡ List<? extends Number>

위 그림의 왼쪽을 보면, 화살표가 아래에서 위로 올라갑니다.

  1. 가장 아래 List<Integer>가 있고,
  2. 그 위에 List<? extends Integer>가 있고,
  3. 그 위에 List<? extends Number>가 있습니다.

즉, **"Integer가 Number의 자식이듯이, List<? extends Integer>도 List<? extends Number>의 자식이다"**라는 관계가 성립합니다. 이를 공변이라고 합니다.

  • 특징:
    • 우리가 아는 상식적인 상속 관계와 같습니다.
    • Read 전용: 상위 타입으로 안전하게 꺼낼 수 있습니다.

3. 반공변 (Contravariance) : <? super T>

"방향이 반대(Contra)로 변한다."

상속 방향이 원래 타입의 방향과 정반대로 뒤집힙니다. (여기가 제일 헷갈리면서도 재미있는 부분입니다!)

  • 원래 관계: Integer ➡ Number (Integer가 자식)
  • 제네릭 관계: List<? super Number> ➡ List<? super Integer>
    • 어? Number 쪽이 자식이 되고, Integer 쪽이 부모가 됐네?

위 그림의 오른쪽 화살표를 자세히 보세요.

  1. List<? super Number>에서 출발한 화살표가
  2. List<? super Integer>로 향하고 있습니다.

즉, **"List<? super Number>가 List<? super Integer>의 하위 타입(Subtype)이 된다"**는 뜻입니다.

 왜 뒤집힐까? (논리적 증명)

논리적으로 생각해보면 당연합니다. **"범위가 더 좁은 것이 하위 타입"**이기 때문입니다.

  • List<? super Integer>의 범위: Integer, Number, Object (더 넓음 ➡ 상위 타입)
  • List<? super Number>의 범위: Number, Object (더 좁음 ➡ 하위 타입)

Number가 Integer보다 상위 타입이지만, super 와일드카드를 씌우면 조건이 더 까다로운(범위가 좁은) super Number 쪽이 하위 타입이 되는 현상, 이것이 바로 반공변입니다.

  • 특징:
    • 타입 계층 구조가 역전됩니다.
    • Write 전용: 하위 타입 객체를 안전하게 add 할 수 있습니다.
    • Read 시 주의: 상위 타입이 어디까지 올라갈지 모르므로(Object일 수도 있음), 꺼낼 때는 무조건 **Object**로만 받아야 합니다.

4. 요약 정리

구분 와일드카드 문법 방향성 (vs 원래 타입) 서브타입 관계 예시 특징
공변 (Covariance) <? extends T> 일치 (유지) List<? extends Integer>

⬇ (자식)

List<? extends Number>
- Producer (생산)

- get() 안전

- add() 불가
반공변 (Contravariance) <? super T> 반대 (역전) List<? super Number>

⬇ (자식)

List<? super Integer>
- Consumer (소비)

- add() 안전

- get()은 Object

그림으로 한 번에 이해하기

첨부한 이미지의 화살표 흐름을 다시 한 번 봅시다.

  • 가운데 최상위: List<?> (모든 것의 부모)
  • 왼쪽 (extends): Integer에서 Number로 올라가는 순서 그대로 리스트도 올라갑니다. (공변)
  • 오른쪽 (super): Number 리스트가 Integer 리스트보다 **아래(자식)**에 위치합니다. (반공변)
    • 화살표가 List<Number> ➡ List<? super Number> ➡ List<? super Integer> 순서로 올라가는 것을 꼭 확인하세요!

[결론]

  • extends"우리가 아는 상속 순서 그대로" (공변)
  • super"상속 순서가 반대로 뒤집힘" (반공변)
  • 이 원리 덕분에 우리는 PECS (Producer-Extends, Consumer-Super) 원칙을 유연하게 적용할 수 있습니다.

5. 최종 요약 정리

종류 문법 의미 데이터 추가(Write) 데이터 읽기(Read) 변성(Variance)
Unbounded <?> 타입 모름 ❌ 불가 Object -
Upper Bound <? extends T> T와 그 자식들 ❌ 불가 T 공변 (방향 유지)
Lower Bound <? super T> T와 그 부모들 T(자식) 가능 Object 반공변 (방향 역전)

💡 PECS 원칙 (Producer-Extends, Consumer-Super)

  • 데이터를 제공(Produce)/조회만 한다면? ➡ extends (공변)
  • 데이터를 소비(Consume)/추가해야 한다면? ➡ super (반공변)