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

    들어가기 전

    이 글은 인프런 백기선님의 강의를 복습하며 작성한 글입니다. 


    이 글의 요약

    • 제네릭과 배열은 특성상 맞지 않다.
    • 제네릭 가변인수는 컴파일러가 금지한 제네릭 배열을 생성한다. 제네릭 배열은 Heap 오염 위험성이 잇다. 
    • 제네릭 배열은 Heap 오염 위험성이 있다. 따라서 배열을 가급적이면 List로 바꾸는 것이 좋다.
    • 제네릭 가변인수를 어쩔 수 없이 사용해야 한다면 다음 두 가지를 지킨다.
      • 제네릭 가변인수에 어떠한 값도 넣지 않는다.
      • 제네릭 가변인수를 메서드 밖으로 반환하지 않는다. 
    • 타입 한정 매개변수 <T>는 소거된 후 일반적으로 가장 포괄적인 Object로 변환된다.
    • 배열을 사용하는 경우 가급적 List로 바꾸자. 아래 장점이 있다.
      • 제네릭을 사용해 타입 안정성을 확보할 수 있다.
      • Heap 오염을 우려하지 않아도 된다. 따라서 @Varargs를 써야하는지 고민할 필요도 없다.

    핵심정리 : 제네릭과 가변인수를 동시에 쓰는 것은 위험하다.

    • 제네릭 가변인수 배열에 값을 저장하는 것은 안전하지 않다.
      • Heap 메모리 오염이 발생할 수 있다. (컴파일 경고 발생)
      • 자바7에 추가된 @SafeVarargs 애노테이션으로 컴파일 경고를 무시할 수 있다.
    • 제네릭 가변인수 배열의 참조를 밖으로 노출하면 힙 오염을 외부로 전달할 수 있다.
      • 예외적으로, @SafeVarArgs를 사용한 메서드에 넘기는 것은 안전하다. 
      • 예외적으로, 배열의 내용의 일부 함수를 호출하는 일반 메서드로 넘기는 것은 안전하다
    • 아이템 28의 조언에 따라 가변인수를 List로 바꾼다면
      • 배열없이 제네릭만 사용하므로 컴파일러가 타입 안정성을 보장할 수 있다.
      • @SafeVarargs 어노테이션을 사용할 필요가 없다
      • 실수로 안전하다고 판단할 걱정도 없다. 

     

     

    제네릭 가변인수의 Heap 오염

    가변인수는 '...'으로 표현해서 여러 개의 인자를 동적으로 받을 수 있는 것을 의미한다.

    • 가변인수를 제네릭과 함께 사용하는 경우, Heap Polution이 발생할 수 있다. Heap Polution은 아래 경우에 발생한다.
    • 따라서 컴파일러는 제네릭 배열을 생성하는 것을 문법적으로 막아놓는다. 하지만 제네릭 + 가변인수를 사용하는 경우에 한정적으로 제네릭 배열이 생성된다. 
    static void dangerous(List<String>... stringLists) {
        // 제네릭 배열이 생성되는 것은 이 케이스가 유일함. 
        List<String>[] stringLists1 = stringLists; // 배열과 제네릭을 함께 쓴 녀석이 나온다.
    
        List<Integer> intList = List.of(42);
        Object[] objects = stringLists; // List[] 타입의 배열이 된다. List의 상위는 Object이기 때문에 이렇게 동작한다.
        objects[0] = intList; // 힙 오염 발생
        String s = stringLists[0].get(0); // ClassCastException
    }

    제네릭<String>을 사용하는 가장 큰 이유는 컴파일 타임부터 타입 안정성을 확보하는 용도다. 

    • 제네릭 + 가변인수를 함께 사용하는 경우 Heap Polution이 발생해서 컴파일 타임의 타입 안정성을 확보할 수 없다. 따라서 제네릭 + 가변인수는 제네릭 + List로 바꾸는 것이 낫다. 

     

    제네릭 가변인수의 상황

    제네릭 가변인수를 사용할 때는 위험한 상황 / 안전한 상황이 있다. 아래에서 위험한 상황과 안전한 상황을 각각 살펴보고자 한다. 

     

    제네릭 가변인수 위험한 상황

    • 아래의 Dangerous 클래스는 위험한다. 
    • 제네릭 배열이 생성되고, 제네릭 배열에 Heap Polution이 발생했기 때문이다. 
    public class Dangerous {
        // 코드 32-1 제네릭과 varargs를 혼용하면 타입 안정성이 깨진다! (192 - 192쪽)
        static void dangerous(List<String>... stringLists) {
            List<String>[] stringLists1 = stringLists; // 배열과 제네릭을 함께 쓴 녀석이 나온다.
    
            List<Integer> intList = List.of(42);
            Object[] objects = stringLists; // List[] 타입의 배열이 된다. List의 상위는 Object이기 때문에 이렇게 동작한다.
            objects[0] = intList; // 힙 오염 발생
            String s = stringLists[0].get(0); // ClassCastException
        }
    
        public static void main(String[] args) {
            dangerous(List.of("There be dragons!"));
        }
    }
    

     


    제네릭 가변인수 안전하게 쓰이는 경우

    제네릭 가변인수를 어쩔 수 없이 써야만 하는 상황이 있다. 이런 경우에는 가변인자와 제네릭을 함께 사용하는 안전한 방법을 고려해서 사용해야한다. 만약 안전하게 쓰이는 경우라면 제네릭 가변인자를 쓸 때 발생하는 컴파일 경고 (Heap Polution)를 무시하도록 어노테이션을 이용한다.

    • @SuppressWarnings("unchecked")
    • @SafeVarargs

    두 가지가 가능한데, @SafeVarargs를 사용하는 것이 추천된다. @SuppressWarnings는 매개변수에 사용될 수 없어서 메서드 전체에 사용되는데, 이 경우 매개변수의 Heap Polution을 포함한 다른 컴파일 경고도 모두 무시하기 때문이다. 따라서 가능한 좁은 범위인 @SafeVarargs를 이용해서 제네릭 가변인수의 Heap Polution만 타겟해서 무시하는 것이 좋다. 

    // 코드 32-3 제네릭 varargs 매개변수를 안전하게 사용하는 메서드 (195쪽)
    public class FlattenWithVarargs {
    
        // 매개변수로 List를 가변인자로 받아서, 각 List를 합친다.
        @SafeVarargs
        private 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;
        }
    
        public static void main(String[] args) {
            List<Integer> flatList = flatten(
                    List.of(1, 2), List.of(3, 4, 5), List.of(6, 7)
            );
            System.out.println(flatList);
        }
    }

     


    제네릭 가변인수를 안전하게 사용하는 방법

    위에서는 제네릭 가변인수가 안전한 경우를 봤다. 그렇다면 어떻게 코드를 작성해야 제네릭 가변인수를 안전하게 사용하는 것일까? 아래 두 가지 일을 하지 않는다면, 제네릭 가변인수는 안전하게 사용되는 것으로 생각할 수 있다.

    • 가변인자로 받은 것(배열 같은 곳)에 무언가를 넣지 않는다.
    • 컴파일러가 생성해주는 제네릭 배열을 절대로 메서드 밖으로 전달하지 않는다. 예를 들면 아래의 경우 lists라는 파라메터를 밖으로 바로 리턴하면 안된다. 

    예를 들어 아래의 lists 라는 제네릭 배열 매개변수를 절대로 메서드 밖으로 리턴해서는 안된다. 

    // 참고 : lists는 Producer로 동작한다. PE-CS 원칙
    @SafeVarargs
    private 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;
    }

    제네릭 가변인자를 메서드 밖으로 던지는 경우 → 타입 안정성을 잃고 런타임 에러

    아래의 PickTwoDangerous 클래스를 살펴보자. 여기서 toArray() 메서드를 봐야한다.

    • toArray() : 가변인수로 받은 제네릭 배열이 그대로 리턴된다. 제네릭 배열은 컴파일 환경에서 제네릭이 소거되고 가장 포괄적인 범위인 Object[]로 리턴된다. 
    // 미묘한 힙 오염 발생 (193-194쪽)
    public class PickTwoDangerous {
        // 코드 32-2 자신의 제네릭 매개변수 배열의 참조를 노출한다. - 안전하지 않다! (193쪽)
        static <T> T[] toArray(T... args) {
            T[] args1 = args; // 타입 한정 매개변수 T는 포괄적임. 따라서 컴파일 환경에서 소거되고 Object[]로 전달됨.
            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) { // 194쪽
            // pickTwo의 결과로 Object[]을 전달받음. 여기서 String[]으로 classCast를 해야함.
            // 그런데 Object[] -> String[]으로 Downcasting은 할 수 없음. 따라서 classCastException이 발생함.
            String[] attributes = pickTwo("좋은", "빠른", "저렴한"); // classCastException 발생.
            System.out.println(Arrays.toString(attributes));
        }
    
    }

    따라서 다음 코드를 살펴보면 다음과 같다.

    • main()에서 pickTwo()의 호출 결과는 기본적으로 Object[]이 반환되어야 한다. 
    • 하지만 pickTwo()의 인자가 String이기 때문에 컴파일러는 T가 String인 것을 안다.
    • 따라서 컴파일러는 pickTwo()의 메서드 실행결과로 받는 Object[]을 String[]로 타입 변환해주려고 한다.

    Object[]을 String[] 타입으로 다운 캐스팅 하려고 하지만, 불가능한 문법이다. 따라서 castClassException이 런타임에 발생하게 된다. 이것은 내부적으로 사용하는 제네릭 배열이 메서드 밖으로 나왔기 때문에 발생하는 문제다. 제네릭 배열은 Object[]로 반환되고, Object[]이 제네릭 타입에 맞추어서 캐스팅 되려는 시점에 주로 런타임 에러가 발생한다. 


    배열 대신 List로 수정해서 안전하게 사용.

    이전에는 제네릭 가변인자에서 생성되는 제네릭 배열을 메서드 밖으로 반환했기 때문에 Heap Polution이 발생했다. 제네릭 배열을 메서드 밖으로 전달하지 않으면 해결된다. 하지만 가장 근본적인 해결책은 배열을 사용하는 대신 List를 사용하고, List와 제네릭을 함께 사용하는 것이다. 

    아래 SafePickTwo는 배열을 List로 바꾼 후 제네릭을 사용해서 타입 안정성을 확보했다. 배열 대신 List<제네릭>을 사용하게 되면 타입 안정성도 확보되고, @SafeVarargs를 사용해야하는지도 고민할 필요 조차 없어진다. 

    // 배열 대신 List를 이용해 안전하게 바뀐 PickTwo (196쪽)
    public class SafePickTwo {
    
        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) { // 194쪽
            List<String> attributes = pickTwo("좋은", "빠른", "저렴한");
            System.out.println(attributes);
        }
    }

     

    정리

    • 제네릭 가변인자가 만들어주는 제네릭 배열을 메서드 밖으로 노출시키는 것은 안전하지 않다
    • 가변인자를 쓰는 것보다는 List를 써서 제네릭을 활용하는 것이 더욱 타입 안정성을 확보할 수 있는 좋은 방법이다. 

    참고

    아래 코드에서도 컴파일 경고는 발생한다. 배열을 사용하는 것 자체가 Heap 오염의 위험성이 있다는 것을 가정한다. 여기서 Obj[0] = 1을 하면, String[]에 Integer가 들어가면 에러가 발생할 수 있다고 인텔리제이가 알려준다.

    그런데 아래의 T[]은 더욱 최악이다. 제네릭 T는 컴파일 시점에는 소거되고, T로 변경될 수 있는 가장 포괄적인 타입인 Object[]로 바뀌게 된다. 따라서 String만 있는 String[]가 Object[]로 바뀌고, Object[] args가 hello에 참조될 수 있다. 가장 큰 문제는 hello[0] = 1에서 컴파일 경고조차 뜨지 않는다는 것이다. 기대대로라면 String[]  = 1을 하는 것이기 때문에 반드시 컴파일 경고가 떠야하는 문제다.  

    제네릭 배열은 이처럼 Object[]로 바뀌면서 의도치 않은 Heap 오염을 야기시킬 수 있다. 

     

     

    댓글

    Designed by JB FACTORY