이펙티브 자바 완벽 공략 2부

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

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


아이템 32. 핵심 정리

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

Java의 가변인자(Varargs)는 메서드를 호출하는 쪽에서 전달할 인자의 개수를 자유롭게 정할 수 있도록 해준다.

다음과 같이 ... 문법을 사용한다.

public static void print(
        String... values
) {
    for (String value : values) {
        System.out.println(value);
    }
}

호출하는 쪽에서는 원하는 개수만큼 인자를 넘길 수 있다.

print("A");

print("A", "B");

print("A", "B", "C");

문법만 보면 단순하고 편리하다.

문제는 여기에 제네릭 타입이 결합할 때 발생한다.

public static void method(
        List<String>... lists
) {
    // ...
}

이 코드는 컴파일 자체는 가능하다.

하지만 컴파일러는 다음과 비슷한 경고를 발생시킬 수 있다.

Possible heap pollution from parameterized vararg type

정확한 용어는 Heap Pollution이다.

왜 단순히 List<String>...라고 선언했을 뿐인데 이런 경고가 발생하는 것일까?

이를 이해하려면 가변인자가 내부적으로 어떻게 동작하는지부터 다시 살펴봐야 한다.


가변인자는 내부적으로 배열을 사용한다

다음 메서드를 보자.

public static void print(
        String... values
) {
    System.out.println(values.length);
}

메서드 내부에서 values는 사실상 배열처럼 사용된다.

String[]

호출하는 쪽에서

print(
        "A",
        "B",
        "C"
);

라고 작성하면 개념적으로는 다음과 비슷한 배열이 만들어져 전달된다.

String[]

[
    "A",
    "B",
    "C"
]

즉 가변인자 문법은 사용하기 편하도록 감싼 문법이고, 실제 메서드 내부에서는 배열을 사용한다.

여기까지는 큰 문제가 없다.

문제는 다음과 같은 코드다.

public static void method(
        List<String>... lists
) {
}

이 경우 가변인자 배열의 요소 타입이 List<String>이라는 매개변수화 타입이 된다.

바로 여기서 제네릭과 배열의 충돌이 다시 등장한다.


배열과 제네릭은 타입 시스템의 성격이 다르다

아이템 28에서 배열과 제네릭에는 중요한 차이가 있다고 살펴봤다.

배열은 공변이다.

String[] strings = new String[10];

Object[] objects = strings;

String[]을 Object[]로 사용할 수 있다.

반면 제네릭은 기본적으로 불공변이다.

List<String> strings =
        new ArrayList<>();

List<Object> objects =
        strings;

이 코드는 컴파일되지 않는다.

그리고 또 하나의 중요한 차이가 있다.

배열은 런타임에 자신의 원소 타입을 알고 있는 실체화 타입이다.

String[]

이라면 JVM은 해당 배열이 String을 담는 배열이라는 사실을 런타임에도 알고 있다.

반면

List<String>

의 String 같은 제네릭 타입 인수는 런타임에 그대로 실체화되는 방식이 아니다.

Java 제네릭은 타입 소거를 중심으로 구현되기 때문이다.


그래서 제네릭 배열 생성을 금지한다

다음 코드는 작성할 수 없다.

List<String>[] lists =
        new List<String>[10];

컴파일러가 막는다.

generic array creation

이라는 컴파일 오류가 발생한다.

왜일까?

배열은 런타임에 자신의 원소 타입을 검사해야 한다.

하지만 List<String>의 String 타입 인수는 런타임에 완전히 실체화되지 않는다.

결국 JVM이

List<String>

과

List<Integer>

를 배열 저장 검사에서 완벽하게 구별할 수 없기 때문에 타입 안전성을 보장하기 어려워진다.

그래서 Java에서는 이런 배열 생성을 아예 막는다.


그런데 제네릭 가변인자는 허용된다

이상한 점이 하나 생긴다.

다음은 불가능하다.

new List<String>[10];

그런데 다음은 가능하다.

static void method(
        List<String>... lists
) {
}

가변인자는 내부적으로 배열을 사용한다.

따라서 제네릭 가변인자를 사용하면 언어 차원에서 결국 제네릭 타입과 배열이 만나는 상황이 생긴다.

Java는 이 문법을 완전히 금지하지는 않았다.

대신 컴파일러가 경고한다.

Possible heap pollution
from parameterized vararg type

즉

이 코드는 사용할 수 있지만 타입 안전성이 깨질 가능성이 있으니 주의하라.

