본문 바로가기

3. 기술 공부/Java (Spring, Spring Boot)

[Effective Java 3E] 5. 제네릭 (Generic)

클래스와 인터페이스 선언에 타입 매개변수(type parameter)가 쓰이면, 이것을 제네릭 클래스 혹은 제네릭 인터페이스라고 말한다.

제네릭 클래스 List<E>가 있고 이것이 List<String>으로 선언된다면, 이 때 E를 정규 타입 매개변수(formal type parameter)라고 하고 String이 실제 타입 매개변수(Actual type parameter)라고 한다. 

 

Item 26. 로타입(raw type)은 사용하지 말라

제네릭 타입을 정의하면 그에 딸린 로타입도 함께 정의된다. 로타입이란 타입변수를 사용하지 않을 때를 말하며 위의 List<E>의 경우엔 List가 된다. 자바 언어체계에서 로타입으로 변수를 선언할 수가 있는데, 로타입을 그대로 사용하는 것은 안전하지 않으므로 매개변수화된 타입을 사용하는 것이 바람직하다. 이때 안전하지 않다는 것은 컴파일 타임에 타입 오류를 검출할 수 없다는 의미로 이해할 수 있을 것 같다.

아래와 같이 로타입으로 선언된 컬렉션에서 의도치않은 타입의 원소를 넣더라도 컴파일 타임에 오류를 검출하지 못하고 런타임에 에러를 발생한다는 한계점이 있다.

Collection strings = ...;strings.add(1);
String string = (String) strings.get(1); //ClassCastException이 runtime에 발생한다.

아래와 같이 사용하면 add를 하는 경우 컴파일 오류가 발생하기 때문에 보다 안전성과 표현력이 높은 코드를 짤 수 있다.

Collection<String> strings = ...;
string.add(1); // 컴파일 오류!

그렇다면 애초에 왜 사용하지도 못할 로타입이 있는 것일까? 이는 제네릭이 없던 시절 자바와의 호환성을 위해서이다. 제네릭이 없던 시절의 코드와 호환이 되기 위해서는 로타입을 사용하는 메서드에 매개변수화 타입의 인스턴스를 전달하더라도 동작해야했기 때문에 로타입을 지원하고 제네릭 구현에서는 erasure 방식을 사용하게 된 것이다.

** 제네릭의 erasure란? 제네릭 클래스는 원소의 타입을 컴파일타임에만 검사하며 런타임에는 타입정보가 소거되어 타입을 알 수 없다.

 

List와 같은 로 타입은 사용하면 안되나 List<Object>는 괜찮은데 이때 List<Object>는 모든 타입을 허용한다는 의사를 명확하게 컴파일러에게 전달한 것이라고 보기 때문이다. 이때 List<Object>를 매개변수로 받는 메서드에 List<String>을 전달할 수 없는데, 이는 제네릭의 하위타입 규칙 때문이다. List<String>은 List의 하위타입이지만 List<Object>의 하위타입은 아니다. 

 

원소의 타입을 몰라도 되는 메소드를 작성하고 싶을 땐 비한정적 와일드 카드(<?>)를 쓰는게 좋다. Set<?>과 Set의 차이는 전자는 안전하고 후자는 안전하지 않다는데에 있다. 로 타입 컬렉션에서는 아무 원소나 넣을 수 있으니 타입불변식을 훼손하기 쉽다. Set<?>처럼 비한정적 와일드카드를 사용하면 Set<?>에 null을 제외한 어떤 원소도 넣을 수 없고, 컬렉션에서 꺼낼 수 있는 객체의 타입도 전혀 알수가 없다. 

만약 이러한 제약을 받아들일 수 없다면 제네릭 메서드 또는 한정적 와일드카드 타입을 사용하면 된다.

 

예외적으로 로타입을 써야하는 경우는 아래와 같다.

(1) class 리터럴에는 로타입을 써야한다. (List<String>.class과 같은 매개변수화된 타입은 허용되지 않음)

(2) instance of 연산자의 경우 로타입과 비한정적 와일드카드 타입이 똑같이 동작하므로 깔끔히 로타입을 쓰는 것이 좋다.

 

Item 27. 비검사경고를 제거하라

제네릭을 사용하게 되면 수많은 컴파일러 경고가 발생하는데 이는 최대한 발생하지 않도록 제거하는 것이 좋다. 그럼에도 불구하고 경고를 제거할 수없지만 타입세이프하다고 확신할 수 있다면 @SuppressWarnings("unchecked") 애너테이션을 달아서 경고를 숨길 수 있다. 해당 애너테이션은 최대한 좁은 범위에 달아야하며, 지역변수 선언쪽으로 옮기는 것이 바람직하다.

 

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

