자바의 타입 소거

자바 컴파일러는 제네릭 클래스나 함수를 일반 클래스나 함수로 변환합니다. 이 과정에서 타입 매개 변수를 타입 선언에 정의된 한정적 타입(bounded type)이나 Object 클래스로 대체하는데, 이를 타입 소거(Type Erasure)라고 합니다.
선수지식
이 글에서 논의할 개념을 이해하려면 타입 시스템과 제네릭에 대한 기본 지식이 필요합니다. 타입 소거를 확실히 파악하기 위해서 기본 사항을 먼저 살펴보는 것이 좋습니다.
타입 소거가 중요한 이유는 무엇인가요?
타입 소거는 다른 타입을 위한 추가적인 클래스가 생성되지 않도록 보장합니다.
필요한 곳에 자동 형변환을 수행하여 타입 안정성을 유지합니다.
타입 안정성과 함께 다형성을 지원하기 위해 합성 함수를 생성합니다.
다음과 같은 상황에서의 타입 소거를 논의하려고 합니다.
특정 타입 매개변수를 갖는 제네릭 클래스나 함수
한정적 타입 매개변수를 갖는 제네릭 클래스나 함수
제네릭 클래스를 상속받는 클래스
특정 타입 매개변수를 갖는 제네릭 클래스나 함수에서의 타입 소거
모든 타입의 요소를 담을 수 있는 컨테이너 클래스를 만들어 보겠습니다.
// 어떤 타입의 요소든 저장할 수 있는 제네릭 클래스
class Container<T>{
T value;
Container(T value){this.value = value;}
public void setValue(T value) {
this.value = value;
}
}
타입 소거를 하게 되면 컴파일러는 해당 클래스를 바이트 코드로 변환합니다. 이 과정에서 모든 타입 매개변수를 제거하고 Object 클래스로 대체합니다.
// 컴파일러에 의한 타입 소거 후의 클래스
class Container{
Object value; // Object로 대체된 타입 T
Container(Object value){this.value = value;}
public void setValue(Object value) {
this.value = value;
}
}
컴파일러는 제네릭 함수에도 동일하게 작동합니다. 타입 소거 전후의 제네릭 함수를 살펴보겠습니다.
// 어떤 타입의 요소든 출력할 수 있는 제네릭 함수
public <T> void printValue(T value) {
System.out.println(value);
}
타입 소거 후는 다음과 같습니다.
// 컴파일러에 의한 타입 소거 후의 함수
public void printValue(Object value) { // Object로 대체된 타입 T
System.out.println(value);
}
한정적 타입 매개변수를 갖는 제네릭 클래스나 함수에서의 타입 소거
클래스나 함수 선언에 한정적 타입 매개변수가 있다면 컴파일러는 타입을 한정적 타입으로 대체합니다.
// 상한 타입을 갖는 제네릭 클래스
// 타입은 Number거나 Number의 하위 타입이어야 합니다.
class Container<T extends Number>{
T value;
Container(T value){this.value = value;}
public void setValue(T value) {
this.value = value;
}
}
타입 소거 후는 다음과 같습니다.
// 컴파일러에 의한 타입 소거 후의 컨테이너 클래스
class Container {
// 타입 T는 상한 타입인 Number로 대체되었습니다.
Number value;
Container(Number value) {
this.value = value;
}
public void setValue(Number value) {
this.value = value;
}
}
제네릭 클래스를 상속받는 클래스에서의 타입 소거
다음 예시를 고려해 봅시다. 두 개의 클래스가 있습니다. 첫 번째는 어떤 타입의 데이터가 주어지더라도 저장할 수 있는 제네릭 클래스 Node입니다. 두 번째는 제네릭이 아닌 비제네릭 클래스인데, 문자열 매개변수만 저장할 수 있는 클래스 Node를 상속하고 있습니다.
// 모든 타입의 값을 저장할 수 있는 제네릭 클래스
class Node<T>{
T value;
Node(T value){
this.value = value;
}
//제네릭 클래스의 setter 메서드
public void setValue(T value) {
this.value = value;
}
}
// 문자열 타입 값만 저장할 수 있는 비제네릭 클래스
class StringNode extends Node<String>{
StringNode(String value) {
super(value);
}
// node 값을 설정하는 setter 메서드
@Override
public void setValue(String value) {
super.setValue(value);
}
}
이전 섹션에서 타입 소거의 몇 가지 예시에 대해 논의해 보았습니다. 이제 여태까지 해왔던 대로 타입 소거 후의 클래스 구조를 살펴보겠습니다.
컴파일러는 클래스 Node와 클래스 StringNode에서 타입을 소거한 후 다음과 같은 비 제네릭 클래스를 생성하게 됩니다.
// 컴파일러에 의한 타입 소거 후의 클래스 Node
class Node{
Object value;
Node(Object value){
this.value = value;
}
// 타입 소거 후 제네릭 클래스의 setter 메서드
public void setValue(Object value) {
this.value = value;
}
}
// 컴파일러에 의한 타입 소거 후의 클래스 StringNode
class StringNode extends Node{
StringNode(String value) {
super(value);
}
// node 값을 설정하는 setter 메서드
public void setValue(String value) {
super.setValue(value);
}
}
보시다시피 클래스 StringNode의 setValue(String value) 메서드는 클래스 Node의 setValue(Object value) 메서드를 오버라이딩하지 않습니다. 두 메서드의 매개변수 타입이 다르기 때문입니다. 이는 다형성 규칙을 위반하는 것입니다.
예시의 도움을 받아 이것이 실질적으로 어떤 문제로 이어지는지 이해해 봅시다.
StringNode stringNode = new StringNode("Hello");
Node node = stringNode;
node.setValue(5);
StringNode의 인스턴스를 생성하여 Node의 원시 타입 참조에 할당하였습니다. 이는 Node가 StringNode의 부모 클래스이기 때문에 문제가 되지 않습니다. 문자열 타입이 아닌 객체를 삽입하려고 한다고 해도 클래스 Node의 setValue(Object value) 메서드가 있기 때문에 이 또한 허용됩니다. 그러나 사실 객체 삽입이 허용되어서는 안 됩니다. 우리가 생성한 StringNode 인스턴스는 오직 문자열만 저장해야 하기 때문입니다.
이 문제는 타입 소거 후 StringNode의 setValue가 부모의 메서드를 위임하지 않기 때문에 발생하는 것입니다. 타입 소거 후 클래스 구조를 생성하는 과정에서 무언가를 놓쳤다는 것을 의미하는 것이죠. 컴파일러는 이러한 문제를 합성 메서드를 만들어 해결하는데, 이를 Bridge 메서드라고 합니다.
매개변수화된 클래스를 상속하거나 매개변수화된 인터페이스를 구현하는 클래스 또는 인터페이스를 컴파일할 때, 컴파일러는 타입 소거 과정의 한 부분으로 브리지 메서드라는 합성 메서드를 생성할 수 있습니다.
클래스 StringNode의 실제 클래스 구조는 다음과 같이 Bridge 메서드를 포함하는 형태로 나타날 것입니다.
// 컴파일러에 의한 타입 소거 후의 클래스 StringNode
class StringNode extends Node{
StringNode(String value) {
super(value);
}
// 다형성을 유지하도록 하는 Bridge 메서드
@Override
public void setValue(Object value){
super.setValue((String)value);
}
// node 값을 설정하는 setter 메서드
public void setValue(String value) {
super.setValue(value);
}
}
이제 Object 매개변수를 가진 setValue 메서드 호출은 매개변수를 문자열로 변환하려고 시도하고 그것이 실패하면 ClassCastException을 던집니다.
결론
제네릭은 타입 안정성을 제공하는 데 사용되며 자바 컴파일러는 코드를 컴파일한 후 적절한 타입으로 매개변수 타입을 제거합니다. 컴파일러가 타입 안정성과 다형성을 지원하기 위한 다른 모든 작업들을 수행하는 것입니다.
제네릭의 내부 작동 방식을 명확히 이해함으로써 예상치 못한 런타임 문제를 일으키는 실수를 방지하며 보다 효율적인 코드를 작성할 수 있습니다.




