Generic Type Erasure (제네릭 타입 소거)

이영규·2023년 3월 7일

Generic 에 대해 잘 알고 있다고 생각했는데 새로운 사실을 알게되어 간단하게 정리.


제네릭과 타입소거

Generic 은 jdk 1.5 에 도입되었으며, 하위 버전 호환성을 위해 타입 소거하는 방식을 채택했다.
여기서 타입 소거란, 런타임 때 타입을 지워 타입 정보를 알 수 없게 하는 것을 말한다.
예를 들면,

public <T> List<T> getListOf(T t1, T t2) {
  ...
}

이런 코드가 런타임에는

public List getListOf(Object t1, Object t2) {
  ...
}

이렇게 바뀐다는 이야기이다.

Generic 이 도입되기 전, java 에서는 List<T> 대신 List 와 같은 것들이 사용되었다.
따라서 타입 소거를 하지 않으면 하위 버전의 호환성이 깨지기 때문에 도입했다고 한다.
(참고로 kotlin 에서는 타입 소거를 하지 않는다.)

실전 예시

이 내용을 이해하고 아래의 코드를 보자.

public class GenericTest {

    public void test() {
        String test = "test";
        
        Wrapper<Integer> wrapped = wrapping(test);
        
        Integer body = wrapped.getBody();   // CCE! (1)
        System.out.println("body = " + body);
        
        String mapped = mapping(wrapped, String.class);
        System.out.println("mapped = " + mapped);   // "test"
    }

    private <T> Wrapper<T> wrapping(String sample) {
        return new Wrapper<>((T) sample);   // why not CCE? (2)
    }

    private <S, T> T mapping(Wrapper<S> wrapped, Class<T> destinationType) {
        ModelMapper modelMapper = new ModelMapper();

        S body = wrapped.getBody(); // why not CCE? (3)
        return modelMapper.map(body, destinationType);
    }

    public static class Wrapper<T> {
        private final T body;

        public Wrapper(T body) {
            this.body = body;
        }

        public T getBody() {
            return body;
        }
    }
}
  • 제네릭을 사용해서 Wrapper 클래스를 만듬
  • "test" 라는 문자열을 Integer 안에 집어넣음 (정수로 변환될 수 없는 문자열)
  • (2) 에서 명시적으로 타입을 캐스팅 하기 때문에 컴파일 타임에서는 오류가 발생하지 않음

1. Why Class Cast Exception?

자, 이제 하나씩 보자.

    public void test() {
        String test = "test";
        
        Wrapper<Integer> wrapped = wrapping(test);    // (1)
        Integer body = wrapped.getBody();   // CCE! (2)
        System.out.println("body = " + body);
        
        ...
    }
    
    private <T> Wrapper<T> wrapping(String sample) {
        return new Wrapper<>((T) sample);  // (3)
    }
    
    public static class Wrapper<T> {
        private final T body;

        public Wrapper(T body) {
            this.body = body;
        }

        public T getBody() {
            return body;
        }
    }

이 코드를 실행하면 (2) 번 부분에서 ClassCastException 이 발생한다.

1. 첫번째 의문

  • (1) 번과 (3) 번 부분의 wrapping() 에서 명시적으로 타입 변환을 해주었다.
    • new Wrapper<>((T) sample)
  • 이 때 T 는 Integer 이다.
  • 그러면 (3) 번 부분에서 에러가 발생해야 하는 게 아닌가? (String -> Integer 변환)

아니다.
런타임 때는 타입이 소거되며, 제네릭 타입은 실제 사용될 때까지 Object 타입으로 이동한다.
즉, wrapping() 메서드는 런타임 때는 실제로 아래와 같이 동작하는 것이다.

    public void test() {
        String test = "test";
        
        Wrapper wrapped = wrapping(test);    // (1)
        Integer body = wrapped.getBody();   // CCE! (2)
        System.out.println("body = " + body);
        
        ...
    }
    
    private Wrapper wrapping(String sample) {
        return new Wrapper<>(sample);  // (3)
    }
  • <T>(T) 는 모두 소거된다.
  • 따라서 컴파일 타임에는 명시적 형변환 (T) 가 의미있었지만, 런타임 때는 소거되므로 아무런 의미를 갖지 못 한다.
  • (3) 의 순간까지 raw type, 즉 Object 형태로 "test"Wrapper 안에 들어있다.

2. 두번째 의문

  • 그럼 (2) 에서는 왜 에러가 발생하는가?

간단하다. Integer body 변수 안에 getBody() 의 결과를 넣으려고 하기 때문이다.

  • (2) 의 순간에 getBody 를 통해 꺼낸 Object "test" 를 Integer 에 넣으려고 시도하며, 이 때 ClassCastException 이 발생하게 된다!

그렇다면 CCE 를 회피하려면 어떻게 하면 될까?
getBody() 의 결과를 Object 로 받으면 된다.

