[java] 토비의 봄 TV라이브 #2.5, #2.6

본 시리즈의 개요

될수있는 대로 토비님의 방송을 듣고 그 후기를 남겨볼까 합니다. 토비님의 정규방송 주소는 아래와 같고 일반적인 방송공지는 페북이나 슬렉에서 하시는 편입니다.
토비의 봄 TV

추가방송 주제 – 슈퍼타입토큰의 뒷 얘기들

2회차의 슈퍼타입토큰을 방송하신 뒤 여러가지 고민을 하신 토비님. 2회에 걸쳐서 추가방송을 하시게 됩니다(추가방송을 하게 한 원인에 직간접적으로 영향을 끼쳐드려 심심한 사과의 말씀을 드립니다)
2회차에서 만들었던 TypesafeMap에는 몇 가지 문제가 있었는데 이를 보완하면서 일어나는 설명입니다. 이 보완 방송은 두 번에 걸쳐 진행되었으며 각각 2.5회와 2.6회로 명명되었습니다.

2.5회에서 다룬 이전 구현의 문제

2회에서 제작한 구현은 다음과 같습니다.

static class TypesafeMap{
  Map<Type, Object> map = new HashMap<>();

  <T> void put(TypeReference<T> tr, T value){map.put(tr, value);}
  <T> T get(TypeReference<T> tr){
    if(tr.type instanceof Class<?>) return ((Class<T>)tr.type).cast(map.get(tr));
    else return ((Class<T>)((ParameterizedType)tr.type).getRawType()).cast(map.get(tr));
  }
}

타입토큰으로 TypeReference의 래퍼클래스를 넘기는 방식이다 보니 매번 인스턴스가 만들어지고 클래스도 매번 새로 정의됩니다.
하지만 Map에서는 같은 키값으로 인지해야 하기 때문에 TypeReference클래스에 equals, hashCode 등을 추가로 작성했습니다.

static class TypeReference<T>{
  Type type;

  public TypeReference(){
    Type stype = getClass().getGenericSuperclass();
    if(stype instanceof ParameterizedType) this.type = ((ParameterizedType)stype).getActualTypeArguments()[0];
    else throw new RuntimeException();
  }
  @Override
  public boolean equals(Object o){
    if(this == o) return true;
    if(o == null || getClass().getSuperclass() != o.getClass().getSuperclass()) return false;
    TypeReference<?> that = (TypeReference<?>) o;
    return type.equals(that.type);
  }
  @Override
  public int hashCode(){return type.hashCode();}
}

하지만 생각해보니 괜히 복잡하게 할 필요가 없었습니다…(라고 토비님께서 말하십니다 ^^;)
그렇습니다, 토비님도 평범한 인간이었던 것입니다!

해결의 단초 Type

뭐가 삽질이었냐라는 해답은 Type에 있습니다. 슈퍼토큰상황에서의 Type클래스는 이미 ParameterizedType으로 만들어지는 것입니다. 문서에 기술된 바에 따르면 Type의 인스턴스는 기존에 없던 타입이라면 새로운 타입인스턴스를 만들어냅니다(문서…토비님이 문서는 안보여주셨..) 그렇기 때문에 Type만으로도 충분히 식별가능한 상태가 되는 것이죠. 결국 Type이 제네릭별로 고유하게 식별되므로 그걸 래핑한 TypeReference를 굳이 식별자로 할 필요가 없습니다.

추가적인 이너클래스의 문제

TypeReference의 상속클래스의 인스턴스를 만드는 행위는 결국 이너클래스를 생성하는 것이고 정적클래스가 아니니 아우터클래스를 참조하게 됩니다. 이 경우 이너클래스가 쓰레드등에 노출되면 아우터클래스가 해지될 수 없으므로 동시성에서도 문제를 일으키거나 메모리관리자 릭이 발생하는 원인이 됩니다.

개선하기

이미 Type을 기반으로 할 수 있다는 발상을 통해 다음과 같이 수정할 수 있습니다.

static class TypesafeMap{
  Map<TypeReference<?>, Object> map = new HashMap<>();

  <T> void put(TypeReference<T> tr, T value){map.put(tr.type, value);}
  <T> T get(TypeReference<T> tr){
    if(tr.type instanceof Class<?>) return ((Class<T>)tr.type).cast(map.get(tr.type));
    else return ((Class<T>)((ParameterizedType)tr.type).getRawType()).cast(map.get(tr.type));
  }
}

