Effective Java : 아이템33. 타입 안전 이종 컨테이너를 고려하라

    들어가기 전

    이 글은 인프런 백기선님의 이펙티브 자바 강의를 복슴하져 작성한 글입니다.


    이 글의 요약

     

     


    핵심 정리 : 타입 토큰을 사용한 타입 안전 이종 컨테이너

    • 타입 안전 이종 컨테이너
      • 타입안전 이종 컨테이너는 한 타입의 객체만 담을 수 있는 컨테이너가 아니라, 여러 다른 타입 (이종)을 담을 수 있는 타입 안전한 컨테이너.
    • 타입 토큰 
      • 타입 토큰은 String.class, Class<String>을 의미한다.
    • 타입 안전 이종 컨테이너 구현 방법
      • 컨테이너가 아니라 "키"를 매개변수화 하라!
      • 하지만 두 가지 단점이 존재한다. 

    타입 안전 컨테이너 객체란? 

    컨테이너 객체는 다른 오브젝트를 넣을 수 있는 객체들인데, 예시로는 Map, Set, Optional 같은 클래스다. 그런데 이 때까지 우리가 컨테이너를 사용해왔던 방법은 단 하나의 타입만 넣을 수 있다.  

    아래 코드를 보자

    // 타입 안전 컨테이너
    public class FavoriteFirst<T> {
    
        List<T> value;
    
        // 타입 안전하며, 하나의 타입만 넣을 수 있는 컨테이너
        public static void main(String[] args) {
            FavoriteFirst<String> names = new FavoriteFirst<>();
            names.value.add("whiteship"); // 문자열만 넣을 수 있음
    
            FavoriteFirst<Integer> numbers = new FavoriteFirst<>();
            numbers.value.add(1);
        }
    }
    
    • FavoritesFirst<String>은 String 타입만 넣을 수 있다. 컴파일에 타입 관련 에러가 발생하므로, 타입 안전하다. 
    • FavoritesFirst<Integer>는 Integer 타입만 넣을 수 있다. 컴파일에 타입 관련 에러가 발생하므로, 타입 안전하다. 

    이런 것은 타입 안정성이 있으면서 한 가지 타입만 넣을 수 있는 컨테이너다. 일종의 타입 안전한 일종 컨테이너로 볼 수 있다. 


    이종 컨테이너란?

    앞에서는 하나의 타입만 넣을 수 있다. 이종 컨테이너는 서로 다른 타입을 넣을 수 있는 컨테이너 객체다. 예를 들어 하나의 Map안에 String, Integer 타입이 동시에 들어갈 수 있다. 아래 코드를 살펴보자.

    // 이종 컨테이너. 타입 안전하지 않음.
    public class FavoriteSecond {
    
        private Map<Class, Object> map = new HashMap<>();
    
        // class가 예약어라 clazz를 사용한다. 
        public void put(Class clazz, Object value) {
            this.map.put(clazz, value);
        }
    
        public Object pop(Class clazz) {
            return this.map.get(clazz);
        }
        
        // 타입안전하지 않은 이종 컨테이너.
        public static void main(String[] args) {
            FavoriteSecond favorites = new FavoriteSecond();
            favorites.put(String.class, 1); // 컴파일 에러 발생하지 않음. 타입 안전하지 않음. 
            favorites.put(Integer.class, "keesun"); // 컴파일 에러 발생하지 않음. 타입 안전하지 않음.
            favorites.put(String.class, 1); // 컴파일 에러 발생하지 않음. 타입 안전하지 않음.
    
        }
    }

    코드를 살펴보자.

    • 아래에서 Map의 Key를 Class 타입, Value를 가장 범용적인 Object로 선언한다. 
    • put(), get() 메서드에는 제네릭을 전혀 활용하지 않는다. 또한 매개변수로 전달되는 Class, Object 간의 타입 안정성이 보장되지 않는다.
    • 사용하는 쪽에서는 put()을 했을 때 문제의 코드에서도 컴파일 에러가 발생하지 않는다. 

    가장 문제가 있는 부분은 사용하는 쪽이다. 아래 이미지에서 볼 수 있듯이 사용하는 쪽에서 컴파일 에러가 발생해야하는 곳에서 컴파일 에러가 발생하지 않는 문제점이 있다. 즉, '이종 컨테이너'의 역할을 하지만 '타입 안정성'은 존재하지 않는다. 

     

     

    타입 토큰을 이용한 타입 안전한 이종 컨테이너 

    타입 안전 이종 컨테이너를 구현하기 위해서는 타입 토큰(Class<제네릭>)을 Map의 Key에 선언해주는 형태로 구현할 수 있다.

    먼저 Class라는 클래스는 제네릭 클래스다. 따라서 제네릭을 이용해서 선언해 줄 수 있는데, 아래에서 볼 수 있듯이 Class<String>만 하면 불공변의 성질에 따라 Class<String>만 Key로 들어올 수 있다. 반면 Class<?>를 사용하면 어떠한 Class<>이든 Key로 들어올 수 있게 된다. 

    Map<Class<String>, Object> map = new HashMap(); // Key는 Class<String>만 가능
    Map<Class<?>, Object> map = new HashMap(); // Key는 Class<어떠한 타입>이든 가능

    하지만 위와 같이 선언해서 사용하는 경우 컴파일 시점의 타입 안전을 보장하지는 못한다. 왜냐하면 Key로 들어가는 Class와 Object로 전달된 녀석이 같은 타입이라는 것을 보장하지 않는다. 한 가지 예시로 Key에는 String.class를 넘겨주고, Object에 1을 넣어줘도 컴파일 에러는 발생하지 않는다. 

    Class가 Raw 타입이기 때문에 타입을 무시하고, 변경 가능한지에 대한 어떠한 체크도 존재하지 않기 때문이다. 

    따라서 이 부분을 해결하기 위해서 매개변수 clazz, value를 제네릭으로 이용해서 연결시켜 줄 수 있다. 연결시켜 주게 되면 타입 안전성이 메서드 수준에서 보장되고, put()을 사용하는 클라이언트에서 컴파일 시점에 잘못된 타입인지 바로 확인할 수 있게 된다.

    따라서 제네릭을 이용한 타입 안전 이종 컨테이너의 코드는 아래에서 확인할 수 있다.

    // 타입 안전 이종 컨테이너
    public class FavoriteThird {
    
        // Class<?>를 사용하는 것만으로는 타입을 보장하지 못한다. 대신, 어떠한 객체도 들어올 수 있다.
        private final Map<Class<?>, Object> map = new HashMap<>();
    
        // Class<?>의 타입 안정성을 메서드 수준에서 보장해준다.
        public <T> void put(Class<T> clazz, T value) {
            this.map.put(clazz, value);
        }
    
        // 꺼낸 객체가 T 타입으로 캐스팅 가능한지 검사를 하지 않아서 에러가 발생함.
        @SuppressWarnings("unchecked")
        public <T> T get(Class<T> clazz) {
            return (T) this.map.get(clazz);
        }
    
        // 타입안전하지 않은 이종 컨테이너.
        public static void main(String[] args) {
            FavoriteThird favorites = new FavoriteThird();
    
            // favorites.put(String.class, 1); // 컴파일 에러 발생함.
            favorites.put(Integer.class, 1);
            favorites.put(String.class, "keesun");
    
            String s = favorites.get(String.class);
    
        }
    }

    여기에서 주목해야 할 부분은 @SuppressWarnings("unchecked")라는 경고 무시 어노테이션이 붙어있다는 것이다. 왜 이게 붙어있어야만 하는 것일까?

    • (T) this.map.get(clazz); 에서는 컴파일 경고가 뜬다.
    • 컴파일 경고 내용은 (T)로 타입 캐스팅 할 수 있는지 검사 하지 않았다는 것이다.

    우리는 이 경고를 무시해도 괜찮다. 왜냐하면 넣을 때 항상 Class<T>, T일 때만 컨테이너에 넣어주기 때문이다. 따라서 꺼내올 때도 항상 Class<T>에 대해서는 T만 가져올 것이기 때문에 무시해도 괜찮다. 따라서 @SuppressWarnings를 이용해서 해당 컴파일 경고를 무시한다. 

     

    컴파일 경고를 해결한 타입 안전한 이종 컨테이너 

    앞서서 타입 캐스팅이 가능한지를 검사하지 않고 형변환을 했기 때문에 컴파일 경고가 발생했다. 가장 좋은 방법은 타입 캐스팅 가능한지를 검사한 후에 형변환을 해서 컴파일 경고를 삭제하는 것이다. 따라서 아래와 같이 Class 클래스가 제공하는 cast라는 메서드를 사용해서 검사 후 형변환을 해서 경고를 제거한다. 

     

    약점 

    한 가지 약점이 존재한다. 타입 토큰을 이용해서 타입안전 이종 컨테이너를 구현했더라도, 클라이언트가 코드를 사용할 때 Class의 Raw 타입으로 타입 캐스팅해서 넘겨주면 타입 안전이 깨진다는 것이다. 아래에서 볼 수 있듯이 컴파일 에러가 뜨지 않는다.

    타입 관련 컴파일 에러 발생 X

    왜냐하면 put() 메서드에 Class Raw 타입이 전달된다. 이 때 제네릭은 소거로 구현되어있기 때문에 T는 Object로 바뀌게 되고, 따라서 Class<Object>와 Object Value과 연결되어서 컴파일 에러가 발생하지 않는 것이다. 그렇지만 String.class에 값이 1이 들어가기 때문에 타입 안정성은 깨졌다고 볼 수 있다. 

    이 문제는 컴파일 에러로 변경할 수 있는 방법이 없다. 오로지 Fast-fail해서 전체 시스템으로 문제가 퍼지기 전에 조기에 종료하는 방법뿐이다. Fast-fail이 할 수 있도록 하는 것은 map에 값을 넣기 전에 해당 타입으로 캐스팅이 가능한지 확인하는 것이다. 따라서 아래와 같이 코드를 수정해서 해결해 볼 수 있다.

     

    최종 구현 코드

    최종 구현 코드는 아래와 같이 작성할 수 있다. 

    // 타입 안전 이종 컨테이너
    public class FavoriteThirdImprove {
    
        // Class<?>를 사용하는 것만으로는 타입을 보장하지 못한다. 대신, 어떠한 객체도 들어올 수 있다.
        private final Map<Class<?>, Object> map = new HashMap<>();
    
        // Class RawType으로 캐스팅되서 전달되는 경우, 타입 안정성 깨짐
        // 컴파일 에러 발생하지 않는데, 컴파일 에러로 바꿀 수 있는 방법이 없음.
        // 런타임에서 Fast-fail로 바꾸는 방법만 존재함.
        public <T> void put(Class<T> clazz, T value) {
            this.map.put(clazz, clazz.cast(value));
            // this.map.put(clazz, value);
        }
    
        // 이렇게 하면 타입 안정성을 보장하지 못함.
        /*public <T> void put(Class clazz, Object value) {
            this.map.put(clazz, value);
        }*/
    
    
        public <T> T get(Class<T> clazz) {
            // 형변환 검사 후 리턴해서 경고를 없애준다.
            return clazz.cast(this.map.get(clazz));
        }
    
        // 타입안전하지 않은 이종 컨테이너.
        public static void main(String[] args) {
            FavoriteThirdImprove favorites = new FavoriteThirdImprove();
    
            // favorites.put(String.class, 1); // 컴파일 에러 발생함.
            favorites.put(Integer.class, 1);
            favorites.put(String.class, "keesun");
    
            // Class의 Raw 타입으로 캐스팅해서 전달하면 깨진다.
            // 컴파일 에러가 발생해야하는데, 발생하지 않음.
            // 런타임 에러가 발생하고, 컴파일 에러로 잡을 수 있는 방법은 없음.
            favorites.put((Class) String.class, 1);
    
            String s = favorites.get(String.class);
    
        }
    }

     

    한계

    예를 들어 이종 컨테이너에 Key로 List<String>.class, List<Map>.class을 넣고 싶을 수도 있다. 이렇게 넣어서 각 List가 가지는 제네릭에 따라서 객체를 구별해서 넣고 사용하고 싶다. 그렇지만 List<String>.class 같은 문법은 컴파일러가 허용하지 않기 때문에 그렇게 사용할 수 없다. 이 부분은 타입 토큰만 이용해서는 해결할 수 없다. 


    완벽 공략 47. 수퍼 타입 토큰 (익명 클래스와 제네릭 클래스 상속을 사용한 타입 토큰)

    • 닐 게프터의 슈퍼 타입 토큰
    • 상속을 사용한 경우 제네릭 타입을 알아낼 수 있다. 이 경우에는 제네릭 타입이 제거되지 않기 때문이다. 
    • 상속 클래스를 직접 사용, 혹은 익명 클래스를 사용해도 된다.

     

    타입 토큰

    타입 토큰은 컴파일 / 런타임 시점에 타입 정보를 알아내기 위해서 메서드에 전달하는 Class<T>, String.class 같은 클래스 리터럴을 의미한다. 그런데 이 때의 클래스 리터럴의 단점은 List<Integer>.class, List<String>.class 같은 것은 지원하지 않고 List.class 형태만 지원을 한다. 따라서 List 내부에 어떠한 타입이 들어있는지 제네릭으로 구분한 것을 사용하고 싶은 경우에는 타입 토큰만으로는 한계가 있다. 

     

    슈퍼 타입 토큰

    슈퍼타입 토큰은 상속을 이용한 타입 토큰이다. 상속을 할 경우, 제네릭의 정보가 그대로 남아있다는 점을 기반으로 제네릭의 타입을 유추할 수 있다. 그리고 이 기능을 이용해서 LIst<Integer>.class 같은 클래스 리터럴을 Type Reference로 우회적으로 구현할 수 있다. 

     

    슈퍼 타입은 어떻게 제네릭 타입을 알아내는가?

    슈퍼 타입 토큰을 이해하기 전, 슈퍼 타입이 어떤 메서드를 통해서 제네릭 타입을 알아낼 수 있는지 공부해야한다. 관련한 전체 코드는 우선 아래와 같다.

    public class GenericTypeInfer {
    
        static class Super<T> {
            T value; // T는 어떻게 추론할 수 있을까?
        }
    
        static class Sub extends Super<String> {
        }
    
        public static void main(String[] args) throws NoSuchFieldException {
            Super<String> stringSuper = new Super<>();
            // 필드의 타입은 Object다.
            // 제네릭은 소거로 구현되어있다. String은 컴파일 시점에 모두 소거되고 Object로 바뀐다.
            // 그리고 사용하는 쪽에서 String으로 Object를 캐스팅하는 코드가 들어간다.
            System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
    
            // 이 경우 제네릭 타입이 String인 것을 알 수 있다.
            Type type = Sub.class.getGenericSuperclass();
            ParameterizedType ptype = (ParameterizedType) type;
            System.out.println(ptype.getActualTypeArguments()[0]);
        }
    }

    Super 클래스를 보자. Super<String>으로 생성한 stringSuper에는 당연히 문자열이 들어갈 수 있다. 또한 우리는 제네릭에 의해서 Super 클래스의 필드에 있는 value의 타입이 String이기를 원한다. 하지만 아래 코드를 이용해서 필드의 타입 정보를 살펴보면 Object다. 

    왜냐하면 제네릭은 소거로 구현되어 있기 때문이다. 컴파일 할 때는 제네릭이 모두 Object로 바뀌고, 사용하는 쪽에서 캐스팅 하는 코드가 컴파일러를 통해서 삽입된다. 따라서 런타임에 제네릭 필드의 타입 정보를 알고 싶어도 절대로 알 수가 없다. 그런데 한 가지 알 수 있는 방법이 존재한다. 

    제네릭 타입을 상속받은 클래스에서 ParameterizedType을 가져오면 제네릭이 런타임에서 어떤 형태로 사용되고 있는지를 알 수 있다. 왜냐하면 상속받은 경우에는 제네릭 타입 정보가 남기 때문이다. 

    • getGenericSuperClass()를 이용하면 부모 클래스에 대한 정보를 가져올 수 있다. 이 때, 메서드는 extends Super<String>을 바라본다. 
    • ParameteriazedType으로 캐스팅하면 getActualArguments()를 이용해서 제네릭 배열을 가져올 수 있다. 
      • 제네릭 배열인 이유는 제네릭을 선언할 때 <T, V, E> 형식으로 여러 개 선언할 수도 있기 때문이다. 

    이런 형식으로 코드를 작성해보자. 그러면 각각의 실행 결과는 아래에서 볼 수 있듯이, 부모 클래스의 제네릭은 Object로 타입 추론되지만, 자식 클래스의 제네릭은 String으로 정확히 추론이 가능하다. 

     

    익명 클래스로도 가능 

    익명 클래스를 이용해 상속받은 클래스를 만들 수 있다. 익명 클래스를 아래와 같이 생성하면 클래스의 정의 / 인스턴스를 바로 사용할 수 있다. 따라서 클래스의 정보를 바로 사용해서 ParametrizedType을 구해서 제네릭 타입을 추론해 볼 수 있다.

    // 굳이 제네릭을 사용하지 않고, 익명 클래스를 사용해도 된다.
    Type type1 = (new Super<String>() {}).getClass().getGenericSuperclass();
    ParameterizedType ptype1 = (ParameterizedType) type1;
    System.out.println(ptype1.getActualTypeArguments()[0]);

    익명 클래스를 사용해서 파라메터라이즈 타입을 만들수 있고, 여기서 actualTypeArgument를 구현할 수 있고, 이걸로 Favorite 컨테이너의 Key에 넣어준다. 이걸 이용하면 List<String>, List<Integer>도 가능하다. 

     

    Super 타입 토큰을 이용한 Type Reference 생성

    앞서서 부모 클래스를 상속받으면, 상속 받을 때의 제네릭 정보가 그대로 남아있다는 것을 알 수 있다. 이 성질을 이용해서 클래스 리터럴을 구현할 수 있다. 전체적인 흐름은 다음과 같다. 

    • TypeReference 제네릭 추상 클래스를 생성하고, 내부적으로 Type 정보를 가진다. 
    • TypeReference를 상속받은 자식 클래스를 생성하고, 여기서 제네릭의 타입을 지정한다.
    • Key에는 TypeReference를 넣는데, 이 때 상속받은 자식 클래스의 제네릭 타입에 의해서 TypeReference 타입이 각각 달라진다. 

    TypeRef라는 추상 클래스를 보자.

    • 생성자에서 반드시 자식 클래스의 타입을 알아내고, TypRef의 내부 필드에 저장하도록 한다. 
    • HashCode는 Type의 hashCode가 같은지를 판단한다. 
    public abstract class TypeRef<T> {
    
        private final Type type;
    
        protected TypeRef() {
            // 부모의 제네릭에 자식이 선언한 값을 넣는다. 그리고 그 정보가 넘은 것을 이용해 List<String>.class 같은 느낌을 구현함.
            ParameterizedType superClass = (ParameterizedType) getClass().getGenericSuperclass();
            this.type = superClass.getActualTypeArguments()[0];
        }
    
        @Override
        public boolean equals(Object o) {
            return o instanceof TypeRef && ((TypeRef) o).type.equals(type);
        }
    
        @Override
        public int hashCode() {
            return type.hashCode();
        }
    
        public Type getType() {
            return type;
        }
    }

    이렇게 구현하면 TypeRef 자체를 Map의 Key로 사용하지만, TypeRef가 같은 객체인지 확인하는 방법은 TypeRef가 가지고 있는 type의 해시코드가 같은지를 확인한다. 따라서 TypeRef<String> 타입을 가진 녀석들은 서로 같은 객체가 될 것이다. 

     

    그러면 사용할 때는 어떻게 사용할 수 있을까? 아래 코드에서 볼 수 있다.

    public class Favorites2 {
    
        private final Map<TypeRef<?>, Object> favorites = new HashMap<>();
    
        public <T> void put(TypeRef<T> typeRef, T thing) {
            favorites.put(typeRef, thing);
        }
    
        @SuppressWarnings("unchecked")
        public <T> T get(TypeRef<T> typeRef) {
            return (T) favorites.get(typeRef);
        }
    
        public static void main(String[] args) {
            Favorites2 f = new Favorites2();
    
            f.put(new TypeRef<List<String>>(){}, List.of("a","b","c") );
            f.put(new TypeRef<List<Integer>>(){}, List.of(1,2,3) );
            f.get(new TypeRef<List<String>>() {}).forEach(System.out::println);
            f.get(new TypeRef<List<Integer>>() {}).forEach(System.out::println);
        }
    }
    • put() : TypeRef<?>를 이용한다
    • get() : T 타입으로 가져온다.
      • T타입으로 가져올 때 getClass.cast()로 형변환을 할 수는 없다. TypeRef의 getClass 결과는 Class가 아니라 TypeRef이기 때문이다. 따라서 cast() 메서드가 지원되지 않는다. cast()를 사용할 수 없기 때문에 체크하지 않고 형변환으로 처리한다. 그리고 @SuppressWarnings("unchecked")로 컴파일 경고를 무시한다. 

    이렇게 코드를 구현하면 우리가 앞서 하고 싶었던 List<String>, List<Integer>를 서로 다른 Key로 인식하고 사용할 수 있게 된다. 어떻게 보면 List의 제네릭 클래스 리터럴을 지원하지 않기 때문에 부모 클래스의 제네릭 정보가 남은 것을 이용한 Wrapper 클래스를 하나 만들어서 수동으로 제네릭 클래스 리터럴을 구현한 셈이다. 

    또한 Map에서 TypeRef<List<String>>을 가져오는 것과 TypeRef<List<Integer>>를 가져오는 것은 서로 다른 것으로 인식하는데, 이것은 TypeRef 클래스의 HashCode() 메서드가 type의 hashcode를 그대로 사용하기 때문이다. 

     

    슈퍼 타입 토큰은 제약 조건이 있다. 

    아래 코드에서 슈퍼 타입 토큰은 안전하게 동작하지 않을 수 있다. 따라서 이 부분을 잘 인지하고 사용해야한다.

    • 아래 코드를 실행해보면, TypeRef의 타입이 항상 java.util.List<T>인 것을 알 수 있다.
    • 따라서 ls, li는 java.util.List<T> 타입이 해시코드가 같기 때문에 항상 같은 객체를 f로부터 반환받는다. 

    이런 형식으로 구멍이 생길 수도 있다. 이런 제네릭 클래스는 사용하는 쪽에서 캐스팅 코드가 들어가야하는데, 사용하는 쪽이 아니다보니 List<T>로 계속 선언이 되어있고, 해시코드가 같은 형식으로 구현되어서 문제가 발생한다. 

    public class Oops {
    
        static Favorites2 f = new Favorites2();
    
        static <T> List<T> favoriteList() {
            // 익명 자식 클래스로 선언
            TypeRef<List<T>> ref = new TypeRef<List<T>>() {};
            System.out.println(ref.getType().getTypeName()); // 항상 java.util.List<T>가 됨. 
    
            List<T> result = f.get(ref); // List<T>로 해시코드가 같음.
            if (result == null) {
                result = new ArrayList<T>();
                f.put(ref, result);
            }
            return result;
        }
    
        public static void main(String[] args) {
            // List<T>로 ls, li의 해시코드가 동일함. 따라서 ls, li는 동일한 객체를 반환받아서 공유한다. 
            List<String> ls = favoriteList(); 
            List<Integer> li = favoriteList();
            li.add(1);
    
            for (String s : ls) {
                System.out.println(s); // 캐스팅 에러 발생함.
            }
        }
    }

    댓글

    Designed by JB FACTORY