아래와 같은 코드가 될 것이다.

    public void test() {
        String test = "test";
        
        Wrapper<Integer> wrapped = wrapping(test);    // (1)
        Object body = wrapped.getBody();   // "NOT" CCE! (2)
        System.out.println("body = " + body);
        
        ...
    }
    
    private <T> Wrapper<T> wrapping(String sample) {
        return new Wrapper<>((T) sample);  // (3)
    }

이렇게 작성하면 컴파일 타임에도, 런타임에도 오류가 발생하지 않는다.
그리고 system.out 으로 "body = test" 가 정상 출력 된다.

참고로 getBody() 의 결과를 String 으로 받으려고 하면 컴파일 타임에서 오류가 발생한다.

2. Why not Class Cast Exception?

public class GenericTest {

    public void test() {
        String test = "test";
        
        Wrapper<Integer> wrapped = wrapping(test);
        
        String mapped = mapping(wrapped, String.class);
        System.out.println("mapped = " + mapped);   // "test"
    }

    private <T> Wrapper<T> wrapping(String sample) {
        return new Wrapper<>((T) sample);
    }

    private <S, T> T mapping(Wrapper<S> wrapped, Class<T> destinationType) {
        ModelMapper modelMapper = new ModelMapper();

        S body = wrapped.getBody(); // why not CCE? (1)
        return modelMapper.map(body, destinationType);
    }

    public static class Wrapper<T> {
        private final T body;

        public Wrapper(T body) {
            this.body = body;
        }

        public T getBody() {
            return body;
        }
    }
}

이 코드를 실행하면 에러 없이 system.out 으로 "mapped = test" 가 출력된다.

  • mapping 메서드에서 S 는 Integer, T 는 String 이다.
  • (1) 번 부분에서 getBody() 를 실행해 S 에 넣는다.
  • 첫번째 의문에서 봤던 상황과 동일한 상황이 아닌가? 왜 CCE 가 발생하지 않지?

결과적으로 같은 이유 때문이다.

  • 런타임 때 <S>, <T> 는 모두 소거된다. (사라진다.)
  • 런타임 때 ST 는 Object 로 소거된다.

즉,

    private <S, T> T mapping(Wrapper<S> wrapped, Class<T> destinationType) {
        ModelMapper modelMapper = new ModelMapper();

        S body = wrapped.getBody(); // why not CCE? (1)
        return modelMapper.map(body, destinationType);
    }
    
    ...
    
        public T getBody() {
            return body;
        }

이 코드가

    private Object mapping(Wrapper wrapped, Class destinationType) {
        ModelMapper modelMapper = new ModelMapper();

        Object body = wrapped.getBody(); // why not CCE? (1)
        return modelMapper.map(body, destinationType);
    }
    
    ...
    
        public Object getBody() {
            return body;
        }    

이렇게 바뀐다는 것이다.
따라서 아무 문제없이 동작한다.

"test" 는 Object 형태로 계속 돌아다니다가 최종적으로 다시 String 으로 변환될 것이다.

정리

다시 정리해보자.

public class GenericTest {

    public void test() {
        String test = "test";
        
        Wrapper<Integer> wrapped = wrapping(test);
        
        // Integer body = wrapped.getBody();   // CCE! (1)
        // System.out.println("body = " + body);
        
        String mapped = mapping(wrapped, String.class);
        System.out.println("mapped = " + mapped);   // "test"
    }

    private <T> Wrapper<T> wrapping(String sample) {
        return new Wrapper<>((T) sample);   // why not CCE? (2)
    }

    private <S, T> T mapping(Wrapper<S> wrapped, Class<T> destinationType) {
        ModelMapper modelMapper = new ModelMapper();

        S body = wrapped.getBody(); // why not CCE? (3)
        return modelMapper.map(body, destinationType);
    }

    public static class Wrapper<T> {
        private final T body;

        public Wrapper(T body) {
            this.body = body;
        }

        public T getBody() {
            return body;
        }
    }
}
  • 사실 여기서 Wrapper<> 안에 들어가는 타입은 아무런 의미를 갖지 않는다!
  • 왜냐하면 결국 소거되어 버리기 때문!!!
  • Boolean 이든, Double 이든, 뭐든 상관 없다.
  • 그 타입과 무관하게 다시 String 으로 mapping 되어 출력될 것이다.

타입 소거를 기억하자.

  • 런타임 때 <T>(T) 는 모두 소거된다. (사라진다.)
    • 따라서 컴파일 타임에는 명시적 형변환 (T) 가 의미있지만, 런타임 때는 소거되므로 아무런 의미를 갖지 못 한다.
  • 런타임 때 T 는 Object 로 소거된다.

이러한 타입 소거로 인한 제네릭의 특징을 비 구체화(non-reify) 라고 한다.

  • 구체화(reify) : 런타임에도 자신의 타입 정보를 알고 있는 것
  • 비 구체화(non-reify) : 런타임 때는 소거되어 컴파일 때보다 적은 정보를 가지고 있는 것

p.s. 제네릭 하면 공변과 불공변이라는 개념도 있는데, 이 부분은 다음에 정리해보겠다.

profile
더 빠르게 더 많이 성장하고 싶은 개발자입니다

0개의 댓글