안동민 개발노트

본문 시작

생산자·소비자 와일드카드

와일드카드의 읽기·쓰기 실패를 재현하고 제네릭 메서드와 PECS 기준으로 상한·하한을 선택합니다.

Box<Dog>와 Box<Animal>은 서로 대입되지 않습니다.

하지만 어떤 메서드는 Dog든 Cat이든 읽기만 하거나, 반대로 Animal 하위 값을 특정 상위 컨테이너에 쓰기만 합니다.

와일드카드는 제네릭 타입 정의를 바꾸지 않고 소비하는 메서드가 안전하게 허용할 타입 범위를 표현합니다.


생산자 와일드카드의 쓰기 제한

상한 와일드카드는 “정확한 타입은 모르지만 Animal의 어떤 하위 타입”입니다.

실제 객체가 Box<Cat>일 수도 있으므로 Dog를 쓰는 것은 안전하지 않습니다.

lab/ExtendsWildcardWriteFailure.java
public final class ExtendsWildcardWriteFailure {
    public static void main(String[] args) {
        Box<Dog> dogs = new Box<>();
        addDog(dogs);
    }

    private static void addDog(Box<? extends Animal> box) {
        box.set(new Dog("dog"));
    }

    private static final class Box<T> {
        private T value;
        void set(T value) { this.value = value; }
    }

    private interface Animal { String name(); }
    private record Dog(String name) implements Animal { }
}
javac 핵심 진단
error: incompatible types: Dog cannot be converted to CAP#1

CAP#1은 컴파일러가 캡처한 미지의 구체 타입입니다.

null을 제외한 특정 Animal을 안전하게 넣을 근거가 없습니다.

대신 get 결과는 최소 Animal이라는 사실이 보장되므로 읽기 생산자에 적합합니다.


소비자 와일드카드의 읽기 제한

하한 와일드카드는 Dog 또는 그 부모 타입의 박스를 받습니다.

어느 경우든 Dog를 저장하는 것은 안전합니다.

그러나 실제 박스가 Box<Object>일 수 있으므로 꺼낸 값이 Dog라는 보장은 없고 정적 반환 타입은 Object입니다.

lab/SuperWildcardReadFailure.java
public final class SuperWildcardReadFailure {
    public static void main(String[] args) {
        Box<Animal> animals = new Box<>();
        Dog dog = readDog(animals);
        System.out.println(dog.name());
    }

    private static Dog readDog(Box<? super Dog> box) {
        box.set(new Dog("stored"));
        return box.get();
    }

    private static final class Box<T> {
        private T value;
        void set(T value) { this.value = value; }
        T get() { return value; }
    }

    private interface Animal { String name(); }
    private record Dog(String name) implements Animal { }
}
javac 핵심 진단
error: incompatible types: CAP#1 cannot be converted to Dog

하한 와일드카드는 소비자 역할에 적합합니다.

Dog를 받아들이는 목적에는 충분하지만 구체 반환 관계까지 필요하면 타입 매개변수를 사용해야 합니다.


비제한 와일드카드

Box<?>는 Box<? extends Object>와 같은 허용 범위를 가지며 모든 Box를 받을 수 있습니다.

값의 toString, null 여부, 박스 자체의 공통 동작처럼 정확한 타입이 필요 없는 읽기에 적합합니다.

비매개변수화 타입과 달리 미지 타입이 존재한다는 사실을 보존하므로 임의 값을 넣지 못합니다.

src/WildcardReadAndCopy.java
import java.util.ArrayList;
import java.util.List;

public final class WildcardReadAndCopy {
    public static void main(String[] args) {
        List<Dog> source = List.of(new Dog("d1"), new Dog("d2"));
        List<Animal> target = new ArrayList<>();

        printAny(source);
        copyDogs(source, target);
        System.out.println("copied=" + target);
    }

    private static void printAny(List<?> values) {
        System.out.println("size=" + values.size() + ",first=" + values.getFirst());
    }

    private static void copyDogs(List<? extends Dog> source, List<? super Dog> target) {
        for (Dog dog : source) {
            target.add(dog);
        }
    }

