Java의 Generics: wildcard ? extends와 ? super

1. 개요

Generics는 Java 코드의 타입 안전성을 컴파일 시점에 보장해줍니다. 하지만 List<Product>를 받는 메서드를 작성하면, Book이 Product의 자식 클래스임에도 불구하고 List<Book>을 받아들이지 않는다는 것을 알게 될 것입니다. 이 글에서는 그 이유를 설명합니다. 그 다음 wildcard ? extends와 ? super를 사용하여 더 유연한 API를 작성하는 방법을 안내합니다. 이 글을 읽은 후에는 PECS 원칙에 따라 어떤 wildcard를 선택할지 알게 되고, 이해하기 어려운 컴파일 오류를 피할 수 있을 것입니다.

2. 문제: generics는 불변(invariant)입니다

이 글 전체에서 사용할 간단한 클래스 계층을 먼저 살펴봅시다:

public class Product {
    private final String name;
    private final double price;

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public String name() { return name; }
    public double price() { return price; }
}

public class Book extends Product {
    public Book(String name, double price) {
        super(name, price);
    }
}

Book은 Product입니다. 그렇다면 List<Book>이 List<Product>일까요?

List<Book> books = new ArrayList<>();
List<Product> products = books; // 컴파일 오류: 호환되지 않는 타입

답은 아니오입니다. Java의 Generics는 *불변(invariant)*입니다. A가 B의 자식이더라도, List<A>와 List<B>는 여전히 관계없는 두 가지 타입입니다.

그 이유는 타입 안전성에 있습니다. 위 줄이 컴파일되었다고 가정하면, 다음과 같이 작성할 수 있습니다:

products.add(new Product("Bút bi", 5_000)); // Product를 "Book만 포함하는" 리스트에 추가
Book first = books.get(0);                    // ClassCastException!

배열은 다릅니다. Java의 배열은 *공변(covariant)*이므로, 같은 오류는 런타임에만 노출됩니다:

Object[] items = new String[2];
items[0] = 42; // 컴파일 OK, 런타임에 ArrayStoreException 발생

Generics는 컴파일 시점에 오류를 차단하는 방식을 선택합니다. 대신 유연성이 떨어진다는 대가가 있습니다. Wildcard는 그 유연성을 되찾기 위해 나타났습니다.

3. 상한 와일드카드: ? extends T

3.1. 컬렉션에서 데이터 읽기

상품 목록의 총 가격을 계산해야 한다고 가정해봅시다. 처음 버전은 보통 다음과 같습니다:

static double totalPrice(List<Product> products) { ... }

이 메서드는 List<Book>을 받지 않습니다. ? extends로 수정해봅시다:

public final class CatalogUtils {

    public static double totalPrice(List<? extends Product> products) {
        double total = 0;
        for (Product p : products) {
            total += p.price();
        }
        return total;
    }
}

List<? extends Product>는 “어떤 타입의 Product 또는 Product의 자식 클래스의 원소를 포함하는 리스트”를 의미합니다. 덕분에:

  • 필요하면 List<Product>, List<Book>, 또는 List<Ebook>을 전달할 수 있습니다.
  • 읽어온 각 원소는 반드시 Product이므로, for (Product p : products) 루프는 유효합니다.
List<Book> books = List.of(
        new Book("Clean Code", 300_000),
        new Book("Effective Java", 450_000));

System.out.println(CatalogUtils.totalPrice(books));
750000.0

3.2. 왜 요소를 추가할 수 없나요?

? extends가 있는 리스트에 요소를 추가해봅시다:

static void addBook(List<? extends Product> products) {
    products.add(new Book("Refactoring", 400_000)); // 컴파일 오류
    products.add(null);                              // OK, 하지만 의미 없음
}

컴파일러는 ?가 정확히 무엇인지 모릅니다. 전달된 리스트가 List<Ebook>일 수 있습니다. 이 경우 일반 Book을 추가하면 타입 안전성이 깨집니다. 따라서 컴파일러는 null을 제외한 모든 add 작업을 금지합니다.

기억하기 쉬운 규칙: ? extends T는 T를 읽어내기만 할 수 있습니다. 이 컬렉션은 producer 역할을 합니다. 즉, 데이터를 제공하는 곳입니다.

4. 하한 와일드카드: ? super T

이제 반대로 해봅시다. 당신은 기본 책을 Book을 포함할 수 있는 모든 리스트에 추가하는 메서드를 원합니다:

