C++에는 typeiddynamic_cast가 있다. 둘 다 실행 중에 객체의 실제 타입을 알아내는 기능이고, 이를 위해 컴파일러는 **RTTI(run-time type information, 실행 시간 타입 정보)**를 바이너리에 심는다.

RTTI가 무엇인가

RTTI는 실행 중에 객체의 실제 타입을 물어볼 수 있게 해 준다. typeid가 돌려주는 std::type_info 객체로 타입 이름을 얻거나, 두 타입이 같은지 비교한다.

#include <iostream>
#include <typeinfo>
 
struct Animal { virtual ~Animal() = default; };
struct Dog : Animal {};
struct Cat : Animal {};
 
int main() {
  Animal* a = new Dog{};                       // 정적 타입은 Animal*
  const std::type_info& t = typeid(*a);        // 런타임에 실제 타입을 조회
  std::cout << t.name() << '\n';               // 3Dog  ← 실제로는 Dog (앞의 3은 이름 길이)
  std::cout << (typeid(*a) == typeid(Dog)) << '\n';  // 1  ← 같은 타입
  std::cout << (typeid(*a) == typeid(Cat)) << '\n';  // 0
}

a의 정적 타입은 Animal*이지만 typeid(*a)는 실제 타입인 Dog를 알아낸다. 이게 “실행 시간 타입 정보”다 — 컴파일 시점이 아니라 실행 중에 타입을 안다. .name()이 내놓는 3Dog에서 앞의 3은 이름 길이다. g++·clang은 .name()으로 사람이 읽는 이름이 아니라 맹글링된(mangled) 이름을 돌려주는데, Itanium C++ ABI에선 단순 클래스 이름을 <길이><이름>으로 인코딩하기 때문이다(그래서 Dog3Dog, NonPolyBase11NonPolyBase). 표준은 .name()이 “구현이 정한 문자열”만 반환하라고 정해서(implementation-defined) 컴파일러마다 다르다 — MSVC는 class Dog처럼 사람이 읽는 형태를 준다. 사람이 읽는 이름이 필요하면 g++·clang에선 <cxxabi.h>abi::__cxa_demangle로 풀 수 있다.

이 조회가 가능하려면 컴파일러가 각 타입의 이름과 type_info 구조를 바이너리 어딘가에 심어 둬야 한다. 그 “어딘가”가 바로 이 글의 주제다. 그리고 RTTI는 virtual 함수를 하나 이상 가진 클래스에만 생긴다 — 위에서 Animalvirtual 소멸자를 둔 이유다.

RTTI는 끌 수 있다

RTTI는 컴파일러 플래그로 끌 수 있다 — gcc·clang은 -fno-rtti, MSVC는 /GR-. 끄면 바이너리에 타입 정보를 안 심으니 크기가 줄어든다는 이야기를 종종 본다. 정말 그럴까. 얼마나 줄까. 그리고 크기가 안 변한다면 속도는 어떤가. 직접 재봤다.

재는 방법

같은 일을 하는 코드를 세 가지로 만들었다. 소재는 흔한 예제 — 기반 클래스 Car와 파생 OffRoader/Van/Roadster.

(1) dynamic_cast — 타입을 모른 채 하나씩 캐스팅해 보는, 흔히 “코드 냄새”라 불리는 방식이다.

void prepareCarForFun(Car* car) {
  if (auto* p = dynamic_cast<OffRoader*>(car)) p->turnOnAllWheelDrive();
  if (auto* p = dynamic_cast<Van*>(car))       p->attachThirdSeatRow();
  if (auto* p = dynamic_cast<Roadster*>(car))  p->removeRoof();
}

(2) 가상 디스패치 판dynamic_cast를 없애고 가상 함수 하나로 다형성을 쓴다. RTTI는 여전히 켜져 있다.

class Car { public: virtual ~Car() = default; virtual void doSomeFun() = 0; };
// 각 파생 클래스가 doSomeFun()을 override
for (auto& car : myCars) car->doSomeFun();

(3) 가상 디스패치 판 + -fno-rtti — (2)와 같은 코드를 RTTI를 끄고 컴파일한다.

환경은 WSL Ubuntu 22.04, g++ 11.4.0, x86-64다. 최적화 없이(-O0)와 켜고(-O3) 각각 컴파일해 바이트 단위로 쟀다.

결과 1 — dynamic_cast를 없애는 것만으로는 안 줄어든다

