정적 타입 언어에서의 타입 소거와 런타임 타입 유지(실재화)를 C, C++, Java, Python 예제로 비교해 설명하고, 특히 Java 제네릭이 소거 기반으로 구현되는 이유와 그 한계를 직관적으로 소개한다.
이 글에서는 프로그래밍 언어에서의 타입 소거(type erasure)와 실재화(reification) 개념을 이야기해 보려 합니다. 특정 언어의 구체적인 규칙을 깊게 파고들 생각은 없습니다. 대신 여러 언어의 간단한 예시를 통해, 필요하다면 더 진지한 공부로 나아갈 수 있을 만큼의 직관과 배경을 제공하려 합니다. 보시다시피 개념 자체는 아주 단순하고 익숙합니다. 각 언어의 더 깊은 디테일은 그 언어의 의미 체계와 구현상의 특이점에 더 가깝습니다.
중요한 참고: C++에는 _type erasure_라 불리는 프로그래밍 패턴이 있는데, 이것은 여기서 말하는 개념과는 꽤 다릅니다 [1]. 이 글에서도 C++ 예시를 쓰지만, 그것은 여기서 다루는 원래 개념이 C++에 어떻게 적용되는지를 보여 주기 위한 것입니다. 해당 프로그래밍 패턴은 별도의 글에서 다루겠습니다.
이 섹션의 제목은 타입 소거가 무엇인지 “한 줄 요약”입니다. 몇 가지 예외를 빼면, 이는 컴파일 타임(즉, 정적) 타입 검사가 어느 정도 있는 언어에만 적용됩니다. 기본 원리는 C 같은 저수준 언어에서 생성된 기계어가 어떻게 생겼는지 조금이라도 아는 분께는 바로 익숙할 것입니다. C는 정적 타입 언어이지만, 이 사실은 오직 컴파일러에게만 중요합니다. 생성된 기계 코드는 타입에 대해 전혀 알지 못합니다.
예를 들어, 다음 C 코드 조각을 보세요:
typedef struct Frob_t {
int x;
int y;
int arr[10];
} Frob;
int extract(Frob* frob) {
return frob->y * frob->arr[7];
}
함수 extract를 컴파일할 때 컴파일러는 타입 검사를 수행합니다. 예컨대 struct에 선언되지 않은 필드에 접근하는 코드를 허용하지 않습니다. 또한 extract에 다른 struct(혹은 float)의 포인터를 넘기는 것도 허용하지 않습니다. 하지만 우리를 이렇게 도와준 다음, 컴파일러가 생성하는 코드는 타입을 전혀 고려하지 않습니다:
0: 8b 47 04 mov 0x4(%rdi),%eax
3: 0f af 47 24 imul 0x24(%rdi),%eax
7: c3 retq
컴파일러는 스택 프레임 레이아웃과 ABI의 기타 세부사항을 알고 있으며, 올바른 타입의 구조체가 전달되었다고 가정하는 코드를 생성합니다. 실제 타입이 이 함수가 기대하는 것과 다르면 문제가 생깁니다(매핑되지 않은 메모리에 접근하거나, 잘못된 데이터를 읽는 등).
약간만 바꾼 예시가 이를 더 분명히 보여 줍니다:
int extract_cast(void* p) {
Frob* frob = p;
return frob->y * frob->arr[7];
}
컴파일러는 이 함수로부터도 완전히 동일한 코드를 생성합니다. 이는 타입이 언제 중요하고 언제 중요하지 않은지 알려 주는 좋은 신호입니다. 더 흥미로운 점은 extract_cast가 프로그래머가 스스로 발등을 찍기 매우 쉽게 만든다는 것입니다:
SomeOtherStruct ss;
extract_cast(&ss); // 이런 실수
일반적으로 _타입 소거_는 언어의 이러한 의미론을 설명하는 개념입니다. 타입은 컴파일러에게 중요하며, 컴파일러는 이를 이용해 코드를 생성하고 프로그래머가 오류를 피하도록 돕습니다. 그러나 일단 모든 것이 타입 검사되면, 타입은 단순히 지워지고 컴파일러가 생성한 코드는 타입을 전혀 신경 쓰지 않습니다. 다음 섹션에서는 정반대 접근과 비교하여 맥락을 잡아 보겠습니다.
소거가 의미하는 바가 컴파일러가 실제 생성된 코드에서 모든 타입 정보를 버린다는 것이라면, _실재화_는 그 반대입니다. 즉, 런타임에도 타입을 유지하여 다양한 검사를 수행합니다. 고전적인 Java 예시로 이를 살펴보죠:
class Main {
public static void main(String[] args) {
String strings[] = {"a", "b"};
Object objects[] = strings;
objects[0] = 5;
}
}
이 코드는 String 배열을 만들고, 그것을 일반적인 Object 배열로 변환합니다. Java에서 배열은 공변이기 때문에 유효하며, 컴파일러는 불평하지 않습니다. 그러나 다음 줄에서 정수를 배열에 대입하려 하면, 이는 _런타임_에 예외가 발생합니다:
Exception in thread "main" java.lang.ArrayStoreException: java.lang.Integer
at Main.main(Main.java:5)
생성된 코드 안에 타입 검사가 삽입되어 있으며, 잘못된 대입이 시도되었을 때 동작한 것입니다. 다시 말해, objects의 타입이 _실재화_되어 있습니다. 실재화는 대략 “추상적인 것을 실제/구체로 만드는 것”으로 정의되며, 타입에 적용하면 “컴파일 타임의 타입이 실제 런타임 개체로 변환된다”는 뜻입니다.
C++에도 어느 정도 타입 실재화가 있습니다. 예를 들어 dynamic_cast가 그렇습니다:
struct Base {
virtual void basefunc() {
printf("basefunc\n");
}
};
struct Derived : public Base {
void derivedfunc() {
printf("derived\n");
}
};
void call_derived(Base* b) {
Derived* d = dynamic_cast<Derived*>(b);
if (d != nullptr) {
d->derivedfunc();
} else {
printf("cast failed\n");
}
}
호출은 다음과 같이 할 수 있습니다:
int main() {
Derived d;
call_derived(&d);
Base b;
call_derived(&b);
}
첫 번째 호출은 derivedfunc를 성공적으로 호출합니다. 두 번째 호출은 그렇지 못하는데, dynamic_cast가 런타임에 nullptr을 반환하기 때문입니다. 여기서는 C++의 런타임 타입 정보 (RTTI) 기능을 사용하고 있습니다. 실제 타입 표현이 생성된 코드에 저장되어 있는데(대개는 모든 다형 객체가 가리키는 vtable에 붙어 있습니다), 이를 이용하는 것입니다. C++에는 typeid 기능도 있지만, 여기서는 더 자주 쓰이는 dynamic_cast를 보여 주었습니다.
특히 이 예시와 글 서두의 C 예시의 차이를 주의 깊게 보세요. 개념적으로는 비슷합니다. 일반적인 타입의 포인터(C에서는 void*, C++ 예시에서는 베이스 타입)를 사용해 구체적인 타입과 상호작용합니다. C에는 내장된 런타임 타입 기능이 없지만, C++에서는 일부 경우 RTTI를 사용할 수 있습니다. RTTI가 활성화되어 있으면 dynamic_cast를 사용해 런타임(실재화된) 타입 표현과 상호작용할 수 있는데, 제한적이지만 유용합니다.
프로그래밍 언어의 타입 이론에 익숙하지 않더라도, 많은 분이 타입 소거를 접하는 지점이 바로 Java 제네릭입니다. 제네릭은 언어에 뒤늦게 덧붙여졌고, 이미 많은 코드가 작성된 후였습니다. Java 설계자들은 이진 호환성이라는 과제에 직면했는데, 새로운 컴파일러로 컴파일된 코드가 오래된 VM에서도 돌아가길 원했습니다.
해결책은 제네릭을 전적으로 컴파일러 안에서 타입 소거를 사용해 구현하는 것이었습니다. 다음은 공식 Java 제네릭 튜토리얼의 인용문입니다:
제네릭은 컴파일 타임에 더 엄격한 타입 검사를 제공하고 제네릭 프로그래밍을 지원하기 위해 Java 언어에 도입되었습니다. 제네릭을 구현하기 위해 Java 컴파일러는 타입 소거를 적용합니다:
- 제네릭 타입의 모든 타입 매개변수를 그 경계(bound)나, 경계가 없다면 Object로 대체합니다. 따라서 생성된 바이트코드는 일반 클래스, 인터페이스, 메서드만을 포함합니다.
- 타입 안전성을 유지하기 위해 필요한 경우 타입 캐스트를 삽입합니다.
- 제네릭 타입을 확장한 경우 다형성을 보존하기 위해 브리지 메서드를 생성합니다.
다음은 무슨 일이 일어나는지 보여 주는 아주 단순한 예시로, Stack Overflow 답변에서 가져왔습니다. 이 코드는:
import java.util.List;
import java.util.ArrayList;
class Main {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("Hi");
String x = list.get(0);
System.out.println(x);
}
}
제네릭 List를 사용합니다. 하지만 컴파일러가 바이트코드를 내보내기 전에 만들어 내는 코드는 다음과 동등합니다:
import java.util.List;
import java.util.ArrayList;
class Main {
public static void main(String[] args) {
List list = new ArrayList();
list.add("Hi");
String x = (String) list.get(0);
System.out.println(x);
}
}
여기서 List는 Object의 컨테이너이므로, 임의의 요소를 대입할 수 있습니다(앞 섹션의 실재화 예시와 유사합니다). 그리고 컴파일러는 그 요소를 문자열로 접근할 때 캐스트를 삽입합니다. 이 경우 컴파일러는 타입 안전성을 철저히 보존하므로, 원래 코드에서 list가 List<String>임을 보고 list.add(5)를 허용하지 않습니다. 따라서 (String) 캐스트는 안전해야 합니다.
타입 소거를 사용해 제네릭을 과거 버전과의 호환성을 유지한 채 구현하는 아이디어는 깔끔하지만, 문제가 전혀 없는 것은 아닙니다. 런타임에 타입을 사용할 수 없다는 점(예: instanceof 및 기타 리플렉션 기능을 사용할 수 없음)을 한계로 지적하는 이들도 있습니다. C#과 Dart 2 같은 다른 언어들은 런타임에도 타입 정보를 보존하는 _실재화된 제네릭(reified generics)_을 제공합니다.
위에서 설명한 이론과 기술은 정적 타입 언어에만 적용된다는 것이 자명하길 바랍니다. Python 같은 동적 타입 언어에서는 컴파일 타임 타입 개념이 거의 없고, 타입은 완전히 실재화된 개념입니다. 다음과 같은 사소한 오류조차도:
class Foo:
def bar(self): pass
f = Foo()
f.joe() # <--- 존재하지 않는 메서드 호출
런타임에 터집니다. 정적 타입 검사가 없기 때문입니다 [2]. 타입은 런타임에 명백히 존재하며, type()과 isinstance() 같은 함수가 완전한 리플렉션 기능을 제공합니다. 심지어 type() 함수는 완전히 런타임에 새로운 타입을 생성할 수도 있습니다.
[1]하지만 “c++ type erasure”로 검색하면 아마 그 패턴이 먼저 나올 것입니다.
[2]명확히 하자면—이건 버그가 아니라 Python의 특징입니다. 새로운 메서드는 런타임에 동적으로 클래스에 추가될 수 있습니다(여기서도 f.joe() 호출 전에 어떤 코드가 Foo에 joe 메서드를 정의했을 수 있습니다). 컴파일러는 이런 일이 일어날 수도 있고 안 일어날 수도 있다는 사실을 전혀 알 방법이 없습니다. 따라서 이런 호출이 유효하다고 가정해야 하며, 메모리 손상 같은 심각한 오류를 피하기 위해 런타임 검사를 신뢰해야 합니다.