이펙티브 자바 5장-2) 제네릭

동동주·2025년 11월 20일

이펙티브 자바

목록 보기
6/13

🎯 아이템 31: 한정적 와일드카드를 사용해 API 유연성을 높이라

제네릭을 쓰다 보면 “아니 Integer가 Number의 하위 타입인데 왜 안 들어가?” 같은 상황이 종종 터진다. 그 핵심은 제네릭은 불공변(invariant) 이라는 점 때문이다. 이 문제를 해결하는 핵심 도구가 바로 한정적 와일드카드(extends / super) 다.

제네릭은 불공변이다

  • List<String>List<Object>의 하위 타입이 아니다.
  • 서로 다른 타입이면 상하 관계가 아예 없다.
  • 그래서 “Number 스택에 Integer 리스트를 넣기” 같은 게 그냥은 안 된다.

이 문제를 와일드카드로 해결할 수 있다.

예제: Stack에 pushAll 추가하기

public void pushAll(Iterable<E> src) {
    for (E e : src) {
        push(e);
    }
}

겉보기엔 멀쩡하지만, 아래 코드는 컴파일 에러가 터진다.

StackWithGeneric<Number> stack = new StackWithGeneric<>();
List<Integer> integers = List.of(1, 2, 3);

stack.pushAll(integers); // ❌ 불공변 때문에 오류

해결: 생산자(Producer)에서는 extends 사용

public void pushAll(Iterable<? extends E> src) {
    for (E e : src) push(e);
}
  • ? extends E 덕에 E의 하위 타입도 받아들일 수 있게 된다.
  • Integer → Number 같은 흐름이 가능해진다.

popAll에서 만나는 또 다른 불공변 문제

public void popAll(Collection<E> dst) { ... }

아래 코드는 오류가 난다:

Collection<Object> objects = new ArrayList<>();
stack.popAll(objects);  // ❌ 역시 불공변

Number를 pop한 결과를 Object 컬렉션에 넣고 싶은데 불가능하다.

해결: 소비자(Consumer)에서는 super 사용

public void popAll(Collection<? super E> dst) {
    while(size > 0)
        dst.add(pop());
}
  • ? super E 덕에 E의 상위 타입을 받을 수 있다.
  • Object가 Number의 상위 타입이므로 OK.

🍱 핵심 원칙 — PECS

Producer → Extends
Consumer → Super

✔ 값을 “생산하는” 쪽은 extends
✔ 값을 “소비(담기)” 하는 쪽은 super

자바 제네릭 할 때는 그냥 무조건 떠올리면 되는 룰이다.

🎓 특이 케이스: Comparable은 소비자다

Comparable은 이렇게 생겼다:

public interface Comparable<T> {
    int compareTo(T o);
}

compareTo의 매개변수는 "비교 기준을 소비"한다.
그래서 PECS 규칙상 super 를 써야 한다.

잘못된 선언

public static <E extends Comparable<E>> E max(List<E> list);

이건 E 스스로만 비교 가능할 때만 동작한다.

올바른 선언

public static <E extends Comparable<? super E>> E max(List<E> list);
  • Person 기준으로 Comparable을 구현해두고,
  • Student 리스트를 넘겨도 동작하게 만들 수 있다.

타입 매개변수가 한 번만 등장한다면? → 와일드카드 추천

예: swap 메서드

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

private static <E> void swapHelper(List<E> list, int i, int j) {
    list.set(i, list.set(j, list.get(i)));
}
  • API 밖에는 와일드카드를 보여주고,
  • 내부에서는 타입 매개변수로 작업하는 패턴.

🎯 아이템 32: 제네릭과 가변인수를 함께 쓸 때는 신중하라

가변인수(varargs)와 제네릭을 같이 쓰면 아주 달달해 보이지만… 사실 둘은 궁합이 굉장히 나쁜 조합이다.
가변인수는 배열을 내부적으로 만든 뒤 공개하는 구조라, 배열과 제네릭의 근본적인 불일치가 드러나기 때문이다.

