본문 바로가기
Dev Books/Effective Java

[item 32] 제네릭과 가변인수를 함께 쓸 때는 신중하라

by Thumper 2025. 2. 6.

1. 가변인수와 제네릭을 혼용하면 타입 안정성이 흔들린다.

가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다.
그런데 내부로 감춰야 했을 이 배열을 그만 클라이언트에 노출하는 문제가 생겼다.
그 결과 varags 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.

✔️ 예시 : 제네릭과 varags를 혼용하면 타입 안정성이 깨진다.

매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다.
다음 메서드를 예로 생각해보자.
image

이 메서드에서는 형변환하는 곳이 보이지 않는데도 인수를 건네 호출하면 ClassCastException을 던진다.
마지막 줄에 컴파일러가 생성한 (보이지 않는) 형변환이 숨어 있기 때문이다.
이처럼 타입 안정성이 깨지니 제네릭이 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.

☑️ 힙 오염(Heap Pollution)이 발생하는 경우 경고로 끝나는 이유

제네릭 배열 생성 시 컴파일 오류 발생

image

타입 안전성(type safety) 문제

배열(array)은 런타임 시 타입 정보를 유지하지만, 제네릭(generic)은 타입 소거(type erasure)로 인해 런타임에 타입 정보가 사라진다.
배열은 공변성(covariant) 을 가지므로, List[] 는 Object[] 로 변환될 수 있다.
이 상태에서 다른 타입(List)을 넣을 경우, 런타임에서 타입 불일치 문제가 발생할 위험이 있다.

✔️ 힙 오염(Heap Pollution) 발생 시 경고

image

강제 형변환 (List<String>[]) new List<?>[1]를 사용하면 컴파일 경고가 발생하지만 코드 실행은 가능하다.
배열은 런타임에 타입을 검사하지만, 제네릭은 타입 정보가 소거(Erasure) 되기 때문에 배열에 저장된 객체가 List<String>인지 List<Integer>인지 런타임에서 구분할 수 없다.

objects[0] = intList;  // List<Integer> 가 List<String> 로 저장될 때도 런타임 오류가 발생하지 않고,
                      //  대신 "힙 오염"이 발생한다.

문제는 stringLists[0].get(0) 호출 시 String 타입으로 캐스팅되는데, 실제 객체는 Integer이므로 ClassCastException 이 발생한다.
컴파일러는 이를 정확하게 탐지할 수 없으므로 컴파일 오류 대신 경고를 발생시킨다.

가변 인자와 제네릭을 함께 처리하는 대표적인 예제

이 메서드들이 가변인자(varargs)와 제네릭(generics) 을 함께 사용할 때 발생할 수 있는 타입 안정성 문제 및 힙 오염(Heap Pollution) 가능성을 어떻게 처리하는지 정리해보자.

Arrays.asList(T... a)
Collections.addAll(Collection<? superT> c, T... elements)
EnumSet.of(E first, E... rest)

✔️ Arrays.asList(T... a)

List<String> list = Arrays.asList("A", "B", "C");

가변 인자(T... a)를 받아서 고정 크기 리스트를 반환한다.
내부적으로 배열을 사용하므로 배열 기반 리스트(Fixed-size list) 이다.

image
"Possible heap pollution from parameterized vararg type" 경고를 보낸다.
가변 인자는 내부적으로 배열로 변환된다. 즉, 이 메서드에서 T... elements 는 컴파일러에 의해 T[] 배열로 변환된다.
하지만, 자바는 제네릭 배열 생성을 금지한다. (new T[] 불가)

T[] 가 생성되면, 이 배열이 다른 타입(List)로 변환되거나 다형성(polymorphism)으로 인해 힙 오염(heap pollution)이 발생할 가능성이 있다.
즉, T... 를 통해 생성된 배열은 런타임에 Object[] 처럼 취급될 수 있어, 원치 않는 타입이 들어갈 가능성이 생긴다.


✔️ Collections.addAll(Collection<? superT> c, T... elements)

public static <T> boolean addAll(Collection<? super T> c, T... elements)

Collection<? super T> c 매개변수는, T 또는 T의 상위 타입을 허용하는 컬렉션이다.
T... elements 매개변수는, 가변 인자를 통해 여러 개의 T 타입 요소를 받을 수 있다.

