본문 바로가기

와일드카드와 PECS

Box<Dog>와 Box<Cat>을 함께 받는 메서드를 와일드카드로 만들고, ? extends는 값을 꺼낼 때, ? super는 값을 넣을 때 쓴다는 PECS 규칙과 제네릭 메서드와의 차이를 배웁니다.

제네릭 클래스와 타입 매개변수에서 Dog가 Animal의 하위 타입이어도 Box<Dog>는 Box<Animal>의 하위 타입이 아니라는 것을 확인했습니다. 이런 성질을 불공변(invariant)이라고 했습니다. 그런데 이 규칙은 곧 불편을 낳습니다. 상자에 든 동물의 이름을 출력하는 메서드를 printName(Box<Animal> box)로 만들면, 정작 개 상자를 넘기는 호출이 컴파일되지 않습니다.

컴파일 오류의 핵심
error: incompatible types: Box<Dog> cannot be converted to Box<Animal>

이름을 읽기만 하는 메서드라면 개 상자든 고양이 상자든 받아도 문제가 없어 보입니다. 이 글에서는 이럴 때 쓰는 와일드카드(wildcard) ?를 배웁니다. ? extends와 ? super가 각각 무엇을 허락하고 무엇을 막는지 실제 컴파일 오류로 확인하고, 둘 중 무엇을 고를지 정하는 PECS 규칙과, 앞 글의 제네릭 메서드로 같은 일을 할 때와 무엇이 다른지 비교합니다.

? extends로 여러 하위 타입의 상자 받기

Box<? extends Animal>은 'T가 Animal이거나 그 하위 타입인 어떤 상자'를 뜻합니다. 여기서 ?는 아직 모르는 어떤 타입이고, extends Animal은 그 타입의 위쪽 한계, 즉 상한입니다. 그래서 이 타입의 매개변수에는 Box<Dog>도, Box<Cat>도, Box<Animal>도 넘길 수 있습니다. 이런 와일드카드를 상한 와일드카드(upper-bounded wildcard)라고 합니다.

src/ExtendsWildcardRead.java
public final class ExtendsWildcardRead {
    public static void main(String[] args) {
        Box<Dog> dogs = new Box<>(new Dog("dori"));
        Box<Cat> cats = new Box<>(new Cat("nabi"));
        printName(dogs);
        printName(cats);
    }

    private static void printName(Box<? extends Animal> box) {
        Animal animal = box.get();
        System.out.println("name=" + animal.name());
    }

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

    private interface Animal { String name(); }
    private record Dog(String name) implements Animal { }
    private record Cat(String name) implements Animal { }
}
name=dori
name=nabi

같은 printName이 개 상자와 고양이 상자를 모두 받았습니다. 메서드 안에서 box.get()의 결과를 Animal 변수에 담을 수 있는 이유는, 상자의 실제 타입 인자가 무엇이든 Animal의 하위 타입이라는 것만은 확실하기 때문입니다. 정확한 타입은 몰라도 '적어도 Animal'이라는 것을 알면 꺼낸 값으로 name()을 부를 수 있습니다.

꺼내기는 되지만 넣기는 막힌다

그렇다면 같은 상자에 Dog를 넣는 것은 어떨까요? 다음 메서드는 Box<? extends Animal>로 받은 상자에 새 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 { }
}
컴파일 오류
lab/ExtendsWildcardWriteFailure.java:8: error: incompatible types: Dog cannot be converted to CAP#1
        box.set(new Dog("dog"));
                ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Animal from capture of ? extends Animal
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

main은 실제로 개 상자를 넘겼으니 개를 넣어도 될 것 같지만, 컴파일러는 addDog의 본문만 보고 검사합니다. 이 메서드는 Box<Cat>을 받을 수도 있고, 그 상자에 Dog를 넣으면 고양이 상자에 개가 들어가게 됩니다. 그래서 Dog든 Cat이든 특정한 동물은 넣을 수 없습니다. 와일드카드로 받은 상자에 안전하게 넣을 수 있는 값은 어떤 참조 타입의 변수에도 들어가는 null 하나뿐입니다.

오류 메시지의 CAP#1은 컴파일러가 ? 자리의 모르는 타입에 임시로 붙인 이름입니다. 마지막 줄은 'CAP#1은 ? extends Animal을 캡처해서 만든 새 타입 변수로, Animal의 하위 타입'이라는 뜻입니다. 이렇게 와일드카드 자리에 이름 없는 구체 타입이 하나 있다고 보고 이름을 붙이는 것을 와일드카드 캡처(wildcard capture)라고 합니다. 컴파일러가 이 타입에 대해 아는 것은 'Animal의 어떤 하위 타입'이라는 사실뿐이라서, Dog가 바로 그 타입이라고 확신할 수 없는 것입니다.

정리하면 ? extends Animal로 받은 상자는 값을 꺼내 Animal로 쓰는 데는 문제가 없지만 새 값을 넣지는 못합니다. 메서드 쪽에서 보면 이 상자는 값을 만들어 내주는 쪽, 즉 생산자(producer)입니다.

? super로 값을 넣을 상자 받기

