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는 메서드 매개변수에만 두고, 반환 타입에는 두지 마세요.