는 의미다.


Heap Pollution이란?

Heap Pollution은 단순히 Heap 메모리가 물리적으로 손상된다는 뜻이 아니다.

제네릭 문맥에서는 다음과 같은 상황을 의미한다.

매개변수화 타입이 기대하는 타입과 실제 객체의 타입 관계가 깨진 상태

예를 들어 원래

List<String>

이 들어 있다고 믿고 있는 위치에 실제로는

List<Integer>

가 들어가 있다면 타입 시스템이 기대하는 상태와 실제 객체 상태가 달라진다.

이런 상황이 Heap Pollution이다.


실제 Heap Pollution을 만들어보자

다음 메서드를 보자.

static void dangerous(
        List<String>... stringLists
) {

    Object[] objects =
            stringLists;

    objects[0] =
            List.of(42);

    String value =
            stringLists[0]
                    .get(0);
}

하나씩 살펴보자.

먼저

List<String>... stringLists

는 메서드 내부에서 배열 형태로 다뤄진다.

그리고 배열은 공변이므로 다음이 가능하다.

Object[] objects =
        stringLists;

이제 objects의 컴파일타임 타입은 Object[]이다.

따라서 다음 코드도 문법적으로 가능하다.

objects[0] =
        List.of(42);

List<Integer>도 결국 하나의 객체이므로 Object[]에 저장할 수 있다.

그런데 원래 stringLists를 사용하는 코드는 해당 위치에

List<String>

이 있다고 믿고 있다.

실제로는

List<String>[]이라고 기대

        ↓

Object[] 참조로 우회

        ↓

List<Integer> 삽입

        ↓

타입 계약 붕괴

가 된 것이다.

이것이 Heap Pollution이다.


문제는 값을 꺼낼 때 드러난다

다음 코드를 실행한다고 해보자.

String value =
        stringLists[0]
                .get(0);

컴파일러 입장에서는 stringLists[0]의 타입이

List<String>

이다.

따라서 get(0)의 결과 역시 String이라고 생각한다.

하지만 실제 객체는 앞에서 넣은

List.of(42)

다.

따라서 실제 반환값은 Integer다.

제네릭의 타입 소거 때문에 컴파일러는 필요한 위치에 형변환 코드를 추가한다.

개념적으로는 다음과 비슷하다.

Object 반환

↓

String으로 cast

↓

실제 객체는 Integer

↓

ClassCastException

결국 제네릭을 사용했음에도 런타임에 타입 안전성이 깨졌다.


배열이 왜 List 삽입을 막지 못했을까?

다음 코드는 즉시 실패한다.

Object[] objects =
        new String[1];

objects[0] =
        Integer.valueOf(10);

실제 배열 객체가 String[]이라는 것을 JVM이 알고 있기 때문이다.

따라서 Integer를 넣으려고 하는 순간

ArrayStoreException

이 발생한다.

하지만 제네릭 가변인자의 경우 배열의 런타임 요소 타입은 타입 소거의 영향을 받는다.

List<String>과 List<Integer>의 String, Integer까지 배열의 런타임 저장 검사가 구별하지 못한다.

둘 다 런타임에서는 List 계열 객체다.

그래서 배열 저장 검사에서는 문제가 드러나지 않고, 이후 값을 꺼내 제네릭 타입에 맞게 사용할 때 문제가 드러날 수 있다.


그래서 제네릭 가변인자는 기본적으로 위험하다

핵심 구조를 정리하면 다음과 같다.

Generic Varargs

List<String>...

↓

가변인자는 배열 사용

↓

제네릭 타입 + 배열 결합

↓

타입 소거 때문에
런타임 원소 타입을 완전히 검사하기 어려움

↓

Heap Pollution 가능

↓

ClassCastException 가능

그래서 가능하면 제네릭 타입과 가변인자를 무심코 함께 사용하지 않는 것이 좋다.

하지만 실제 Java API를 보면 제네릭 가변인자를 사용하는 코드가 존재한다.

왜냐하면 특정 조건을 만족하면 안전하게 사용할 수 있기 때문이다.


언제 제네릭 가변인자가 안전할까?

가장 중요한 기준은 두 가지다.

첫 번째는 가변인자 배열에 타입 안전성을 깨뜨리는 값을 저장하지 않는 것이다.

두 번째는 가변인자 배열의 참조가 메서드 밖으로 탈출하지 않도록 하는 것이다.

이 두 조건을 중심으로 생각하면 된다.