image
Collections.addAll()는 T...를 통해 여러 요소를 Collection<? super T>에 안전하게 추가하는 메서드이다.
자동적으로 가장 일반적인 상위 타입(Number)으로 T가 추론된다.
제네릭 배열(T[])이 내부적으로 생성되지만, 안전한 방식으로 사용되므로 힙 오염 문제 없다.


✔️ EnumSet.of(E first, E... rest)

public static <E extends Enum<E>> EnumSet<E> of(E first, E... rest)

E... rest는 EnumSet.of(E first, E... rest) 같은 메서드에서 사용된다.

first → 반드시 하나 이상의 값을 받아야 하므로 첫 번째 요소를 따로 받음.

rest → 나머지 가변 인자 값들을 받음 (E[] 배열로 변환됨).


image

E... rest 도 내부적으로 배열을 생성하지만, EnumSet 은 Enum 타입만 허용하므로 타입 안정성이 보장된다.
따라서 힙 오염(Heap Pollution) 문제는 발생하지 않는다.

2. @SafeVarargs 애너테이션

@SuppressWarnings("unchecked")

이 경고들을 그냥 두거나 호출하는 곳마다@SuppressWarnings("unchecked")을 달아 경고를 숨겨야 했다.
지루하고, 가독성을 떨어드리고, 때로는 진짜 문제를 알려주는 경고마저 숨긴다.

@SafeVarargs

메서드 작성자가 그 메서드가 타입 안전함을 보장하는 장치다.
컴파일러가 이 약속을 믿고 이 메서드가 안전하지 않을 수 있다는 경고를 더이상 하지 않는다.

3. 메서드가 안전한지 확신 할 수 있을 때

메서드가 안전한지는 어떻게 확신할 수 있을까? 가변인수 메서드를 호출할 때 varargs 매개변수를 담는 제네릭 배열이 만들어진다는 사실을 기억하자.
순수하게 인수들을 전달하는 일만 할 때 메서드가 안전하다.

  • 메서드가 varargs 매개변수를 담는 배열에 아무것도 저장하지 않을 때 (그 매개변수들을 덮어쓰지 않으면)
  • varargs 배열의 참조가 밖으로 노출 되지 않을 때 (신뢰할수 없는 코드가 배열에 접근할 수 없다면)

✔️ 안전한 varargs 메서드 예제

image

varargs 배열(elements[])을 변경하지 않는다.

배열을 수정하지 않고 List.of(elements)를 생성하여 감싼다.

varargs 배열의 참조를 외부로 노출하지 않는다.

배열을 그대로 반환하지 않고, 새로운 List로 감싸서 반환한다.

@SafeVarargs 사용

이 메서드는 varargs로 인해 "Heap pollution"이 발생할 위험이 없음을 나타낸다.



⛔ 위험한 varargs 메서드 예제 (안전하지 않은 경우)

image
image

배열이 변경될 수 있다.

array[0] = 42; 처럼 원래 String[]이어야 할 배열에 Integer가 들어갈 수 있다.

Heap Pollution (힙 오염) 발생 가능하다.

String 배열로 전달되었지만, 내부에서 Object[]로 다뤄지고 있어 잘못된 타입이 삽입될 위험이 있다.

ClassCastException 발생 가능하다.

런타임 시 타입 변환 오류 발생 가능하다.

4. 제네릭 varargs 매개변수 배열에 다른 메서드가 접근하도록 허용하지 말자

⛔ 제네릭 배열의 참조를 그대로 반환하는 경우

자신의 제네릭 매개변수 배열의 참조를 노출하므로 안전하지 않다.

image
image
이 메서드가 반환하는 배열의 타입은 이 메서드에 인수를 넘기는 컴파일타임에 결정되는데,
그 시점에는 컴파일러에게 충분한 정보가 주어지지 않아 타입을 잘못 판단할 수 있다.
따라서 자신의 varargs 매개변수 배열을 그대로 반환하면, 힙 오염을 이 메서드를 호출한 쪽의 콜스택으로까지 전이하는 결과를 낳을 수 있다.

import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;

public class PickTwo {
    // 🚨 UNSAFE - 제네릭 배열의 참조를 그대로 반환
    static <T> T[] toArray(T... args) {
        return args;  // 🚨 varargs 배열을 그대로 반환 (외부 노출 위험)
    }

