[Java] Generic Parameterized Type 정보를 런타임까지 유지하는 법? Super Type Token!

식빵·2023년 2월 25일
1

Java Lab

목록 보기
11/21
post-thumbnail

Java Generic 이 생소한 분들은 이 링크에 작성된 글을 가볍게 읽으보고 오는 걸 추천합니다 👍


🥝 제네릭은 소거된다


이미 알고 있겠지만 Java Genericcompile time 에 소거된다.
더 정확히 말하자면 아래와 같다.

  • compile timeunbounded Type 이면 Object 로 치환되고,
  • bounded Type 이면 해당 Bounding 제한을 주는 타입으로 치환된다.

ex) 컴파일 전 (Before Type Erasure)

public <T> List<T> unBoundedGeneric(List<T> list) {...}
public <T extends Building> void boundedGeneric(T t) {...}

ex) 컴파일 후 (After Type Erasure)

public List unBoundedGeneric(List list) {...}
public void boundedGeneric(Building t) {...} // Bounding 제한인 `Building` 타입으로 치환

Bounded Type 이든 아니든 아무튼 Java Generic 을 사용한
타입 정보는 source code 상에서 보이더라도 Compile 을 과정을 거치고
나면 사라지게 된다라는 점이 핵심이다. 잘 기억하고 다음으로 넘어가자.




🥝 ParameterizedTypeReference


그런데 코딩을 하다보면 "제네릭은 소거된다"라는 말이 무색하게
제네릭 타입 정보를 런타임에 참조해서 사용하는 것을 목격할 수 있다.
대표적으로 스프링이 제공하는 ParameterizedTypeReference 클래스가 그렇다.

이 클래스는 Spring MVC 프레임워크에서 제공하는 RestTemplate 과 함께 사용하며,
Java 단에서 외부 서버의 Rest API 로 얻어온 자원을 어떤 타입의 Java Object
변환할지를 미리 지정할 때 사용한다.

먼저 이 클래스가 어떻게 사용되는지를 아래의 코드를 통해 알아보자.
(참고로 jdk 17 버전을 사용해서 작성했다)


예제 코드

import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import java.util.List;

public class TypeReferenceTest {
   
    private final String REST_API_URL 
    	= "https://jsonplaceholder.typicode.com/users";
    
    // 참고로 위 URL 은 아래와 같은 형태로의 JSON 배열을 반환한다.
	/*
     [
      {
        "id": 1,
        "name": "Leanne Graham",
        "username": "Bret",
        "email": "Sincere@april.biz",
        "address": {
          "street": "Kulas Light",
          "suite": "Apt. 556",
          "city": "Gwenborough",
          "zipcode": "92998-3874",
          "geo": {
            "lat": "-37.3159",
            "lng": "81.1496"
          }
        }
      },
      ... 생략 ...
    ] 
    */


 	// 데이터를 담기 위한 record 클래스를 생성했다.
    // record 는 java 17 에 정식으로 등록된 "문법"이다. 모르겠으면 구글링!
	record User(String id, String name, String username,
    			String email, Address address) {}
                
	record Address(String street, String city, String zipcode, Geo geo) {}
    
	record Geo(String lat, String lng) {}
    
	@Test
	void testType() {
		RestTemplate restTemplate = new RestTemplate();
		
		ResponseEntity<List<User>> exchange = restTemplate.exchange(
			"https://jsonplaceholder.typicode.com/users",
			HttpMethod.GET,
			HttpEntity.EMPTY,
			new ParameterizedTypeReference<List<User>>() {} // !!! 요녀석이 핵심이다 !!!
		);
		
		if (exchange.getStatusCode().is2xxSuccessful()) {
			exchange.getBody().forEach(System.out::println);
			// 필요한 작업 수행...
		} else if (exchange.getStatusCode().isError()) {
			// 필요한 에러 처리 작업 수행...
		}
	}
}

참고 링크


실행 결과


ParameterizedTypeReferenceParameterized Type 으로 지정한 Java Object 타입에 맞게 데이터가 파싱되어서 콘솔에 출력되는 것을 확인할 수 있다.




🙄 이게 왜 돼...?


