본문 바로가기
Java/Java

타입 안전 이종 컨테이너와 수퍼 타입 토큰

by oneny 2023. 8. 27.

타입 안전 이종 컨테이너

타입 안전 이종 컨테이너는 한 타입의 객체만 담을 수 있는 컨테이너가 아닌 여러 다른 타입(이종)을 담을 수 있는 타입 안전한 컨테이너를 말한다. 이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해주는데, 이러한 설계 방식을 타입 안전 이종 컨테이너 패턴이라고 한다.

 

일반적으로 사용하는 제네릭의 한계

public class Favorites<T> {

  List<T> value;

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

    Favorites<Integer> numbers = new Favorites<>();
  }
}

제네릭은 클래스에서 클래스에서 사용할 타입을 외부(사용부)에서 사용하게 해주는 일반적인 기법을 의미한다. 자바 제네릭은 보통 위와 같은 형태로 사용한다. 이러한 제네릭을 사용하면, 타입만 다르고 공통된 기능을 가지고 있는 클래스나 메서드들에 파라미터로 타입을 외부에서 전달받아 조금 더 범용적인 코드를 만들 수 있다.

 

타입 안전하지 않은 컨테이너

public class NotSafeFavorites {

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

  public void put(Class type, Object value) {
    this.map.put(type, value);
  }

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

  public static void main(String[] args) {
    NotSafeFavorites favorites = new NotSafeFavorites();
    favorites.put(String.class, "oneny");
    favorites.put(String.class, 1);

    String s = (String) favorites.get(String.class); // ClassCastException
  }
}

이러한 제네릭을 활용하여 각기 다른 타입을 가지고 있는 컬럼들에 값을 넣을 때 해당하는 타입의 value만 넣을 수 있도록 하는 의도로 위 코드를 작성했다. 예를 들어, 문자열이면 문자열에 해당하는 값을, 숫자면 숫자에 해당하는 값을 넣고 싶을 때 Map을 사용해서 map.put(String.class, "oneny")처럼 key에는 타입, value에는 타입의 값이 들어가도록 로직을 작성했다.

하지만 위 코드의 문제점은 타입 안전성이 없다는 것이 문제이다. key가 String이면 값도 String이어야 하는데   favorites.put(String.class, 1);도 허용함으로써 마지막에 String 타입의 값을 get 조회해올 때 String으로 형 변환하면 ClassCasException 예외가 발생한 것을 확인할 수 있다.

만약 타입 안전하게 만들고 싶으면 타입을 컨테이너(NotSafeFavorites)에 설정하는 것이 아니라 키를 매개변수화할 수 있는 이종 컨테이너를 사용해야 한다.

 

타입 안전한 이종 컨테이너

public class 이종Favorites {

  // 비한정적 와일드 카드를 사용해야 한다.
  // 임의의 클래스 타입의 어떤 한 오브젝트가 value로 들어가게 된다.
  private Map<Class<?>, Object> map = new HashMap<>();

  /**
   * 위 와일드 카드로는 아직 다 해결되지 않고 메서드 수준에서 타입을 정의해서
   * 그 타입에 해당하는 value를 받도록 만들면 된다.
   */
  public <T> void put(Class<T> type, T value) {
    // key가 null인지 체크
    this.map.put(Objects.requireNonNull(type), value);
  }

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

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

    String s = favorites.get(String.class);
    Integer i = favorites.get(Integer.class);
    System.out.println("s = " + s);
    System.out.println("i = " + i);
  }
}

put() 메서드처럼 제네릭을 통해 타입을 정의해서 key의 타입이 들어오면 그 타입에 해당하는 value를 받도록 작성하면 위 결과처럼 컴파일 에러가 발생하는 것을 확인할 수 있다. 이러한 런타임에 해당하는 클래스 타입을 파악할 수 있는 String.class(Class<String>), Integer.class(Class<Integer>)를 타입 토큰이라고 한다. 이 타입 토큰을 통해서 해당하는 타입의 값을 꺼내올 수 있다.

 

그리고 get() 메서드를 살펴보면 마찬가지로 타입을 정의해서 클래스 타입으로 해당 타입을 반환으려고 한 것을 확인할 수 있다. 이 때 (T)로 타입 캐스팅하는 코드를 넣어도 해당하는 타입만 값을 넣기 때문에 @SuppressWarnings("unchecked") 애노테이션을 추가해도 괜찮지만, 더 좋은 방법은 검사를 하고 형 변환을 하는 것이다. 따라서 위처럼 Class의 메서드인 cast 메서드를 사용하면 형 변환 시 전달받은 객체를 안전하게 검사한 후 형 변환할 수 있다.

하지만 위처럼 안전하다고 한 이종 컨테이너도 깨뜨릴 수 있는 예외적인 상황이 있다.

 

강제로 Raw 타입의 클래스로 변환

favorites.put((Class) String.class, "oneny");

위 코드처럼 로타입의 클래스로 형 변환하면 put() 메서의 두 번째 인자 value는 꼭 String이 아니어도 되게 된다. 타입은 Type Erasure라는 컴파일러 구현 기법에 따라서 모두 Object가 되어 아무것이나 넘길 수 있게 된다.

 

위 예외를 보는 것처럼 put() 메서드에서 예외가 발생한 것이 아닌 get() 메서드에서 cast() 메서드를 실행하다 런타임예외가 발생한 것을 확인할 수 있다.

 

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