첫 번째 원칙: 배열을 변조하지 않는다

다음처럼 가변인자로 받은 값을 읽기만 한다고 해보자.

static <T> void printAll(
        T... values
) {

    for (T value : values) {
        System.out.println(value);
    }
}

메서드 안에서는 배열을 순회하면서 원소를 읽기만 한다.

values

↓

읽기

↓

출력

다른 타입의 객체를 배열에 집어넣지 않는다.

이런 코드는 타입 안전성을 깨뜨릴 가능성이 낮다.


위험한 형태는 배열을 우회해서 변경하는 것이다

반면 다음 코드는 위험하다.

static void dangerous(
        List<String>... lists
) {

    Object[] objects =
            lists;

    objects[0] =
            List.of(100);
}

Object[]로 우회한 뒤 원래 제네릭 계약과 다른 값을 삽입했다.

이런 메서드에는 절대로

@SafeVarargs

를 붙여서 문제를 숨기면 안 된다.

먼저 구현 자체를 수정해야 한다.


두 번째 원칙: 배열 참조를 외부로 노출하지 않는다

가변인자 배열에 직접 값을 저장하지 않는다고 무조건 안전한 것은 아니다.

다음 코드도 문제가 될 수 있다.

static <T> T[] toArray(
        T... args
) {
    return args;
}

메서드 내부에서는 배열을 수정하지 않는다.

그냥 반환할 뿐이다.

하지만 문제는 가변인자를 위해 만들어진 배열 참조가 메서드 밖으로 빠져나갔다는 것이다.

varargs 배열 생성

↓

메서드 내부

↓

그 배열 자체를 return

↓

외부 코드가 배열 참조 획득

이제 타입 안전성을 메서드 내부에서만 통제할 수 없게 된다.


배열 참조의 탈출이 왜 위험할까?

가변인자 배열은 호출 문맥과 타입 소거가 얽히면서 실제 런타임 배열 타입과 클라이언트가 기대하는 타입이 달라질 수 있다.

대표적인 예가 다음과 같은 메서드다.

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();
}

겉으로 보면 문제가 없어 보인다.

T 세 개를 받고 그중 두 개를 T[]로 반환한다.

하지만 이 코드는 런타임에 실패할 수 있다.


pickTwo의 위험을 단계적으로 살펴보자

클라이언트가 다음과 같이 호출한다고 해보자.

String[] values =
        pickTwo(
                "A",
                "B",
                "C"
        );

클라이언트의 기대는 명확하다.

String[]

반환

그런데 pickTwo() 내부에서 호출되는

toArray(a, b)

를 생각해보자.

toArray()는 가변인자 메서드다.

static <T> T[] toArray(
        T... args
)

컴파일러는 호출 지점에서 가변인자 배열을 만들어야 한다.

하지만 pickTwo() 내부의 T는 타입 소거의 영향을 받는다.

상위 경계가 없다면 T의 소거 타입은 Object다.

따라서 실제 가변인자 배열이 Object[] 형태로 만들어질 수 있다.

그리고 그것을 그대로 반환한다.

Object[]

↓

pickTwo() 밖으로 반환

클라이언트는 String[]을 기대한다

호출 코드는 다음과 같다.

String[] values =
        pickTwo(
                "A",
                "B",
                "C"
        );

컴파일러는 이 결과를 String[]으로 사용해야 한다는 것을 알고 있다.

그래서 필요한 런타임 형변환을 추가할 수 있다.

실제 반환 객체

Object[]

↓

String[]으로 cast 시도

↓

ClassCastException

Object[] 객체는 단순히 형변환한다고 해서 String[] 객체가 되지 않는다.

배열은 런타임 타입을 기억하기 때문이다.


이것이 배열 참조를 외부로 노출하면 위험한 이유다

문제의 핵심은 toArray()가 가변인자 배열을 그대로 반환했다는 것이다.

return args;

가변인자로 만들어진 내부 배열이 API 경계를 넘어갔다.

그 순간 호출자 쪽에서 기대하는 타입과 실제 배열의 런타임 타입 사이에 불일치가 생길 수 있다.

그래서 제네릭 가변인자를 사용할 때는 다음을 강하게 경계해야 한다.

varargs 배열을 return

varargs 배열을 필드에 저장

varargs 배열을 외부 객체에 전달

varargs 배열 참조를
외부 코드가 보관할 수 있게 함

이런 상황을 흔히 배열 참조가 탈출한다(escape) 고 표현할 수 있다.