위의 코드에서는 ParameterizedTypeReference<List<User>>(){} 라는
것을 사용해서 JSON 데이터를 List<User> 형태의 Java 객체로 변환하는 것을 알 수 있다.

이상하지 않은가?

제네릭으로 지정한 타입들은 Source 코드에서만 보이고 이후 Compiler 에 의해 소거된다.
그럼에도 불구하고 위 코드를 보면 제네릭 타입정보가 런타임에도 사용된 걸 알 수 있다.

이게 어떻게 가능한 걸까?




🥝 Super Type Token


위와 같이 납득이 안되는 동작방식은 Super Type Token 이라는 기법 덕분에 가능한 것이다.
이 기법을 사용하면 Compile TimeParameterized Type 의 정보를 미리 캡쳐(Capture) 하고 Run Time 에는 캡쳐한 타입 정보를 자유롭게 사용할 수 있다.

지금부터 이 기법의 작동 방식을 이해해보자.


1. getGenericSuperClass 메소드

먼저 java.lang.Class 타입의 인스턴스가 제공하는
getGenericSuperClass 라는 메소드를 이해할 필요가 있다.
해당 메소드의 Java Doc 의 일부를 읽어보면 아래와 같다.

public Type getGenericSuperclass()

Returns the Type representing the direct superclass of the entity (class, interface, primitive type or void) represented by this Class object.

If the superclass is a parameterized type, the Type object returned must accurately reflect the actual type arguments used in the source code.

...중간 생략...

Returns:
the direct superclass of the class represented by this Class object

핵심을 뽑아내면 아래와 같다.

  • 클래스의 상속 또는 구현 구조에서 바로 위의 부모 클래스(또는 구현 인터페이스)의 Type 정보를 읽는다.
  • 이때, superclass 에서 Paramterized Type 을 사용하면 superclass 의
    Source Code 상에 적혀있는 Paramterized Type정확히 읽어온다.
  • 반환값은 이런 superclass 의 정보를 Type 인스턴스를 반환한다.



2. getGenericSuperClass 실습

위 설명이 이해가 안되면 아래 코드를 실행해보자.

예제 코드

import java.lang.reflect.Type;
import java.util.List;

public class GetGenericSuperClassTest {
	
	static class GodClass<T> {}
	
	static class SuperClass<T> extends GodClass<T> {}
	
    // 간단한 parameterized Type 사용
	static class MyClass extends SuperClass<Integer> {}
    
    // 복잡한 parameterized Type 사용
	static class ComplexTypeClass extends 
    				SuperClass<List<List<Integer>>> {}
	
	@Test
	void getGenericSuperClassTest() {
		
		SuperClass<Integer> superClass = new SuperClass<>();
		Type superClassType = superClass.getClass().getGenericSuperclass();
		System.out.println("superClassType = " + superClassType);
		
		
		MyClass myClass1 = new MyClass();
		Type myClassType = myClass1.getClass().getGenericSuperclass();
		System.out.println("myClassType = " + myClassType);
		
		
		ComplexTypeClass complexClass = new ComplexTypeClass();
		Type complexType = complexClass.getClass().getGenericSuperclass();
		System.out.println("complexType = " + complexType);
		
	}
}

출력결과:

superClassType 
  = ...GodClass<T>
myClassType 
  = ...SuperClass<java.lang.Integer>
complexType 
  = ...SuperClass<java.util.List<java.util.List<java.lang.Integer>>>

생성한 인스턴스의 Class 인스턴스를 조회하고 getGenericSuperclass 메소드를 호출하면
바로 상위 부모 클래스 메타 정보를 읽어오는 것을 확인할 수 있다.

그리고 Super Class 에서 Generic 을 사용할 때 Parameterized Type
T, E 같이 명확하게 지정되지 않은 것은 물론이고,
Integer, List<List<Integer>> 와 같이 명확하게 지정한 경우에도
정확하게 Source Code 상에 적힌 그대로의 타입 정보를 조회하는 것을 확인할 수 있다.

이렇게 바로 위의 부모 클래스에 있는 Parameterized Type 정보를 Compile Time
알아내서 Type 타입의 필드로 저장하고 응용하는 기법이 Super Type Token 기법이다.



3. 익명 클래스를 사용한 Type 정보 저장