기존 구현과 달라진 점은 tr을 map에 put하거나 get할 때의 키로 쓰던걸 지금은 tr.type 즉 Type을 키용도로 사용한다는 것입니다. 이걸 통해 TypeReference인스턴스가 여러개 생겨도 즉시 GC대상이 되고 실제로 메모리참조로 남는건 Type뿐이게 되었습니다. 앞서 설명한대로 Type은 각 인스턴스가 각 타입별로 고유하게 생성되므로 문제가 생기지 않습니다.
또한 TypeReference의 경우에도 더 이상 인스턴스간 비교가 필요없으므로 기존에 구현한 equals와 hashCode를 파기하면 됩니다.

여러 개의 타입을 받는 제네릭인 경우

여태까지 다룬 TypeReference클래스는 하나의 T에 대한 제네릭을 인식용이었습니다. 만약 여러개의 제네릭 타입이 온다면 여러개를 받을 수 있는 타입토큰 클래스를 설계해야합니다. 예를들어 두 개를 받는 녀석이라면 다음과 같이 만듭니다.

tatic class TwoTypeReference<T,R>{
    public Type typeT, typeR;

    public TwoTypeReference(){
        Type stype = getClass().getGenericSuperclass();
        if(stype instanceof ParameterizedType){
            Type[] types = ((ParameterizedType)stype).getActualTypeArguments();
            this.typeT = types[0];
            this.typeR = types[1];
        }else throw new RuntimeException();
    }
}

부가정보 – 제네릭타입을 직접 이용하기

헌데 여기서 조금 더 나아가 직접 제네릭타입을 사용해볼 수 있습니다.

static class TwoTypeReference<T,R>{
  Type typeT, typeS;
  T vt;
  R vr;
}

여기서부터는 설명이 어렵기 때문에 하신 말씀 그대로 적겠습니다.

vt의 타입이 무엇인가를 체크하고 싶다면 리플렉션을 통해 접근하면 타입을 가져올 수 있는데, getActualTypeArguments를 통해 T, R의 Type을 알아낼 수 있고, 그 타입을 통해 필드를 리플렉션하면서 비교하면 해당 필드가 무슨 타입인지 역추적할 수 있다.

음 감이 올 듯 말 듯한 말씀입니다. 아니 정확하게는 구현의 가능할듯 말듯한 힌트를 주신 느낌이랄까. 상상을 해보죠.