반대 방향도 있습니다. Box<? super Dog>는 'T가 Dog이거나 그 상위 타입인 어떤 상자'를 뜻합니다. super Dog는 모르는 타입의 아래쪽 한계, 즉 하한이므로 이런 와일드카드를 하한 와일드카드(lower-bounded wildcard)라고 합니다. 이 매개변수에는 Box<Dog>, Box<Animal>, Box<Object>를 넘길 수 있습니다.

이번에는 넣기가 안전합니다. 세 상자 모두 Dog를 담을 수 있으니 box.set(new Dog(...))은 컴파일됩니다. 그러나 꺼낸 값을 Dog로 받으려 하면 막힙니다.

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 { }
}
컴파일 오류
lab/SuperWildcardReadFailure.java:10: error: incompatible types: CAP#1 cannot be converted to Dog
        return box.get();
                      ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object super: Dog from capture of ? super Dog
1 error

오류는 set이 있는 9번 줄이 아니라 return box.get();이 있는 10번 줄에서 납니다. 넣기는 통과하고 꺼내기에서 막힌 것입니다. 이 상자의 실제 타입이 Box<Object>일 수도 있다고 생각하면 이유가 보입니다. 그런 상자에는 다른 코드가 문자열을 넣어 두었을 수 있으므로, 꺼낸 값이 Dog라고 장담할 수 없습니다. 메시지의 CAP#1 extends Object super: Dog가 바로 'Dog의 어떤 상위 타입이고, 확실한 것은 Object라는 사실뿐'이라는 뜻입니다. 그래서 꺼낸 값은 Object 변수에만 담을 수 있습니다.

이 메서드에서 상자는 값을 받아들이는 쪽, 즉 소비자(consumer)입니다. 넣은 Dog를 다시 Dog로 꺼내야 하는 메서드라면 와일드카드가 아니라 뒤에서 볼 타입 매개변수를 써야 합니다.

모든 상자를 받는 ?

Box<?>처럼 한계 없이 ?만 쓴 것을 비한정 와일드카드(unbounded wildcard)라고 합니다. 어떤 타입 인자의 상자든 받을 수 있고, Box<? extends Object>와 같은 범위를 가집니다. 꺼낸 값은 Object로만 쓸 수 있으므로, 원소의 개수나 toString() 결과처럼 정확한 타입이 필요 없는 일에 알맞습니다.

다음 프로그램의 printAny는 List<?>로 어떤 목록이든 받아 크기와 첫 원소를 출력하고, copyDogs는 개 목록에서 꺼낸 개를 다른 목록에 넣습니다. List는 여러 값을 순서대로 담는 표준 라이브러리의 제네릭 인터페이스이고, List.of(...)는 원소를 고정한 목록을, new ArrayList<>()는 원소를 추가할 수 있는 빈 목록을 만듭니다. 목록 자체는 선형 자료구조 구현 장에서 직접 만들어 보며 자세히 다룹니다.

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]]

printAny는 원소가 무엇인지 몰라도 되는 일만 합니다. getFirst()는 Java 21에서 List에 추가된 메서드로 첫 원소를 돌려주는데, List<?>에서 꺼냈으니 그 결과는 Object로만 다룰 수 있습니다. 그래도 문자열과 이어 붙이면 toString()이 호출되므로 레코드의 기본 문자열 표현인 Dog[name=d1]이 출력됩니다.

copyDogs의 두 매개변수에는 앞에서 본 두 와일드카드가 하나씩 쓰였습니다. source에서는 개를 꺼내기만 하므로 ? extends Dog이고, target에는 개를 넣기만 하므로 ? super Dog입니다. 덕분에 List<Dog>에서 List<Animal>로 옮기는 것처럼, 두 목록의 타입 인자가 달라도 하나의 메서드로 복사할 수 있습니다. 둘째 줄은 target에 같은 두 개가 순서대로 들어갔다는 것을 보여 줍니다.

List<?>는 raw 타입 List와 다릅니다. raw 타입은 타입 검사를 건너뛰므로 List<String>을 raw 변수에 담으면 경고만 나올 뿐 정수를 넣는 코드가 컴파일되고, 문자열 목록이 망가집니다. 반면 List<?>는 '원소 타입이 정해져 있지만 모를 뿐'이라는 정보를 유지하므로, null을 빼면 어떤 값도 넣지 못하게 막습니다. 모든 타입의 목록을 받고 싶다면 raw 타입이 아니라 List<?>를 쓰세요.

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

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

와일드카드 · 허용 연산

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

extends의 미지 타입에 Dog를 쓰거나 super의 읽기를 Dog로 반환할 수 없습니다. List<?>의 실제 관찰은 Object 관점입니다.