안전한 예제: flatten

이번에는 안전하게 사용할 수 있는 예제를 살펴보자.

여러 List를 받아 하나의 List로 합치는 메서드다.

@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;
}

lists는 제네릭 가변인자이므로 기본적으로 경고의 대상이 될 수 있다.

하지만 내부 구현을 살펴보면 다르다.


flatten은 가변인자 배열을 읽기만 한다

코드를 보면

for (List<? extends T> list :
        lists)

가변인자 배열에서 값을 가져온다.

그리고 가져온 List 안의 원소를 새로운 결과 리스트에 옮긴다.

result.addAll(list);

데이터 흐름은 다음과 같다.

varargs 배열

↓

List 하나씩 읽음

↓

List의 원소 읽음

↓

새로운 ArrayList<T>에 저장

가변인자 배열 자체를 변조하지 않는다.


배열 자체도 외부로 반환하지 않는다

반환하는 것은

return result;

이다.

여기서 result는 새롭게 생성한

ArrayList<T>

이다.

가변인자 배열인

lists

를 반환하는 것이 아니다.

즉

가변인자 배열

→ 메서드 내부에서만 사용

→ 외부로 노출되지 않음

이라는 조건을 만족한다.

따라서 해당 구현이 타입 안전하다는 것을 개발자가 검증했다면 @SafeVarargs를 적용할 수 있다.


@SafeVarargs는 무엇을 의미할까?

다음과 같이 사용한다.

@SafeVarargs
static <T> List<T> flatten(
        List<? extends T>... lists
) {
    // ...
}

이 애너테이션은

이 메서드의 제네릭 가변인자 사용은 타입 안전하다.

라고 개발자가 선언하는 것이다.

중요한 점은 애너테이션이 코드를 안전하게 만들어주는 것이 아니라는 것이다.

코드가 먼저 안전해야 한다.

그다음 컴파일러에게

이 경고는 내가 검토했고
이 구현에서는 문제가 없다.

라고 알려주는 것이다.


@SafeVarargs를 무작정 붙이면 안 된다

다음 메서드를 생각해보자.

@SafeVarargs
static void dangerous(
        List<String>... lists
) {

    Object[] array =
            lists;

    array[0] =
            List.of(42);
}

애너테이션을 붙였다고 타입 오염이 사라지는 것은 아니다.

여전히 Heap Pollution이 발생한다.

@SafeVarargs

↓

경고는 줄어듦

하지만

↓

타입 안전성은 그대로 깨져 있음

즉 @SafeVarargs는 안전성 보장 장치가 아니라 개발자의 안전성 주장이다.

잘못 사용하면 오히려 컴파일러의 중요한 경고를 숨기게 된다.


@SafeVarargs와 @SuppressWarnings의 차이

아이템 27에서는 다음 애너테이션을 살펴봤다.

@SuppressWarnings("unchecked")

이것도 경고를 억제한다.

하지만 의미와 범위가 다르다.

예를 들어 메서드 전체에

@SuppressWarnings("unchecked")

를 붙이면 해당 범위에서 발생하는 여러 unchecked 경고까지 함께 숨길 수 있다.

개발자가 원했던 것은 제네릭 가변인자와 관련된 경고 하나였는데, 나중에 다른 비검사 연산이 추가되더라도 발견하지 못할 수 있다.

반면 @SafeVarargs는 바로 제네릭 가변인자 사용의 안전성을 선언하기 위해 만들어진 애너테이션이다.

따라서 실제로 안전한 제네릭 가변인자 메서드라면 의도가 더 분명하다.


두 애너테이션을 비교하면

구분@SuppressWarnings("unchecked")@SafeVarargs
주된 목적unchecked 경고 억제제네릭 가변인자 사용의 안전성 선언
범위적용 위치의 unchecked 경고varargs 관련 타입 안전성 경고
실제 안전성을 만들어줌XX
개발자의 검증 필요OO
제네릭 varargs에 대한 의도 표현상대적으로 약함명확함

중요한 원칙은 동일하다.

경고를 없애기 위해 애너테이션을 붙이는 것이 아니라, 코드가 안전하다는 것을 증명한 뒤 경고를 억제해야 한다.


@SafeVarargs를 붙여도 되는 기준

제네릭 가변인자 메서드가 있다면 우선 다음 두 가지를 확인한다.

가변인자 배열에 값을 저장하는가?

예를 들어

Object[] array =
        args;

array[0] =
        something;

