Backend/Java

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

김세진 2024. 2. 22. 20:09
반응형

 

 

 

 

개요

 

가변인수(varargs)란 다음과 같이 명시한 타입의 인수를 0개 이상 받을 수 있는 것을 의미한다.

static int sum(int... args) {
    int sum = 0;
    for (int arg : args)
        sum += arg;
    return sum;
}

 

메서드가 호출되면 인수의 개수와 길이가 같은 배열을 생성한 뒤 인수들을 이 배열에 저장하여 가변인수 메서드에 건네준다. 그런데 이 배열은 자바의 내부에서만 동작하지 않고 클라이언트 코드로 노출되었다.

 

여기서, Item 28 에서 자세히 언급한 문제가 발생할 가능성이 생기게 된다.

 

 


제네릭 배열

 

static void dangerous(List<String>... stringLists) {
    List<Integer> intList = List.of(42);
    Object[] objects = stringLists;
    objects[0] = intList;   // 힙 오염 발생

    String s = stringLists[0].get(0); // ClassCastException
}

 

List<String>만 담겠다고 선언했던 stringLists에 List<Integer>가 할당되어 타입 안전성을 보장하지 못하는 힙 오염(Heap Pollution)이 발생하고 마지막 라인에 원소를 꺼내오는 과정에서 컴파일러가 자동 생성한 형변환 코드를 거칠 때 ClassCastException이 발생하게 된다.

 

위와 같은 위협은 자바 내부에서 제네릭 배열의 생성을 금하면서 방지되었다. 그런데 위와 같이 가변인수와 제네릭을 혼용하는 문법을 허용하면서 제네릭 배열이 생성될 수 있게 된 것이다.

 

Why?

이러한 모순을 허용한 이유는 제네릭이나 매개변수화 타입의 가변인수 메서드가 실무에서 매우 유용하기 때문이다. 자바 라이브러리 내에서도 이러한 형태의 메서드들을 제공하고 있다. 이 메서드들은 위에서 언급한 문제가 없도록 타입 안전하게 작성되었다.

 

1. Arrays.asList(T... a)

2. Collections.addAll(Collection<? super T> c, T... elements)

3. EnumSet.of(E first, E... rest)

 

 


경고 숨기기

 

자바 라이브러리 코드는 안전하게 작성되었을지언정, 클라이언트 코드에서 생성한 제네릭 가변인수 메서드도 타입 안전하게 작성되었는지는 알 길이 없다. 때문에 IDE에서는 해당 메서드들에 대해 아래와 같이 경고한다.

 

 

이를 실제로 호출할 때에도 마찬가지로 아래와 같이 경고한다.

 

 

문제는 자신이 작성한 메서드가 타입 안전하게 작성되었다 하더라도, 컴파일러가 판단할 수 없어 이같은 경고를 내보낸다는 것이다. 따라서 개발자들은 @SuppressWarnings("unchecked") 애너테이션을 달아 이러한 경고를 숨기곤 했는데, 가독성을 떨어뜨리고 실제 관련된 문제가 발생해도 경고를 숨기는 결과로 이어지기도 했다.

 

자바 7부터는 @SafeVarargs 애너테이션을 제공하여 이 문제를 보완했다. 해당 애너테이션을 사용하면 메서드 작성자가 그 메서드가 타입 안전함을 보장한다는 의미로 사용된다. 그리고 컴파일러도 더 이상 경고를 하지 않는다.

 

@SafeVarargs는 그 자체로 어떤 기능을 하는 것이 아니라 규약이기 때문에, 달리 말하면 메서드가 안전한 것이 확실하지 않다면 절대 @SafeVarargs 애너테이션을 사용하면 안 된다.

 

 


타입 안전하게 작성하기

 

이 varargs 매개변수 배열이 순수하게 인수들을 전달하는 용도로만 사용된다면 힙 오염이 발생할 일이 없어 타입 안전하게 될 것이다. 따라서 다음의 두 규칙을 지키면 그 제네릭 가변인수 메서드는 타입 안전하다고 할 수 있다.

 