와일드카드 사용 계약과 실패 경계 독립된 세 원문입니다. extends Animal 박스에 Dog를 쓰는 시도는 알 수 없는 구체 타입 때문에 컴파일 실패합니다. super Dog 박스에서 Dog로 반환하려는 읽기는 Object 관찰 계약 때문에 컴파일 실패합니다. List<?>의 첫 원소 읽기는 Object 관찰로 정상 출력하며 나눈 size와 first 필드는 원 stdout 한 줄입니다. 와일드카드가 수정 불가 구현을 뜻하지는 않습니다. 원문 A · extends 쓰기 Box 읽기 관찰 Box<? extends Animal> Dog 넣기 시도 알 수 없는 구체 하위 T 컴파일 실패 Dog → CAP#1 불가 원문 B · super 읽기 Box에 Dog 쓰기 Box<? super Dog> get을 Dog로 반환 시도 읽기 계약은 Object 컴파일 실패 CAP#1 → Dog 불가 원문 C · List<?> 읽기 두 Dog의 목록 d1 다음 d2 printAny로 관찰 getFirst는 Object로 읽음 정상 출력 한 줄 size=2,first= Dog[name=d1] 영역마다 별도 원문 프로그램 실선: 사용과 검사·관찰 결과 와일드카드는 불변 목록 표시 아님
와일드카드 사용 계약과 실패 경계 독립된 세 원문입니다. extends Animal 박스에 Dog를 쓰는 시도는 알 수 없는 구체 타입 때문에 컴파일 실패합니다. super Dog 박스에서 Dog로 반환하려는 읽기는 Object 관찰 계약 때문에 컴파일 실패합니다. List<?>의 첫 원소 읽기는 Object 관찰로 정상 출력하며 나눈 size와 first 필드는 원 stdout 한 줄입니다. 와일드카드가 수정 불가 구현을 뜻하지는 않습니다. 원문 A · extends 쓰기 Box 읽기 관찰 Box<? extends Animal> Dog 넣기 시도 알 수 없는 구체 하위 T 컴파일 실패 Dog → CAP#1 불가 원문 B · super 읽기 Box에 Dog 쓰기 Box<? super Dog> get을 Dog로 반환 시도 읽기 계약은 Object 컴파일 실패 CAP#1 → Dog 불가 원문 C · List<?> 읽기 두 Dog의 목록 d1 다음 d2 printAny로 관찰 getFirst는 Object로 읽음 정상 출력 한 줄 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]
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은 타입상 저장할 수 있습니다. 타입 검사가 허용하는 연산과 컬렉션 구현이 실제 수정을 지원하는지는 별개의 조건입니다.

넣지 못한다고 바꿀 수 없는 것은 아니다

? extends로 받은 목록이 수정할 수 없는 목록이 된다고 생각하기 쉽지만 그렇지 않습니다. 와일드카드가 막는 것은 특정 타입의 값을 넣는 일뿐이고, 원소의 타입과 상관없는 remove나 clear는 그대로 컴파일됩니다. 다음 프로그램에서 확인해 보겠습니다.

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

public final class WildcardMutationScope {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>(List.of(1, 2));
        List<? extends Number> producer = numbers;
        Number first = producer.getFirst();
        producer.remove(0);
        System.out.println("first=" + first + ",remaining=" + numbers);
        producer.clear();
        System.out.println("after-clear=" + numbers.size());

        List<?> fixed = List.of(1);
        try {
            fixed.clear();
        } catch (UnsupportedOperationException error) {
            System.out.println("fixed=unmodifiable");
        }
    }
}
first=1,remaining=[2]
after-clear=0
fixed=unmodifiable

producer는 numbers와 같은 ArrayList를 가리킵니다. producer.remove(0)으로 첫 원소를 지우자 numbers에서도 1이 사라졌고, clear() 뒤에는 크기가 0이 되었습니다. 반대로 마지막 fixed.clear()는 타입 검사를 통과했는데도 실행 중에 UnsupportedOperationException이 발생했습니다. List.of가 만든 목록은 원소를 바꿀 수 없는 수정 불가 목록(unmodifiable list)이라서, 바꾸는 메서드를 호출하면 이 예외를 던지기 때문입니다.

즉 와일드카드는 컴파일러가 허락하는 연산을 정할 뿐이고, 목록을 실제로 바꿀 수 있는지는 넘겨받은 객체의 구현이 정합니다. List의 수정 불가 목록 설명도 수정 메서드를 호출하면 UnsupportedOperationException이 발생한다고 적고 있습니다. 앞의 copyDogs에서 List.of로 만든 source는 읽기만 했고, 원소를 넣은 target은 ArrayList였기 때문에 문제가 없었습니다.

PECS: 꺼내면 extends, 넣으면 super

와일드카드를 고를 때는 메서드가 그 매개변수로 무엇을 하는지만 보면 됩니다. 값을 꺼내 쓰기만 하면 그 매개변수는 생산자이므로 ? extends를, 값을 넣기만 하면 소비자이므로 ? super를 씁니다. 이를 줄여 PECS(Producer Extends, Consumer Super)라고 부릅니다. 『이펙티브 자바』가 널리 알린 기억법입니다.

메서드가 매개변수로 하는 일매개변수 타입의 예넘길 수 있는 인자
꺼내서 Animal로 쓴다List<? extends Animal>List<Animal>, List<Dog>, List<Cat>
Dog를 넣는다List<? super Dog>List<Dog>, List<Animal>, List<Object>
정확한 타입이 필요 없다List<?>모든 List
꺼낸 것을 같은 목록에 다시 넣는다List<T> (제네릭 메서드)모든 List

마지막 줄처럼 한 매개변수에서 꺼내기와 넣기를 모두 하면 와일드카드로는 표현할 수 없습니다. 이때는 정확한 타입이 필요하므로 앞 글의 제네릭 메서드처럼 타입 매개변수를 씁니다.