public static void addDefaultBooks(List<? super Book> target) {
    target.add(new Book("Clean Code", 300_000));
    target.add(new Book("Effective Java", 450_000));
}

List<? super Book>은 “원소의 타입이 Book 또는 Book의 상위 클래스인 리스트”를 의미합니다. 그런 모든 타입은 Book을 포함할 수 있으므로, add(new Book(...))는 항상 안전합니다.

List<Book> books = new ArrayList<>();
List<Product> products = new ArrayList<>();
List<Object> anything = new ArrayList<>();

CatalogUtils.addDefaultBooks(books);    // OK
CatalogUtils.addDefaultBooks(products); // OK
CatalogUtils.addDefaultBooks(anything); // OK

System.out.println(products.size());
2

반면에 읽어낼 때는 원소가 Object라는 것만 보장됩니다:

static void printFirst(List<? super Book> target) {
    Object first = target.get(0); // OK
    Book book = target.get(0);    // 컴파일 오류
}

리스트가 여러 것을 포함할 수 있는 List<Object>일 수 있으므로, 컴파일러는 Object 타입만 보장할 수 있습니다.

규칙: ? super T는 T를 쓰기 위한 것입니다. 이 컬렉션은 consumer 역할을 합니다. 즉, 당신이 제공하는 데이터를 소비하는 곳입니다.

5. PECS 원칙

위의 두 규칙을 합치면 PECS: Producer Extends, Consumer Super가 됩니다.

  • 당신에게 T를 제공하는 매개변수는 ? extends T를 사용합니다.
  • 당신으로부터 T를 받는 매개변수는 ? super T를 사용합니다.
  • 읽기와 쓰기를 모두 하는 매개변수는 wildcard를 사용하지 말고, 정확한 타입 T를 선언합니다.

5.1. 예제: 두 리스트 간 복사

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

source는 producer이므로 extends를 사용합니다. target은 consumer이므로 super를 사용합니다. 결과적으로 이 메서드는 매우 유연합니다:

List<Book> books = List.of(new Book("DDD", 500_000));
List<Product> cart = new ArrayList<>();

CatalogUtils.<Book>copy(books, cart);   // T = Book: Book을 읽고, 상위 리스트에 씁니다
CatalogUtils.<Product>copy(books, cart); // T = Product도 유효합니다

JDK도 이 원칙을 제대로 적용합니다. Collections.copy의 서명은:

public static <T> void copy(List<? super T> dest, List<? extends T> src)

5.2. 예제: 상위 클래스를 위한 Comparator 공유

List.sort의 서명은 sort(Comparator<? super E> c)입니다. Comparator은 consumer입니다. 원소를 받아 비교하기 때문입니다. ? super 덕분에 Product용으로 작성된 comparator를 사용하여 List<Book>을 정렬할 수 있습니다:

Comparator<Product> byPrice = Comparator.comparingDouble(Product::price);

List<Book> books = new ArrayList<>(List.of(
        new Book("Effective Java", 450_000),
        new Book("Clean Code", 300_000)));

books.sort(byPrice);
books.forEach(b -> System.out.println(b.name()));
Clean Code
Effective Java

이 패턴은 Stream API에서 자주 볼 수 있습니다. 예를 들어 map(Function<? super T, ? extends R> mapper): 함수의 입력은 consumer(super), 출력은 producer(extends)입니다.

6. 무한정 와일드카드: ?

메서드가 원소 타입을 신경 쓰지 않을 때 ?를 사용합니다:

public static int countNonNull(List<?> items) {
    int count = 0;
    for (Object item : items) {
        if (item != null) count++;
    }
    return count;
}

List<?>는 모든 리스트를 받습니다: List<Book>, List<String>, List<Integer>. 읽어온 원소는 Object이며, null 외에는 추가할 수 없습니다.

List<?>와 List<Object>를 혼동하지 마세요. List<Object>을 받는 메서드는 List<String>을 받지 않습니다. 이것도 섹션 2의 불변성 때문입니다.

7. 동작 테스트

pom.xml에 JUnit 5와 AssertJ를 추가합니다:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

프로젝트가 Spring Boot 3.x를 사용하면 spring-boot-starter-test에 이미 두 라이브러리가 포함되어 있습니다.