이 아이템의 핵심은 단 하나다:

제네릭 가변인수는 안전하지 않지만, 위험한 두 행동만 하지 않으면 ‘조건부’로 써도 된다.

가변인수 메서드의 근본적인 문제

  • T... args 를 사용하면 자동으로 배열이 생성된다.
  • 그런데 이 배열이 외부에 노출될 수 있고,
    게다가 배열은 실체화 타입(reified), 제네릭은 소거 타입(erased) 라서 타입 안정성이 깨지기 쉽다.
  • 그래서 가변인수에 제네릭 타입(예: List<String>...)이 들어가면
    → 컴파일러가 경고를 띄우고, 실제로 ClassCastException이 터질 수 있다.

실체화 불가 타입을 varargs로 받으면 생기는 일

public static void reifyExampleMethod(List<String>... stringLists) {
    List<Integer> intList = List.of(42);
    Object[] objects = stringLists;
    objects[0] = intList;              
    String s = stringLists[0].get(0);  // ❌ ClassCastException
}

stringListsList<String>[] 로 추정되지만, 런타임에서는 그냥 Object[] 처럼 행동한다.
외부에서 잘못된 값을 끼워 넣으면 그대로 ClassCastException이 터진다.

varargs 배열을 외부에 노출하거나, 다른 배열로 옮겨 담아 조작하는 것은 금지해야 한다.

자바 표준 API에서도 제네릭 varargs를 쓴다 — 그럼 왜 괜찮을까?

예를 들어 List.of(), Arrays.asList() 등이 그렇다.

@SafeVarargs
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

핵심: 안전한 방식으로 쓰기 때문이다.

  • varargs 배열을 외부에 그대로 반환하지 않는다.
  • varargs 배열을 다른 배열에 저장해 조작하지 않는다.

위 두 가지를 지키면 @SafeVarargs 를 붙여 “이건 안전하다”라고 표시해도 좋다.

⚠ 위험한 제네릭 varargs 사용 — toArray()

public static <T> T[] toArray(T... args) {
    return args;  // ❌ 그대로 반환하면 매우 위험
}

이 메서드는 varargs 배열을 그대로 노출하는데, 이때 반환 타입이 호출 시점에 잘못 추론될 수 있다.

이 메서드를 기반으로 한 pickTwo() 는 아래와 같이 완전한 폭탄이 된다.

public static <T> T[] pickTwo(T a, T b, T c) {
    switch(ThreadLocalRandom.current().nextInt(3)) {
        case 0: return toArray(a, b);
        case 1: return toArray(a, c);
        default: return toArray(b, c);
    }
}

테스트:

String[] pickTwo = pickTwo("일", "이", "삼"); // ❌ runtime error

pickTwo()는 실제로는 Object[] 를 반환하지만,
컴파일러는 String[] 이라고 믿어버려
ClassCastException 발생!

게다가 원인이 toArray()에 있다는 점이 아주 먼 거리라서… 디버깅도 어렵다.

안전한 제네릭 varargs 사용 예: flatten()

@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
    List<T> result = new ArrayList<>();
    for (List<? extends T> list : lists) {
        result.addAll(list);
    }
    return result;
}

이 메서드는 안전하다. 왜냐하면:

  • varargs 배열을 외부로 노출하지 않는다.
  • varargs 배열을 조작하거나 다른 배열에 저장하지 않는다.

그래서 @SafeVarargs 를 달 수 있다.

💡 단, @SafeVarargs는 재정의 가능한 메서드(인스턴스 메서드)에는 달 수 없다.
하위 클래스가 위험한 방식으로 바꿔버리면 안전이 깨지기 때문.

varargs 대신 List로 대체하는 방법도 있다

static <T> List<T> flatten(List<List<? extends T>> lists) {
    List<T> result = new ArrayList<>();
    for (List<? extends T> list : lists) {
        result.addAll(list);
    }
    return result;
}