자바 표준 라이브러리도 이 규칙을 따릅니다. Collections.copy의 선언은 <T> void copy(List<? super T> dest, List<? extends T> src)로, 복사할 원소를 꺼내는 src에는 extends를, 원소를 받는 dest에는 super를 썼습니다. 목록에 다른 컬렉션의 원소를 모두 더하는 addAll(Collection<? extends E> c)도, 정렬 기준을 받는 sort(Comparator<? super E> c)도 같은 규칙으로 읽을 수 있습니다. 비교자는 원소를 받아 비교하므로 소비자이고, 그래서 Comparator<Animal> 하나로 List<Dog>를 정렬할 수 있습니다.

와일드카드 메서드와 제네릭 메서드 비교하기

앞 글에서 배운 제네릭 메서드로도 여러 하위 타입의 상자를 받을 수 있습니다. 그렇다면 둘 중 무엇을 써야 할까요? 같은 상자를 두 방식으로 받아 보겠습니다.

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

Box는 get이라는 컴포넌트 하나를 가진 레코드라서, 생성자에 넘긴 값을 get()으로 돌려줍니다. view와 preserve는 같은 상자에서 같은 Dog 객체를 꺼내지만 돌려주는 타입이 다릅니다. view의 반환 타입은 Animal로 고정되어 있으므로 main은 결과로 name()만 부를 수 있습니다. 반면 preserve는 Box<T>의 T가 Dog로 추론되고 반환 타입도 그 T이므로, main이 결과를 Dog로 받아 code()를 호출할 수 있습니다.

같은 Box를 읽는 두 반환 계약

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

반환 계약 · 같은 객체의 두 관점

같은 Box를 읽는 두 반환 계약

두 호출은 같은 dori를 읽습니다. view는 Animal 관점으로 반환하고 preserve는 입력의 T를 유지해 Dog의 code를 호출하게 합니다.

같은 Dog 박스의 부모 관찰과 T 보존 하나의 Box<Dog>에 dori A-7을 보관합니다. view는 같은 Dog를 Animal 선언으로 반환하여 view=dori를 읽고 preserve는 T=Dog로 반환하여 code=A-7을 읽습니다. 실선은 같은 박스를 두 호출에 전달하고 반환값을 관찰하는 관계입니다. 서로 다른 Dog 두 개를 만들지 않습니다. 한 Box<Dog> Dog dori / A-7 view의 부모 관찰 preserve의 T 관계 view 매개변수 Box<? extends Animal> preserve 매개변수 Box<T>, T는 Dog Animal로 반환 같은 Dog 객체 view=dori Dog로 반환 같은 Dog 객체 code=A-7 실선: 같은 박스를 두 호출에 전달 · 두 반환은 같은 Dog 객체 view의 부모 관찰과 preserve의 입력·반환 T 관계를 구별
같은 Dog 박스의 부모 관찰과 T 보존 하나의 Box<Dog>에 dori A-7을 보관합니다. view는 같은 Dog를 Animal 선언으로 반환하여 view=dori를 읽고 preserve는 T=Dog로 반환하여 code=A-7을 읽습니다. 실선은 같은 박스를 두 호출에 전달하고 반환값을 관찰하는 관계입니다. 서로 다른 Dog 두 개를 만들지 않습니다. 한 Box<Dog> Dog dori / A-7 view의 부모 관찰 view 매개변수 Box<? extends Animal> Animal로 반환 같은 Dog 객체 view=dori preserve의 T 관계 preserve 매개변수 Box<T>, T는 Dog Dog로 반환 같은 Dog 객체 code=A-7 실선: 같은 박스를 각 호출에 전달 두 반환은 같은 Dog를 가리킴 다른 객체 두 개를 만들지 않음

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는 입력의 타입에 이름을 붙이므로, 그 이름을 반환 타입이나 다른 매개변수에 다시 써서 '들어온 것과 같은 타입이 나간다'는 관계를 표현할 수 있습니다. 와일드카드 ?에는 이름이 없어서 그런 관계를 표현하지 못합니다. 대신 관계가 필요 없을 때는 선언이 짧고 의도가 분명합니다. 그래서 다음 기준으로 고르면 됩니다. 타입 매개변수가 선언에 한 번만 나오면 와일드카드로 바꿀 수 있고, 반환 타입이나 다른 매개변수와 연결되어 두 번 이상 나오면 타입 매개변수가 필요합니다.

static void printAll(List<?> list)    // 타입이 한 번만 쓰인다: 와일드카드로 충분하다
static <T> T firstOf(List<T> list)    // 입력과 반환이 같은 T로 이어진다: 타입 매개변수가 필요하다

더 깊이: 와일드카드로 받은 목록의 원소 바꾸기

List<?>로 받은 목록에서 두 원소의 자리를 바꾸는 메서드를 생각해 봅시다. list.set(0, list.get(1))은 같은 목록에서 꺼낸 값을 같은 목록에 넣는 것이니 안전해 보이지만 Object cannot be converted to CAP#1 오류가 납니다. 컴파일러가 보기에 get의 결과는 Object이고, set이 받는 것은 이름 없는 타입 CAP#1이기 때문입니다.