처럼 타입 안전성을 깨뜨릴 수 있는 값을 저장한다면 안전하지 않다.


가변인자 배열의 참조가 밖으로 빠져나가는가?

다음과 같은 코드도 위험하다.

return args;

또는

this.args = args;

외부 코드가 가변인자 배열 참조를 보관하거나 변경할 수 있다면 메서드 하나만 보고 타입 안전성을 보장하기 어려워진다.

따라서 아주 실용적으로 기억하면 다음과 같다.

제네릭 varargs 배열을

1. 변조하지 않고

2. 외부에 노출하지 않는다.

이 두 조건이 매우 중요하다.


단순히 읽기만 한다면 안전성을 판단하기 쉽다

예를 들어 다음 메서드는 비교적 분석하기 쉽다.

@SafeVarargs
static <T> void printAll(
        T... values
) {

    for (T value : values) {
        System.out.println(value);
    }
}

가변인자 배열의 원소를 읽어서 사용하는 것뿐이다.

values

↓

read

↓

print

배열에 다른 객체를 넣지 않는다.

배열 참조를 반환하지도 않는다.

이런 형태가 @SafeVarargs를 적용하기 좋은 전형적인 예다.


그렇다고 배열을 다른 메서드에 절대 전달하면 안 되는 것은 아니다

엄밀히 말하면 가변인자 배열 참조를 다른 코드에 전달한다고 해서 무조건 타입 안전성이 깨지는 것은 아니다.

전달받은 메서드가 배열을 안전하게 사용한다는 것을 확실히 보장할 수 있다면 안전할 수도 있다.

예를 들어 내부적으로만 사용하는 안전한 메서드에 전달하고, 그 메서드 역시 배열을 변조하거나 외부에 노출하지 않는다면 문제가 없을 수 있다.

하지만 API 설계 관점에서는 이런 안전성 조건이 여러 메서드에 걸쳐 퍼질수록 분석하기 어려워진다.

그래서 일반적인 원칙으로는

제네릭 가변인자 배열의 참조를 불필요하게 외부로 노출하거나 전달하지 않는다.

라고 기억하는 편이 안전하다.


가장 좋은 해결책은 배열 자체를 피하는 것일 수 있다

지금까지는

제네릭 가변인자를 꼭 사용해야 한다.

는 상황을 가정했다.

하지만 더 근본적인 질문이 있다.

정말 제네릭 가변인자를
사용해야 하는가?

아이템 28에서 살펴봤듯 제네릭과 배열은 타입 시스템 특성상 잘 어울리지 않는다.

그래서 가능한 경우 배열 대신 List를 사용하면 문제 자체를 줄일 수 있다.


pickTwo를 List로 바꿔보자

앞에서 위험했던 메서드는 다음과 비슷했다.

static <T> T[] pickTwo(
        T a,
        T b,
        T c
) {
    // ...
}

결과로 제네릭 배열을 반환하는 구조다.

이를 List 기반으로 바꿀 수 있다.

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();
}

이제 반환 타입은

List<T>

다.


클라이언트도 타입 안전하게 사용할 수 있다

다음처럼 사용한다.

List<String> values =
        pickTwo(
                "A",
                "B",
                "C"
        );

가변인자를 위한 내부 배열을 반환하는 과정도 없다.

Object[] → String[] 같은 런타임 캐스팅 문제도 없다.

T 값들

↓

List<T>

↓

클라이언트

↓

List<String>

타입 관계를 제네릭 컬렉션 안에서 유지할 수 있다.


flatten 역시 List 기반으로 바꿀 수 있다

가변인자 방식은 다음과 같다.

@SafeVarargs
static <T> List<T> flatten(
        List<? extends T>... lists
) {
    // ...
}

안전하게 구현할 수는 있지만 여전히

@SafeVarargs

라는 추가적인 안전성 선언이 필요하다.

이를 처음부터 List 기반으로 만들 수도 있다.

static <T> List<T> flatten(
        List<? extends List<? extends T>> lists
) {

    List<T> result =
            new ArrayList<>();

    for (List<? extends T> list :
            lists) {

        result.addAll(list);
    }

    return result;
}

조금 복잡해 보이지만 타입 구조를 하나씩 보면 의미가 명확하다.


바깥 List도 Producer다

다음 타입을 보자.

List<? extends List<? extends T>>

바깥쪽 List에서 내부 List를 가져온다.

따라서 바깥쪽도 Producer다.

바깥 List

↓