1. 배열에 새로운 값을 할당하거나 덮어쓰지 않는다.

2. 배열을 외부로 노출하지 않는다.

 

2번의 경우 제네릭 varargs 매개변수로 생성된 제네릭 배열을 다른 메서드에서 활용할 수 있게 되면서 타입 안전성이 보장되지 않기 때문이다. 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();
}

 

이 메서드는 T 타입 인수 3개를 받아 그 중 2개를 무작위로 골라 담은 배열을 전달한다. 이 때, a, b, c 는 각각 어떤 타입이 될 지 컴파일타임에는 알 수 없다. 따라서 어떤 타입의 객체를 넘기더라도 담을 수 있는 배열인 Object[] 배열을 생성한 뒤 여기에 담아 반환한다. 

 

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

 

위의 두 메서드를 실사용하는 main 코드이다. 이는 별다른 경고 없이 컴파일되지만, 실행하면 ClassCastException을 던진다. String[] 으로 선언된 attributes에 pickTwo에서 반환한 Object[]를 담으려 하면서 형변환이 실패하는 것이다. Object는 String의 하위 타입이 아니기 때문이다.

 

따라서 제네릭 varagrs 매개변수 배열에 다른 메서드가 접근하도록 허용하면 안전하지 않다.

 

예외

1. @SafeVarargs 애너테이션이 달린, 즉 타입 안전한 다른 varargs 메서드에 넘기는 것은 안전하다.

2. 배열 내용의 일부 함수를 호출만 하는 일반 메서드에 넘기는 것은 안전하다.

 

안전한 메서드

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

 

처음에 언급한 1, 2번 규칙이 지켜진 메서드이다. 배열에 새로운 값을 할당하거나 덮어쓰지 않고, 배열을 외부로 노출하지도 않는다. 단지 배열의 원소를 순서대로 하나의 리스트에 담아 반환할 뿐이다. 이렇게 최종 반환된 값은 제네릭 배열이 아니기 때문에 안전하다. 또한 @SafeVarargs 애너테이션으로 타입 안전함을 보장한다.

 

그럼 언제 @SafeVarargs를 사용해야 할까? 답은 제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 모든 메서드에 @SafeVarargs를 달면 된다. 이는 무작정 달라는 뜻이 아닌, 반드시 메서드를 충분히 검수한 뒤 공개하라는 의미이다.

 


List로 대체

 

메서드에 제네릭 varargs 매개변수를 사용한 뒤 검수하여 @SafeVarargs를 선언하는 것이 다소 번거롭고 위험하게 느껴질 수 있다. 그렇다면 Item 28의 조언처럼 배열 대신 List를 사용하면 된다.

 

정적 팩터리 메서드인 List.of 는 임의개의 인수를 받아 적절한 타입의 리스트로 반환해준다. toArray의 List 버전으로 자바 라이브러리 차원에서 제공하기 때문에 안전하다.

 

audience = flatten(List.of(friends, romans, countrymen));

 

이렇게 제네릭을 사용하면 컴파일러가 메서드의 타입 안전성을 검증할 수 있으므로 안전하고, 직접 검증하여 @SafeVarargs를 달지 않아도 된다. 단, 배열을 사용하는 것보다는 조금 느려질 수 있다. 따라서 속도가 아주 민감한 상황에서는 충분히 고려해보자.

 

직전에 언급한 pickTwo 메서드에 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();
}

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

 

해당 코드는 배열 대신 제네릭만 사용하기 때문에 타입 안전하다.

 

 


정리

 

1. 가변인수는 배열을 노출하여 제네릭과 만나면 타입 안전성이 보장되지 않는다.

2. 제네릭 varargs 매개변수를 사용하고자 한다면 타입 안전한지 반드시 확인 후 @SafeVarargs 애너테이션을 달아 타입 안전성을 보장 하도록 하자.

3. 혹은 varargs 매개변수 대신 List로 대체하여 제네릭만 사용하는 방법을 택함도 고려해보자.

 

 

 

 

반응형