해결 방법은 와일드카드에 이름을 붙여 주는 비공개 도우미 메서드를 두는 것입니다. private static <T> void swapHelper(List<T> list)를 만들고 List<?>를 그대로 넘기면, 컴파일러가 캡처한 타입을 T로 추론해 줍니다. 도우미 안에서는 꺼낸 값이 T이고 넣는 자리도 T이므로 set이 컴파일됩니다.

static void swapFirstTwo(List<?> list) {
    swapHelper(list);  // 캡처한 타입이 T로 추론된다
}

private static <T> void swapHelper(List<T> list) {
    T first = list.get(0);
    list.set(0, list.get(1));
    list.set(1, first);
}

List.of("a", "b", "c")를 복사한 ArrayList를 넘기면 [b, a, c]가 됩니다. 표준 라이브러리의 Collections.swap(List<?> list, int i, int j)도 공개 선언을 이렇게 와일드카드로 두어서, 호출하는 쪽은 타입 매개변수를 신경 쓸 필요가 없습니다. 공개 API는 단순한 와일드카드로 두고 복잡한 타입 관계는 안쪽으로 감추는 방식입니다.

와일드카드를 쓸 때 주의할 점

와일드카드는 메서드의 매개변수에 쓸 때 가장 쓸모가 있습니다. 반환 타입에 List<? extends Animal>처럼 와일드카드를 쓰면, 받은 쪽이 다시 '모르는 타입'을 다뤄야 하므로 원소를 넣을 수도 없고 정확한 타입을 알 수도 없습니다. 반환 타입은 List<Animal>이나 타입 매개변수 T처럼 이름 있는 타입으로 두는 편이 쓰기 편합니다.

와일드카드를 다른 타입 인자 안에 겹쳐 쓸 때도 조심해야 합니다. 주인별 동물 목록을 받으려고 Map<String, List<? extends Animal>>을 매개변수로 두면, 정작 Map<String, List<Dog>>를 넘기는 호출이 incompatible types 오류로 거부됩니다. 와일드카드는 바로 그 자리의 타입 인자에만 적용되므로, List<Dog>는 List<? extends Animal>에 대입할 수 있어도 맵의 값 타입 자리에서는 두 타입이 서로 다른 타입 인자로 취급되기 때문입니다. 받으려면 Map<String, ? extends List<? extends Animal>>처럼 바깥에도 와일드카드를 써야 하는데, 이쯤 되면 읽는 사람도 호출하는 사람도 선언을 해석하기 어렵습니다. 공개 메서드에는 흔한 생산자, 소비자 형태만 두고, 그보다 복잡한 관계는 앞의 '더 깊이'에서 본 것처럼 내부 도우미 제네릭 메서드로 옮기세요.

정리

  • 제네릭은 불공변이라 Box<Dog>를 Box<Animal> 매개변수에 넘길 수 없습니다. 와일드카드 ?는 여러 매개변수화 타입을 한 매개변수로 받게 해 줍니다.
  • ? extends Animal로 받은 상자에서는 값을 꺼내 Animal로 쓸 수 있지만, null을 빼면 값을 넣을 수 없습니다. 이 상자는 생산자입니다.
  • ? super Dog로 받은 상자에는 Dog를 넣을 수 있지만, 꺼낸 값은 Object로만 다룰 수 있습니다. 이 상자는 소비자입니다.
  • ?만 쓴 비한정 와일드카드는 모든 타입의 상자를 받고, raw 타입과 달리 아무 값이나 넣지 못하게 막습니다. 와일드카드는 넣을 수 있는 값을 제한할 뿐, remove나 clear까지 막지는 않습니다.
  • PECS: 꺼내면 extends, 넣으면 super, 둘 다 하거나 입력과 반환의 타입을 연결해야 하면 타입 매개변수를 씁니다.

연습 문제

문제 1: PECS로 목록 복사하기

copy(List<? extends T> source, List<? super T> target)를 제네릭 메서드로 작성하세요. score()를 가진 Scored 인터페이스와 이를 구현한 레코드 ScoredPost(long postId, int score)를 두고, List.of(new ScoredPost(101L, 80), new ScoredPost(102L, 95))의 원소를 빈 ArrayList<Scored>로 복사합니다. copy는 원소를 source 순서대로 target 끝에 추가하기만 합니다. 복사한 뒤 target의 원소 수와 점수 합계를 count=, total= 형식으로 출력하세요.

이어서 다음 두 상황의 결과도 예상해 보세요. 첫째, target에 처음부터 점수 10인 게시글이 하나 들어 있었다면 count와 total은 얼마일까요? 둘째, 그 상태에서 같은 copy를 한 번 더 호출하면 앞의 결과를 덮어쓸까요?

해설 보기
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 = 0;
        for (Scored value : target) {
            total += value.score();
        }
        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

copy의 T는 source에서 꺼낸 값과 target에 넣을 값을 같은 타입으로 묶어 줍니다. 이 호출에서 컴파일러는 T를 ScoredPost로 추론합니다. List<ScoredPost>는 List<? extends ScoredPost>에, List<Scored>는 List<? super ScoredPost>에 들어맞기 때문입니다. source는 값을 내주므로 extends, target은 값을 받으므로 super인 PECS 방향 그대로입니다.