이 우회하는 상황까지 막고 싶다면 put() 메서드를 실행할 때 검사하도록 만들 수 있다. 그러면 get() 메서드가 실행할 때가 아닌 put() 메서드가 실행할 때 조금 더 빨리 예외를 내도록 할 수 있다. 하지만 컴파일할 때 비검사 경고가 뜨긴 하지만 컴파일 차원에서는 막기 힘들다.

 

List<String> 같은 실체화 불가 타입 사용 불가

List 클래스를 타입으로 했을때 값으로 Integer나 String만 들어가는 List로 넣고 싶지만 문법적으로 허용되지 않는다. 그냥 List.class를 얻을 수 밖에 없다. 하지만 수퍼 타입 토큰이라는 부르는 방법을 사용하여 이를 구분할 수 있다.

 

수퍼 타입 토큰

수퍼 타입 토큰은 상속을 사용한 경우 제네릭 타입이 제거되지 않기 때문에 제네릭 타입을 알아낼 수 있다.

 

제네릭으로 들어온 타입 알아내기

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

    // Type type = (new Super<String>() {}).getClass().getGenericSuperClass(); 도 가능
    Type type = Sub.class.getGenericSuperclass(); // Super<String>을 참조
    ParameterizedType pType = (ParameterizedType) type; // Super<String>을 참조

    // ParameterizedType으로 형 변환해야 제네릭들을 가져올 수 있음
    System.out.println(pType.getActualTypeArguments()[0]);
  }
}

먼저, Super 객체가 가지고 있는 value 필드의 타입을 출력하면 String이 아닌 Object가 출력되는 것을 확인할 수 있다. 이 이유는 컴파일될 때 제네릭이 Object로 바뀌고 나중에 사용하는 쪽에서 캐스팅하는 코드가 삽입이 되는 Type Erasure 때문이다. 따라서 아무리 타입을 꺼내려 해도 런타임에는 알 수가 없다.

하지만 상속을 사용하는 경우에는 타입에 대한 정보가 남는다. 위 코드를 보면 Sub 클래스는 Super<String> 클래스를 상속받고, pType.getActualTypeArguments() 메서드를 통해 제네릭 정보를 가져오면 Object가 아닌 String 클래스가 출력되는 것을 확인할 수 있다.

다시 말해, 상속을 사용하지 않고 그냥 제네릭을 사용하는 경우에는 타입을 알아낼 수 있는 방법이 없지만, 상속을 사용한 경우에는 해당하는 하위 타입으로부터 제네릭 타입 정보를 알아낼 수 있다. 따라서 이 알아낸 타입 정보를 통해 위 문제를 해결할 수 있다.

 

수퍼 타입 토큰 생성

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 Objects.hash(type);
  }

  public Type getType() {
    return type;
  }
}

TypeRef라는 추상 클래스가 있고, 생성자를 보면 제네릭으로 들어오는 타입을 추론해 ParameterizedType을 통해서 타입 정보를 알아내 type 필드에 할당했다. 그리고 type 필드 즉, 제네릭 타입이 Map의 key로 사용한다.

 

수퍼 타입 토큰을 활용한 이중 컨테이너 생성

public class SuperFavorites {

  private final Map<TypeRef<?>, Object> favorites = new HashMap<>();

  public <T> void put(TypeRef<T> typeRef, T thing) {
    favorites.put(typeRef, thing);
  }

  public <T> T get(TypeRef<T> typeRef) {
    return (T) favorites.get(typeRef);
  }

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

    // type 필드를 출력하면 java.util.List<java.lang.String>
    f.put(new TypeRef<List<String>>() {}, List.of("a", "b", "c"));
    // type 필드를 출력하면 java.util.List<java.lang.Integer>
    f.put(new TypeRef<List<Integer>>() {}, List.of(1, 2, 3));

    for (Object value : f.favorites.values()) {
      System.out.println(value);
    }

    f.get(new TypeRef<List<Integer>>() {}).forEach(System.out::println);
    f.get(new TypeRef<List<String>>() {}).forEach(System.out::println);
  }
}

TypeRef 객체를 key로 넘기는데 제네릭으로 위에서 살펴봤듯이 제네릭으로 넘어가는 타입을 type 필드에 할당하고, 해당 type 필드가 즉, key가 되기 때문에 List<String>과 List<Integer>도 구분해서 key로 사용할 수 있게 된다.

 

Generic Type Erasure

위 이종 컨테이너, 수퍼 타입 토큰을 얘기하면서 Type Erasure에 대한 키워드가 나왔다. Type Erasure는 뭘까? 여기서 Erasure란, 원소 타입을 컴파일 타입에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것이다. 한 마디로, 컴파일 타임에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거한다.

 

Java 컴파일러의 타입 소거

  1. unbounded Type(<?>, <T>)는 Object로 변환한다.
  2. bound type(<E extends Comparable>)의 경우는 Object가 아닌 Comparable로 변환한다.
  3. 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메서드에만 소거 규칙을 적용한다.
  4. 타입 안정성 보존을 위해 필요하다면 type casting을 넣는다.
  5. 확장된 제네릭 타입에서 다형서을 보존하기 위해 bridge method를 생성한다.

 

출처

이펙티브 자바 완벽 공략 2부

[JAVA] 타입 토큰과 슈퍼 타입 토큰이란?

타입 안전 이종 컨테이너 - 민동현