빌드-O0-O3
(1) dynamic_cast60,72018,416
(2) 가상 디스패치 (RTTI on)60,72818,456
(3) 가상 디스패치 (RTTI off)60,29618,024

첫 발견은 예상 밖이었다. (1)→(2), 즉 dynamic_cast를 걷어낸 것만으로는 바이너리가 안 줄었다. 오히려 8바이트(-O0), 40바이트(-O3) 늘었다. dynamic_cast를 지워도 그 클래스들은 여전히 다형(polymorphic)이라 RTTI가 그대로 남기 때문이다. 크기를 건드리는 건 캐스팅을 지우는 게 아니라 RTTI 자체를 끄는 것이다.

RTTI를 끈 (3)은 (2)보다 432바이트 줄었다(-O0·-O3 둘 다). 60KB, 18KB에서 432바이트다 — 0.7%, 2.3%. “줄긴 준다”가 정직한 요약이다.

결과 2 — strip하면 차이가 사라진다

그런데 위 숫자는 심볼 테이블 같은 디버그·링크 정보를 포함한 크기다. 배포 바이너리는 보통 strip으로 그걸 걷어낸다. strip한 뒤 다시 쟀더니:

빌드-O0 (strip)-O3 (strip)
(1) dynamic_cast31,02414,640
(2) 가상 디스패치 (RTTI on)31,02414,640
(3) 가상 디스패치 (RTTI off)31,02414,640

세 빌드가 전부 같은 크기다. RTTI를 켜든 끄든 파일 크기가 한 바이트도 안 달라진다. 앞의 432바이트 차이는 대부분 심볼 이름이었고, 그건 strip이 지운다.

결과 3 — 그래도 진짜 차이는 있다, 다만 정렬에 묻힌다

strip 후 파일 크기가 같다고 RTTI 정보가 공짜인 건 아니다. 로드되는 섹션만 떼어 재면(O3):

        text    data    bss     dec
RTTI on 5247    928     280     6455
RTTI off4744    840     280     5864

실제로 적재되는 코드·데이터는 591바이트 차이가 난다 — 6,455 중 591이니 약 **9%**다. 이 591바이트가 어디에 있는지 섹션별로 보면 RTTI가 무엇을 심는지 드러난다.

섹션RTTI onRTTI off하는 일
.rodata9054타입 이름 문자열(11PolyDerived 같은)
.data.rel.ro208120type_info 객체 + vtable의 typeinfo 슬롯

그럼 왜 파일 크기(14,640)는 같았나. ELF는 세그먼트를 4KB 페이지 경계에 정렬한다. 591바이트짜리 차이는 같은 페이지 안에서 반올림돼 사라진다. 섹션 수준에선 분명 존재하는 차이가 파일 수준에선 정렬에 묻히는 것이다. 어떻게 재느냐에 따라 “9% 줄었다”도 되고 “한 바이트도 안 줄었다”도 된다.

정리하면, RTTI를 꺼서 얻는 크기 이득은 이 규모의 프로그램에선 사실상 없다. 심는 정보가 타입 이름 문자열과 클래스당 작은 type_info 구조라, 클래스가 수천 개인 대형 코드베이스가 아니면 체감되지 않는다.

속도는 어떤가 — 여기서 진짜 차이가 난다

크기가 사실상 안 변한다면 속도는? RTTI의 비용은 모든 코드에 붙는 세금이 아니라 dynamic_casttypeid를 실제로 실행할 때 드는 값이다. 그래서 같은 디스패치를 세 방식으로 재봤다 — (1) dynamic_cast 사슬, (2) typeid 비교 사슬, (3) 가상 함수. Car 포인터를 6천만 번 디스패치했다(항목 3,000개 × 반복 20,000, g++ 11.4, -O2).

방식6천만 회 총 시간1회당가상 대비
dynamic_cast 사슬~888 ms~14.8 ns~10배
typeid 비교~189 ms~3.2 ns~2.2배
가상 디스패치~86 ms~1.4 ns1배

dynamic_cast는 가상 호출의 약 10배 느리다. 상속 계층을 런타임에 훑어 타입이 맞는지 확인하고, 실패하면 다음 후보로 넘어가기 때문이다(이 예는 타입이 셋이라 성공까지 평균 두 번 시도). typeid 비교는 훨씬 싸다 — 계층을 안 훑고 type_info 포인터만 견주므로 — 그래도 가상 호출의 두 배쯤이다. -O3dynamic_cast를 못 살렸다(오히려 미세하게 느렸는데 측정 노이즈 범위다).

