본 시리즈의 개요
될수있는 대로 토비님의 방송을 듣고 그 후기를 남겨볼까 합니다. 토비님의 정규방송 주소는 아래와 같고 일반적인 방송공지는 페북이나 슬렉에서 하시는 편입니다.
토비의 봄 TV
2회차 주제 – 슈퍼타입토큰
식별자 또는 해쉬맵의 키등의 구분자로서 타입을 이용하는 경우를 타입토큰이라 하는데 타입토큰의 경우 제네릭에 대응할 수 없습니다.
자바의 제네릭 구현방식이 하위호환성을 위해 이레이저방식이라 실제 런타임에 타입을 조사하면 Object로 통일되기 때문이죠.
이에 비해 상위 클래스에 대한 제네릭정보는 하위호환성에 문제가 없으므로 그대로 남겨둔다는 점을 이용하여 익명클래스를 활용하여 즉시 추상클래스의 구상클래스인스턴스를 만드는 방식으로 타입토큰을 생성하면 제네릭 정보를 활용할 수 있게 됩니다. 이러한 리플렉션과 타입정보를 결합하여 런타임의 강제 형변환위험을 제거하고 보다 안전한 타입으로 코딩할 수 있게 됩니다.
2006년 정도에 닐가프터가 제안한 기법입니다.
토비님의 담화문 – 나는 방송을 왜 하는가?(약간 각색함)
팟캐스트를 비롯하여 여러가지 방송이 범람하는 시대다. 하지만 뭔가 시류에 맞춰 방송을 하자라는 생각으로 이걸 진행하는 것은 아니다.
99년부터 호주 살면서 그 전까지 MS일을 하다 2000년부터 본격적으로 자바를 하게 되었는데, 실제로 2004년부터 2007년까지는 블로깅을 열심히 했다. 자바커뮤니티에 아는 사람도 없고 미국 커뮤니티 등에서 눈팅하면서, 개인적으로 공부하면서 알게된 걸 블로깅했는데 당시에는 구독자 20~30명 정도였지만 공유하는게 좋았고 스스로 정리되어 기분 좋은 일이었다.
다들 아는 그 책을 쓰고나니 정말 힘들었고, 이제는 더 이상 글 쓰는게 너무 싫었다. 블로그고 뭐고 글로 뭔가 만드는 것에 대한 스트레스를 벗어나 3,4년간 코딩만 했다. 이렇게만 살다보니 좀 심심하기도 했는데, 개발하면서 쌓여있던 여러 가지 지식을 풀어낼 기회를 노렸지만, 글을 쓰려니 시간이 많이 들어 부담되다보니 포기하게 되었다.
사실 방송은 2000년 중순에 IBM DW에서 웨비나 등으로 진행한 적이 있다. 그 때 좋았던 게 말로 설명하고 미리 작성한 슬라이드를 보여주니 짧은 시간 내에 더 많은 걸 전달하고 자신도 편했다는 점이다. 그 뒤로 한국스프링사용자모임에서도 스크린캐스트를 하자고 준비하다 흐지부지 되었다.
요즘 들어 인터넷 실시간 방송은 채팅도 되고 실시간이다보니 재미도 있다. 녹방일 때는 자주 포기하게 되고, 질은 올라갈지는 몰라도 생방같은 즉흥성이나 재미가 없다. 일상적인 모습을 여과없이 보여주며 편안하게 생방하니 준비도 거의 필요없다. 앞으로도 슬라이드 준비같은 건 거의 없을 것이다. 주로 코드로 직접 만드는 과정을 보여주는 게 중심이 될거고 블로깅을 대신해서 방송한다고 생각해주시면 좋겠다.
제네릭의 기초
그저 단순한 Object를 확정적으로 반환하는 create()메소드를 정의해봅니다.
public class TypeToken{ static Object create(){ return new Object(); } public static void main(String[] args){ Object o = create(); System.out.println(o.getClass()); } }
위코드에서 create()의 반환타입을 자유롭게 변경하고 싶다면 제네릭을 사용하면 됩니다. Class는 자바의 모든 클래스를 대표하는 메타정보로 제네릭으로 정의되어있습니다.
public class TypeToken{ //제네릭메소드 static <T> T create(Class<T> clazz) throws Exception{ return clazz.newInstance(); } public static void main(String[] args) throws Exception { String o1 = create(String.class); System.out.println(o1.getClass()); //String List o2 = create(List.class); System.out.println(o1.getClass()); //List //Integer o = create(Integer.class); //에러 - Integer는 인자없는 기본 생성자가 없다 System.out.println(o.getClass()); } }
위의 코드에서는 메소드의 반환형에 제네릭을 적용했지만 이제 제네릭클래스를 살펴보죠. 특정 타입만 변경되는 경우 다형성의 일종으로 제네릭을 사용합니다.
실제로는 컴파일은 잘됩니다만 런타임 시에 타입을 읽어보면 그 지정타입이 아니라 전부 Object가 됩니다. 이유는 자바컴파일러의 type erasure에 의해 타입정보가 삭제되기 때문이죠. 또한 런타임 타입캐스팅 코드가 삽입되는 식으로 컴파일됩니다(하위호환성을 위해)
public class TypeToken{ //제네릭클래스 static class Generic<T>{ T value; //필드에도 적용가능 void set(T t){} //메소드에 적용 T get(){return null;} } public static void main(String[] args) throws Exception { Generic<Integer> i = new Generic<Integer>(); i.value = 1; i.set(10); Generic<String> s = new Generic<String>(); s.value = "String"; //런타임시에 s.value의 타입은 String이 아님 //실제로는 아래처럼 번역됨 s.value = (Object)"String"; } }
런타임 형변환의 위험성
즉 제네릭은 컴파일을 통과시키지만 실제로 런타임 타입변환을 하는 식으로 변경되기 때문에 일반적인 런타임타입변경의 위험성도 그대로 갖을 수 있습니다.
런타임 타입변경의 위험성은 아래와 같은 코드로 생각해볼 수 있습니다.
Object o = "string"; Integer i = (Integer)o; System.out.println(i);
위의 코드는 컴파일 잘됩니다. 클래스간 위상에 문제가 없기 때문이죠. 하지만 실제로 실행하면 ClassCastException 이 발생합니다. o는 실제로 문자열이니 정수가 될 수는 없기 때문입니다. 즉 컴파일을 통과시켰음에도 런타임안정성이 확보되지 않는 코드가 바로 런타임 타입변환코드입니다.
이를 개선하여 아래와 같이 작성하면 타입안정적인 코드를 작성할 수 있게 됩니다.
public class TypeToken{ static <T> T create(Class<T> clazz) throws Exception{ return clazz.newInstance(); } public static void main(String[] args) throws Exception { Generic<Integer> i = create(Generic<Integer>); i.value = 1; i.set(10); } }
이를 활용하여 타입안정적인 맵을 만들어보죠. 우선 타입에 위험한 맵을 살펴보겠습니다.
class TypeunsafeMap{ Map<String, Object> map = new HashMap(); void run(){ map.put("a", "a"); map.put("b", 1); } }
위의 맵이 호스트측에서 아래와 같이 사용되면 런타임 에러가 발생하게 됩니다.
Integer i = (Integer)map.get("b"); String s = (String)map.get("a");
맵의 타입안정성을 확보하려면 강제캐스팅을 제거하고 제네릭을 활용해야 합니다.
타입토큰
타입안정성을 확보하려면 “a”, “b”같은 값을 식별토큰으로 쓰는게 아니라 타입 즉 클래스를 식별토큰으로 사용하는 기법을 사용합니다.
결국 특정 클래스 정보를 넘겨서 안정성을 노리는 기법을 TypeToken이라 하는 것이죠.
타입을 토큰으로 사용하는 타입안전맵을 작성해보죠.
static class TypesafeMap{ Map<Class<?>, Object> map = new HashMap<>(); <T> void put(Class<T> clazz, T value){ map.put(clazz, value); } <T> get(Class<T> clazz){ return clazz.cast(map.get(clazz)); } }
위의 코드에서 각 메소드는 T를 기반으로 Class를 식별하는 제네릭이며 동시에 clazz를 토큰으로 사용하고 있습니다.
- 제네릭을 통해 다양한 클래스를 넘길 수 있게 하여 토큰으로 활용할 수 있게 하면서도
라는 형을 value의 형에도 적용하여 런타임 형변환 없이 값의 형도 구속할 수 있게 된거죠.
이를 활용하는 코드는 다음과 같을 것입니다.
TypesafeMap m = new TypesafeMap(); m.put(String.class, "string"); m.put(Integer.class, 3); m.put(List.class, Arrays.asList(1,2,3)); Sytem.out.println(m.get(String.class)); //"string" Sytem.out.println(m.get(Integer.class)); //3 Sytem.out.println(m.get(List.class)); //[1,2,3]
(토큰 자체가 들어있는 값의 형이기도 하기 때문에 제한적일거라 생각이 들 수도 있겠지만 이 맵의 활용처는 일반적인 컬렉션이 아니라고 일단은 생각해두죠 ^^)
어쨌든 잘 작동합니다.
제네릭클래스 토큰의 문제점
문제는 동일한 클래스에 다른 값을 넣으면 덮어써진다는 점입니다. 예를 들면 아래와 같죠.
m.put(List.class, Arrays.asList(1,2,3)); m.put(List.class, Arrays.asList("a","b","c")); Sytem.out.println(m.get(List.class)); //["a","b","c"]
그야 당연한게 List.class를 키로 했으니 마지막에 쓴 값으로 덮어지는 것입니다. 하지만 엄밀히 말해 두 개의 리스트는 제네릭으로 보면 다른 타입입니다.
List<Integer> 타입과 List<String>타입이죠. 그럼 그렇게 쓰면 어떨까요?
m.put(List<Integer>.class, Arrays.asList(1,2,3)); m.put(List<String>.class, Arrays.asList("a","b","c"));
결과는 참담하게도 에러입니다. 제네릭에 대해서는 클래스 정보가 존재하지 않기 때문에 예외가 발생해버립니다.
자바1.5에서 도입된 제네릭은 구현 방법에 따라 erasure방식과 reification 이라는 방식이 있습니다. c#은 reification 즉 각 제네릭타입의 조합에 따른 형을 실제로 만들어내는 방식을 사용합니다. 하지만 자바는 erasure방식으로 오히려 이 방식에서는 형을 지워버리고 Object로 통일시킨 뒤 런타임 형변환을 추가하도록 코드를 바꾸는 방식입니다. c#은 하위호환성을 포기하고 새롭게 정의한 대신 자바는 하위호환성에 문제가 없는 방식을 채택한 것이죠. 그렇기 때문에 제네릭타입은 그냥 클래스를 얻으려고 하면 제대로 작동하지 않게 되는 것입니다.
슈퍼타입토큰
이에 대한 해결책으로 super type token을 고안해냅니다. 제네릭클래스를 상속받은 클래스에 리플렉션을 이용하면 부모클래스의 정보를 얻어올 수 있는데 부모클래스의 정보에는 제네릭정보가 그대로 들어있습니다.
왜일까요?
제네릭클래스를 상속받는 클래스라는건 1.5이전에는 작성할 수 없는 코드입니다. 즉 하위호환성에 전혀 문제가 없기 때문에 바이트코드상에 부모클래스의 제네릭정보를 남겨두는건 전혀 문제가 되지 않기 때문입니다.
이를 활용한 테크닉이 슈퍼타입토큰입니다. 우선 이를 이해하기 위해 정적인 서브클래싱을 이용해 부모의 제네릭타입을 얻어와보는 것부터 시작해봅니다.
//제네릭부모클래스 class Sup<T>{ T value; } //정적으로 정의된 부모를 상속하는 서브클래스 class Sub extends Sup<String>{}
그렇다면 이제 Sub클래스의 인스턴스를 만들면 리플렉션을 이용해 부모의 제네릭타입인 String을 얻을 수 있을 것입니다. 이를 구현해보죠.
Sub sub = new Sub(); //부모의 제네릭클래스정보를 얻음. Type type = sub.getClass().getGenericSuperclass(); //타입으로부터 파라메터타입을 다시 얻음 ParamererizedType ptype = (ParameterizedType)type; //파라메타타입의 인자를 확인하면 그 안에 제네릭타입이 들어있다! System.out.println( ptype.getActualTypeArguments()[0] ); //String
위의 원리를 이용하면 익명클래스구상을 통해 손쉽게 SuperTypeToken을 사용할 수 있게 됩니다.
static class Sup<T>{ T value; } public static void main(String[] args){ //부모클래스를 상속하는 익명클래스구상체 Sup b = new Sup<String>(){}; //상동 ParameterizedType t = (ParameterizedType)b.getClass().getGenericSuperclass(); System.out.println(t.getActualTypeArguments()[0]); //String }
파라메터 타입을 매칭시키는 과정은 단순반복이므로 이를 추상화한 TypeReference클래스를 정의합니다.
abstract class TypeReference<T>{ Type type; //실제 얻어낸 제네릭타입 public TypeReference(){ //abstract이므로 this는 서브클래싱된 객체 Type stype = getClass().getGenericSuperclass(); this.type = ((ParamererizedType)stype).getActualTypeArguments()[0]; } }
이제 부모의 제네릭타입을 얻을 수 있다는 점을 이용한 슈퍼타입토큰을 1차적으로 일반화했습니다. 이를 활용하여 map을 개선해보죠.
class TypesafeMap{ //슈퍼타입토큰을 사용! Map<TypeReference<?>, Object> map = new HashMap<>(); <T> void put(TypeReference<T> tr, T value){ map.put(tr, value); } <T> get(TypeReference<T> tr){ return ((Class<T>)tr.type).cast(map.get(tr)); } }
크게 바뀐 점은 없습니다만 Class
- 토큰 자체는 Class가 아니라 TypeReference의 인스턴스참조다.
- get할 때는 TypeReference의 type속성에 담긴 제네릭타입으로 캐스팅한다.
get에 캐스팅은 좋은 전략입니다만 TypeReference는 사실 제네릭클래스 타입을 대표하기 위한 토큰인데 인스턴스로서 삽입되어 참조값형 토큰이 되어버렸습니다.
이를 보완하려면 TypeReference클래스가 해쉬맵에서 인스턴스로서 식별되지 않고 타입으로 식별되도록 hashCode와 equals를 재정의해야 합니다.
abstract class TypeReference<T>{ Type type; public TypeReference(){ Type stype = getClass().getGenericSuperclass(); if(stype instanceof ParameterizedType){ this.type = ((ParamererizedType)stype).getActualTypeArguments()[0]; }else throw new RuntimeException(); } public int hashCode(){ return type.hashCode(); //type을 기준으로 식별(type은 Class이므로 Class레벨만 식별됨) } 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); //마찬가지로 두 객체 간의 type을 비교 } }
이제 안심하고 Map에 여러번 TypeReference의 인스턴스를 넣어도 동일한 제네릭익명클래스인스턴스가 개별로 식별되지 않을 것입니다. 사용해보죠.
TypesafeMap m = new TypesafeMap(); m.put(new TypeReference<Integer>(){}, 3); m.put(new TypeReference<String>(){}, "abc"); System.out.println(m.get(new TypeReference<Integer>(){})); //3 System.out.println(m.get(new TypeReference<String>(){})); //"abc"
원래 되던 건 잘되는걸 확인했으니 이 모든 노가다를 했던 원래 목적인 서브제네릭타입에 도전해보죠.
TypesafeMap m = new TypesafeMap(); m.put(new TypeReference<List<Integer>>(){}, Array.asList(1,2,3)); m.put(new TypeReference<List<String>>(){}, Array.asList("a","b","c")); System.out.println(m.get(new TypeReference<List<Integer>>(){})); //에러발생 System.out.println(m.get(new TypeReference<List<String>>(){})); //에러발생
헐! 안됩니다. TypeReference는 준비되었지만 이번엔 TypesafeMap이 준비가 덜 된 것이죠. 이제 get을 개선하여 제네릭타입을 받을 수 있게 변경해보죠.
class TypesafeMap{ Map<TypeReference<?>, Object> map = new HashMap<>(); <T> void put(TypeReference<T> tr, T value){ map.put(tr, value); } <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)); } } }
결국 tr의 type이 제네릭타입인 경우를 나눠서 보다 깊이 리플렉션을 돌며 RawType을 얻어 캐스팅하는 걸로 분리했습니다.
여기서는 List
(토비님이 방송에서 시간관계상 개선은 안해주셨지만 스프링의 ResolvableType을 쓰면 훨씬 간단하다는 멘트를 슬랙에 남겨주셨습니다)
드디어 원래 목표로 했던 것을 달성했습니다.
TypesafeMap m = new TypesafeMap(); m.put(new TypeReference<List<Integer>>(){}, Array.asList(1,2,3)); m.put(new TypeReference<List<String>>(){}, Array.asList("a","b","c")); System.out.println(m.get(new TypeReference<List<Integer>>(){})); //[1,2,3] System.out.println(m.get(new TypeReference<List<String>>(){})); //["a","b","c"]
스프링에서의 슈퍼타입토큰
스프링에는 미리 구현된 ParameterizedTypeReference 클래스가 제공됩니다.
//Spring 3.2이상에서 사용가능 public class SpringTypeReference{ public static void main(String[] args){ ParameterizedTypeReference<?> type = new ParameterizedTypeReference(List<Integer>)(){}; System.out.println(type.getType()); } }
이를 응용하는 간단한 예제를 구성해보죠. 우선 데이터를 공급하는 간단한 컨트롤러를 구현합니다.
public class Sample{ //User클래스 static public class User{ String name; public User(String name){ this.name = name; } public User(){} public String getName(){ return this.name; } } @RestController public static class MyController{ @RequestMapping("/") public List<User> users(){ //배열에 User를 출력한다 return Arrays.asList(new User("a"), new User("b"), new User("c")); } } public static main(String[] args){ SpringApplication.run(Sample.class, args); } }
간단한 Rest서비스로 배열에 User를 담아서 출력해주고 있습니다. 이를 소비하는 측을 만들어 보죠.
public class SpringTypeReference{ public static main(Stirng[] args){ RestTemplate rt = new RestTemplate(); List<Sample.User> users = rt.getForObject("http://localhost:8080", List<Sample.User>.class); System.out.println(users.get(0).getName()); //에러! } }
위의 코드는 에러가 납니다. 이유는 좀 복잡한데
- 우선 제네릭은 이레이저로 타입이 제거되어버렸고
- getForObject는 추정하기 애매한 타입은 죄다 LinkedHashMap으로 바꿔버립니다.
따라서 진짜 User클래스에 있는 getName()이라는 메소드는 존재하지 않게 되는거죠. LinkedHashMap으로 바뀐다는 점을 안다면 name속성이 있다는 점을 이용해 다음과 같이 하면 그럭저럭 돌긴할 겁니다.
public class SpringTypeReference{ public static main(Stirng[] args){ RestTemplate rt = new RestTemplate(); //아예 Map으로 받아버리고 List<Map> users = rt.getForObject("http://localhost:8080", List.class); System.out.println(users.get(0).get("name")); //get으로 가져옴 } }
하지만 이래서야 이번 방송 전체에서 다루고 있는 런타임 형안정성확보에 정면으로 배치되는 코드일 뿐입니다. 바른 해법은 슈퍼타입토큰입니다.
public class SpringTypeReference{ public static main(Stirng[] args){ RestTemplate rt = new RestTemplate(); //exchange를 사용함 List<Sample.User> users = rt.exchange( "http://localhost:8080", HttpMethod.GET, null, new ParamtererizedTypeReference<List<User>>(){} //여기서 제네릭설정성공! ).getBody(); System.out.println(users.get(0).getName()); //성공! } }
위와 같은 예제를 넘어 많은 경우에 암묵적으로 Spring이 이러한 원리를 적용해 처리해주고 있습니다.
예를들어 일반적인 컴포넌트를 제네릭으로 정의하는 경우 보통 클래스인 경우 서비스와의 바인딩은 가능합니다.
public class Sample{ static class GenericService<T>{ @Autowired T t; } @Component static class MyService extends GenericService<String>{ } @Component static class MyService2 extends GenericService<Integer>{ } }
하지만 제네릭 안에 정의된 클래스가 오는 경우는 3.x까지는 바인딩할 수 없었습니다.
public class Sample{ static class GenericService<T>{ @Autowired //에러발생 오브젝트 매핑불가 T t; } @Component static class MyService extends GenericService<MyBean1>{ } @Component static class MyService2 extends GenericService<MyBean2>{ } }
이에 대해 3.x대의 해법은 다음과 같습니다.
static class GenericService<T>{ @Autowired ApplicationContext ctx; @PostConstruct void init(){ Class tType; t = ctx.getBean(tType); } }
하지만 4.x대는 그냥 처리됩니다.
static class GenericService<T>{ @Autowired T t; }
내부에서의 슈퍼타입토큰 처리가 진일보한 것이죠.
결론
10시에 시작해 11시52분에 종료했습니다. 방송이 더 길고 분량도 더 많아진 느낌입니다. 머 간단하고 쉬운걸 하신다면서 한다는게 이런..=.=
P.S 슬랙 캡쳐분이 이번화에도 있습니다.
recent comment