copy 안에서 target은 List<? super T>이므로, 거기서 꺼낸 값은 Object로만 다룰 수 있습니다. 하지만 main이 선언한 target의 타입은 여전히 List<Scored>라서, 복사가 끝난 뒤 main의 향상된 for에서는 원소를 Scored로 꺼내 score()를 호출할 수 있습니다. for 반복과 중첩 반복에서 배운 향상된 for는 배열뿐 아니라 이런 목록에도 쓸 수 있습니다. total은 0에서 시작해 80과 95를 더해 175가 됩니다. 아래 그림에서 메서드 안과 main에서 보이는 타입이 어떻게 다른지 확인해 보세요.

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

source의 두 참조를 target에 copy한 뒤 Scored로 읽어 175를 누산합니다. PECS의 공급/소비 역할을 유지하며 목록과 합계의 실제 전후를 보여 줄 수 있습니다.

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

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

PECS · 메서드와 호출자 관점

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

copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다.

메서드의 읽기·쓰기 계약과 호출자 목록source List<ScoredPost>의 두 참조를 extends T로 순회하고 super T target 끝에 추가합니다. 호출자의 target 선언은 List<Scored> 그대로이며 score 합계 175와 count 2를 읽습니다. 실선은 순회·추가·호출자 읽기의 연결입니다. 원소를 새 객체로 복제하거나 호출자 target을 List<Object>로 바꾸지 않습니다.
호출자의 sourceList<ScoredPost>101 / 80, 102 / 95
연결 관계호출자의 source · List<ScoredPost> · 101 / 80, 102 / 95 → copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회
copy의 읽기 계약List<? extends T>source의 두 참조를 순회
연결 관계copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회 → copy의 쓰기 계약 · List<? super T> · target.add(value)
copy의 쓰기 계약List<? super T>target.add(value)
연결 관계copy의 쓰기 계약 · List<? super T> · target.add(value) → 호출자의 target · List<Scored> · 같은 두 객체 참조 보관
호출자의 targetList<Scored>같은 두 객체 참조 보관
연결 관계호출자의 target · List<Scored> · 같은 두 객체 참조 보관 → Scored로 읽기 · score로 80 + 95 · count=2 / total=175
Scored로 읽기score로 80 + 95count=2 / total=175

실선: source 순회 → target에 참조 추가 → 호출자가 Scored로 읽기와일드카드는 메서드의 사용 계약 · caller target 타입을 Object로 바꾸거나 원소를 복제하지 않음

메서드의 읽기·쓰기 계약과 호출자 목록source List<ScoredPost>의 두 참조를 extends T로 순회하고 super T target 끝에 추가합니다. 호출자의 target 선언은 List<Scored> 그대로이며 score 합계 175와 count 2를 읽습니다. 실선은 순회·추가·호출자 읽기의 연결입니다. 원소를 새 객체로 복제하거나 호출자 target을 List<Object>로 바꾸지 않습니다.
호출자의 sourceList<ScoredPost>101 / 80, 102 / 95
연결 관계호출자의 source · List<ScoredPost> · 101 / 80, 102 / 95 → copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회
copy의 읽기 계약List<? extends T>source의 두 참조를 순회
연결 관계copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회 → copy의 쓰기 계약 · List<? super T> · target.add(value)
copy의 쓰기 계약List<? super T>target.add(value)
연결 관계copy의 쓰기 계약 · List<? super T> · target.add(value) → 호출자의 target · List<Scored> · 같은 두 객체 참조 보관
호출자의 targetList<Scored>같은 두 객체 참조 보관
연결 관계호출자의 target · List<Scored> · 같은 두 객체 참조 보관 → Scored로 읽기 · score로 80 + 95 · count=2 / total=175
Scored로 읽기score로 80 + 95count=2 / total=175

실선: 순회·추가·읽기 연결원소 객체를 새로 복제하지 않음target의 선언 타입은 그대로

copy 안의 source

보이는 타입
List<? extends T>
가능한 동작
for (T value : source)
원소를 T로 읽음

copy 안의 target

보이는 타입
List<? super T>
가능한 동작
target.add(value)
T 값을 추가

main의 target

보이는 타입
List<Scored>
가능한 동작
for (Scored value : target)
value.score()로 합산
count=2
total=175

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

이 예의 조건

  • 객체 원소를 복제하거나 caller target 타입을 바꾸지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
5101
680, 102
795
원 도표의 설명과 주석
구분내용
전체 읽기 정보복사 메서드와 호출자가 보는 타입 복사 메서드와 호출자가 보는 타입 copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다. PECS · 메서드와 호출자 관점 복사 메서드와 호출자가 보는 타입 copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다. copy 안의 source 보이는 타입 List<? extends T> 가능한 동작 for (T value : source) 원소를 T로 읽음 copy 안의 target 보이는 타입 List<? super T> 가능한 동작 target.add(value) T 값을 추가 main 의 target 보이는 타입 List<Scored> 가능한 동작 for (Scored value : target) value.score() 로 합산 count=2 total=175 copy 의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기 준비

