개발 공부/Java

이펙티브 자바 아이템 33 - 타입 안전 이종 컨테이너를 고려하라 - 핵심 정리 / 완벽 공략

개발인생 2022. 12. 20. 13:13
반응형

아이템 33 - 타입 안전 이종 컨테이너를 고려하라 - 핵심정리 / 완벽 공략

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

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

컨테이너란 다른 어떤 오브젝트를 넣을 수 있는 Set, Map 을 의미한다.

우리가 여태 사용한 제네릭은 오직 한 가지 타입 만 넣을 수 있는 컨테이너를 만드는 것이었다.

public class Favorites<T> {

    List<T> value;

    public static void main(String[] args) {
       Favorites<String> names = new Favorites<>();
       names.value.add("TEST");
    }
}

경우에 따라서는 이종(동족이 아닌, 서로 다른타입) 컨테이너 가 필요할 수 있다.

예를들어 데이터베이스의 각 컬럼들을 저장할 떄 해당하는 타입의 value 만 넣을 수 있도록하는 작업이 있다.

public class Favorites {

   private Map<Class, Object> map = new HashMap<>();

   public void put(Class clazz, T value) {
      this.map.put(Objects.requireNonNull(clazz), value);
   }

   public Object get(Class clazz) {
      return this.map.get(clazz);
   }

   public static void main(String[] args) {
      Favorites favorites = new Favorites();
      favorites.put(String.class, 2); // 타입 안정성이 꺠진다
      favorites.put(Integer.class, "kee");
   }
}

Map<Class, Object> 를 사용해 값을 정의할 수도 있지만 타입 안정성 이 보장되자도 않고

제네릭 을 제대로 활용하지 않게된다.

public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), value);
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);
    }
}

이러한 경우에는 컨테이너에 타입을 선언하는게 아니라 Map 안에 들어가는 Key 에 선언을 해야한다.

Class 라는 클래스를 보면 제네릭 타입 이다.

// Class 내부
public final class Class<T> implements java.io.Serializable,
                              GenericDeclaration,
                              Type,
                              AnnotatedElement,
                              TypeDescriptor.OfField<Class<?>>,
                              Constable {
   private static final int ANNOTATION = 0x00002000;
   private static final int ENUM = 0x00004000;
   private static final int SYNTHETIC = 0x00001000;

   private static native void registerNatives();

   static {
      registerNatives();
   }
   //*******
}

String.classClass<String> 과 같은 것이다.

String.class클래스 리터럴 , 타입 토큰 이라고 부른다.

런타임, 컴파일 타임에 해당하는 클래스의 타입을 파악할 수 있는 정보이다.

임의의 클래스를 Key 로 받을 것이기 때문에 비한정적 와일드 카드 를 사용해

Map<Class<?>, Object> 로 선언한다.

public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), value);
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);
    }
}

private Map<Class<?>, Object> map = new HashMap<>(); 만 선언해서는 String 키에 숫자가 들어가는 것을 막을 수 없으므로

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

메서드 수준에서도 제네릭 타입을 정의해야한다.

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, 22); // 에러 발생
        favorites.put(Integer.class, "KEEE"); // 에러 발생

    }

이렇게 작성하면 문자열 키에 숫자를 전달하는 걸 막을 수 있다.

값을 꺼내올 때도 클래스 리터럴 에 해당하는 정보로 꺼내올 수 있다.

clazz.cast() 를 사용하면 해당 타입으로 형변환이 가능한지 검사하고 형변환을 하기 때문에 경고 메세지가 발생하지 않는다.

여기까지가 기본적인 타입 안전 이종 컨테이너 이다.


public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), value);
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();

        favorites.put((Class)String.class, 123123);

        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);
    }
}

위의 코드처럼 favorites.put((Class)String.class, 123123)

String.classClass 로 형변환해 강제적으로 로타입 으로 전달하게 되면 아무 타입이다 넣을 수 있다.

Class 타입으로 값이 전달되면 제네릭의 소거 방식에 의해서 Object 가 되기 때문에 아무 값이나 넘길 수 있게된다.

