티스토리 뷰

Java

토비의봄#02. Super Type Token

LichKing 2016. 11. 12. 13:46

1. Generics

java5에서 부터 추가된 제네릭은 타입을 파라미터로 만들어 넘어오는 파라미터에 따라 다른 타입이 되게끔한다. 이로서 얻을 수 있는 이득은 타입이 동적으로 변하게 되기때문에 개발자가 직접 타입체크를 하고 타입 캐스팅을 하는 코드가 없어지게되어 정적인 타입 안전성을 확보할 수 있다.


public class Test {
public static void main(String[] arg) {
MyOptional<String> stringOptional1 = MyOptional.ofNullable("hello");
MyOptional<String> stringOptional2 = MyOptional.ofNullable(123);
}
}

class MyOptional<T> {
T t;

private MyOptional(T t){
this.t = t;
}

public static <T> MyOptional<T> ofNullable(T t){
return new MyOptional<>(t);
}
}

MyOptional<T>라는 제네릭 클래스를 정의했다. 제네릭으로 타입을 제한하고있기때문에 stringOptional2 변수는 컴파일에러가 발생한다. 제네릭 포스팅은 이전에 한적이 있다(http://multifrontgarden.tistory.com/104).


제네릭은 클래스나 메서드를 유연하게 만들어주지만 깊게 사용하기가 어려운 문법이다. 가끔 이해할 수 없는 결과를 보일때도 있는데(가끔 타입 안전성이 깨질때가 존재), 그런 이유는 대부분 타입 소거(Type Erasure)때문이다. 타입 소거는 어느정도 알고는 있었는데 글로 정리하기가 힘들어 지난 포스팅에서는 아예 언급을 안했는데 이번에 토비님 방송을 보며 어느정도 정리가 되어 언급을 하려한다(물론 이번에도 타입 소거가 메인 주제는 아니다;;).


2. TypeErasure

다양한 자료들을 다루는 자료구조의 구현체는 Collection 구현체들은 자바의 가장 대표적인 제네릭 클래스들이다. 어떤 자료가 들어갈지 모르기때문에 제네릭으로 타입을 강제하고 사용하기 마련인데 제네릭으로 타입을 강제하지않아도 컴파일은 성공하고, 런타임시에도 (타입체크만 잘해준다면) 예외없이 잘 작동한다.

List<Integer> numbers1 = Arrays.asList(1, 2, 3, 4, 5);
List numbers2 = Arrays.asList(1, 2, 3, 4, 5);


타입을 제한하고 싶지 않은 경우가 있을 수도 있잖아? 라고 생각할 수도있겠지만 그런경우에도 <Object>와 같은 형태로 제네릭을 사용해야한다. 단지 제네릭을 사용하지않고도 제네릭 클래스를 사용할 수 있는 이유는 제네릭이 없던시절(Java5 미만)의 코드 하위호환을 위해서다.

Java5 이상의 컴파일러도 이전 코드를 문제없이 컴파일 하기위해 자바는 컴파일시에 제네릭 정보들을 소거시킨다. 이것이 타입 소거인것이다. 제네릭정보를 소거시키기때문에 바이트코드에 제네릭에 대한 정보는 아무것도 없고 런타임시에는 제네릭이 없는 셈이다.



class MyOptional<T> {
T t;

private MyOptional(T t){
this.t = t;
}

public static <T> MyOptional<T> ofNullable(T t){
return new MyOptional<>(t);
}

public T get(){
return this.t;
}
}

위 코드를

class MyOptional {
Object t;

private MyOptional(Object t){
this.t = t;
}

public static MyOptional ofNullable(Object t){
return new MyOptional(t);
}

public Object get(){
return (String)this.t;
}
}

컴파일러는 이런식으로 바꾼다. 제네릭은 사라진다.


public class Test {
public static void main(String[] arg) {
MyOptional<String> stringOptional = MyOptional.ofNullable("hello");

System.out.println(stringOptional.getTypeName());
}
}

class MyOptional<T> {
T t;

private MyOptional(T t){
this.t = t;
}

public static <T> MyOptional<T> ofNullable(T t){
return new MyOptional<>(t);
}

public T get(){
return this.t;
}

public Type getTypeName(){
try {
return this.getClass().getDeclaredField("t").getType();
} catch (NoSuchFieldException e) {
e.printStackTrace();
return null;
}
}
}

좀 더 확인해보고싶다면 이 코드를 실행해보자. 리플렉션을 이용해 t 필드의 타입을 확인하는 코드다. Object가 출력되는걸 확인 할 수 있다.


* C#은 타입 소거가 아니라 타입 구체화(Type Reification)을 통해 제네릭을 구현하여 런타임시에도 제네릭이 소거되지 않는 방법을 사용했다. 이를 구현하기위해 이전코드들과의 하위호환을 포기했다.


3. TypeSafetyMap

이펙티브자바에서도 나왔던 예제인데 이번에 토비님 방송에서도 나왔다. 제네릭과 Map을 이용하면 TypeSafetyMap을 만들 수 있는데, Map은 Key와 Value로 이루어진 자료구조이다. 간단히 말하면 Key를 타입으로 지정하는 것이다.


public class Test {
public static void main(String[] arg) {
TypeSafetyMap map = new TypeSafetyMap();

map.put(Integer.class, 1);
map.put(Integer.class, "hello");
map.put(String.class, "hello");
map.put(String.class, 2);
}
}


class TypeSafetyMap {
Map<Class<?>, Object> map;

{
this.map = new HashMap<>();
}

public <T> void put(Class<T> clazz, T t){
map.put(clazz, t);
}

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

TypeSafetyMap은 타입 클래스를 key로 받고 그걸로 들어오는 데이터의 타입체크를 하게된다. 제네릭덕분에 컴파일시에 타입안전성을 확보할 수 있는데 이때 key에 들어오는 클래스 객체를 타입 토큰(Type Token)이라 부른다. 타입 안전성이 확보되기 때문에 put()메서드를 호출한 4줄중 2, 4번째 줄은 컴파일에러가 발생한다. 코드는 특별히 어려운 부분은 없다. Map을 한번 래핑해 제네릭 메서드를 이용해 타입안전성을 확보한다.


map.put(List.class, Arrays.asList(1, 2, 3, 4, 5));
map.put(List.class, Arrays.asList("a", "b", "c"));

애초에 제네릭 클래스인 리스트와 같은 클래스도 적용이 가능하다. 하지만 이 코드는 뭔가 이상한걸 알 수 있다. List<Integer>와 List<String>는 불변(Invariant) 타입으로 서로 완전히 다른타입의 리스트임에도 불구하고 해당 코드는 List<String>이 List<Ineger>를 덮어쓰게 될것이다. 심지어는 이런 코드도 나오게 된다.

map.put(List.class, Arrays.asList(1, "a"));


List에 제네릭으로 타입제한을 걸지 못해서 그런것같다. 가장 단순하게 드는 생각은 이것이다.

map.put(List<Integer>.class, Arrays.asList(1, 2, 3, 4, 5));
map.put(List<String>.class, Arrays.asList("a", "b", "c"));


코드를 짜보면 알겠지만 이 코드는 컴파일 에러가 발생한다. 타입 소거에서 말한대로 컴파일시에는 사라질 정보들이라서 저런식으로 타입안전성을 확보할 수가 없기때문이다.


4. Super Type Token

그럼 자바에서는 저런경우에는 타입안전성을 보장할 방법이 없는걸까? 결과부터 말하자면 제네릭은 컴파일시에 제거되지만 몇 가지의 경우 소거되지않는다. 그리고 그 기법중 하나를 이용하여 위의 경우 타입안전성을 확보할 수 있다.


public class Test {
public static void main(String[] arg) {
Sub sub = new Sub();

System.out.println(((ParameterizedType)sub.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());
}
}

class TypeReference<T>{
}

class Sub extends TypeReference<String>{
}


바로 이런 경우이다. 제네릭 클래스를 정의한 후 그 제네릭 클래스를 상속하여 사용하는 경우 그때 제네릭을 지정하면 그 제네릭정보는 소거되지 않고 런타임시에도 정보가 남게된다. 


코드에 대해 조금 설명하자면

1. 인스턴스의 구현 클래스를 찾고

2. 그 클래스의 상위 클래스인 제네릭 클래스를 찾아서 ParameterizedType으로 캐스팅을 한다.

3. 명시적 캐스팅이 발생한다는건 다운캐스팅이라는 것이다. getGenericSuperclass()가 반환하는 타입은 ParameterizedType의 상위 타입일것이다. 슈퍼 클래스가 제네릭 클래스가 아니라면 캐스팅시 예외가 발생한다.

4. 제네릭 타입 파라미터에서 실제 인자들을 찾아 그중의 첫번째 파라미터를 가져온다.

5. 제네릭은 Map<K, V>와 같이 2개 이상도 들어갈수있으니 필요에따라 숫자가 꼭 0이어야 하는건 아니다.

6. 그 첫번째 파라미터의 타입이름을 가져온다.

뭐 대충 이런 구조다.


아직 감은 오지않겠지만 일단 실마리는 찾았다. 이걸 좀 더 응용하면 애초에 이걸 고민하게했던 타입안전성 맵에도 활용할 수 있게된다. 일단 이걸 여러 타입에 사용하려면 그때마다 클래스를 만들어야한다. 그럼 제네릭에 타입이 추가될때마다 새 클래스를 정의해야할까? 참 미련한 짓이다. 익명클래스를 활용하자.


public class Test {
public static void main(String[] arg) {
TypeReference sub1 = new TypeReference<String>() {};
TypeReference sub2 = new TypeReference<Integer>() {};

System.out.println(((ParameterizedType)sub1.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());

System.out.println(((ParameterizedType)sub2.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());
}
}

class TypeReference<T>{
}


익명 클래스를 활용하면 각종 타입에 대응하기가 좀 더 수월해진다. 한가지 주의할점은 제네릭 정보가 남는건 하위 타입에서 상위 타입의 타입 파라미터를 전달했을때이므로 반드시 하위 타입이어야한다는것이다. 그리고 하위타입임을 알리기위해 구현부 {}를 붙여야한다. 이걸 빼도 컴파일시 에러는 발생하지 않는다. TypeReference의 상위 클래스는 Object 이므로 타입파라미터가 존재하지않아 캐스팅시 런타임 예외만 발생하게된다.


public class Test {
public static void main(String[] arg) {
TypeReference intType = new TypeReference<Integer>();
TypeReference stringType = new TypeReference<String>();

System.out.println(((ParameterizedType)sub1.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());

System.out.println(((ParameterizedType)sub2.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());
}
}

얘는 하위 타입이 아니라서 타입소거가 이루어진다. 뿐더러 런타임 예외까지 발생.


가능한 TypeReference는 객체화가 이루어지지않는게 컴파일시 안전성을 높여줄것같다. 추상클래스로 정의하자.


public class Test {
public static void main(String[] arg) {
TypeReference intType = new TypeReference<Integer>(); // 컴파일 에러 발생
TypeReference stringType = new TypeReference<String>();

System.out.println(((ParameterizedType)sub1.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());

System.out.println(((ParameterizedType)sub2.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());
}
}

abstract class TypeReference<T>{
}


슈퍼 타입에 대한 제네릭 정보는 얼마나 남게되는걸까? 리스트같은 클래스를 이용해 중첩적으로 활용해도 정보가 남는걸까?


public static void main(String[] arg) {
TypeReference intType = new TypeReference<List<Integer>>(){};
TypeReference stringType = new TypeReference<Set<Map<String, List<Integer>>>>(){};

System.out.println(((ParameterizedType)sub1.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());

System.out.println(((ParameterizedType)sub2.getClass().getGenericSuperclass())
.getActualTypeArguments()[0]
.getTypeName());
}

오 대박! 왜 대박인지 알고싶으면 출력해보자.


class TypeSafetyMap {
Map<TypeReference<?>, Object> map;

{
this.map = new HashMap<>();
}

public <T> void put(TypeReference<T> typeReference, T t){
map.put(typeReference, t);
}

public <T> T get(TypeReference<T> typeReference){
Type type = ((ParameterizedType)typeReference.getClass().getGenericSuperclass())
.getActualTypeArguments()[0];

if(typeReference.type instanceof Class<?>) {
return ((Class<T>) type).cast(map.get(typeReference));
}

return ((Class<T>)((ParameterizedType)type).getRawType())
.cast(map.get(typeReference));
}
}


이제 제일 처음 만들었던 TypeSafetyMap을 수정하자. key를 TypeReference로 하자. 다른부분은 크게 안바꼈는데 get()이 좀 복잡해졌다. get()에 있는 if문은 중첩제네릭클래스인지 아닌지를 확인하는 것이다. <List> 일경우엔 파라미터타입 자체가 List.class 라서 캐스팅을 하면 되지만 중첩 제네릭, 즉 <List<String>> 같은 타입일경우 List.class로 타입캐스팅이 되지않는다. 때문에 raw type을 구해서 raw type으로 캐스팅하는 코드가 추가되었다. 이외에 상위 클래스가 제네릭 클래스가 아닐경우를 체크하는부분은 굳이 넣지 않았다.


public static void main(String[] arg) {
TypeSafetyMap map = new TypeSafetyMap();

map.put(new TypeReference<Integer>() {}, 1);

System.out.println(map.get(new TypeReference<Integer>() {}));
}


그리고나서 테스트를 해보자. null이 출력되는걸 볼 수 있다. 왜 null일까? 잘 생각해보면 결국 TypeSafetyMap이 래핑하고있는 내부 Map의 key는 TypeReference다. Map은 key 중복을 체크할때 key로 들어온 객체의 hashCode() 와 equals() 메서드를 호출하게 된다. 현재 put 과 get을 할때 사용할때는 둘다 익명 객체 생성으로 서로 다른 인스턴스이기때문에 hashCode()와 equals()는 당연히 다르다고 판별되어 같은 키가 아니라고 판단할 것이다. 일단 빠르게 잘되는지부터 확인하고싶다면 이렇게 하면 된다.


public static void main(String[] arg) {
TypeSafetyMap map = new TypeSafetyMap();

TypeReference tr = new TypeReference<Integer>() {};

map.put(tr, 1);
System.out.println(map.get(tr));
}

인스턴스를 변수에 담아 동일한 인스턴스로 put() 했다가 get()하니 1이 출력되는걸 볼 수 있다. 하지만 이건 테스트를 위한거고 궁극적으로는 hashCode()와 equals()를 오버라이딩 해줘야한다.


abstract class TypeReference<T>{
Type type;

protected TypeReference(){
type = ((ParameterizedType)this.getClass().getGenericSuperclass())
.getActualTypeArguments()[0];
}

@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 != null ? type.equals(that.type) : that.type == null;

}

@Override
public int hashCode() {
return type != null ? type.hashCode() : 0;
}
}


인스턴스가 같은지를 비교하기위한 용도로 Type 필드를 추가하고 IDE가 지원하는 오버라이딩을 이용했다. equals() 같은 경우는 조금 수정을 해야한다.


public static void main(String[] arg) {
TypeSafetyMap map = new TypeSafetyMap();


map.put(new TypeReference<Integer>() {}, 1);
System.out.println(map.get(new TypeReference<Integer>() {}));
}

그리고 다시 인스턴스 생성방식으로 호출하면 정상출력을 확인할 수 있다.


자, 이제 원래 이런 짓(?)을 시작하게만들었던 중첩 제네릭도 확인해보자.


public static void main(String[] arg) {
TypeSafetyMap map = new TypeSafetyMap();

map.put(new TypeReference<Integer>() {}, 12);
map.put(new TypeReference<List<String>>() {}, Arrays.asList("a", "b", "c"));
map.put(new TypeReference<List<Integer>>() {}, Arrays.asList(1, 2, 3));
System.out.println(map.get(new TypeReference<Integer>() {}));
System.out.println(map.get(new TypeReference<List<Integer>>() {}));
System.out.println(map.get(new TypeReference<List<String>>() {}));
}

아.. 성공적으로 돌아간다. 포스팅에 올라오는 코드는 돌아가는걸 확인하고 올리고있지만 방송에 토비님 코드를 그대로 따라치는게 아니라 방송한번 보고 따로 타이핑을 하는거라 중간중간 생각치못한 일이 많았기에 성공적으로 돌아가는게 나왔을땐 정말 눈물이 날뻔했다.

equals() 구현하는것도 몇분을 헤맸는지 ㅜㅜ...


이로써 타입안전성을 위한 래퍼 맵이 단순한 타입 토큰으로 Raw Type의 타입 안전성만 보장받는게 아니라 상위 클래스의 제네릭 정보까지도 보장받는 Super Type Token으로 사용하여 더욱 확실한 타입안전성을 갖게 되었다. 사실 이런 래퍼 맵보다는 서로 다른 타입끼리 형변환, 대표적으로 JSON을 자바의 객체로 변환할때 많이 유용하게 사용할 수 있다.


#추가방송

5. 리팩토링

private Map<TypeReference<?>, Object> map;

맵의 키를 TypeReference로 사용하고 있다. 그리고 키같은경우는 일시적으로 사용하는 객체이므로 익명클래스를 이용해 사용하고있다. 꼭 익명 클래스를 사용할 필요는 없지만 맵의 키로 쓰겠다고 타입마다 일일이 클래스파일을 만드는건 그다지 효율적으로 보이지 않는다.

익명 클래스를 이용할때는 메모리 측면의 이슈가 하나있는데 일단 익명클래스의 본질을 약간 생각해볼 필요가 있다.

익명클래스는 사용 코드에서 즉시적으로 클래스를 정의하고 객체를 생성하는 내부 클래스(Inner Class)이다. 외부 클래스의 인스턴스가 생성되고 그 인스턴스내에서 생성되는 내부 객체이기때문에 내부객체가 맵의 key로 사용되게되면 외부 객체에 대한 연결끈을 놓지않아 key로 사용된 TypeReference 객체뿐만 아니라 그 외부 객체까지도 GC대상에서 벗어나게되어 메모리에 부하를 주게된다. 그런데 사실 우리가 진짜 필요한건 TypeReference 객체가 아니라 그 객체가 갖고있는 슈퍼클래스의 제네릭 타입 정보이다. key를 TypeReference로 주기때문에 고생해가며 equals(), hashCode()까지 재정의 했어야하는데 key를 type으로 주게되면 그런것들이 필요없어지게된다.


private Map<Type, Object> map;

진짜 필요한건 Type이므로 key자체를 type으로 잡자.


public abstract class TypeReference<T> {
private Type type;

public TypeReference(){
this.type = ((ParameterizedType)getClass().getGenericSuperclass())
.getActualTypeArguments()[0];
}

public Type type(){
return this.type;
}
}


TypeReference에 hashCode()와 equals()를 재정의 했던 이유는 map의 key로 사용될때 같은 타입일 경우 같은 객체로 보기위함이었다(동등성). 그런데 key를 Type으로 쓸거니 얘네가 같은지를 확인할 필요는 없어졌으므로 해당 코드는 삭제한다. 그리고 type을 꺼내오기위한 메서드를 추가했다.


public class TypeSafetyMap {
private Map<Type, Object> map;

{
map = new HashMap<>();
}

public <T> void put(TypeReference<T> typeReference, T t){
this.map.put(typeReference.type(), t);
}

@SuppressWarnings("unchecked")
public <T> T get(TypeReference<T> typeReference){
Type type = ((ParameterizedType)typeReference.getClass().getGenericSuperclass())
.getActualTypeArguments()[0];
Class<T> clazz;

if(type instanceof ParameterizedType){
clazz = (Class<T>)((ParameterizedType)type).getRawType();
}else{
clazz = (Class<T>) type;
}

return clazz.cast(map.get(typeReference.type()));
}
}

크게 달라진점은 없다. 단지 map에 put()하고 get()할때 Type을 사용하는 것 뿐이다. 작동은 동일하게 하는걸 확인할 수 있다. 


6. ResolvableType in Spring

어렵게어렵게 리플렉션까지 써가며 슈퍼타입토큰을 구현했는데 스프링에선 좀 더 좋은 API를 제공하고있다.


public class Test {
public static void main(String[] arg) {
ResolvableType type = ResolvableType.forInstance(new TypeReference<List<String>>() {
});

System.out.println(type.getSuperType().getGeneric(0).getType().getTypeName());
}
}


리플렉션으로 작성하게되면 이런저런 타입 캐스팅도 필요하게되고, Checked Exception도 신경써줘야하는데, 스프링의 ResolvableType을 사용하게되면 좀 더 편리하게 사용할 수 있다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함