내부 List를 공급

↓

Producer

그래서

? extends

를 사용한다.


안쪽 List 역시 Producer다

내부의

List<? extends T>

역시 T 계열 원소를 공급한다.

예를 들어 T가 Number라면

List<Integer>
List<Double>
List<Long>

등을 받을 수 있다.

따라서 다음 같은 구조도 가능해진다.

List<Integer> ─┐
               │
List<Double> ──┼→ flatten
               │
List<Long> ────┘
               ↓
          List<Number>

아이템 31에서 살펴본 PECS 원칙이 그대로 적용된다.


List 기반 API의 장점

List 기반으로 변경하면 몇 가지 문제가 자연스럽게 사라진다.

첫 번째로 제네릭 배열 자체를 다루지 않는다.

Generic Array

X

두 번째로 Heap Pollution 가능성이 크게 줄어든다.

세 번째로

@SafeVarargs

를 붙이고 그 안전성을 사람이 직접 증명해야 하는 부담도 사라질 수 있다.

그리고 무엇보다 타입 관계를 제네릭 컬렉션으로 컴파일러가 추적할 수 있다.


대신 호출 문법은 조금 달라질 수 있다

가변인자의 장점은 호출이 편하다는 것이다.

flatten(
        list1,
        list2,
        list3
);

List 기반 API라면 다음처럼 작성할 수 있다.

flatten(
        List.of(
                list1,
                list2,
                list3
        )
);

호출하는 코드가 약간 더 길어진다.

즉 선택에는 트레이드오프가 있다.

Varargs

장점
→ 호출이 편함

단점
→ 제네릭과 결합하면 안전성 검토 필요

반면

List

장점
→ 타입 안전성 관리가 상대적으로 쉬움

단점
→ 호출 코드가 조금 장황할 수 있음

이다.


그래서 무조건 varargs를 금지하라는 의미는 아니다

아이템 32의 제목은

제네릭과 가변인수를 함께 쓰지 말라.

가 아니다.

신중하게 사용하라다.

즉 다음과 같은 경우라면 충분한 검토 후 사용할 수 있다.

제네릭 varargs가 API 사용성에
실질적인 장점이 있다.

+

varargs 배열을 변조하지 않는다.

+

배열 참조를 외부에 노출하지 않는다.

+

타입 안전성을 확실하게 증명할 수 있다.

이런 경우라면

@SafeVarargs

를 통해 의도를 표현할 수 있다.


반대로 List가 자연스럽다면 List를 우선 검토한다

다음과 같은 상황이라면 굳이 제네릭 가변인자를 고집할 이유가 없다.

단순히 여러 값을 전달하고 싶다.

↓

이미 Collection으로 표현하기 자연스럽다.

↓

성능이나 호출 편의성 때문에
varargs가 꼭 필요한 것도 아니다.

그렇다면

List<T>

나

Collection<T>

을 사용하는 것이 더 단순하고 안전할 수 있다.

아이템 28과 아이템 32가 결국 같은 방향을 가리키는 이유다.


아이템 28과 아이템 32의 연결

아이템 28에서는

배열보다는 리스트를 사용하라.

고 했다.

주된 이유 중 하나가 배열과 제네릭의 타입 체계가 잘 맞지 않기 때문이다.

아이템 32에서는 그 문제가 가변인자를 통해 다시 나타난다.

Varargs

↓

내부적으로 배열

↓

Generic Varargs

↓

Generic + Array

↓

Heap Pollution 가능

따라서 둘을 연결하면 다음과 같은 결론에 도달한다.

제네릭 데이터를 다룬다.

↓

배열이 꼭 필요한가?

↓

아니라면 List를 먼저 검토한다.

@SafeVarargs는 안전성의 증명서가 아니라 약속이다

이 부분은 꼭 기억할 필요가 있다.

다음 코드에

@SafeVarargs

가 붙어 있다고 해서 컴파일러나 JVM이 메서드 내부를 분석해서

이 메서드는 정말 안전함

이라고 인증해주는 것은 아니다.

오히려 반대다.

개발자가 컴파일러에게

내가 이 코드를 검토했고 안전하다는 것을 책임지겠다.

라고 말하는 것이다.

그래서 @SafeVarargs가 보이면 개발자 입장에서는 오히려 다음을 확인해야 한다.

정말 배열을 변조하지 않는가?

배열 참조가 탈출하지 않는가?

다른 메서드로 넘긴 뒤
위험한 작업이 일어나지 않는가?