ScoredPost 두 개를 extends T로 읽습니다.

  • source: 101/80,102/95
  • target 선언: List<Scored>
  • 메서드의 와일드카드가 읽기/쓰기 방향을 제한한다.
참조 추가

source의 두 참조를 super T 대상 끝에 추가합니다.

  • target count: 2
  • 객체 정체성: source와 같은 두 객체
  • 대상에 객체 복제 대신 기존 참조를 보관한다.
호출자 누산

Scored 계약으로 80과 95를 읽습니다.

  • total: 175
  • count: 2
  • caller의 선언 타입으로 허용된 행동을 사용한다.

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

source의 두 참조를 target에 copy한 뒤 Scored로 읽어 175를 누산합니다. PECS의 공급/소비 역할을 유지하며 목록과 합계의 실제 전후를 보여 줄 수 있습니다.

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

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

PECS · 메서드와 호출자 관점

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

copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다.

메서드의 읽기·쓰기 계약과 호출자 목록source List<ScoredPost>의 두 참조를 extends T로 순회하고 super T target 끝에 추가합니다. 호출자의 target 선언은 List<Scored> 그대로이며 score 합계 175와 count 2를 읽습니다. 실선은 순회·추가·호출자 읽기의 연결입니다. 원소를 새 객체로 복제하거나 호출자 target을 List<Object>로 바꾸지 않습니다.
호출자의 sourceList<ScoredPost>101 / 80, 102 / 95
연결 관계호출자의 source · List<ScoredPost> · 101 / 80, 102 / 95 → copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회
copy의 읽기 계약List<? extends T>source의 두 참조를 순회
연결 관계copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회 → copy의 쓰기 계약 · List<? super T> · target.add(value)
copy의 쓰기 계약List<? super T>target.add(value)
연결 관계copy의 쓰기 계약 · List<? super T> · target.add(value) → 호출자의 target · List<Scored> · 같은 두 객체 참조 보관
호출자의 targetList<Scored>같은 두 객체 참조 보관
연결 관계호출자의 target · List<Scored> · 같은 두 객체 참조 보관 → Scored로 읽기 · score로 80 + 95 · count=2 / total=175
Scored로 읽기score로 80 + 95count=2 / total=175

실선: source 순회 → target에 참조 추가 → 호출자가 Scored로 읽기와일드카드는 메서드의 사용 계약 · caller target 타입을 Object로 바꾸거나 원소를 복제하지 않음

메서드의 읽기·쓰기 계약과 호출자 목록source List<ScoredPost>의 두 참조를 extends T로 순회하고 super T target 끝에 추가합니다. 호출자의 target 선언은 List<Scored> 그대로이며 score 합계 175와 count 2를 읽습니다. 실선은 순회·추가·호출자 읽기의 연결입니다. 원소를 새 객체로 복제하거나 호출자 target을 List<Object>로 바꾸지 않습니다.
호출자의 sourceList<ScoredPost>101 / 80, 102 / 95
연결 관계호출자의 source · List<ScoredPost> · 101 / 80, 102 / 95 → copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회
copy의 읽기 계약List<? extends T>source의 두 참조를 순회
연결 관계copy의 읽기 계약 · List<? extends T> · source의 두 참조를 순회 → copy의 쓰기 계약 · List<? super T> · target.add(value)
copy의 쓰기 계약List<? super T>target.add(value)
연결 관계copy의 쓰기 계약 · List<? super T> · target.add(value) → 호출자의 target · List<Scored> · 같은 두 객체 참조 보관
호출자의 targetList<Scored>같은 두 객체 참조 보관
연결 관계호출자의 target · List<Scored> · 같은 두 객체 참조 보관 → Scored로 읽기 · score로 80 + 95 · count=2 / total=175
Scored로 읽기score로 80 + 95count=2 / total=175

실선: 순회·추가·읽기 연결원소 객체를 새로 복제하지 않음target의 선언 타입은 그대로

copy 안의 source

보이는 타입
List<? extends T>
가능한 동작
for (T value : source)
원소를 T로 읽음

copy 안의 target

보이는 타입
List<? super T>
가능한 동작
target.add(value)
T 값을 추가

main의 target

보이는 타입
List<Scored>
가능한 동작
for (Scored value : target)
value.score()로 합산
count=2
total=175

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

이 예의 조건

  • 객체 원소를 복제하거나 caller target 타입을 바꾸지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
5101
680, 102
795
원 도표의 설명과 주석
구분내용
전체 읽기 정보복사 메서드와 호출자가 보는 타입 복사 메서드와 호출자가 보는 타입 copy의 source는 T를 생산하고 target은 T를 소비한다. 호출자의 target 변수는 List<Scored>여서 복사 후 Scored 점수를 합산할 수 있다. PECS · 메서드와 호출자 관점 복사 메서드와 호출자가 보는 타입 copy 안에서 source는 T를 생산하고 target은 T를 받습니다. main의 target 선언은 여전히 List<Scored>여서 점수를 읽을 수 있습니다. copy 안의 source 보이는 타입 List<? extends T> 가능한 동작 for (T value : source) 원소를 T로 읽음 copy 안의 target 보이는 타입 List<? super T> 가능한 동작 target.add(value) T 값을 추가 main 의 target 보이는 타입 List<Scored> 가능한 동작 for (Scored value : target) value.score() 로 합산 count=2 total=175 copy 의 하한 와일드카드로 꺼낸 값은 Object 관점입니다. 호출자에게는 원래 List<Scored> 선언이 남아 있으므로 Scored로 읽을 수 있습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
읽기 준비

