JAVA

Java 와일드카드 완전 정복 — <? extends T>, <? super T> 그리고 PECS 원칙

IT Lab 2026. 2. 20. 20:00

Java 제네릭 와일드카드의 핵심인 extends/super 차이와 PECS 원칙을 실무 예제로 한 번에 정리합니다.

 

제네릭을 쓰다 보면 List<Number> 자리에 List<Integer>를 넣고 싶은데 컴파일 에러가 나서 당황하실 때가 있어요. 반대로 “읽기만 할 건데 왜 타입이 이렇게 까다롭지?” 같은 생각도 들고요. 이럴 때 문제를 풀어주는 도구가 바로 와일드카드(?)입니다.

핵심 개념 — Java 와일드카드와 PECS 원칙이 중요한 이유

Java PECS 원칙을 Producer/Consumer 흐름으로 보여주는 간단한 다이어그램

와일드카드는 “정확한 타입을 지금은 모르지만, 어떤 범위인지는 안다”를 타입 시스템에 표현하는 문법입니다. 핵심은 두 가지예요.

  • <? extends T>: T의 하위 타입 중 하나 (Upper bounded wildcard)
  • <? super T>: T의 상위 타입 중 하나 (Lower bounded wildcard)

여기서 중요한 포인트는 “상속”이 아니라 “제네릭의 불공변성(invariance)”입니다.
Integer extends Number는 맞지만, List<Integer> extends List<Number>아니에요. 그래서 컬렉션을 API로 받을 때는, 딱 떨어지는 List<T>보다 “범위”를 받는 와일드카드가 더 유연하고 안전합니다.

<? extends T>는 “꺼내 읽기”에 강합니다

List<? extends Number>List<Integer>, List<Long> 등을 모두 받을 수 있어요. 대신 이 리스트에 뭔가를 add하려고 하면 대부분 막힙니다.

왜냐하면 컴파일러 입장에서는 이 리스트가 List<Integer>일 수도, List<Double>일 수도 있는데, 거기에 Number를 넣는 순간 타입 안전성이 깨질 수 있거든요.
즉, extendsProducer(생산자): 값을 “내보내는” 쪽에 적합합니다.

<? super T>는 “집어넣기”에 강합니다

List<? super Integer>List<Integer>, List<Number>, List<Object>를 받을 수 있어요. 여기에 Integer는 안전하게 넣을 수 있습니다.
대신 꺼낼 때는 타입을 Object로만 확신할 수 있어요(정확히는 컴파일러가 보장 가능한 최소 타입이 Object).

즉, superConsumer(소비자): 값을 “받아먹는” 쪽에 적합합니다.

PECS 원칙: Producer Extends, Consumer Super

실무에서 와일드카드를 빠르게 결정하는 공식이 PECS입니다.

  • **Producer(읽기/제공)**면 extends
  • **Consumer(쓰기/수집)**면 super

아래 표처럼 정리해 두면, API 설계할 때 흔들릴 일이 확 줄어듭니다.

목적 추천 시그니처 할 수 있는 일 제한
읽기 중심(생산) List<? extends T> T로 안전하게 get() add() 거의 불가(null만 가능)
쓰기 중심(소비) List<? super T> T를 안전하게 add() get()Object 수준
읽기+쓰기 둘 다 List<T> get/add 모두 타입 안정 호출부 타입 유연성 낮음

흐름으로 이해하기: “읽는 쪽” vs “쓰는 쪽”

flowchart LR
  A["Source: Producer"] --> B["List<? extends T>"]
  C["Destination: Consumer"] --> D["List<? super T>"]
  B --> E["Safe: read as T"]
  D --> F["Safe: write T"]

한 줄 설명: extends는 읽기 안전, super는 쓰기 안전이라는 방향성을 보여줍니다.

코드 예제 — extends/super 실무 적용을 한 번에 실행해보기

아래 코드는 “읽기 전용 집계(extends)”와 “복사/수집(super)”를 한 파일에서 보여줍니다. 그대로 복붙해서 실행하실 수 있어요.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class WildcardDemo {

    // Producer Extends: numbers는 "Number를 생산(읽기)"하는 역할
    static double sum(List<? extends Number> numbers) {
        double total = 0.0;
        for (Number n : numbers) { // 안전하게 Number로 읽을 수 있음
            total += n.doubleValue();
        }
        // numbers.add(1); // 컴파일 에러: 어떤 하위 타입 리스트인지 몰라서 안전하지 않음
        return total;
    }

    // Consumer Super: dest는 "T를 소비(쓰기)"하는 역할
    static <T> void copy(List<? extends T> src, List<? super T> dest) {
        for (T t : src) {
            dest.add(t); // 안전하게 T를 넣을 수 있음
        }
    }

    // 실무에서 자주 나오는 형태: "입력은 유연하게, 출력은 구체적으로"
    static List<Number> normalizeToNumberList(List<? extends Number> input) {
        List<Number> out = new ArrayList<>();
        // out은 우리가 소유한 구체 타입(List<Number>)이라 add가 자유롭습니다.
        for (Number n : input) {
            out.add(n);
        }
        return out;
    }

    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1, 2, 3);
        List<Double> doubles = Arrays.asList(0.5, 1.5);

        System.out.println("sum(ints) = " + sum(ints));
        System.out.println("sum(doubles) = " + sum(doubles));

        List<Number> numbers = new ArrayList<>();
        copy(ints, numbers);      // src: List<Integer> -> dest: List<Number>
        copy(doubles, numbers);   // src: List<Double>  -> dest: List<Number>
        System.out.println("numbers after copy = " + numbers);

        List<Object> objects = new ArrayList<>();
        copy(ints, objects); // dest를 더 상위 타입으로도 받을 수 있음 (super의 장점)
        System.out.println("objects after copy = " + objects);

        List<Number> normalized = normalizeToNumberList(ints);
        System.out.println("normalized = " + normalized);
    }
}

실무 팁

💡 실무에서는: “API 입력 파라미터는 와일드카드, 내부 구현은 구체 타입”으로 잡아보세요

  • 외부에서 받는 값(입력)은 List<? extends T>처럼 유연하게 받으면 호출부의 제약이 줄어듭니다.
  • 내부에서 새로 만드는 컬렉션(내가 소유하는 컬렉션)은 List<T>처럼 구체 타입으로 두는 게 유지보수에 좋아요.
  • 특히 “받아서 가공 후 새 리스트로 반환” 패턴은 <? extends T> + 내부 List<T> 조합이 가장 깔끔합니다.

💡 실무에서는: <? super T>는 “배치 적재/수집” 코드에서 빛납니다

  • 예: 여러 타입 소스에서 읽어 List<Number>Collection<Object>에 누적 적재하는 로직
  • copy(src, dest)처럼 src는 extends, dest는 super로 두면 제네릭 메서드의 재사용성이 확 올라갑니다.
  • 단, super 컬렉션에서 꺼내 쓸 때는 Object로만 보장되므로, “넣기 전용”이라는 의도를 코드로 드러내는 용도로 쓰는 게 안전합니다.

핵심 요약

  • <? extends T>는 읽기(Producer)에 강하고, <? super T>는 쓰기(Consumer)에 강합니다.
  • PECS: Producer Extends, Consumer Super만 기억해도 와일드카드 선택이 빨라집니다.
  • 실무 API는 입력을 유연하게(와일드카드), 내부는 구체적으로(명시 타입) 설계해 보세요.

다음 글: [#18 제네릭 실전 패턴]