앞서 본 ParameterizedTypeReference 클래스도 위에서 설명한 기법을 사용한다.
다만 앞서 본 예시 코드의 ComplexTypeClass 처럼 클래스 이름을 직접 지정하는
방식 대신 익명 클래스(Anonymous class) 방식을 사용한다.

굳이 익명 클래스를 쓰는 이유는 아래 코드를 돌려보면 직관적으로 이해할 수 있다.


예제 코드

// 일반적인 클래스의 인스턴스 생성
var list = new ArrayList<String>();

// 익명 내부 클래스를 통한 인스턴스 생성
var anonymousClassList = new ArrayList<String>() {};

// 각각의 인스턴스의 타입 정보에서 바로 위의 superclass 타입 정보를 print 한다.
Type listGenricSupClass = list.getClass().getGenericSuperclass();
System.out.println("listGenricSupClass = " + listGenricSupClass);

Type anonymousGenricSupClass = anonymousClassList.getClass().getGenericSuperclass();
System.out.println("anonymousGenricSupClass = " + anonymousGenricSupClass);

출력 결과

listGenricSupClass = java.util.AbstractList<E>
anonymousGenricSupClass = java.util.ArrayList<java.lang.String>

위의 출력결과를 자세히 관찰해보자.

일반적인 클래스의 인스턴스의 getGenericSuperclass 메소드의 결과물은
우리가 원하는 Generic 타입 정보를 제공하지 않는다.

우리는 Generic 타입의 상세한 정보를 저장하고 런타임에 사용하기 위함인데,
애초에 그 정보가 모호한 상태, 즉 <E> 같은 것을 저장하면 아무 의미가 없다.

이런 결과가 나오는 이유는 당연하다.
getGenericSuperclass 메소드는 인스턴스 타입의 상속관계 상 바로 위 타입에
대한 정보를 가져오기 때문이다.

var list = new ArrayList<String>(); 에서 list 인스턴스 자신의 타입은
ArrayList<E> 이며, 자신의 타입 상속관계 상 바로 위는 AbstractList<E> 이다.


그렇다면 익명 클래스를 사용해서 인스턴스를 생성하면 어떨까?


앞선 코드에서 new ArrayList<String>() {}; 처럼 생성자를 호출하면
이것은 익명클래스 extend ArrayList<String> {} 라는 클래스의 생성자를 호출한 것이다.

이런 익명 클래스의 동작 방식 덕분에 getGenericSuperclass 메소드의 결과물은
익명클래스 의 바로 위의 부모 클래스인 ArrayList<String> 이다!!

이거다! 이게 바로 익명 내부 클래스를 쓰는 이유다!
결과적으로 우리는 ArrayList<String> 라는 타입 정보를 저장할 수 있게 되고,
런타임까지 해당 타입 정보 유지 및 사용이 가능한 것이다.


우리가 이전에 봤던 ParameterizedTypeReference 를 그냥 생성자로 호출하지 않고,
익명 클래스로 한번 감싸서 사용한 것이 이런 이유다.

ResponseEntity<List<User>> exchange = restTemplate.exchange(
	"https://jsonplaceholder.typicode.com/users",
	HttpMethod.GET,
	HttpEntity.EMPTY,
	new ParameterizedTypeReference<List<User>>() {} // !!! 익명 클래스 사용 !!!
);



4. ParameterizedTypeReference 클래스 관찰

마지막으로 ParameterizedTypeReference<T> 클래스 내부를 가볍게 보고 이 글을 마치겠다.

여기서 중요하게 볼 것은 두 가지다.

  1. Super Type Token 의 전형적인 방식인 getGenericSuperClass 메소드 호출을
    통한 Generic Type 정보를 미리 저장하는 것을 확인할 수 있다.

  2. abstract class 로 선언함으로써 강제로 익명 클래스를 사용하도록 유도한다.


이런 내부 구현 덕분에 RestTemplate 을 통한 Http Response Body 에 있는
Json 값을 ParameterizedTypeReference 내부에 미리 저장한 Type 정보를
기반으로 Java Object 변환이 가능했던 것이다.




읽느라 고생하셨습니다!
이만 글을 마치도록 하겠습니다. 😁



💬 참조 링크

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글