이런 상황에서 값을 꺼내려고 할 때 에러가 발생한다.

public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
    }

이 상황을 막고싶다면 값을 넣을 때 clazz.cast() 를 통해

해당 클래스의 타입으로 캐스팅이 되는 value 값을 검사한다.

단점으로는 근본적으로 클라이언트가 악의적으로 로 타입 으로 넘기는 것을 막을수는 없다는 것이다.

public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);

        favorites.put(List.class, List.of(1, 2, 3));
        favorites.put(List.class, List.of("a", "b", "c"));

        List list = favorites.get(List.class);
        list.forEach(System.out::println);
    }
}

List 를 전달할 때 List 라는 Key 값이 중복되기 때문에 값이 덮어씌어지게 된다.

public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);

        favorites.put(List<Integer>.class, List.of(1, 2, 3)); // 에러 발생
        favorites.put(List<String>.class, List.of("a", "b", "c"));

        List list = favorites.get(List.class);
        list.forEach(System.out::println);
    }
}

favorites.put(List<Integer>.class, List.of(1, 2, 3)); 처럼 타입을 구분하려고 하지만 에러가 발생한다.

List<Integer>.class 는 문법이 허용하지 않는다.

List<Integer>.class 처럼 타입을 가지고 있는 클래스 리터럴존재하지 않는다.

때문에 제네릭을 가지고 있는 클래스를 구분할 수 있는 방법이 없다.

그러나 누군가 슈퍼 타입 토큰 을 이용해 구분할 수 있는 방법을 고안했다.


슈퍼 타입 토큰

컴파일 타임 이나 런 타임 에 타입 정보를 알아내기 위해 메서드에 전달하는 클래스 리터럴타입 토큰 이라고 한다.

타입 토큰 의 단점은 제네릭 타입 에 대한 클래스 리터럴 을 구할 수 없다는 것이다.

이러한 단점을 극복하기 위한 방법으로 슈퍼 타입 토큰 이 나왔다.

슈퍼 타입 토큰 을 이해하기 위해서는 타입을 알아내는 방법부터 알아야한다.

public class GenericTypeInfer {

    static class Super<T> {
        T value;
    }

    public static void main(String[] args) throws NoSuchFieldException {
        Super<String> stringSuper = new Super<>();
        System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
    }
}

Super<T> 는 일반적인 제네릭 컨테이너 역할을 하는 클래스이다.

stringSuper.getClass().getDeclaredField("value").getType() 를 사용해 해당 클래스가 가지고 있는 필드의 타입을 꺼내보면

Object 타입이 나온다.

제네릭에서 사용하는 소거 방식 때문이다.

그래서 타입을 알고싶어도 런 타임에 타입을 알수가 없다.

public class GenericTypeInfer {

    static class Super<T> {
        T value;
    }

    static class Sub extends Super<String> {

    }

    public static void main(String[] args) throws NoSuchFieldException {
        Super<String> stringSuper = new Super<>();
        System.out.println(stringSuper.getClass().getDeclaredField("value").getType());
    }
}

그러나 상속 을 받은 경우에는 타입 정보가 남아있게된다.

static class Sub extends Super<String> {

    }

Sub 클래스는 Super<String> 을 상속받았다.

public class GenericTypeInfer {

    static class Super<T> {
        T value;
    }

    static class Sub extends Super<String> {

    }

    public static void main(String[] args) throws NoSuchFieldException {
        Super<String> stringSuper = new Super<>();
        System.out.println(stringSuper.getClass().getDeclaredField("value").getType());

        Sub sub = new Sub();
        Type type = sub.getClass().getGenericSuperclass();

        ParameterizedType pType = (ParameterizedType) type;
        Type actualTypeArgument = pType.getActualTypeArguments()[0];

        System.out.println(actualTypeArgument);
    }
}

.getClass().getGenericSuperclass() 를 사용하고 ParameterizedType 로 타입 변환을 해주면

파라미터화된 타입 을 알 수 있다.

ParameterizedType 타입으로 변환을 해야 getActualTypeArguments() 메서드를 사용할 수 있다.

