RTTI와 CTTI의 비용 구조를 비교하며, RTTI는 선형적으로 확장되는 반면 CTTI는 최악의 경우 지수적으로 확장되는 이유를 설명합니다.
2026-09-02
런타임 타입 정보(RTTI)에는 비용이 있지만, “무비용”으로 팔리는 컴파일 타임 타입 정보(CTTI)와 비교하면 그 비용은 온순한 편이며, CTTI는 결코 무비용이 아니다.
N개 타입 -> N개 항목). 한 번의 비용이다.측정할 수 있는 선형 메모리 비용과 측정할 수 없는 지수적 컴파일 시간 비용 사이에는 절충이 있는데, 사람들은 후자를 “무비용”이라고 광고하며 지지한다.
내가 자주 받는 질문은 Odin이 형식화된 출력 같은 기능에서 왜 컴파일 타임 타입 정보(CTTI) 대신 런타임 타입 정보(RTTI)를 사용하느냐는 것이다. 그것은 추가 런타임 비용 아닌가? 또 CTTI와 관련된 모든 것은 “무비용” 아닌가? 당연히 모든 계산을 컴파일 타임에 구워 넣어서 컴파일러가 그 타입 집합에 맞게 최적화하도록 해야 하는 것 아닌가?
“컴파일 타임에 알려짐”과 “컴파일 타임에 열거하는 비용이 없음” 사이에는 구분해야 할 미묘한 차이가 있으며, 둘을 혼동하는 것은 범주 오류다. 이런 종류의 이해 부족은 하나의 대상, 하나의 인스턴스화에 대해 국소적으로 추론하고, 집합 전체나 그러한 시스템의 창발적 특성에 대해서는 한 번도 생각하지 않기 때문에 생긴다.
이 글에서는 둘의 실제 비용을 설명해 보려 한다. 이를 대략적으로라도 설명하는 사람은 극히 적어 보인다.
RTTI는 일반적으로 테이블에 저장되며, Odin도 다르지 않다. 프로그램의 각 타입은 이 테이블에 저장된다. 여기서 “각 타입”은 언어로 표현할 수 있는 모든 타입이나 프로그램에서 표현된 모든 타입조차 아니라, RTTI 용도로 실제 정보가 보존되고 사용되는 각 타입을 뜻한다. RTTI 테이블은 해당 타입의 크기, 정렬, 종류, 필드, 그리고 언어나 언어 설계자가 유지할 가치가 있다고 결정한 모든 정보를 저장한다. 궁극적으로 타입은 그저 _데이터_이므로, 이 집합은 유한하고 알려져 있다.
RTTI의 비용은 RTTI를 처리하는 프로시저, 테이블, 의미 검사라는 몇몇 위치에 분산된다.
fmt.println으로 값을 출력하거나 일반적으로 무엇이든 직렬화/역직렬화할 때, 타입 테이블에 참조된 타입을 순회하는 하나의 프로시저(또는 프로시저 집합)를 호출한다. 프로그램에 타입이 더 많아진다고 해서 그 프로시저의 코드가 커지지 않으며, 링커가 타입별로 이를 복제할 필요도 없고, 전달된 새로운 타입 조합마다 더 많은 _코드_를 만들지도 않는다. 동일한 코드가 타입 테이블의 타입 정보만 읽는다.
이 런타임 타입 정보를 처리하는 _코드_는 항상 동일한 크기와 형태를 가지며, 증가하는 것은 그것이 읽는 타입 테이블 자체뿐이다(그리고 말했듯이 그것은 선형적으로 증가한다).
타입 테이블의 복잡도는 선형이다. N개 타입은 N개 항목을 제공한다. 일반적으로(그리고 바라건대) 이 타입 테이블은 읽기 전용 데이터 섹션(예: Odin의 @(rodata))에 위치하므로, 이 테이블의 비용은 컴파일 시점, 바이너리, 실행 프로그램의 메모리에서 한 번 지불된다. 그러면 바이너리를 직접 보고 그 테이블이 바이너리에서 몇 바이트를 차지하는지 확인할 수 있다. 이 모든 것은 아주 쉽게 측정할 수 있다. RTTI를 싫어한다고 말하는 많은 사람이 비용이 인용할 만큼 단순하기 때문에 숫자를 제시할 수 있는 이유가 바로 그것이다. CTTI에 대해서는 같은 말을 쉽게 할 수 없지만, 나중에 다루겠다.
RTTI에서는 의미적으로 추가 “검사”할 것이 없다. 검사기는 any 타입(또는 해당 언어에서 타입이 소거된 핸들 타입)의 매개변수를 받는 프로시저 하나만 검사하고, 이를 한 번 타입 검사한 뒤 사용되었다고 판단한다. 이 경우 타입별 코드가 없으므로 타입마다 무엇이든 재검사할 필요가 없다.
RTTI는 테이블 크기에 대해서는 선형이고, 작성/생성되는 코드 양에 대해서는 상수이며, 의미 검사에서는 “0”이다.
따라서 최악의 경우에도 RTTI는 선형적으로 확장된다. 비용은 (분명히) 테이블 간접 참조, 직렬화 프로시저의 코드 크기, 특정 타입을 다루는 코드에 대한 특수 최적화 부재라는 측면에서 순전히 런타임 비용이다.
애초에 CTTI를 사용하는 핵심 이유는 사용하는 모든 위치에서 _코드_를 특수화할 수 있기 때문이다. 연산(예: 출력)을 적용하는 모든 서로 다른 타입은 해당 연산의 생성된 버전을 각자 갖는다. 작은 타입 집합에서는 이것이 완전히 괜찮고, 심지어 멋지다. 컴파일러와 최적화기가 다룰 수 있는 작은 코드 집합이며, 간접 참조가 거의 또는 전혀 없고, 순회할 테이블도 없다. CTTI는 반드시 추가 테이블을 필요로 하지는 않지만, 실제로는 실용적 이유로 대개 어느 정도를 생성한다.
정말로 작은 타입 집합만 있다면 CTTI는 훌륭한 도구가 될 수 있다. 하지만 타입 안전 형식화 출력에 크게 의존하는 작은 프로그램조차도 실제로 그런 작은 경우는 결코 유지되지 않으며, 아주 빠르게 거대해진다. 그래서 오늘날 CTTI를 사용하는 모든 방식에는 어디서나 치러야 하는 비용이 따르며, 그것은 지수적으로 증가한다.
중요한 워크로드에서 CTTI를 사용할 때 그것은 결코 작은 타입 집합만이 아니라 언제나 타입의 _조합_이며, 그 조합론은 폭발하는 경향이 있다. N개 타입에 대한 기본적인 출력기, N개 타입을 K개 형식으로 직렬화하는 직렬화기, 또는 K개의 서로 다른 타입 인자로 매개변수적 다형화된 컨테이너 타입을 예로 들자. 이 예시들이 타입 인자에서 어떤 형태로든 다항이 되는 순간, 즉 여러 값/타입 매개변수, 여러 반환값, 여러 필드를 갖는 순간에는 언어 곳곳에서 이런 조합 폭발이 일어난다.
이는 인스턴스화 수가 단지 N이 아니라 N×K 또는 Nᵏ가 된다는 뜻이다. 당신은 Nᵏ개의 프로시저를 작성하지 않았지만, 컴파일러가 당신을 위해 단일화했다. 이런. 그리고 각 인스턴스화는 서로 다른 대상이므로, 매번 별도로 의미 검사를 거쳐야 하고(C++/Odin 방식의 매개변수적 다형성을 가정할 때), 이어서 매번 별도로 코드가 생성되어야 한다. 이 둘은 컴파일 단계에서 비용이 큰 측면이며, 둘 다 방금 지수적이 되었다. “테이블”(저장과 접근 모두)을 아꼈을지는 모르지만, 컴파일러 내부와 최종 바이너리에서 그 비용을 Nᵏ번 치렀다.
불행히도 이는 너무 많은 프로그래머가 무시하도록 가장 강하게 길들여지고 훈련받은 비용이다. 컴파일러가 할 수 있다면, 해야 하는지 의문을 제기하기보다 컴파일러에게 시키는 편이 낫다고 배웠기 때문이다. 그리고 빌드가 600ms가 아니라 6분이 걸리고 바이너리가 400MiB가 되는 날이 오면, 누구도 그 터무니없는 팽창을 일으킨 코드 줄을 지목할 수 없다. 코드 자체의 조합론이 원인이었으므로 특정 줄 하나일 수는 없다.
CTTI의 확장 복잡도를 계산하는 것은 조금 더 복잡하지만, 여전히 가능하다.
K=0인 경우 프로시저 인스턴스 수는 1이다. K=1인 경우 프로시저 인스턴스의 최대 수는 N이다. K=2인 경우 프로시저 인스턴스의 최대 수는 N²이다. 기타 등등, 이는 N개 타입의 조합 수에 대해 일반화할 수 있다.
따라서 가장 흔한 출력 프로시저의 경우조차도 이는 인스턴스화에 따라 지수적으로 확장된다. 일부 언어는 출력에 이런 순진한 접근법을 쓰면서 문제가 되지 않을 것이라 생각한다. 심지어 “입력마다 인스턴스화는 하나뿐”이라고 말하지만, 내부가 이제 조합적이라는 사실은 잊는다. 그러므로 최선의 경우 이 접근법은 N×K이지만, 최악의 경우 Nᵏ이다. 이를 어떻게 생각하느냐에 따라 거듭제곱 법칙(상수 K) 또는 지수적(상수 N)이라고 볼 수 있지만, 현실적으로 N은 실제로 “고정”되어 있지 않으므로 나는 이를 지수적이라고 생각한다. 어느 쪽이든 선형보다 엄청나게 나쁘다. 출력을 위해 CTTI를 사용하는 일부 언어(예: Rust)는 컴파일러 복잡도 폭발을 최소화하려는 명시적 예외 처리를 컴파일러에 넣어 이 재앙을 완화하려 하지만, 중대형 프로젝트에서 바이너리 문제를 반드시 해결하지는 못한다.
일부 언어는 CTTI 관련 코드 생성을 만들기 위한 명시적 태깅으로 조합 폭발을 완화하여, 대신 곱셈적 복잡도를 강제하기도 한다. 예를 들어 Rust의 serde는 명령줄 인터페이스 매개변수, 그래픽 사용자 인터페이스 양식, 보기 좋은 출력 등에 사용할 수 있다. 나는 이 모든 것을 Odin에서 RTTI와 구조체 필드 태그로 구현하며, 이것이 다루기 훨씬 쉽다고 생각한다.
주. 매개변수적 다형 출력에 대한 순진한 접근법의 문제를 간단한 예로 들어 보겠다. int, float, string, bool처럼 단 4개 타입만 있고 가변 인자 매개변수적 다형 출력 프로시저가 있는 언어를 생각해 보자. 서로 다른 각 인자 타입 시퀀스는 고유한 인스턴스화를 필요로 하므로, K개 인자에 대해서는 대략 Nᵏ = 4ᵏ개의 조합이 있다. 다섯 번째 타입을 추가하면 ~5ᵏ가 된다. (대개 숨겨진) 조합 폭발을 보려면 5개보다 많은 인자를 절대 출력하지 않는다고 가정하자. 그러면 최대 1365개의 인스턴스화가 가능하다. 타입을 하나 더 추가하면 3906개가 된다. 최대 인자 수를 6개로 올리면 19531개가 된다. 적어도 경계가 있고 하나의 출력 프로시저에 대해서는 “그렇다”고 말할 수 있다. “'그렇다'라는 단어의 의미가 무엇인지에 달려 있다.” - Bill Clinton. 하지만 출력 프로시저는 프로그램의 매개변수적 다형성 사용 전부와 쉽게 상호작용하며, 전체 규모는 코드를 읽는 것만으로 쉽게 예측할 수 있는 수준을 빠르게 벗어난다.
내 의견으로는 비용 측면에서 이는 전혀 어려운 선택이 아니다. 2016년에 Odin을 시작했을 때 나는 망설임 없이 곧바로 RTTI를 선택했다. 사람들은 볼 수 있고 문제가 되면 우회할 수도 있는 측정 가능한 선형 메모리 비용을, 볼 수 없는 지수적 컴파일 시간 비용과 맞바꾸는 듯하다. 그리고 두 번째 비용을 볼 수 없다는 이유만으로 그것을 “무비용 추상화”라고 부르도록 스스로를 설득했다. 나는 “무비용”이라는 표현을 정말 싫어한다. 거의 언제나 엄청난 양을 숨기기 때문이다.
나는 하나의 대상을 고립시켜 추론하고 집단에 대해서는 결코 생각하지 않는 습관인 개별 요소 사고방식에 관해 쓴 적이 있다. 언어 설계에서 반사적으로 CTTI를 택하는 것은 바로 그 사고방식이 타입 이론 모자를 쓴 모습이다.
나는 데이터 주도적 접근법(테이블 위에서 동작하는 하나의 프로시저)이기 때문에 Odin에서 RTTI를 선택했다. CTTI 접근법은 매우 코드 주도적인 접근법으로, 데이터가 존재하기를 원하지 않는 척하는 대가로 프로시저의 각 인스턴스화에 대해 더 나은 _코드_를 만들 수도 있다.
비용 논증 위에는 아키텍처 설계 논증도 있으며, 이것이 내가 Odin에서 RTTI를 선택한 큰 이유다.
Odin에서 fmt.println은 RTTI를 통해 모든 것에 동작한다. 모든 형태의 직렬화/역직렬화/마샬링/역마샬링은 RTTI를 사용한다.
Odin의 RTTI에서 아름다운 점 중 하나는 typeid가 결정적이라는 것이다. 타입이 같다고 가정하면 프로그램과 무관하게 타입별로 동일하다. typeid가 무엇이든 재인스턴스화하지 않고 LIB/DLL 경계를 넘을 때, 안정적인 표준 레이아웃을 지닌 _데이터_일 뿐이므로 그 경계를 넘어 동작할 “수” 있다. 동적 라이브러리 경계를 공짜로 넘을 수 없듯이, 양쪽이 동일하게 단일화된 코드를 생성하는 데 동의해야 하는 Nᵏ개의 생성 프로시저는 없다. CTTI에서는 양쪽이 동일한 인스턴스화를 생성했어야 하며, 아니면 당신이 그것을 재현해야 한다. 데이터는 그러한 경계를 아무 문제 없이 넘는다.
이 설계 논증은 사실상 일관성 대 타입별 영리함이다. 모두가 이해하고 그 위에 구축할 수 있는, 균일하게 적용 가능한 하나의 아이디어다. N개의 서로 다른 생성물과 그것을 전부 검사하느라 신음하는 컴파일러가 아니라.
그렇다면 CTTI는 그 대가로 무엇을 주는가?
그리고 내가 웃기는 부분은 중복 제거를 링커에 의존하는 일이 그 주장을 인정한다는 점이다. 그 모든 것을 빌어먹게 생성하고, 전부 검사한 뒤에야 다른 도구에게 시간을 들여 대부분을 버리라고 요청한 것이다… 내가 Odin을 만든 큰 이유는 “일 만들기”라는 그 광기에서 벗어나기 위해서였다.
분명히 하자면, CTTI가 더 나은 절충인 경우도 있다. Odin은 base:intrinsics를 통해 다소 어색하게 사용할 수 있는 형태의 CTTI를 제공한다. 이는 사람들이 사용을 최소화하도록 유도하려는 의도도 일부 있지만, 데이터 주도 설계를 공유하지 않으며 컴파일 타임에 평가되는 프로시저/내장 기능을 통해 순전히 코드에서 파생되기 때문이기도 하다.
위 경우에서 CTTI가 제공하는 특수화는 훌륭하지만, 나는 이런 경우가 생각보다 드물다고 주장하고 싶다.
물론 RTTI에도 자체적인 비용 집합이 있다.
여기서 내 요점은 “CTTI는 나쁘고 RTTI는 좋다”가 아니다. 오히려 많은 사람이 검사, 코드 생성, 바이너리 크기를 고려할 때 CTTI의 비용이 최악의 경우 지수적이고 일반적인 경우 곱셈적이라는 사실을 깨닫지 못한다고 생각한다. CTTI의 이점은 거의 언제나 _국소적_이지만, 전역적 영향을 미친다. 그리고 언어를 설계할 때는 거의 언제나 기본값으로 RTTI를 사용하고, 절대적으로 필요할 때만 CTTI를 사용해야 한다고 주장하고 싶다.
RTTI는 타입 수에 대해 선형 비용을 가지며, 이는 런타임(테이블 조회/간접 참조), 바이너리 크기, 메모리 사용량에서 지불된다. CTTI는 최악의 경우 타입 수에 대해 지수적 비용을, 일반적인 경우 곱셈적 비용을 가지며, 이는 컴파일 타임(검사와 코드 생성) 및 바이너리 크기에서 지불된다.