Java/EffectiveJava

Effective Java Item32. 제네릭과 가변인수를 함께 쓸 때는 신중하라.

Flambee 2025. 2. 9. 18:27

가변인수(varargs) 메서드와 제네릭은 자바 5때 함께 추가되었으나, 둘의 조합은 잘 어울리지 않는다.

  1. 가변인수는 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 해주는데, 구현 방식에 허점이 있다. 그 허점이란, 가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 하나 만들어지는데, 그 배열을 내부에 감추지 않고 클라이언트에게 노출하는 문제가 생기게 되면서 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.
// possible heap pollution from parameterized vararg type 
static void dangerous(List<String>... stringLists) {  
    List<Integer> intList = List.of(42);  
    Object[] objects = stringLists; // 배열 공변  
    objects[0] = intList; // 제네릭의 소거 방식으로 인하여  List[] 이 된다.  
    String s = stringLists[0].get(0);  
}

즉, 제네릭 가변인수 배열 매겨변수에 값을 저장하는 것은 안전하지 않다. 그러면 허용한 이유는 무엇일까? 그 이유는 제네릭이나 매개변수화 타입의 varags 매개변수를 받는 메서드가 실무에서 매우 유용하기 때문이다. 대표적인 케이스는 Arrays.asList(T... a), Collections.addAll(Collection<? super T> c, T.. elements), EnumSet.of(E first, E.. rest) 이며, varags를 사용하게 되더라도 내부 메서드가 타입이 안전하게 설계하면 된다.

경고를 내지 않고, 타입 안전하다는 것을 내려주기 위해서는 어떻게 해야할까? 위에서 말한 대표적인 케이스 모두 @SafeVarags 애너테이션이 달려져있는데, 이 애너테이션을 사용하면 메서드 작성자가 그 메서드가 타입 안전함을 보장한다는 것을 표시할 수 있다. 즉, @SafeVarags를 사용하면 경고 메시지가 더 이상 나타나지 않는다.

  1. 가변인수 매개변수 배열에 아무것도 저장하지 않고도 타입 안전성을 깰 수도 있다.
static <T> T[] toArray(T... args) {
    return args;
}

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);
        case 2: return toArray(b, c);
    }
    throw new AssertionError;
}

public static void main(String[] args) {
    String[] attributes = pickTwo("좋은", "빠른", "저렴한");
}

이 경우 toArray에 넘겨지는 T... 가변인자는 컴파일 시점에는 괜찮지만, 런타임 시점에 String[] 이 아닌, Object[]로 새로운 매개변수가 만들어지며, 결국 ClassCastException을 발생시킨다.

그 이유는 첫째, 제네릭 타입인 경우 컴파일 시점에 T..를 Object[]로 타입을 생성하기 때문이며, 둘째는 Object[]로 만들어지게 되면, Object[]String[]의 하위 타입이 아니므로 형변환이 실패하기 때문이다.

이 경우에는 @SafeVarags를 사용하는 정적 팩토리 메서드인 List.of를 활용하여 배열 없이 제네릭만 사용하여 타입 안전을 지켜줄 수 있다.

item.28 배열보다는 리스트를 사용하라. 핵심 정리 중.
"배열과 제네릭에는 매우 다른 타입 규칙이 적용. 배열은 공변이고 실체화되는 반면, 제네릭은 불공변이고 타입 정보가 소거된다. 그 결과 배열은 런타임에는 타입 안전하지만 컴파일타임에는 그렇지 않다. 제네릭은 반대다.(컴파일에는 타입 안전하지만, 런타임에는 그렇지 않다.) 그래서 둘은 섞어 쓰기란 쉽지 않다. 둘을 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 적용해보자."

static <T> List<T> pickTwo(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);
        case 2: return List.of(b, c);
    }
    throw new AssertionError;
}

public static void main(String[] args) {
    List<String> attributes = pickTwo("좋은", "빠른", "저렴한");
}

단, 클라이언트 코드가 살짝 지저분해지고 속도가 조금 느려질 수 있다.

가변인자 예외 사항

  • @SafeVarags가 제대로 애노테이트된 또 다른 가변인자 메서드에 넘기는 것은 안전하다.
@SafeVarargs
static <T> void safeMethod(T... elements) {
    anotherSafeMethod(elements); // ✅ 안전한 varargs 메서드에 넘기기
}

@SafeVarargs
static <T> void anotherSafeMethod(T... elements) {
    for (T element : elements) {
        System.out.println(element);
    }
}
  • 이 배열 내용의 일부 함수를 호출만 하는 (가변인자를 받지 않는) 일반 메서드에 넘기는 것도 안전하다.
@SafeVarargs
static <T> void safeMethod(T... elements) {
    processElements(elements); 
}

static <T> void processElements(T[] elements) { // ✅ varags를 받지 않는 일반 메서드
    for (T element : elements) {
        System.out.println(element);
    }
}

가변인자 수정 사례 (둘 중 하나라도 어겼다면 수정하라.)

  • 가변인자 매개변수 배열에 아무것도 저장하지 않는다.
  • 그 배열(혹은 복제본)을 신뢰할 수 없는 코드에 노출하지 않는다.