getActualTypeArguments() 는 타입 배열을 반환하는데 Super<T, K, B> 처럼 제네릭 타입을 여러개 지정할 수 있기 때문이다.

반환받은 배열의 첫번째 값을 출력해보면 Super<String> 의 파라미터 타입인 String 을 가져올 수 있다.

상속을 사용하지 않고 제네릭을 사용했을 때는 타입을 알아낼 방법이 없지만

상속 을 사용했을 때는 해당하는 인스턴스의 타입으로부터 제네릭 타입을 알아낼 수 있다.

public class GenericTypeInfer {

    static class Super<T> {
        T value;
    }

    public static void main(String[] args) throws NoSuchFieldException {
        Super<String> stringSuper = new Super<>();
        System.out.println(stringSuper.getClass().getDeclaredField("value").getType());

        Type type = (new Super<String>(){}).getClass().getGenericSuperclass();
        ParameterizedType pType = (ParameterizedType) type;
        Type actualTypeArgument = pType.getActualTypeArguments()[0];
        System.out.println(actualTypeArgument);

    }
}

Type type = (new Super<String>(){}).getClass().getGenericSuperclass(); 처럼 클래스 정의도 필요없이

익명 내부 클래스로 선언해 바로 제네릭타 입을 알아낼 수 있다.

익명 내부 클래스는 내부 클래스이자 클래스 정의임과 동시에 해당하는 클래스의 인스턴스 이기 때문이다.

이렇게 알아낸 타입으로 타입 이종 컨테이너 를 구현하는 것이다.

public abstract class TypeRef<T> {
    private final Type type;

    protected TypeRef() {
        ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
        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 라는 추상 클래스를 만든다.

TypeRef 을 상속받은 클래스를 통해 제네릭 타입을 추론하는 것이다.

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> typeRref) {
        return (T)(favorites.get(typeRref));
    }

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

TypeRefMap 에서 사용한다.

이전 버전처럼 Classcast() 메서드를 사용할 수 없다.

TypeRefgetType() 의 클래스가 Type 이지 우리가 원하는 T 의 타입이 아니기 때문이다.

@SuppressWarnings("unchecked") 를 통해 경고를 무시했지만 잘못된 경우가 발생할 수 있다.

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

위처럼 <List<Integer>List<String> 을 구분하여 사용할 수 있게된다.

        favorites.put(List<Integer>.class, List.of(1, 2, 3)); // 에러 발생 
        favorites.put(List<String>.class, List.of("a", "b", "c"));

여전히 위처럼 사용할 수는 없지만 그래도 <List<Integer>List<String> 을 구분하여 Key 로 사용할 수 있게되었다.

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

위의 코드에서는 new TypeRef<List<String>>() {} 처럼 구체적으로 타입을 명시해서 사용했기 때문에 안전하게 사용할 수 있다.

    public static void main(String[] args) {
        Favorites2 f = new Favorites2();

        TypeRef<List<String>> stringTypeRef = new TypeRef<>() {};
        System.out.println(stringTypeRef.getType());

        TypeRef<List<Integer>> integerTypeRef = new TypeRef<>() {};
        System.out.println(integerTypeRef.getType());

        f.put(stringTypeRef, List.of("a", "b", "c"));
        f.put(integerTypeRef, List.of(1, 2, 3));
        f.get(stringTypeRef).forEach(System.out::println);
        f.get(integerTypeRef).forEach(System.out::println);
    }

실제로 둘의 타입을 꺼내서 확인해보면 다른 타입이 출력된다.

class Oops {
    static Favorites2 f = new Favorites2();

    static <T> List<T> favoriteList() {
        TypeRef<List<T>> ref = new TypeRef<>() {};
        System.out.println(ref.getType());

        List<T> result = f.get(ref);
        if (result == null) {
            result = new ArrayList<T>();
            f.put(ref, result);
        }
        return result;
    }

