자바의 레퍼런스 타입은 런타임에 지워짐

public static class Sup<T> {
    T value;
}

@Test
void simpleTest() throws Exception {
    Sup<String> s = new Sup<>();
    Sup<Integer> i = new Sup<>();

    System.out.println(s.getClass().getDeclaredField("value").getType().getTypeName());
    System.out.println(i.getClass().getDeclaredField("value").getType().getTypeName());
}

예상결과

class java.lang.String
class java.lang.Integer

실제결과

class java.lang.Object
class java.lang.Object

왜 다 지워져버리는걸까?

이는 T의 타입 정보가 런타임까지 전달되지 않고, 컴파일 시점에 Object로 타입 소거되었기 때문이다. Sup이나 Sup 모두 동일한 바이트코드를 가지며, 리플렉션을 통해 타입 정보를 확인하려 해도 Object로 나오는 것이 정상이다.

여기에는 역사적인 이유가 있는데, 자바 1.4 이전 버전과의 호환성을 맞추기 위해 컴파일 타임에 타입을 소거시키는 것이다.

그래서 확장성있으면서도 안전한 형 보존을 위해서는 추가적인 작업이 필요하다.

예시: ObjectMapper 유틸리티

기존 프로젝트에서 ObjectMapper를 사용해서 json을 파싱할때면, 항상

ObjectMapper om = new ObjectMapper();
// ...
om.writeValueAsString(...);
om.readValue(...);

와 같은식으로, ObjectMapper 빈은 스프링이 자동으로 빈 컨텍스트에 넣어주는데도 불구하고, 새로운 ObjectMapper를 만들어서 사용했고, 코드의 재사용성이 현저히 떨어졌다.

이를 개선하기 위해 JsonUtil 클래스를 만들었다.

level 1

@Component
public class JsonUtil {

    private final ObjectMapper objectMapper;

    public JsonUtil(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public <T> T fromJson(String payload, Class<T> clazz) {
        try {
            return objectMapper.readValue(payload, clazz);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 파싱 오류", e);
        }
    }
}

해당 클래스는 json payload를 인자로 받아 T 타입 클래스로 변경해주는 역할을 하는 친구이다.

이대로도 쓰는데 문제는 없지만, POJO 클래스에 한해서이다.

예컨대, 아래와 같은 코드는 동작할 수 없다.

List<AwesomeClass> list = fromJson(payload, List<AwesomeClass>.class); // ⚠️ 컴파일 에러가 발생한다.

Java에서는 List<AwesomeClass>.class처럼 제네릭 타입을 클래스 리터럴로 직접 참조할 수 없기 때문에 컴파일 에러가 발생한다.

그렇지만 당연히 list타입을 담는 json을 파싱하고 싶을 수 있다. 이를 지원하기 위해서 익명 클래스를 활용할 수 있다. 이 한계를 해결하기 위해 TypeReference<List<AwesomeClass>>와 같은 슈퍼타입 토큰을 사용해야 한다.

level 2

@Component
public class JsonUtil {

    private final ObjectMapper objectMapper;

    public JsonUtil(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    public <T> T fromJson(String payload, Class<T> clazz) {
        try {
            return objectMapper.readValue(payload, clazz);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 파싱 오류", e);
        }
    }

    public <T> T fromJson(String payload, TypeReference<T> typeReference) {
        try {
            return objectMapper.readValue(payload, typeReference);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 파싱 오류", e);
        }
    }
}

public <T> T fromJson(String payload, TypeReference<T> typeReference) 클래스가 하나 더 생겼다.

TypeReference는 추상 클래스로, 익명 클래스를 만드는데 사용된다.

익명클래스에 제네릭으로 타입을 던지면, 런타임에 타입 정보가 소실되지 않고 보존되어 리플렉션을 우회할 수 있다.

다시 아까의 예시로 넘어가서,

public static class Sup<T> {
    T value;
}

@Test
void simpleTest() throws Exception {
    Sup<String> s = new Sup<>();
    Sup<Integer> i = new Sup<>();

    System.out.println(s.getClass().getDeclaredField("value").getType().getTypeName());
    System.out.println(i.getClass().getDeclaredField("value").getType().getTypeName());

    TypeReference<List<String>> superTypeS = new TypeReference<>() {};
    TypeReference<List<Integer>> superTypeI = new TypeReference<>() {};

    System.out.println(superTypeS.getType().getTypeName());
    System.out.println(superTypeI.getType().getTypeName());
}

해당 코드의 결과는

class java.lang.Object
class java.lang.Object
java.util.List<java.lang.String>
java.util.List<java.lang.Integer>

이렇게 List 및 List의 타입이 런타임에 보존됨을 확인할 수 있다.

자바는 제네릭 정보를 클래스 정의에 직접 포함하지 않지만

익명 클래스를 만들면 그 부모 클래스의 제네릭 정보는 Class 객체에 남는다.

핵심은 익명 클래스를 활용해서 컴파일 타임에 타입 정보를 Type 객체로 추출해서 저장하는 것이다. 이건 컴파일러가 익명 내부 클래스의 super class 정보에 제네릭 타입을 포함시켜주기 때문에 가능한 트릭으로, 이러한 기법을 슈퍼타입토큰 이라고 부르는데, 자바 언어 개발자인 Neal Gafter가 제시한 것으로, TypeReference를 사용한 익명클래스 말고도 여러 방법으로 슈퍼타입토큰을 구현할 수 있다.

예시:

  • TypeReference: 익명 클래스를 이용해 제네릭 타입을 캡처하고 Type 객체로 추출할 수 있도록 설계된 Jackson의 유틸리티 클래스.
  • ResolvableType: 스프링 프레임워크 내부에서 리플렉션을 통해 제네릭 타입 정보를 추론하고, 빈 주입이나 AOP 등 런타임 분석에 활용하는 도구.
  • ParameterizedTypeReference: 스프링에서 HTTP 요청/응답 시 제네릭 타입 정보를 유지하기 위해 사용하는 TypeReference의 변형 구현체.
  • Guava TypeToken: 구글 Guava에서 제공하는 타입 헬퍼 클래스로, 제네릭 타입의 Type 정보를 유지할 뿐 아니라 타입 간 assignability 검사도 지원한다.
  • Dagger/Guice TypeLiteral: 의존성 주입 컨테이너에서 제네릭 타입을 안전하게 구분하기 위해 사용하는 추상 클래스 기반 토큰.

주의: TypeReference는 타입만 런타임까지 전달할 뿐, 내부 value값은 따로 저장해야한다.