public TypeReference(){
  for(Type t : ((ParameterizedType)stype).getActualTypeArguments()){
    for(Field f : getClass().getFields()){
      if(f.getGenericType() == t){//이넘이 그넘이여!}
    }
  }
}

이런건가…=.=; 하지만 vt가 T고 vr이 R이라고 알기 이전에 이미 f로부터 얻을 수 있는거 같은데..음..어디에 어떻게 쓰는지 감이 안오네요. 나중에 P.S쪽에 토비님께 여쭌 결과를 붙이겠습니다 → 토비님 까먹고 계심.

여러 개의 타입을 받는 제네릭의 실제 자바 예

여러개의 타입을 받아야하는 제네릭 타입에 대한 대표적인 예로 Function 인터페이스가 있습니다.

interface Function<T, R>{
  R apply(T t);
}

이 경우는 처음부터 타입이 두 개인 제네릭입니다. 따라서 두개의 타입을 받아들이는 TypeReference가 필요해지죠. 심지어 이 BiFunction은 세 개가 됩니다.

interface BiFunction<T, U, R>{
  R apply(T t, U u);
}
  • 람다부분과 관련된 설명은 주제 외라 생략합니다 ^^; 람다식과 제네릭스에 대한 더 깊은 내용은 2016년 11월20일 방송에 나갈 예정입니다.

근데 진짜로 구현해보기

위의 방송내용에서 bi타입을 구현해보세요 하고 그냥 토비님은 넘어가시고 실제로 구현해주시지는 않습니다. 물론 생성자는 위에 든 예처럼 types[0], types[1]로 얻으면 됩니다.
하지만 이렇게 되면 TypesafeMap은 그냥 tr을 쓸 수 없게 됩니다.
키로 type두 개를 넣을수는 없는 노릇이고 고유한 인스턴스로써의 언어 상에 보장은 Type객체 뿐이지 그 조합이 아닙니다.
이대로가면 복합타입에 대한 equals와 hashCode를 구현하는 새로운 클래스를 정의할 수 밖에 없습니다. 헌데 또 이렇게 래핑클래스를 작성하면 그 로직(equals)도 다양한 경우에 검증되지 않고, 언어스펙을 이용하는 것이 아니라 불안합니다. 차라리 TypesafeMap쪽을 2단계 키로 찾도록 고치는 편이 낫다고 판단했습니다.

static class TypesafeMap{
    Map<Type, HashMap<Type,Object>> map = new HashMap<>();
    static private class Default{}
    <T> void put(TypeReference<T> tr, T value){
        if(!map.containsKey(tr.type))map.put(tr.type, new HashMap<Type,Object>());
        map.get(tr.type).put(null, value);
    }
    <T> T get(TypeReference<T> tr){
        if(!map.containsKey(tr.type)) return null;
        if(tr.type instanceof Class<?>) return ((Class<T>)tr.type).cast(map.get(tr.type).get(null));
        else return ((Class<T>)((ParameterizedType)tr.type).getRawType()).cast(map.get(tr.type).get(null));
    }
    <T,R> void put(TwoTypeReference<T,R> tr, <T,R> value){
        if(!map.containsKey(tr.typeT))map.put(tr.typeT, new HashMap<Type,Object>());
        map.get(tr.typeT).put(tr.typeR, value);
    }
    <T,R> T get(TwoTypeReference<T,R> tr){
        if(!map.containsKey(tr.typeT)) return null;
        if(tr.typeR instanceof Class<?>) return ((Class<T>)tr.typeR).cast(map.get(tr.typeT).get(tr.typeR));
        else return ((Class<T>)((ParameterizedType)tr.typeR).getRawType()).cast(map.get(tr.typeT).get(tr.typeR));
    }
}

헐.. 맞게 한거야 머야..잘 모르겠습니다=.=; 확실하게 틀린 부분은 <T,R> void put(TwoTypeReference<T,R> tr, <T,R> value) 부분의 두 번째 value에 대한 타입입니다.
<T,R> value 따위로 타입을 적을 수는 없죠. 헌데 여기에 뭘 넣어야할지 멍하네요. 이것도 토비님께 물어서 해답을 구해보죠. → 토비님 까먹고 계심2
여튼 주신 추가 정보는 다음과 같습니다.
screenshot_5

스프링의 ResolvableType

4.0에 필립웹이란 분이 만들어 도입된 클래스로 복잡한 리플렉션을 통한 Type접근을 추상화하여 손쉽게 사용하도록 래핑한 클래스입니다.

ResolvableType rt = ResolvableType.forInstance(new TypeReference<List<String>>(){});
//부모의 제네릭의 수
System.out.println(rt.getSuperType().getGenerics().length); //1
//첫번째가져오기
System.out.println(rt.getSuperType().getGeneric(0).getType()); //List<java.lang.String>
//제네릭 중첩에서 2번째 뎁스로 들어감
System.out.println(rt.getSuperType().getGeneric(0).getNested(2).getType()); //java.lang.String
//풀 수 없는 제네릭이 있냥? 없다
System.out.println(rt.getSuperType().hasUnresolvableGenerics()); //false
//풀 수 없는 제네릭이 있냥? 있다
System.out.println(ResolvableType.forInstance(new ArrayList<String>()).hasUnresolvableGenerics()); //true

훨씬 간결하고 편리하게 사용할 수 있는 헬퍼클래스입니다. 내부에는 캐쉬처리도 되고 많은 기능을 제공합니다.

2.6회 – 안드로이드에서 안되는 문제

이 문제는 제가 질문드린 문제로 여기껏 작성했던 슈퍼타입토큰관련 코드가 안드로이드에서는 정상적으로 작동하지 않는다는 점을 발견하여 시작되었습니다. 이 문제를 받은 토비님은 이 문제를 해결해가는 과정을 보여주시고 싶었습니다. 어짜피 토비님을 비롯하여 언어나 환경의 경계면까지 전체 스펙을 사용하는 개발레벨에 도달한 사람들은 자주 경계면상황이란거에 직면합니다. 스펙대로 돌아가지 않거나 환경자체가 잘못된 경우를 만나는 거죠. 그런 경우에도 어떻게 바르게 문제를 풀어가는가에 대한 노하우를 전수해주시려고 합니다. 2.6회는 안드로이드 스튜디오로 진행됩니다.

다시 기존 구현된 TypesafeMap은 tr.type을 기반으로 키를 잡는데 제네릭타입을 넣는 경우 키를 제대로 인식하지 않습니다.

//기존구현
static class TypeReference<T>{
    Type type;
    public TypeReference(){
        Type stype = getClass().getGenericSuperclass();
        if(stype instanceof ParameterizedType){
            this.type = ((ParameterizedType)stype).getActualTypeArguments()[0];
        }else throw  new RuntimeException();
    }
}
static class TypesafeMap{
    Map<Type, Object>> map = new HashMap<>();
    <T> void put(TypeReference<T> tr, T value){map.put(tr.type, value);}
    <T> T get(TypeReference<T> tr){
        if(tr.type instanceof Class<?>) return ((Class<T>)tr.type).cast(map.get(tr.type));
        else return ((Class<T>)((ParameterizedType)tr.type).getRawType()).cast(map.get(tr.type));
    }
}
public SuperTypeToken(){ //테스트
    Map<String,Object> map = new HashMap<>();
    map.put("1","2");
    m.put(new TypeReference<Map<String,Object>>(){}, map);
    Log.i("bs", m.get(new TypeReference<Map<String,Object>>(){}) == null ? "null" : "!!"); //null이 나옴
}

왜 null이 나오는가에 대한 범인을 찾아야합니다. 사실 TypesafeMap은 TypeReference내부에서 getActualTypeArguments()했을 때 내부에 들어있는 Type인스턴들이 고유한 타입별로 일종의 싱글톤이 생성된다라는 가정하에 제작되었습니다. 이것부터 검증해야합니다. 간단히 이를 코드로 표현해봅니다.

Log.i("bs", new TypeReference<Map<String, Object>>(){}.type == new TypeReference<Map<String, Object>>(){}.type);

만약 내부에서 생성되는 Type의 인스턴스가 싱글톤이라면 위의 결과가 true가 되어야겠죠. 하지만 결과는 false가 나와버립니다. 하지만 Map에서 키를 비교할때는 ==를 비교하지 않고 equals로 비교합니다.
그럼 이번에는 equals로 비교해보죠.

Log.i("bs", new TypeReference<Map<String, Object>>(){}.type.equals(new TypeReference<Map<String, Object>>(){}.type));

이것마저도 false가 나옵니다. 즉 기본적인 전재가 깨진 것이죠. 그럼 대체 이것은 누구의 잘못일까요? 이럴 땐 역시 언어 스펙을 살펴봐야합니다. 즉 Type에 대한 구현이 실제 equals에 대한 동등성을 제약하고 있는건지 아니면 구현의 차이일뿐인가를 확인해야합니다. 실제 안드로이드의 문서를 확인해보면 다음과 같이 나옵니다.

/**
 * ParameterizedType represents a parameterized type such as
 * Collection&lt;String&gt;.
 *
 * <p>A parameterized type is created the first time it is needed by a
 * reflective method, as specified in this package. When a
 * parameterized type p is created, the generic type declaration that
 * p instantiates is resolved, and all type arguments of p are created
 * recursively. See {@link java.lang.reflect.TypeVariable
 * TypeVariable} for details on the creation process for type
 * variables. Repeated creation of a parameterized type has no effect.
 *
 * <p>Instances of classes that implement this interface must implement
 * an equals() method that equates any two instances that share the
 * same generic type declaration and have equal type parameters.
 *
 * @since 1.5
 */
public interface ParameterizedType extends Type {

마지막 문단을 살펴보면 equals구현에 대한 굉장히 명확한 가이드라인을 제공하고 있습니다. 즉 안드로이드의 자바구현이 잘못되었다는 사실을 알 수 있습니다!
그렇다면 원래 제작했던 코드가 틀린게 아닙니다. 단지 안드로이드의 구현이 잘못된 것이므로 반대로 오라클자바에서는 잘되는 코드로서 문제가 없는 스펙대로의 구현입니다.

하지만 안드로이드에서 안되니 이 문제를 해결해야합니다.

Type.toString 이용

다행히 Type인스턴스의 toString구현을 로그로 확인해보니 풀패키지로 클래스이름이 잘 나오고 있습니다.

Log.i("bs", new TypeReference<Map<String, Object>>(){}.type.toString());
//java.util.Map<java.lang.String, java.lang.Object>

물론 안드로이드자바도 dex로더가 있어서 완전히 안심할 수는 없습니다만, 실무적으로도 충분히 만족할만한 구분자입니다. 이제 테스트를 통해 문자열로 비교하면 같다는 것을 최종확인합니다.

Log.i("bs", new TypeReference<Map<String, Object>>(){}.type.toString().equals(
    new TypeReference<Map<String, Object>>(){}.type.toString()
));
//true -심봤다!

드디어 true를 보았습니다. 문자열을 키로 사용하는 방식으로 개선합니다!

static class TypesafeMap{
    Map<String, Object>> map = new HashMap<>();
    <T> void put(TypeReference<T> tr, T value){map.put(tr.type.toString(), value);}
    <T> T get(TypeReference<T> tr){
        if(tr.type instanceof Class<?>) return ((Class<T>)tr.type).cast(map.get(tr.type.toString()));
        else return ((Class<T>)((ParameterizedType)tr.type).getRawType()).cast(map.get(tr.type.toString()));
    }
}

이제 드디어 됩니다!

결론

굉장히 혼란스럽네요. 글은 정리했지만 아직도 풀리지 않고 사사 받아야하는 많은 것들이 남아있습니다. 의문이 풀리는대로 업데이트를 해보겠습니다.