배열과 제네릭타입의 차이는 아래와 같다.

(1) 배열은 공변이고 제네릭는 불공변하다. 예를들어 Sub가 Super의 하위 타입일 때 배열 Sub[]는 Super[]의 하위타입이 되지만, List<Sub>, List<Super>는 서로의 하위타입도 상위타입도 아니다.

Object[] array = new Long[1];
array[0] = "문자열"; // 런타임 에러
List<Object> list = new ArrayList<Long>();// 컴파일 에러

(2) 배열은 실체화되고, 제네릭은 실체화불가하다. 이는 즉 배열은 런타임에도 원소 타입을 인지하고 확인하지만, 제네릭은 타입정보가 런타임에는 소거(erasure)되어 알수없다는 의미다.

**E, List<E>, List<String>과 같은 타입은 런타임에 타입정보를 알수없는 실체화불가타입이며, 매개변수화 타입 가운데 실체화될수 있는 타입은 List<?>와 같은 비한정적 와일드카드 타입뿐이다. 

이 두가지 차이로 배열과 제네릭은 함께 사용하기 어려운데, 이에 따라 배열은 제네릭타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. List<E>[], new E[]와 같은 문법이 옳지 않다는 것을 의미한다.

제네릭 배열을 만들지 못하게 막은 이유는 제네릭 배열을 사용하게 되면 타입세이프하지 않은 아래와 같은 케이스가 있기 때문으로, 이를 허용하면 런타임에 ClassCastException이 발생하는 일을 막아주겠다는 제네릭 타입 시스템의 취지에 어긋나게 된다.

List<String>[] stringLists = new List<String>[1]; //이게 만약 가능하다면 런타임에는 List[]
List<Integer> intList = List.of(1); // List
Object[] obejcts = stringLists; // Object[] = List[]
objects[0] = intList; Object[0] = List
String s = stringLists[0].get(0); // 실제로 내부에는 List<String>[] 안에 List<Integer>가 들어있기 때문에 런타임 오류

결론적으로 배열과 리스트를 섞어 쓰다가 컴파일 오류나 경고를 만나면, 가장 먼저 배열을 리스트로 대체하는 방법을 사용하는 것이 적절할 것이다.

 

Item 29. 이왕이면 제네릭 타입으로 만들라.

제네릭 타입을 새로 만드는 일은 어렵지만 클라이언트에서 직접 형변환해야 하는 타입보다 제네릭 타입이 더 안전하고 쓰기 편하기 때문에 그 값어치는 충분하다. 기존 클래스 중 제네릭 타입으로 변환할 수 있는 타입이 있다면 변경해야한다. 

 

+) 제네릭 타입 클래스를 작성할 때, 앞서 Item 28에서 리스트를 사용하라고 말했지만 배열을 직접 사용해야 하는 경우도 있다. (List의 구현도 결국 배열을 사용하며, 배열을 사용하는 편이 성능에서 이점이 있기 때문) 이런 경우에는 배열을 Object[]로 선언한 후 리턴할 때에 타입 E로 형변환을 해주거나, 배열을 선언할 때 (E[]) new Object[size]와 같이 형변환 해주는 방법이 있을 것읻다.

+) 모든 타입은 자기자신의 하위타입이므로 class DelayQueue<E extends Delayed> implements BlockingQueue<E>로 선언된 클래스가 있을 때 DelayQueue<Delayed>로도 사용할 수 있음을 기억하자.

 

Item 30. 이왕이면 제네릭 메서드로 만들라.

제네릭 타입과  마찬가지로 클라이언트에서 입력 매개변수와 반환값을 명시적으로 형변환해야하는 메서드보다 제네릭 메서드가 더 안전하고 사용하기 쉽다. 타입과 마찬가지로 형변환을 해줘야하는 기존 메서드를 제네릭하게 변경해야 한다.

 

+) 재귀적 타입한정(recursive type bound)은 자기자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다. 재귀적 타입 한정은 주로 타입의 자연적 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.

타입한정인 <E extends comparable<E>>는 "모든 타입 E는 자신과 비교할 수 있다"라고 읽을 수 있다. 상호 비교가능하다는 의미를 명확하게 표현한 것이다.

 

Item 31. 한정적 와일드카드를 사용해 API유연성을 높이라.

