[Java] 제네릭 와일드카드 & PECS

HenryHong·2026년 1월 19일

java

목록 보기
6/15
post-thumbnail

자바 제네릭을 어느 정도 쓰다 보면, “왜 List<Integer>List<Number>의 하위 타입이 아니지?”, “? extends? super는 대충 알겠는데 막상 쓰려니 헷갈린다” 같은 지점에서 자주 막힌다. 이 글에서는 변성(공변/불공변)을 짚고, PECS 원칙과 함께 실무에서의 사용 기준까지 한 번에 정리해 보려고 한다.


제네릭 변성 간단 정리

먼저 “변성(variance)” 개념부터 아주 가볍게 짚고 가자.

  • 공변(Covariant)
    • “타입 관계가 그대로 따라간다”는 의미다.
    • 예를 들어 IntegerNumber의 하위 타입이다.
      공변이면 List<Integer>List<Number>의 하위 타입이 된다.
  • 불공변(Invariant)
    • “타입 관계를 따로따로 본다”는 의미다.
    • 자바의 일반적인 제네릭은 불공변이다.
      그래서 List<Integer>List<Number>는 서로 상속 관계가 없다.
  • 반공변(Contravariant)
    • “타입 관계가 반대로 뒤집힌다”는 개념이다.
    • 자바에서는 제네릭 타입 자체가 반공변인 것은 아니고, ? super T 와일드카드가 “부분적으로” 반공변처럼 동작한다고 볼 수 있다.

요약하면:

  • List<Integer>List<Number>가 아니다(불공변).
  • 대신, List<? extends Number> 같은 와일드카드가 등장하면서 공변/반공변을 흉내내는 식으로 사용 범위를 넓힌다.

PECS 한 줄 요약

PECS는 다음의 머리글자다.

Producer – extends
Consumer – super

  • Producer(생산자)
    • 컬렉션에서 값을 꺼내 읽기만 하는 쪽.
    • “이 컬렉션은 T (또는 그 하위 타입)를 제공한다(produce)”라고 볼 수 있을 때.
    • ? extends T 사용.
  • Consumer(소비자)
    • 컬렉션에 값을 집어넣는(저장하는) 쪽.
    • “이 컬렉션은 T를 받아 먹는다(consume)”라고 볼 수 있을 때.
    • ? super T 사용.

진짜 한 줄로만 기억하려면:

값을 꺼내 쓰는 쪽이면 extends,
값을 집어넣는 쪽이면 super.


4개 보기로 까보는 와일드카드

다음 네 개의 설명을 예제로 하나씩 살펴보자.

A. 데이터를 읽어오는 용도(Producer)라면 <? extends T>를 사용하여 유연성을 높일 수 있다 ?
B. 데이터를 저장하는 용도(Consumer)라면 <? extends T>를 사용해야 안전하다 ?
C. List<? extends Number>는 새로운 요소를 추가(add)하는 데 적합한 구조다 ?
D. List<? super Integer>에서 요소를 꺼낼(get) 때 결과값은 항상 Integer 타입임이 보장된다 ?

A. Producer에서는 ? 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의 자식들이 들어 있는 어떤 리스트”라고 보면 된다.
  • 읽기 전용(Producer)에는 아주 잘 맞는다.
  • 하지만 add에는 제약이 걸린다(이건 C에서 다시 본다).

B. Consumer에 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>인데 메서드 안에서 Doubleadd하게 허용하면 타입 안정성이 깨진다.
  • 이 위험을 막기 위해, 자바는 ? extends T에 대해 null 외의 add를 금지한다.

즉:

  • Consumer(저장/쓰기)에 ? extends를 쓰면
    • 정작 값을 집어넣지 못하는 컬렉션이 되어버린다.
  • 그래서 PECS에서 Consumer에는 ? super T를 사용해야 한다.

C. 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이 들어가는 모순이 생긴다.
  • 자바는 이를 막기 위해 “null만 허용”이라는 극단적인 규칙을 적용한다.

그래서 List<? extends Number>는:

  • 읽기(Producer)에는 좋다.
  • add 같은 “쓰기”에는 적합하지 않다.

D. 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)의 실제 타입이
    • Integer인지
    • Number인지
    • Object인지
      정확히 알 수 없다.
  • 안전하게 볼 수 있는 최소 공통 타입은 Object뿐이라, 반환 타입은 Object로 취급된다.

즉:

  • ? super T
    • T넣는 것은 안전하다.
    • 하지만 꺼낼 때는 항상 T라고 볼 수 없어서 Object로만 다룬다.

? 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하는 것”은 안전하다.
    • Integer는 Integer의 인스턴스
    • Integer는 Number의 하위 타입
    • Integer는 Object의 하위 타입
  • 반대로 get은 안전한 타입이 Object뿐이므로 이렇게 된다.
List<? super Integer> list = new ArrayList<Number>();
Object x = list.get(0);    // OK
// Integer y = list.get(0); // 컴파일 에러

머릿속 이미지:

  • ? extends T
    • T의 자식들이 들어 있는 그릇.
    • 꺼내서 T처럼 쓰는 건 OK,
    • 새로 넣는 건 위험(= 사실상 못 넣음).
  • ? super T
    • T 또는 T 부모들이 들어 있는 그릇.
    • T를 넣는 건 항상 OK,
    • 꺼낼 때는 Object처럼만 안전.

실무에서: 와일드카드 vs <T> 타입 파라미터

글로벌하게 보면, “이 메서드는 ‘유틸리티 함수’에 가깝다 vs 타입 파라미터를 돌려 쓰는 구조다”로 나눠서 생각하면 편하다.

언제 와일드카드(? extends / ? super)를 쓸까

  • 한 방향으로만 사용되는 메서드일 때
    • Producer 전용 (읽기만):
      void printAll(List<? extends Number> list) { ... }
    • Consumer 전용 (쓰기만):
      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;
    }
    • 입력, 출력, 내부 변수 모두 같은 T를 공유한다.
    • 이런 경우 ? 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로 연결돼야 한다”
    타입 파라미터(<T>).

마무리: 실무에서 떠올리기 좋은 체크리스트

마지막으로, 실제 코드 짤 때 떠올리기 좋은 질문들만 정리해 보자.

  1. 이 메서드는 컬렉션에서 읽기만 하는가, 쓰기만 하는가, 둘 다 하는가?
    • 읽기만 → ? extends T 고려.
    • 쓰기만 → ? super T 고려.
    • 둘 다 → 대부분 와일드카드 말고 <T> 타입 파라미터로 가는 게 낫다.
  2. 반환 타입에 제네릭 타입이 연관되어 있는가?
    • 반환 타입에도 같은 타입 관계를 보존해야 하면 <T>.
  3. 여러 파라미터 간에 “같은 T”임을 보장해야 하는가?
    • 예: fromto가 같은 타입 리스트여야 하는 경우 → <T>.
    • 단순히 “Number의 하위 아무 리스트나 다 받겠다” 수준이면 ? extends Number.

이 정도만 몸에 붙으면,

  • B, C, D 같은 함정 포인트를 자연스럽게 걸러낼 수 있고
  • 실무 코드에서도 “여긴 와일드카드, 여긴 <T>”가 꽤 명확하게 갈릴 것이다.
profile
주니어 백엔드 개발자

0개의 댓글