ScoredPost 두 개를 extends T로 읽습니다.

  • source: 101/80,102/95
  • target 선언: List<Scored>
  • 메서드의 와일드카드가 읽기/쓰기 방향을 제한한다.
참조 추가

source의 두 참조를 super T 대상 끝에 추가합니다.

  • target count: 2
  • 객체 정체성: source와 같은 두 객체
  • 대상에 객체 복제 대신 기존 참조를 보관한다.
호출자 누산

Scored 계약으로 80과 95를 읽습니다.

  • total: 175
  • count: 2
  • caller의 선언 타입으로 허용된 행동을 사용한다.

target에 점수 10인 게시글이 먼저 있었다면, 기존 1개 뒤에 2개가 추가되어 count=3, total=185(10+80+95)가 됩니다. 그 상태에서 copy를 한 번 더 호출하면 덮어쓰지 않고 같은 두 게시글이 다시 뒤에 붙어 count=5, total=360이 됩니다. 이 copy는 중복을 걸러 내지 않습니다.

copy가 옮기는 것은 객체가 아니라 참조라는 점도 기억해 두세요. 두 목록은 같은 ScoredPost 객체를 가리킵니다. 이 레코드는 바꿀 수 없는 값만 가지므로 문제가 없지만, 원소가 상태를 바꿀 수 있는 객체라면 한쪽 목록에서 원소를 고쳤을 때 다른 쪽 목록에서도 바뀐 것으로 보입니다.

문제 2: 넘길 수 있는 인자 고르기

다음 두 메서드가 있습니다. 이름 그대로 sum은 목록에서 값을 꺼내 더하기만 하고, fill은 목록에 Integer 값을 넣는 메서드라고 생각하면 됩니다.

static double sum(List<? extends Number> values)
static void fill(List<? super Integer> target)

List<Integer>, List<Double>, List<Number>, List<Object> 네 가지 목록 가운데 sum에 넘길 수 있는 것과 fill에 넘길 수 있는 것을 각각 고르세요.

해설 보기

sum에는 List<Integer>, List<Double>, List<Number>를 넘길 수 있습니다. ? extends Number는 Number이거나 그 하위 타입의 목록을 받는데, Object는 Number의 상위 타입이므로 List<Object>는 넘길 수 없습니다.

fill에는 List<Integer>, List<Number>, List<Object>를 넘길 수 있습니다. ? super Integer는 Integer이거나 그 상위 타입의 목록을 받습니다. Double은 Integer의 상위 타입이 아니므로 List<Double>은 넘길 수 없습니다. Double 목록에 정수를 넣으면 안 된다는 것을 떠올리면 당연한 결과입니다. 넘길 수 없는 목록을 넘기면 List<Object> cannot be converted to List<? extends Number>처럼 어떤 와일드카드 타입에 맞지 않는지 알려 주는 오류가 납니다.

문제 3: 컴파일되는 줄 찾기

다음 코드에서 컴파일되지 않는 줄을 찾고, 나머지 줄을 실행했을 때 출력을 예상하세요. 주석으로 1부터 4까지 번호를 붙인 줄이 검토할 대상입니다. 출력을 예상할 때는 컴파일되지 않는 줄을 지웠다고 보고 나머지를 위에서부터 실행합니다.

List<Integer> ints = new ArrayList<>(List.of(1, 2, 3));
List<? extends Number> numbers = ints;
Number first = numbers.get(0);   // 1
numbers.add(null);               // 2
numbers.remove(0);               // 3
numbers.add(4);                  // 4
System.out.println(first + " " + ints);
해설 보기

4번 줄만 컴파일되지 않습니다. numbers의 원소 타입은 'Number의 어떤 하위 타입'이라서 정수 4를 넣어도 되는지 확인할 수 없으므로, int cannot be converted to CAP#1 오류가 납니다.

1번은 꺼낸 값을 Number로 받으므로 컴파일됩니다. 2번처럼 null은 어떤 참조 타입에도 들어갈 수 있으므로 넣을 수 있고, 3번의 remove는 넣을 값의 타입과 상관없는 연산이라 컴파일됩니다. 4번 줄을 지우고 실행하면 ints는 끝에 null이 붙었다가 첫 원소 1이 지워진 상태이므로 1 [2, 3, null]이 출력됩니다. numbers와 ints가 같은 목록을 가리키기 때문입니다.

다음 글

타입 소거와 런타임 한계에서는 지금까지 컴파일러가 해 준 타입 검사가 실행 중에는 어떻게 되는지 살펴봅니다. 컴파일된 코드에서 타입 인자가 지워지는 타입 소거 때문에 new T()나 instanceof T를 쓸 수 없는 이유를 확인하고, 실행 중에 타입 정보가 필요할 때 Class 객체와 Supplier를 넘기는 방법을 배웁니다.