    static <T> T[] pickTwo(T a, T b, T c) {
        switch(ThreadLocalRandom.current().nextInt(3)) {
            case 0: return toArray(a, b);  // 🚨 Heap Pollution 발생 가능
            case 1: return toArray(a, c);
            case 2: return toArray(b, c);
        }
        throw new AssertionError(); // 실행되지 않음 (실제로 도달 불가능)
    }

    public static void main(String[] args) {
        // 🚨 pickTwo가 반환한 제네릭 배열을 String[]으로 받음
        String[] attributes = pickTwo("Good", "Fast", "Cheap");
        System.out.println(Arrays.toString(attributes));
    }
}

toArray(T... args)가 가변 인자로 배열을 받고 그대로 반환한다.

T... args는 내부적으로 T[] 배열을 생성한다.
제네릭 배열 T[]는 런타임 시 Object[]로 취급된다.
즉, toArray가 반환하는 배열은 컴파일 시에는 T[]처럼 보이지만, 실제로는 Object[] 타입이 될 가능성이 높다. 이것이 Heap Pollution (힙 오염) 을 유발한다.

pickTwo 메서드가 toArray를 호출하여 반환된 배열을 직접 노출한다.

pickTwo 메서드는 toArray(a, b), toArray(a, c), toArray(b, c) 중 하나를 반환한다.
이 배열은 String[] attributes로 받지만, 실제로는 Object[]일 수도 있다.

main 메서드에서 String[] attributes = pickTwo("Good", "Fast", "Cheap") 수행한다.

pickTwo가 반환하는 배열은 컴파일러가 String[]으로 예상하지만, 실제로는 Object[]일 수 있다.
다른 코드에서 배열을 조작하면 ClassCastException이 발생할 가능성이 있다.


👌 예: Array 대신 List을 사용하는 PickTwo의 안전한 버전

List를 사용하면 Heap Pollution을 방지할 수 있다.
image

예외

예외가 두가지 있다.

  • @SafeVarargs로 제대로 애노테이트 된 또다른 varargs 메서드에 넘기는건 안전하다.
  • 이 배열 내용의 일부 함수를 호출만 하는(varargs를 받지 않는) 일반 메서드에 넘기는 것도 안전하다.

예외 1: @SafeVarargs로 애노테이션 된 메서드에 넘기는 것이 안전한 경우

image

  • safeVarargsMethod는 varargs 배열을 변경하거나 외부로 노출하지 않으므로 @SafeVarargs가 적용된 메서드에 varargs를 넘기는 것은 안전하다.
  • @SafeVarargs는 메서드가 varargs 배열을 수정하거나 외부로 전달하지 않는다는 조건 하에 사용할 수 있다.

예외 2: varargs를 받지 않는 일반 메서드에 넘기는 경우

image

  • printList는 varargs를 받지 않으며, 대신 List를 받는다.
  • varargs를 사용하여 List로 변환하고 메서드에 넘기는 방식은 안전하다.
  • 여기서는 varargs가 메서드 내에서 직접 처리되지 않고, 이미 변환된 List만 넘기기 때문에 위험하지 않다.

5. varargs 매개변수를 List 매개변수로 바꿀 수 있다.

정적 팩터리 메서드인 List.of를 활용하면, 임의 개수의 인수를 넘길 수 있다.
List.of에도 @SafeVarargs 애너테이션이 달려 있기 때문이다.
image

장점

이 방식의 장점은 다음과 같다.

  • 컴파일러가 이 메서드의 타입 안전성을 검증할 수 있다.
  • @SafeVarargs 애너테이션을 달지 않아도 된다.
  • 실수로 안전하다고 판단할 걱정도 없다.

🤔 핵심정리

가변인수와 제네릭을 혼용하면 타입 안정성이 흔들린다.
가변인수 기능은 배열을 노출하여 추상화가 완벽하지 못하고, 배열과 제네릭의 타입 규칙이 서로 다르기 때문이다.
다음과 같이 기억하자.

  • 제네릭 varargs 매개변수는 타입 안전하지 않지만, 허용된다.
  • 메서드에 제네릭(혹은 매개변수화된) varargs 매개변수를 사용한다면,
    먼저 그 메서드가 타입 안전한지 확인한 다음 @SafeVarargs를 사용하자.

댓글