unchecked cast로
타입 안전성을 깨뜨리지 않는가?

즉 단순한 경고 제거 애너테이션으로 보면 안 된다.


제네릭 가변인자 코드를 리뷰할 때 확인할 것

실무에서 다음 코드가 있다고 해보자.

@SafeVarargs
static <T> void process(
        List<T>... values
) {
    // ...
}

@SafeVarargs가 있으니 바로 안전하다고 판단하지 않는다.

먼저 구현을 확인한다.

배열 내용을 바꾸는가?

values[0] = ...

있다면 신중하게 검토해야 한다.


Object[]로 우회하는가?

Object[] array =
        values;

특히 그 뒤에 배열을 수정한다면 강한 위험 신호다.


배열 자체를 반환하는가?

return values;

가변인자 배열 참조가 탈출한다.

주의해야 한다.


필드 등에 저장하는가?

this.values =
        values;

메서드 호출 이후에도 배열 참조가 살아남는다.

외부에서 타입 안전성을 추론하기 어려워진다.


단순히 읽기만 하는가?

for (List<T> value : values) {
    // 읽기
}

상대적으로 안전성을 설명하기 쉽다.

이런 식으로 코드를 검토해야 한다.


SafeVarargs 사용 자체를 목표로 하면 안 된다

잘못된 접근은 다음과 같다.

경고 발생

↓

@SafeVarargs 추가

↓

경고 사라짐

↓

완료

올바른 순서는 반대다.

경고 발생

↓

왜 경고가 발생하는지 확인

↓

Heap Pollution 가능성 분석

↓

코드 자체가 안전한지 증명

↓

가능하면 List 기반 구조 검토

↓

제네릭 varargs가 꼭 필요하고
구현이 안전하다면

↓

@SafeVarargs

이 순서가 중요하다.


제네릭과 가변인자를 함께 사용할 때 판단 순서

실무에서는 다음과 같이 생각하면 정리하기 쉽다.

먼저 질문한다.

제네릭 varargs가 정말 필요한가?

필요하지 않다면

List<T>

나

Collection<T>

으로 변경할 수 있는지 검토한다.

그래도 가변인자가 호출 API 측면에서 필요하다면 다음을 본다.

varargs 배열을 수정하는가?

수정한다면 위험 가능성을 분석해야 한다.

다음은

varargs 배열이 외부로 탈출하는가?

를 확인한다.

마지막으로

이 메서드의 타입 안전성을
논리적으로 증명할 수 있는가?

를 확인한다.

모두 만족한다면 @SafeVarargs를 사용할 근거가 생긴다.


아이템 26부터 이어지는 제네릭의 방향

지금까지 제네릭 관련 아이템을 연결하면 하나의 흐름이 보인다.

아이템 26에서는 Raw Type을 피하라고 했다.

타입 정보를 버리지 않는다.

아이템 27에서는 비검사 경고를 제거하라고 했다.

컴파일러가 확인하지 못하는
타입 안전성 문제를 줄인다.

아이템 28에서는 배열보다 리스트를 권장했다.

제네릭과 배열의 충돌을 줄인다.

아이템 29에서는 직접 만드는 타입도 제네릭으로 만들라고 했다.

클라이언트의 cast를 제거한다.

아이템 30에서는 메서드 역시 제네릭으로 만들라고 했다.

입력과 출력의 타입 관계를
컴파일러에게 알려준다.

아이템 31에서는 한정적 와일드카드를 활용했다.

타입 안전성을 유지하면서
API를 더 유연하게 만든다.

그리고 아이템 32에서는 제네릭과 배열이 다시 만나는 가변인자 경계를 조심하라고 한다.

결국 이 모든 아이템의 방향은 같다.

가능한 한 타입에 대한 책임을 개발자의 기억이나 런타임 형변환이 아니라 컴파일러의 타입 시스템에 맡긴다.