Ietm 28에서 말한것처럼 매개변수화 타입은 불공변하기때문에 List<Number>과 List<Integer>는 아무 관계가 아니고, 매개변수화 타입 E가 Number일 때 List<E>를 파라미터로 받는 메서드에서 List<Integer>을 전달할 수 없다. 그러나 때로는 좀 더 유연하게 하위 타입의 매개변수화 타입을 전달받고 싶은 경우도 있을 수 있다. 이런 상황을 위해 자바에서는 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다.

위에서 설명한 경우를 한정적 와일드카드 타입으로는 List<? extends E> 로 표현할 수 있으며 이는 즉 E의 하위타입의 List를 의미한다. 

반대로 E의 상위타입의 List는 List<? super E>로 표현할 수 있다.

이처럼 유연성을 극대화하기 위해서는 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 탙입을 사용하는 것이 좋은데, 한편으로 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드 카드 타입을 써도 좋을 것이 없다는 점을 기억해야 한다.

다음 공식을 외워두면 어떤 와일드카드 타입을 써야하는지 기억하기 쉽다. 단, 반환타입에는 한정저거 와일드카드 타입을 사용하면 안된다.

PECS: producer-extends, consumer-super

 

타입 매개변수와 와일드 카드 중에서 무엇을 써야할까? 이 둘은 공통적인 부분이 많아서 둘다 써도 괜찮은 경우가 많다. 답은 public api라면 외부에서 사용할 때에 선언이 간단한 와일드 카드를 쓰는 것이 낫다. 

 

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

가변인수는 구현방식 상 가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 만들어진다. 이 배열은 내부로 감춰지지 않고 클라이언트에 노출되었고, 그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.

매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다. 이렇게 다른 타입 객체를 참조하는 상황에서는 컴파일러가 자동 생성한 형변환이 실패할 수 있으니 제네릭 타입 시스템이 약속한 타입 세이프의 근간이 흔들린다.

    // 코드 32-1 제네릭과 varargs를 혼용하면 타입 안전성이 깨진다! (191-192쪽)
    static void dangerous(List<String>... stringLists) { // List[]
        List<Integer> intList = List.of(42);  //List
        Object[] objects = stringLists; //Object[] = List[]
        objects[0] = intList; // Object[0] = List 힙 오염 발생
        String s = stringLists[0].get(0); // ClassCastException -> 보이지 않는 형변환
    }

그렇다면 왜 제네릭 배열을 직접 생성할 수는 없게했으면서 왜 제네릭 varargs 매개변수를 받는 메서드를 선언할 수 있게 했는가? 이는 제네릭이나 매개변수화 타입의 varargs 매개 변수를 받는 메서드가 실무에서 유용하기 때문..ㅠㅠ Collection.addAll(Collecetion<? super T> c, T... elements), Arrays.asList(T... a) 등이 있는데 이들은 타입 세이프하다.

자바 7 이전에는 제네릭 가변인수 메서드의 작성자가 호출자 쪽에서 발생하는 경고에 대해 해줄 수 있는 것이 없었으나 자바 7 이후로 @SafeVarargs 애너테이션이 추가되어 제네릭 가변인수 메서드 작성자가 클라이언트 측에서 발생하는 경고를 숨길 수 있다. 메서드가 안전하지 않다면 해당 애너테이션을 사용해서는 안된다.

그렇다면 varargs 매개변수를 받는 메서드가 안전한지는 어떻게 확인할 수 있나? 메서드가 해당 배열에 아무것도 저장하지 않고, 그 배열의 참조가 밖으로 노출되지 않는다면 안전하다고 볼 수 있다. 달리 말하면 단지 해당 배열을 인수를 전달할 목적으로만 사용한다면 메서드는 안전하다. (단, 해당 배열을 @SafeVarargs로 제대로 애노테이트된 신뢰할 수 있는 또다른 varargs 메서드에 넘기는 것은 괜찮으며, 배열 내용의 일부 함수만 호출하는 varargs를 받지 않는 일반 메서드에 전달하는 것은 괜찮다.)

@SafeVarargs 애너테이션을 사용하지 않고 varargs 변수를 List로 변경하는 방법도 고려해볼 수 있다.

 

Item 33. 타입 안전 이종 컨테이너를 고려하라.

제네릭은 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다. 하지만 더 유연하게 다양한 타입을 수용할 수 있어야하는 경우가 있다. 이런 경우 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하면 된다.

