전체 목록
JavaHard#65

Java의 제네릭(Generics)에서 와일드카드(?)와 경계 타입(bounded type)을 설명해주세요.

#Java#제네릭#와일드카드#고급
힌트

PECS 원칙(Producer Extends, Consumer Super)을 생각해보세요.

정답 및 해설

Java의 제네릭(Generics)에서 와일드카드(?)와 경계 타입(bounded type)을 설명해주세요.

Java 제네릭의 와일드카드(?)는 타입 파라미터가 불특정할 때 사용하는 특수 기호로, 제네릭 메서드가 다양한 타입의 컬렉션을 유연하게 처리할 수 있게 합니다. 경계 타입(Bounded Type)은 그 유연성에 범위를 지정하여 타입 안전성을 유지하면서도 코드 재사용성을 극대화합니다. PECS 원칙을 이해하면 언제 어떤 와일드카드를 사용해야 하는지 명확히 알 수 있습니다.

제네릭의 기본과 타입 불변성

Java 제네릭은 **타입 불변(invariant)**입니다. 즉, IntegerNumber의 하위 타입이더라도 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-OX값을 꺼내 사용할 때
PECS Consumer? super-Object만O값을 집어넣을 때