핵심 정리

  • 가변인자 T...는 메서드 내부에서 배열 형태로 다뤄진다.
  • 일반적인 실체화 타입의 가변인자는 크게 문제가 없지만 List<String>... 같은 제네릭 가변인자는 배열과 제네릭의 타입 체계가 충돌한다.
  • Java는 new List<String>[10] 같은 제네릭 배열 생성을 막지만 제네릭 가변인자 선언 자체는 허용한다.
  • 제네릭 가변인자를 선언하면 Possible heap pollution from parameterized vararg type과 같은 경고가 발생할 수 있다.
  • Heap Pollution은 제네릭 타입 시스템이 기대하는 타입과 실제 저장된 객체의 타입 관계가 깨진 상태를 의미한다.
  • 제네릭 가변인자 배열을 Object[]로 참조한 뒤 다른 매개변수화 타입의 객체를 저장하면 Heap Pollution이 발생할 수 있다.
  • Heap Pollution 자체가 즉시 오류를 발생시키지 않더라도 나중에 컴파일러가 삽입한 형변환 지점에서 ClassCastException으로 나타날 수 있다.
  • 배열의 런타임 저장 검사는 List 수준의 타입은 확인할 수 있지만 타입 소거 때문에 List<String>과 List<Integer>의 타입 인수 차이까지 완벽하게 검사하지 못한다.
  • 제네릭 가변인자를 안전하게 사용하려면 가변인자 배열을 통해 타입 안전성을 깨뜨리는 값을 저장하지 않아야 한다.
  • 또한 가변인자 배열의 참조가 메서드 외부로 탈출하지 않도록 하는 것이 중요하다.
  • 가변인자 배열 자체를 반환하거나 필드에 보관하거나 외부 코드가 변경할 수 있는 위치로 전달하면 안전성을 보장하기 어려워질 수 있다.
  • toArray(T... args)처럼 가변인자 배열을 그대로 반환하는 메서드는 호출 문맥에서 기대하는 배열 타입과 실제 런타임 배열 타입이 달라져 ClassCastException으로 이어질 수 있다.
  • flatten()처럼 가변인자 배열을 읽기만 하고 새로운 타입 안전한 List에 원소를 옮긴 뒤 해당 List를 반환한다면 안전성을 증명하기 쉽다.
  • @SafeVarargs는 제네릭 가변인자를 사용한 코드가 자동으로 안전해지는 기능이 아니라 개발자가 해당 구현의 안전성을 보장한다는 선언이다.
  • 실제로 Heap Pollution을 만드는 코드에 @SafeVarargs를 붙여도 버그는 사라지지 않는다.
  • 제네릭 가변인자와 관련된 안전성을 표현할 목적이라면 범위가 넓은 @SuppressWarnings("unchecked")보다 의도가 명확한 @SafeVarargs가 적절할 수 있다.
  • 다만 어떤 애너테이션을 사용하든 먼저 코드 자체가 안전하다는 논리적인 근거가 필요하다.
  • 제네릭 가변인자가 반드시 필요한 것이 아니라면 List<T>나 Collection<T> 기반 API로 변경하는 것이 더 안전할 수 있다.
  • pickTwo()처럼 제네릭 배열을 반환하는 코드도 List<T>를 반환하도록 바꾸면 배열 런타임 타입 문제를 피할 수 있다.
  • 여러 제네릭 List를 합치는 API 역시 가변인자 대신 List<? extends List<? extends T>> 같은 Collection 기반 구조로 설계할 수 있다.
  • List 기반 설계에서는 아이템 31의 PECS 원칙을 함께 적용해 Producer에 ? extends T를 사용하면 API의 타입 안전성과 유연성을 동시에 높일 수 있다.
  • 가변인자는 호출 문법이 간결하다는 장점이 있으므로 무조건 금지할 필요는 없으며 타입 안전성을 확실히 증명할 수 있을 때 신중하게 사용하면 된다.
  • 제네릭 가변인자 코드를 리뷰할 때는 배열을 수정하는지, Object[]로 우회하는지, 배열 참조가 밖으로 탈출하는지, @SafeVarargs의 근거가 실제로 존재하는지를 확인해야 한다.
  • 아이템 28과 아이템 32를 함께 보면 결국 제네릭 데이터를 다룰 때 배열이 꼭 필요한 것이 아니라면 List를 우선 검토하는 것이 타입 안전성을 확보하기 쉬운 방법이라는 결론으로 이어진다.

한 줄 요약

제네릭 가변인자는 내부적으로 배열을 사용하기 때문에 Heap Pollution과 런타임 ClassCastException으로 이어질 수 있으므로, 반드시 사용해야 한다면 배열을 변조하거나 외부에 노출하지 않는다는 안전성을 먼저 증명한 뒤 @SafeVarargs를 사용하고, 가능하다면 처음부터 List<T> 같은 컬렉션 기반 API로 설계해 컴파일러의 타입 시스템이 안전성을 보장하도록 만드는 것이 좋다.


© 2020. All rights reserved.

SIKSIK