그리고 -fno-rtti 플래그 자체는 속도 손잡이가 아니다. RTTI를 안 쓰는 순수 가상 디스패치 코드를 RTTI 켠 채와 끈 채로 재보면 차이가 없다(9597 ms vs 9399 ms, 노이즈 안). 빨라지는 건 RTTI를 꺼서가 아니라, 끄느라 dynamic_cast더 빠른 가상 디스패치로 바꿨기 때문이다. 속도 이득은 플래그가 아니라 설계 변경에서 온다.

곁가지 — typeid는 생각보다 조심스럽다

크기와 별개로, RTTI를 쓸 때 걸리기 쉬운 함정 하나. typeid다형 타입에서만 동적 타입을 준다. 비다형 계층에선 정적 타입을 돌려준다.

NonPolyBase* p1 = new NonPolyDerived{};   // virtual 없음
PolyBase*    p2 = new PolyDerived{};       // virtual 소멸자 있음
std::cout << typeid(*p1).name() << '\n';   // 11NonPolyBase  ← 정적 타입!
std::cout << typeid(*p2).name() << '\n';   // 11PolyDerived  ← 동적 타입

p1이 가리키는 실제 객체는 NonPolyDerived인데 typeidNonPolyBase를 내놓는다. 이름 앞의 11은 문자열 길이로, 구현이 정한 표현(implementation-defined)이라 컴파일러마다 다를 수 있다.

그리고 -fno-rtti로 끈 상태에서 이 기능들을 쓰면 컴파일 자체가 막힌다. g++에서 직접 확인한 메시지:

error: cannot use 'typeid' with '-fno-rtti'
error: 'dynamic_cast' not permitted with '-fno-rtti'

즉 RTTI를 끄기로 했다면 typeiddynamic_cast에 대한 의존을 코드 전체에서 먼저 걷어내야 한다.

그래서, 끌 만한가

크기만 보면 동기가 약하다 — 이득이 몇백 바이트고, 그마저 strip과 페이지 정렬에 묻힌다. -fno-rtti라는 플래그 자체는 크기도 속도도 의미 있게 바꾸는 손잡이가 아니다(속도는 노이즈 안, 크기는 페이지에 묻힘).

그래서 이건 플래그를 켜고 끄는 문제가 아니라 설계 방향의 문제다. 두 방향이 있다. 하나는 기반 포인터를 들고 “너 실제로 무슨 타입이야?”를 dynamic_cast·typeid로 되묻는 설계다. 다른 하나는 그걸 물을 필요가 없도록, 필요한 행동을 기반 인터페이스의 가상 함수로 두는 설계다. 앞의 prepareCarForFun이 전자였고, doSomeFun() 가상 함수가 후자였다. dynamic_cast 사슬이 흔히 “코드 냄새”라 불리는 이유는 그것이 대개 가상 함수 하나가 빠졌다는 신호이기 때문이다.

두 방향은 원래 설계 위생상 후자로 기울어야 하는데, 실측해 보니 후자가 더 빠르기까지 했다(약 10배). 그러니 위 성능 수치는 “이 캐스팅이 성능에 중요한가”를 저울질하는 손잡이가 아니라, 이미 옳은 방향을 한 번 더 밀어주는 증거로 읽는 게 맞다. -fno-rtti는 그 방향을 강제하는 장치다 — dynamic_cast를 못 쓰게 막아 더 빠르고 깨끗한 설계로 떠민다.

물론 dynamic_cast가 항상 틀린 건 아니다. 내가 수정할 수 없는 라이브러리 경계에서 타입을 복구해야 할 때, 기반에 가상 함수를 더할 수 없을 때처럼 정당한 좁은 용도가 있다. 다만 그것도 “이 경로가 차가우니 괜찮다”가 아니라 “설계상 다른 수가 없다”는 판단이다. 그런 경우가 아니고 RTTI 의존을 쉽게 떼기 힘든 레거시도 아니라면, RTTI는 꺼볼 만하다 — 줄어든 바이트 때문이 아니라 더 나은 설계로 떠밀리기 때문에.

요컨대 RTTI는 아무 문제에나 꺼내 드는 전가의 보도가 아니다. 기본은 가상 디스패치로 설계하고, dynamic_cast·typeid는 그 설계가 닿지 못하는 특정한 자리에서만 꺼내 쓰는 도구다. “쓸 만한가”의 답은 그래서 “경우에 따라” — 그 경우가 어디인지가 이 글의 전부다.

참고