    private interface Animal { String name(); }
    private record Dog(String name) implements Animal { }
}
size=2,first=Dog[name=d1]
copied=[Dog[name=d1], Dog[name=d2]]

source는 Dog 생산자라 extends, target은 Dog 소비자라 super입니다.

PECS(Producer Extends, Consumer Super)는 이 방향을 기억하는 규칙입니다.

한 매개변수에서 읽고 쓰기를 모두 해야 하면 와일드카드보다 정확한 List<T>가 필요할 수 있습니다.

와일드카드 안에서 허용되는 연산

extends 입력의 Dog 저장과 super 입력의 Dog 반환은 각각 컴파일 실패한다. List<?> 관찰 예제는 구체 타입을 몰라도 원소를 Object 관점에서 읽어 출력한다.

와일드카드 안에서 허용되는 연산
메서드의 매개변수알 수 있는 타입 관계예제의 연산과 결과
Box<? extends Animal>정확한 Animal 하위 타입은 모름
box.set(new Dog(...))
컴파일 실패
Dog를 그 미지 타입에 저장할 수 없음
Box<? super Dog>
Dog 저장은 허용
꺼낸 값의 보장 타입은 Object
return box.get()
Dog 반환에서 컴파일 실패
List<?>원소를 Object 관점에서 관찰
getFirst()로 첫 원소를 출력
size=2,first=Dog[name=d1]
Box<? extends Animal>
알 수 있는 타입 관계: 정확한 Animal 하위 타입은 모름
예제의 연산과 결과:
box.set(new Dog(...))
컴파일 실패
Dog를 그 미지 타입에 저장할 수 없음
Box<? super Dog>
알 수 있는 타입 관계:
Dog 저장은 허용
꺼낸 값의 보장 타입은 Object
예제의 연산과 결과:
return box.get()
Dog 반환에서 컴파일 실패
List<?>
알 수 있는 타입 관계: 원소를 Object 관점에서 관찰
예제의 연산과 결과:
getFirst()로 첫 원소를 출력
size=2,first=Dog[name=d1]

상한 와일드카드에도 null은 타입상 저장할 수 있습니다. 타입 검사가 허용하는 연산과 컬렉션 구현이 실제 수정을 지원하는지는 별개의 조건입니다.


와일드카드와 제네릭 메서드

단지 값을 출력하고 부모 기능만 호출하면 와일드카드 일반 메서드가 더 단순합니다.

입력 박스의 정확한 타입을 반환까지 보존해야 하면 제네릭 메서드의 T가 필요합니다.

src/GenericMethodVersusWildcard.java
public final class GenericMethodVersusWildcard {
    public static void main(String[] args) {
        Box<Dog> box = new Box<>(new Dog("dori", "A-7"));

        Animal viewed = view(box);
        Dog preserved = preserve(box);

        System.out.println("view=" + viewed.name());
        System.out.println("code=" + preserved.code());
    }

    private static Animal view(Box<? extends Animal> box) {
        return box.get();
    }

    private static <T extends Animal> T preserve(Box<T> box) {
        return box.get();
    }

    private record Box<T>(T get) { }
    private interface Animal { String name(); }
    private record Dog(String name, String code) implements Animal { }
}
view=dori
code=A-7

T는 입력과 출력의 동일 타입 관계를 명명합니다.

와일드카드 메서드는 그런 관계를 새로 정의하지 않고 이미 타입 인자가 정해진 Box들을 넓게 받아 부모 관점으로 사용합니다.

같은 Box를 읽는 두 반환 계약

같은 Box<Dog>를 읽어도 view는 Animal을 반환하고 preserve는 입력의 T를 반환한다. main의 Dog preserved 참조는 code를 형변환 없이 호출한다.

같은 Box를 읽는 두 반환 계약
메서드main에서 받는 타입원문에서 사용하는 기능
view(box)
Box<? extends Animal> 입력
Animal viewed
viewed.name()
view=dori
preserve(box)
Box<T> 입력, T 반환
Dog preserved
preserved.code()
code=A-7
view(box)
Box<? extends Animal> 입력
main에서 받는 타입: Animal viewed
원문에서 사용하는 기능:
viewed.name()
view=dori
preserve(box)
Box<T> 입력, T 반환
main에서 받는 타입: Dog preserved
원문에서 사용하는 기능:
preserved.code()
code=A-7

