...
제네릭 타입 소거 (Erasure)
제네릭은 타입 안정성을 보장하며, 실행시간에 오버헤드가 발생하지 않도록 하기위해 JDK 1.5부터 도입된 문법으로, 이전 자바에서는 제네릭 타입 파라미터 없이 자바를 코딩해왔다. 그래서 이전의 자바 버전의 코드와 호환성을 위해 제네릭 코드는 컴파일되면 제네릭 타입은 사라지게 된다.
즉, 클래스 파일(.class)에는 제네릭 타입에 대한 정보는 존재하지 않는 것이다.
컴파일 타임에만 타입 제약 조건을 정의하고, 런타임에는 타입을 제거하기 때문에, 어찌보면 제네릭은 반쪽 짜리 언어 문법이라고 할수 있다. 그래서인지 제네릭을 개발자가 잘못된 방향으로 설계를 하면 잠재적인 힙 오염(heap pollution) 문제에 빠지게 되는 잠재적인 위험성을 가지고 있다. 따라서 올바르게 제네릭을 설계하기 위해선 제네릭의 컴파일 과정을 한번 쯤 알아둘 필요성이 있다.
Reifiable 와 Non-Reifiable
실체화 타입(Reifiable Type) 이란 컴파일 단계에서 타입소거에 의해 지워지지 않는 타입 정보를 말한다. 일반적인 타입이 그 예시이다.
- int, double, float, byte 등 원시 타입
- Number, Integer 등 일반 클래스와 인터페이스 타입
- List, ArrayList, Map 등 자체 Raw type
- List<?>, ArrayList<?> 등 비한정 와일드카드가 포함된 매개변수화 타입
참고로 비한정적 와일드카드 <?>는 애초에 타입 정보를 전혀 명시하지 않은 것이므로, 컴파일시에 타입 소거를 한다고 해도 잃을 정보가 없기 때문에 실체화 타입이라 볼 수 있는 것이다. 컴파일 시점에 Object로 변환된다.
비실체화 타입(Non-Reifiable Type) 이란 컴파일 단계에서 타입소거에 의해서 타입 정보가 제거된 타입을 말한다. 제네릭 타입 파라미터는 모두 제거 된다고 보면 된다
- List<T>, List<E>
- List<Number>, ArrayList<String>
- List<? extends Number>, List<? super String>
제네릭 타입 소거 과정
컴파일러는 제네릭 타입을 이용해서 소스 파일을 체크하고 개발자가 지정한 코드에 따라 필요한 곳에 형변환을 넣어주고 최종적으로 컴파일 코드에 Type Erasure로 제네릭 타입을 제거하게 된다.
제네릭 클래스 Erasure
1. 제네릭 타입의 경계(bound)를 제거한다.
- 제네릭 타입 <T extends Number> 이면, 하위의 T는 Number 로 치환된다.
- 제네릭 타입 <T> 는 Object로 치환된다.
/* 치환 전 */
class Box<T extends Number> {
List<T> list = new ArrayList<>();
void add(T item) {
list.add(item);
}
T getValue(int i) {
return list.get(i);
}
}
/* 치환 후 */
class Box {
List list = new ArrayList(); // Object
void add(Number item) {
list.add(item);
}
Number getValue(int i) {
return list.get(i);
}
}
2. 제네릭 타입을 제거한 후 타입이 일치하지 않는 곳은 형변환을 추가한다.
getValue()메서드 부분에서, List의get()은 Object타입을 반환하므로 Number로 형변환이 필요하다.
/* 치환 전 */
class Box {
List list = new ArrayList(); // Object
void add(Number item) {
list.add(item);
}
Number getValue(int i) {
return list.get(i); // 형변환 불일치 (Number != Object)
}
}
/* 치환 후 */
class Box {
List list = new ArrayList(); // Object
void add(Number item) {
list.add(item);
}
Number getValue(int i) {
return (Number) list.get(i); // 캐스팅 연산자 추가
}
}
제네릭 메서드 Erasure
제네릭 메서드도 타입 소거 과정은 똑같이 진행된다.
/* 치환 전 */
public static <T> int count(T[] anArray, T elem) {
int cnt = 0;
for (T e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
/* 치환 후 */
public static int count(Object[] anArray, Object elem) {
int cnt = 0;
for (Object e : anArray)
if (e.equals(elem))
++cnt;
return cnt;
}
Bridge 메서드
컴파일러는 확장된 제네릭 타입에 대해서 타입 소거를 해도 다형성을 보존하게 하기위해 별도의 bridge method 생성한다.
예를들어 Node 라는 제네릭 클래스가 있고 이를 Integer 서브 타입으로서 상속하는 자식 IntegerNode 일반 클래스가 있다고 하자. IntegerNode 는 Node<Integer>의 setData() 메서드를 오버라이딩(Overriding) 하여 재정의 하였다.
class Node<T> {
public T data;
public Node(T data) {
this.data = data;
}
public void setData(T data) {
System.out.println("Node<T> 클래스의 메서드 호출");
this.data = data;
}
}
class IntegerNode extends Node<Integer> {
public IntegerNode(Integer data) {
super(data);
}
@Override
public void setData(Integer data) {
System.out.println("IntegerNode 클래스의 메서드 호출");
this.data = data + 1000;
}
}
다음 실행부는 런타임 예외(Runtime Exception)가 발생하는 코드이다. 왜냐하면 Integer를 다루는 IntegerNode의 setData() 메서드의 인자로 문자열을 주었기 때문이다. 따라서 에러가 나야 올바른 코드인 것이다.
public static void main(String[] args) {
IntegerNode int_node = new IntegerNode(1);
Node node = int_node; // 업캐스팅
node.setData("Hello");
// ! IntegerNode.setData() 는 정수 타입만 받기 때문에 ClassCastException
}
그런데 왜 컴파일 에러는 뜨지 않을 걸까? IntegerNode를 부모 객체인 Node에 업캐스팅을 하였기 때문이다. (다형성)
즉, 타입 자체는 제네릭 setData(T data) 로 인식되어서 컴파일 상으로는 문제가 없는 것이다. 다만, 값 자체는 setData()Integer data) 이기 때문에 실행하면 런타임 에러가 발생된다.
지금부터 알아볼 것은 제네릭 타입을 소거해도 어떻게 위의 실행부가 잘못됬는지 컴파일러가 인식하는가 에 대한 증명 과정이다.
위의 코드를 컴파일하여 타입 소거 과정이 진행되게 되면 제네릭 타입이 Object로 변경 되면서 아래와 같이 구성 된다.
class Node {
public Object data;
public Node(Object data) {
this.data = data;
}
public void setData(Object data) {
System.out.println("Node<T> 클래스의 메서드 호출");
this.data = data;
}
}
class IntegerNode extends Node {
public IntegerNode(Integer data) {
super(data);
}
public void setData(Integer data) {
System.out.println("IntegerNode 클래스의 메서드 호출");
this.data = data + 1000;
}
}
그런데 위의 타입 소거된 코드를 이전 실행부에 실행하면 이상하게 정상적으로 컴파일이 된다. 제네릭 타입을 소거했는데 오히려 옳지 않은 코드가 실행이 되어버린 것이다. (이게 당최 뭔일 🥴?)
public static void main(String[] args) {
IntegerNode int_node = new IntegerNode(1);
Node node = int_node; // 업캐스팅
node.setData("Hello");
}
이러한 현상이 발생한 이유는 부모와 자식 클래스에 있는 setData 메서드가 타입이 소거되면서 오버라이딩(Overriding)이 아닌 오버로딩(Overloading) 처리가 되어 버렸기 때문이다.
개발자의 의도는 부모 객체인 Node 를 IntegerNode가 상속을 하고 부모의 setData를 Integer 값만 받는 setData로 오버라이딩 시켜 실행부에서는 무조건 정수값만 받게 하려는 것인데, 타입 소거가 되면서 되려 오버로딩 되면서 뭔가 논리가 이상해져 버린 것이다.
따라서 이러한 간극을 없애기 위해 컴파일러는 런타임에 해당 제네릭 타입의 타임 소거를 위한 bridge method를 생성하게 해준다.
class IntegerNode extends Node {
public IntegerNode(Integer data) {
super(data);
}
// Bridge Method
public void setData(Object data) {
this.setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("IntegerNode 클래스의 메서드 호출");
this.data = data + 1000;
}
}
이렇게 되면 문자열 값이 들어오면 먼저 Bridge 메서드에서 받고 바로 내부 Integer 값을 받는 setData를 내부 호출해버리기 때문에 정상적으로 ClassCastException 예외가 발생되게 된다.
# 참고자료
https://www.devinline.com/2014/02/how-generics-works-internally-in-java.html
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.