    public static void main(String[] args) {
        List<String> ls = favoriteList();

        List<Integer> li = favoriteList();
        li.add(1);

        for (String s : ls) System.out.println(s);
    }
}

위의 코드에서 보면 favoriteList 를 호출해 List 를 전달받는다.

이 때 TypeRef<List<T>> ref = new TypeRef<>() {}; 를 정의하는데

마치 List<String>List<Integer> 를 각각 구분한 List 를 받을 것 같지만 그렇지 않다.

    static <T> List<T> favoriteList() {
        TypeRef<List<T>> ref = new TypeRef<>() {};
        System.out.println(ref.getType()); // List<T>

        List<T> result = f.get(ref);
        if (result == null) {
            result = new ArrayList<T>();
            f.put(ref, result);
        }
        return result;
    }

System.out.println(ref.getType()) 를 통해 출력해보면 List<T> 로 같은 타입이 나온다.

이렇게 같은 타입이 나온다면 List<String> 를 요구했을 때 만든 List<String>

List<Integer> 에서도 반환되게 된다.

public abstract class TypeRef<T> {
    private final Type type;

    protected TypeRef() {
        ParameterizedType superclass = (ParameterizedType) getClass().getGenericSuperclass();
        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 클래스에서 재정의한 equalshashCode 를 통해 같은 타입으로 판별되기 때문이다.

때문에 슈퍼 타입 토큰 역시 완벽한 해법이 아니고 구멍이 생길 수 있다.

대신 추상화된 클래스와 익명 클래스를 통해 제네릭 타입의 타입을 알아낼 수 있고,

이렇게 알아낸 타입으로 Map 을 사용하면 List<String>, List<Integer>Key 를 구분해서 사용할 수 있다.


한정적 타입 토큰

public class Favorites {

    private Map<Class<?>, Object> map = new HashMap<>();

    public <T> void put(Class<T> clazz, T value) {
        this.map.put(Objects.requireNonNull(clazz), clazz.cast(value));
    }

    public <T> T get(Class<T> clazz) {
        return clazz.cast(this.map.get(clazz));
    }

    public static void main(String[] args) {
        Favorites favorites = new Favorites();
        favorites.put(String.class, "keesun");
        favorites.put(Integer.class, 2);

        List list = favorites.get(List.class);
        list.forEach(System.out::println);
    }
}

지금까지 이종 컨테이너 에서는 ? 를 통해 비한정적 와일드 카드 타입 을 사용했다.

그러나 특정한 타입 이하로만 한정적으로 타입 토큰 을 사용할 수도 있다.

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

AnnotatedElement 는 타입이 제한되어있는 타입 토큰 을 사용하는 대표적인 예이다.

AnnotatedElement 는 애노테이션을 가지고 있는 그 어떠한 모든것을 말한다.

@Retention(RetentionPolicy.RUNTIME)
public @interface FindMe {
}

FindMe 애노테이션을 만든다.

@FindMe
public class MyService {
}

FindMe 애노테이션을 붙인 MyService 를 정의한다.

// 코드 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 {
        System.out.println(getAnnotation(MyService.class, FindMe.class.getName()));
    }
}

AnnotatedElement 타입에 애노테이션을 붙인 일반적인 클래스를 전달하고

AnnotatedElement 에서 제공하는 getAnnotation() 을 사용하면 제한되어 있는 타입 토큰을 받을 수 있다.

// getAnnotation 메서드
 <T extends Annotation> T getAnnotation(Class<T> annotationClass);

내부를 보면 <T extends Annotation> 를 사용해 타입이 제한 되어 있는 걸 확인할 수 있다.

죽, 특정한 애노테이션 종류를 말한다.

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

getAnnotation 메서드에서 Class<?> annotationType 처럼 비한정적 타입 토큰 을 사용한다.

비한정적 타입 토큰 은 어떤 타입인지는 모르지만 annotationType = Class.forName(annotationTypeName);

전달받은 annotationTypeName 의 이름을 사용하는 애노테이션이 분명히 있다고 가정한다.

Class<?> annotationType타입 토큰애노테이션의 서브 타입 일 것이다.

asSubclass() 는 파라미터로 전달하는 타입의 하위타입으로 변환해주는 메서드이다.

반응형