예를 들어 아래의 Favorites 클래스는 오브젝트를 value로 타입을 key로 저장하여 다양한 타입을 저장하고 불러올 수 있는 컨테이너이다.

// 타입 안전 이종 컨테이너 패턴 (199-202쪽)
public class Favorites {
    // 코드 33-3 타입 안전 이종 컨테이너 패턴 - 구현 (200쪽)
    private Map<Class<?>, Object> favorites = new HashMap<>();

    public <T> void putFavorite(Class<T> type, T instance) {
        favorites.put(Objects.requireNonNull(type), instance);
    }

    public <T> T getFavorite(Class<T> type) {
        return type.cast(favorites.get(type));
    }

//    // 코드 33-4 동적 형변환으로 런타임 타입 안전성 확보 (202쪽)
//    public <T> void putFavorite(Class<T> type, T instance) {
//        favorites.put(Objects.requireNonNull(type), type.cast(instance));
//    }

    // 코드 33-2 타입 안전 이종 컨테이너 패턴 - 클라이언트 (199쪽)
    public static void main(String[] args) {
        Favorites f = new Favorites();
        
        f.putFavorite(String.class, "Java");
        f.putFavorite(Integer.class, 0xcafebabe);
        f.putFavorite(Class.class, Favorites.class);
       
        String favoriteString = f.getFavorite(String.class);
        int favoriteInteger = f.getFavorite(Integer.class);
        Class<?> favoriteClass = f.getFavorite(Class.class);
        
        System.out.printf("%s %x %s%n", favoriteString,
                favoriteInteger, favoriteClass.getName());
    }
}

위와같이 형변환하여 객체를 꺼내는 과정에서 발생할 수 있는 2가지 제약이 있다.

(1) 악의적인 클라이언트가 Class 객체를 제네릭이 아닌 로타입으로 넘기면 Favorites의 안전성이 쉽게 무너진다. 즉 아래와 같이 Class<T>파라미터를 Class로 강제 변환하여 넣는 경우가 있다. 

f.putFavorite((Class)Integer.class, "문자열");

이런 경우를 방지하기 위해서는 동적 형변환을 하여 코드 33-4처럼 동적 형변환을 하여 넣어주면된다. java.util.Collections의 checkedSet, checkedList, checkedMap 같은 메서드가 이 방식을 적용한 컬렉션 래퍼들이다.

 

(2) 실체화 불가 타입(ex. List<String>)에는 사용할 수 없다는 것이다. List<String>.class라고 타입을 넘기면 문법 오류가 발생할 것이다. 결국 List.class 밖에 방법이 없는데 이 경우 List<Integer>, List<String>이 같은 Class 객체를 공유하므로 Favorites 객체는 원하는대로 동작하기 어려울 것이다. 이에 대한 완벽한 우회로는 없으나 슈퍼타입토큰이라는 시도는 해볼 수 있다.

위의 Favorites 타입은 비한정적 타입 토큰을 사용하였으나, 아래와 같이 한정적 타입 토큰을 사용할 수도 있다. 이 경우 Class<?> 타입의 객체가 있고, 이를 한정적 타입토큰을 받는 getAnnotation 메서드로 넘기려면 asSubclass 메서드를 사용할 수 있다. 객체를 Class<? extends Annotation>으로 형변환할수도 있지만, 이 경우 비검사 형변환이므로 경고가 발생한다.

// 코드 33-5 asSubclass를 사용해 한정적 타입 토큰을 안전하게 형변환한다. (204쪽)
public class PrintAnnotation {
    static Annotation getAnnotation(AnnotatedElement element,
                                    String annotationTypeName) {
        Class<?> annotationType = null; // 비한정적 타입 토큰
        try {
            annotationType = Class.forName(annotationTypeName);
        } catch (Exception ex) {
            throw new IllegalArgumentException(ex);
        }
        return element.getAnnotation(
                annotationType.asSubclass(Annotation.class));
    }

    // 명시한 클래스의 명시한 애너테이션을 출력하는 테스트 프로그램
    public static void main(String[] args) throws Exception {
        if (args.length != 2) {
            System.out.println(
                "사용법: java PrintAnnotation <class> <annotation>");
            System.exit(1);
        }
        String className = args[0];
        String annotationTypeName = args[1]; 
        Class<?> klass = Class.forName(className);
        System.out.println(getAnnotation(klass, annotationTypeName));
    }
}

+) 슈퍼타입토큰에 대해서는 잘 정리된 곳을 참고 -> sungminhong.github.io/spring/superTypeToken/