import org.junit.jupiter.api.Test;

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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class CatalogUtilsTest {

    @Test
    void totalPrice_acceptsListOfSubtype() {
        List<Book> books = List.of(
                new Book("Clean Code", 300_000),
                new Book("Effective Java", 450_000));

        assertThat(CatalogUtils.totalPrice(books)).isEqualTo(750_000.0);
    }

    @Test
    void addDefaultBooks_worksWithSupertypeList() {
        List<Object> anything = new ArrayList<>();
        anything.add("책이 아닙니다");

        CatalogUtils.addDefaultBooks(anything);

        assertThat(anything).hasSize(3);
        assertThat(anything.get(1)).isInstanceOf(Book.class);
    }

    @Test
    void copy_movesBooksIntoProductList() {
        List<Book> books = List.of(new Book("DDD", 500_000));
        List<Product> cart = new ArrayList<>();

        CatalogUtils.copy(books, cart);

        assertThat(cart).extracting(Product::name).containsExactly("DDD");
    }

    @Test
    void addDefaultBooks_failsAtRuntimeOnImmutableList() {
        List<Product> immutable = List.of();

        assertThatThrownBy(() -> CatalogUtils.addDefaultBooks(immutable))
                .isInstanceOf(UnsupportedOperationException.class);
    }
}

처음 세 테스트는 메서드가 예상대로 자식 타입과 상위 타입을 받는지 확인합니다. 두 번째 테스트는 List<Object>가 이전 원소를 유지하고 Book을 추가로 받음을 보여줍니다. 마지막 테스트는 중요한 점을 상기시킵니다: wildcard는 컴파일 시점에 타입만 검사합니다. 리스트가 수정을 허용하는지는 모릅니다.

8. 흔한 실수와 사용하지 말아야 할 경우

반환 타입에서 wildcard를 사용합니다.

public static List<? extends Product> findAll() { ... } // 권장하지 않음

호출자는 거의 읽기 전용 리스트를 받게 되며, ?를 어디서나 가지고 다녀야 합니다. Wildcard는 메서드의 매개변수에 있어야 하며, 반환 타입에는 없어야 합니다.

? extends를 사용한 후 add를 호출하려고 합니다. 메서드가 컬렉션에 쓰기를 해야 한다면, extends는 잘못된 선택입니다. PECS를 다시 확인하세요.

정확한 타입의 읽기와 쓰기가 모두 필요할 때 wildcard를 사용합니다. 예를 들어 두 요소를 바꾸는 경우:

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

private static <T> void swapHelper(List<T> list, int i, int j) {
    T tmp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, tmp);
}

List<?>에서 직접 list.set(i, list.get(j))를 호출하면 컴파일 오류가 발생합니다. Helper generic <T>는 ?의 실제 타입을 “캡처”(capture)하여 작업을 수행합니다. 이 기법을 wildcard capture라고 합니다. 공개 API가 반드시 List<?>일 필요가 없다면, <T> void swap(List<T> list, ...)로 직접 선언하는 것이 더 간단합니다.

내부 클래스에서 wildcard를 남용합니다. Wildcard는 라이브러리나 유틸리티처럼 공유 API에서 가장 유용합니다. 한 곳에서만 호출되는 private 메서드에서는 구체적인 타입이 보통 더 읽기 쉽습니다.

참고: ? extends는 리스트를 불변으로 만들지 않습니다. 메서드는 여전히 clear() 또는 remove(...)를 호출할 수 있습니다. 이러한 작업들은 원소 타입을 알 필요가 없기 때문입니다. 데이터를 보호해야 한다면 List.copyOf(...)나 Collections.unmodifiableList(...)를 사용하세요.

참고: 같은 wildcard에서 extends와 super를 동시에 사용할 수 없습니다. 또한 클래스나 메서드에서 선언하는 type parameter에 ? super를 쓸 수 없습니다. (<T super Book>은 유효하지 않습니다.)

9. 결론

Java의 Generics는 불변(invariant)입니다. 따라서 List<Book>은 List<Product>가 아니며, 이는 런타임에 타입 오류를 피하는 데 도움이 됩니다. Wildcard는 API에 유연성을 돌려줍니다:

  • ? extends T는 데이터를 제공하는 매개변수에 사용합니다. 당신은 T를 읽을 수 있지만 추가할 수는 없습니다.
  • ? super T는 데이터를 받는 매개변수에 사용합니다. 당신은 T를 추가할 수 있지만 Object만 읽을 수 있습니다.
  • ?는 원소 타입을 신경 쓰지 않을 때 사용합니다.

혼동할 때는 PECS만 기억하세요: Producer Extends, Consumer Super. 또한 wildcard는 메서드 매개변수에만 두고, 반환 타입에는 두지 마세요.

javagenericswildcardpecscollections