두 호출은 같은 Dog dori를 읽습니다. 달라지는 것은 결과를 통해 컴파일러가 허용하는 멤버의 범위입니다.


타입 매개변수 이름 가림

제네릭 클래스 안에 <T> T method(T value)를 선언하면 메서드의 T가 클래스의 T와 별개이며 더 가까운 이름이 바깥 T를 가립니다.

서로 같은 타입처럼 보이지만 호출마다 전혀 다른 타입이 될 수 있습니다.

메서드 타입에는 R, U 같은 다른 이름을 사용하면 범위가 드러납니다.

예를 들어 class Converter<T> { <R> R convert(T source, Function<T,R> fn) }에서는 T가 인스턴스 입력 타입이고 R은 호출별 결과입니다.

같은 T 이름을 재사용하면 source와 result가 같은 타입이라는 잘못된 인상을 줍니다.

타입 변수 이름은 짧아도 관계 설명의 일부입니다.


와일드카드 선택표

  • 정확한 타입을 몰라도 읽기만 한다: List<?>
  • Animal 하위 값을 Animal로 생산한다: List<? extends Animal>
  • Dog 값을 상위 컨테이너에 소비시킨다: List<? super Dog>
  • 입력 타입을 반환에도 그대로 보존한다: <T> T method(...)
  • 객체 상태 전체가 같은 T를 공유한다: class Box<T>
  • 읽기와 쓰기를 모두 정확한 타입으로 한다: List<T>

와일드카드를 지나치게 중첩하면 호출자가 CAP 진단을 이해하기 어렵습니다.

공개 API는 흔한 생산자·소비자 변형만 제공하고 복잡한 캡처는 내부 도우미 제네릭 메서드로 옮길 수 있습니다.

반환 타입에 ? extends를 노출하면 호출자가 다시 미지 타입을 다뤄야 하므로 가능한 한 안정된 인터페이스나 명명된 T로 반환합니다.


연습 문제

copy(List<? extends T> source, List<? super T> target)를 작성하고 List<ScoredPost>에서 List<Scored>로 복사하세요.

target에서 읽을 때는 Scored 관점으로 점수를 합산합니다.

해설 보기
src/PecsCopyExercise.java
import java.util.ArrayList;
import java.util.List;

public final class PecsCopyExercise {
    public static void main(String[] args) {
        List<ScoredPost> source = List.of(
                new ScoredPost(101L, 80), new ScoredPost(102L, 95));
        List<Scored> target = new ArrayList<>();

        copy(source, target);
        int total = target.stream().mapToInt(Scored::score).sum();
        System.out.println("count=" + target.size());
        System.out.println("total=" + total);
    }

    private static <T> void copy(List<? extends T> source, List<? super T> target) {
        for (T value : source) {
            target.add(value);
        }
    }

    private interface Scored { int score(); }
    private record ScoredPost(long postId, int score) implements Scored { }
}
count=2
total=175

T는 source 원소와 target에 추가할 값의 관계를 묶습니다.

source는 생산하고 target은 소비하므로 PECS 방향과 일치합니다.

복사 메서드와 호출자가 보는 타입

copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다.

복사 메서드와 호출자가 보는 타입
코드의 위치보이는 타입가능한 동작
copy 안의 sourceList<? extends T>
for (T value : source)
원소를 T로 읽음
copy 안의 targetList<? super T>
target.add(value)
T 값을 추가
main의 targetList<Scored>
Scored::score로 합산
count=2
total=175
copy 안의 source
보이는 타입: List<? extends T>
가능한 동작:
for (T value : source)
원소를 T로 읽음
copy 안의 target
보이는 타입: List<? super T>
가능한 동작:
target.add(value)
T 값을 추가
main의 target
보이는 타입: List<Scored>
가능한 동작:
Scored::score로 합산
count=2
total=175

copy의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다.