동일한 기능을 제공하면서 varargs 자체를 없애버린 버전.

➡ varargs를 꼭 쓸 필요가 없을 때는 List 버전이 더 명확하고 안전함.

pickTwo() 문제도 List 기반으로 해결 가능

public static <T> List<T> pickTwo2(T a, T b, T c) {
    switch(ThreadLocalRandom.current().nextInt(3)) {
        case 0: return List.of(a, b);
        case 1: return List.of(a, c);
        default: return List.of(b, c);
    }
}
  • List.of()는 자체적으로 @SafeVarargs 처리되어 있다.
  • 배열 기반이 아니라 List 기반이므로 타입 안전.

varargs 기반보다 훨씬 안전하고 자바의 표준 API를 활용해 더 깔끔해진다.


🎯 아이템 33: 타입 안전 이종 컨테이너를 고려하라

타입 안전 이종 컨테이너란?

여러 타입의 데이터를 하나의 컨테이너에 넣되, 타입 안정성까지 보장하는 기법이다.
핵심 아이디어는 Class를 키로 사용하는 Map을 만드는 것.

static class Favorites {
    private final Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance) {
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));
    }
}

여기서 중요한 점:

  • Map<Class<?>, Object> 이고, 키가 “Class 타입”이다.
  • 넣을 때 <T> void putFavorite(Class<T>, T) 덕분에
    키의 타입과 값의 타입이 맞지 않으면 컴파일 단계에서 막힌다.
  • 꺼낼 때는 type.cast() 덕분에 런타임에서도 타입 안전하게 꺼낼 수 있다.

즉, 자료구조의 제네릭을 컨테이너에 하지 않고, 키(Class)에 한다 — 이게 패턴의 핵심임.

🧪 사용 예시

Favorites f = new Favorites();

f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);

System.out.printf("%s %x %s%n", favoriteString, favoriteInteger, favoriteClass.getName());

출력:

Java cafebabe item33.Item33test$Favorites

타입별로 안전하게 저장·조회되는 걸 확인 가능.

한계와 제약

1) 로 타입(Class raw type) 악용 시 타입 안정성 붕괴

f.putFavorite((Class) List.class, "난 리스트가 아닌데");

이런 식으로 raw type 캐스팅을 막을 방법은 없다.
→ 대신 컴파일 경고가 뜨고, 결국 런타임에 ClassCastException이 난다.

2) 실체화 불가 타입에는 사용할 수 없다

예: List<String>.class 같은 건 존재하지 않음.

그래서 복잡한 제네릭 타입은 기본 방식으로는 저장 불가.

해결책 — 슈퍼 타입 토큰(Super Type Token)

“실체화 불가 타입 저장 문제”를 해결하려 등장한 기법.

예: 스프링의 ParameterizedTypeReference 같은 것.

f.putFavorite(new TypeRef<List<String>>(){}, pets);
List<String> list = f.getFavorite(new TypeRef<List<String>>(){});

익명 클래스를 활용해 실제 제네릭 타입 정보를 런타임까지 끌고 가는 방식.

한정적 타입 토큰(Bounded Type Token)

특정 타입들만 키로 받도록 제한하고 싶을 때 사용.
대표적인 사례가 애너테이션 API임.

public <T extends Annotation> T getAnnotation(Class<T> annotationType);
  • annotationType은 “애너테이션 타입만 받을 수 있는” 한정적 타입 토큰
  • 애너테이션 API 자체가 “타입 안전 이종 컨테이너”와 같은 구조로 돌아감

Class<?>를 한정적 타입 토큰으로 바꾸고 싶을 때

Class.asSubclass() 사용

annotationType.asSubclass(Annotation.class);

변환 실패 시 ClassCastException.


참고 블로그:
https://jake-seo-dev.tistory.com/53?category=906605

0개의 댓글