JavaHard#65
Java의 제네릭(Generics)에서 와일드카드(?)와 경계 타입(bounded type)을 설명해주세요.
#Java#제네릭#와일드카드#고급
힌트
PECS 원칙(Producer Extends, Consumer Super)을 생각해보세요.
정답 및 해설
Java의 제네릭(Generics)에서 와일드카드(?)와 경계 타입(bounded type)을 설명해주세요.
Java 제네릭의 와일드카드(?)는 타입 파라미터가 불특정할 때 사용하는 특수 기호로, 제네릭 메서드가 다양한 타입의 컬렉션을 유연하게 처리할 수 있게 합니다. 경계 타입(Bounded Type)은 그 유연성에 범위를 지정하여 타입 안전성을 유지하면서도 코드 재사용성을 극대화합니다. PECS 원칙을 이해하면 언제 어떤 와일드카드를 사용해야 하는지 명확히 알 수 있습니다.
제네릭의 기본과 타입 불변성
Java 제네릭은 **타입 불변(invariant)**입니다. 즉, Integer가 Number의 하위 타입이더라도 List<Integer>는 List<Number>의 하위 타입이 아닙니다.
// 컴파일 에러 예시
List<Integer> intList = new ArrayList<>();
List<Number> numList = intList; // 컴파일 에러!
// 만약 허용된다면:
// numList.add(3.14); // Double을 Integer 리스트에 추가 가능? → 타입 안전성 파괴
// 하지만 배열은 공변(covariant)
Integer[] intArr = new Integer[3];
Number[] numArr = intArr; // 컴파일 OK (하지만 런타임 ArrayStoreException 위험)
numArr[0] = 3.14; // ArrayStoreException!
이 불변성을 유연하게 다루기 위해 와일드카드가 사용됩니다.
비경계 와일드카드 (Unbounded Wildcard)
?만 사용하는 경우로, 어떤 타입이든 허용합니다. 타입에 관계없이 동작하는 메서드에 사용합니다.
// List<?>: 어떤 타입의 List도 받을 수 있음
public void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
// 다양한 타입의 List 전달 가능
printList(new ArrayList<Integer>());
printList(new ArrayList<String>());
printList(new ArrayList<Double>());
// 제한사항: null 외에는 추가 불가
public void badAdd(List<?> list) {
// list.add("hello"); // 컴파일 에러! 타입을 알 수 없으므로 추가 불가
list.add(null); // null만 추가 가능
Object obj = list.get(0); // 읽기는 Object로 가능
}
Upper Bounded Wildcard (상한 경계)
<? extends T>: T 타입 또는 T의 하위 타입만 허용합니다. 읽기 전용(Producer) 으로 동작합니다.
// Number와 그 하위 타입(Integer, Double, Long 등) 허용
public double sum(List<? extends Number> numbers) {
double total = 0.0;
for (Number n : numbers) {
total += n.doubleValue(); // Number 메서드 사용 가능
}
return total;
}
List<Integer> ints = Arrays.asList(1, 2, 3);
List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
List<Long> longs = Arrays.asList(10L, 20L, 30L);
System.out.println(sum(ints)); // 6.0
System.out.println(sum(doubles)); // 6.6
System.out.println(sum(longs)); // 60.0
// 추가는 불가: 실제 타입이 Integer인지 Double인지 알 수 없음
public void addToUpperBounded(List<? extends Number> list) {
// list.add(1); // 컴파일 에러!
// list.add(1.0); // 컴파일 에러!
Number n = list.get(0); // 읽기는 Number로 가능
}
왜 추가할 수 없는가?
List<? extends Number> list = new ArrayList<Integer>();
// list.add(3.14); // 만약 허용된다면 Integer 리스트에 Double 추가 → 타입 안전성 파괴
// 컴파일러는 실제 타입(Integer? Double? Float?)을 알 수 없으므로 모든 추가를 금지
Lower Bounded Wildcard (하한 경계)
<? super T>: T 타입 또는 T의 상위 타입만 허용합니다. 쓰기 가능(Consumer) 으로 동작합니다.
// Integer와 그 상위 타입(Number, Object) 허용
public void addNumbers(List<? super Integer> list) {
for (int i = 1; i <= 5; i++) {
list.add(i); // Integer 추가 가능 (Integer는 항상 해당 타입에 호환)
}
}
List<Integer> intList = new ArrayList<>();
List<Number> numList = new ArrayList<>();
List<Object> objList = new ArrayList<>();
addNumbers(intList); // OK
addNumbers(numList); // OK
addNumbers(objList); // OK
// 읽기는 Object로만 가능 (어떤 상위 타입인지 알 수 없음)
public void readFromLowerBounded(List<? super Integer> list) {
// Integer i = list.get(0); // 컴파일 에러! List<Number>일 수도 있으므로
Object obj = list.get(0); // Object로만 안전하게 읽을 수 있음
}
활용 예시: Comparator
// Comparator<? super T>: T 또는 상위 타입 Comparator 허용
public static <T> void sort(List<T> list, Comparator<? super T> comp) {
// T의 상위 타입 Comparator도 사용 가능 (유연성)
}
List<Integer> numbers = Arrays.asList(3, 1, 4, 1, 5);
// Comparator<Integer>: Integer 기준
numbers.sort(Comparator.naturalOrder());
// Comparator<Number>도 사용 가능 (Integer의 상위 타입)
numbers.sort((Number a, Number b) -> Double.compare(a.doubleValue(), b.doubleValue()));
PECS 원칙 (Producer Extends, Consumer Super)
Joshua Bloch의 Effective Java에서 제시한 원칙입니다.
- Producer (값을 꺼내 쓰는 쪽) →
? extends T사용 - Consumer (값을 넣는 쪽) →
? super T사용
// PECS 원칙 적용 예시
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
// src: 값을 꺼내는 Producer → extends
// dst: 값을 넣는 Consumer → super
for (T item : src) {
dst.add(item);
}
}
List<Integer> source = Arrays.asList(1, 2, 3, 4, 5);
List<Number> destination = new ArrayList<>();
copy(source, destination);
System.out.println(destination); // [1, 2, 3, 4, 5]
// src에 List<Integer>를 전달할 수 있는 이유: Integer extends Number
// dst에 List<Number>를 전달할 수 있는 이유: Number super Integer
실전 PECS 예시
// Collections.addAll: src는 Producer, dest는 Consumer
public static <T> boolean addAll(Collection<? super T> dest, T... src) {
for (T e : src) {
dest.add(e);
}
return true;
}
// Collections.sort: Comparator는 Consumer
public static <T> void sort(List<T> list, Comparator<? super T> c) { ... }
// Stream.max, min
Optional<T> max(Comparator<? super T> comparator);
제네릭 메서드와 경계 타입 파라미터
// 상한 경계 타입 파라미터
public static <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) throw new IllegalArgumentException("빈 리스트");
T result = list.get(0);
for (T item : list) {
if (item.compareTo(result) > 0) {
result = item;
}
}
return result;
}
// 다중 경계: T & Interface 형태
public static <T extends Comparable<T> & Serializable> void process(T item) {
// T는 Comparable과 Serializable을 모두 구현해야 함
}
// 경계 적용 예
System.out.println(max(Arrays.asList(3, 1, 4, 1, 5))); // 5
System.out.println(max(Arrays.asList("banana", "apple", "cherry"))); // "cherry"
타입 소거(Type Erasure)와 주의사항
Java 제네릭은 컴파일 시 타입 정보가 소거(erasure)되어 런타임에는 원시 타입으로 변환됩니다.
// 컴파일 후 타입 소거
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// 런타임에는 동일한 타입
System.out.println(strList.getClass() == intList.getClass()); // true
// instanceof와 함께 사용 불가
// if (obj instanceof List<String>) {} // 컴파일 에러
// 가능한 방법
if (obj instanceof List<?> list) { // Java 16+ 패턴 매칭
// 원소 타입은 확인 불가
}
// 제네릭 배열 생성 불가
// T[] array = new T[10]; // 컴파일 에러
// List<String>[] lists = new ArrayList<String>[10]; // 컴파일 에러
실전 활용 예시
// 제네릭 유틸리티 클래스
public class CollectionUtils {
// 두 리스트 중 더 큰 원소들로 구성된 리스트 반환
public static <T extends Comparable<? super T>> List<T> merge(
List<? extends T> list1,
List<? extends T> list2) {
List<T> merged = new ArrayList<>(list1);
merged.addAll(list2);
merged.sort(Comparator.naturalOrder());
return merged;
}
// 최솟값 찾기
public static <T extends Comparable<? super T>> T min(Collection<? extends T> collection) {
return collection.stream()
.min(Comparator.naturalOrder())
.orElseThrow(NoSuchElementException::new);
}
// 타입 안전한 역방향 복사
public static <T> void reverseCopy(List<T> src, List<? super T> dst) {
for (int i = src.size() - 1; i >= 0; i--) {
dst.add(src.get(i));
}
}
}
// 사용
List<Integer> a = Arrays.asList(5, 2, 8);
List<Integer> b = Arrays.asList(1, 9, 3);
List<Integer> sorted = CollectionUtils.merge(a, b);
System.out.println(sorted); // [1, 2, 3, 5, 8, 9]
List<Number> dest = new ArrayList<>();
CollectionUtils.reverseCopy(a, dest); // Integer → Number (PECS super)
System.out.println(dest); // [8, 2, 5]
와일드카드 비교 요약
| 구분 | 문법 | 허용 타입 | 읽기 | 쓰기 | 용도 |
|---|---|---|---|---|---|
| 비경계 와일드카드 | <?> | 모든 타입 | Object로 읽기 가능 | null만 가능 | 타입 무관 처리 |
| 상한 경계 | <? extends T> | T 및 T의 하위 타입 | T로 읽기 가능 | 불가 | Producer (꺼내기) |
| 하한 경계 | <? super T> | T 및 T의 상위 타입 | Object로만 읽기 | T 및 하위 타입 추가 가능 | Consumer (넣기) |
| 경계 타입 파라미터 | <T extends T> | T 및 T의 하위 타입 | T로 읽기 가능 | T 사용 가능 | 제네릭 메서드 |
| PECS Producer | ? extends | - | O | X | 값을 꺼내 사용할 때 |
| PECS Consumer | ? super | - | Object만 | O | 값을 집어넣을 때 |