1. 도입
가상 메서드를 가진 계층이 생기면, 초보자의 머릿속에는 때때로 "추측하기 놀이"가 시작됩니다: "혹시 IStorage* 뒤에 어떤 객체가 있는지 알아야 할 수도 있지 않을까?" 바로 이때 RTTI(Run-Time Type Information)가 등장합니다 — 실행 시 객체의 동적 타입에 대한 정보를 얻고, 베이스 포인터를 특정 파생형 포인터로 안전하게 캐스팅하려 시도할 수 있게 해주는 메커니즘입니다.
하루 기분을 이해하는 것이 중요합니다: RTTI는 "나쁘다"도 "부끄럽다"도 아닙니다. 그저 도구일 뿐입니다. 다만 소화기와 같습니다: 있는 게 좋지만 항상 켜두고 계란을 굽는 것은 이상합니다. 이미 다형성을 잘 설계한 아키텍처에서 RTTI는 보통 신호를 보냅니다: 인터페이스가 필요한 동작의 차이를 표현하지 못하거나, 내부 로직을 가상 메서드로 표현해야 할 것을 외부로 꺼내고 있다는 신호입니다.
C++에서 우리가 관심을 가질 RTTI의 두 가지 주요 도구는 dynamic_cast와 typeid입니다.
정적 타입과 동적 타입
dynamic_cast를 다루기 전에, 정적 타입과 동적 타입의 차이를 한 번(그리고 오래도록) 명확히 해두어야 합니다. 그렇지 않으면 RTTI가 마법이 되고, 프로그래밍에서 마법은 보통 "Segmentation fault"와 함께 벽을 바라보는 철학적 표정을 남깁니다.
정적 타입은 변수/매개변수에 코드상으로 적혀있는 것입니다: IStorage*, Shape&, Base*. 동적 타입은 메모리상의 실제 객체 타입입니다: FileStorage, Circle, Derived. Base*을 통해 가상 메서드를 호출하면 C++는 객체의 동적 타입에 따라 구현을 선택합니다.
간단한 예시:
#include <iostream>
struct Base {
virtual ~Base() = default;
virtual void ping() const { std::cout << "Base\n"; } // Base
};
struct Derived : Base {
void ping() const override { std::cout << "Derived\n"; } // Derived
};
int main() {
Derived d;
Base* p = &d; // p의 정적 타입: Base*, 객체의 동적 타입: Derived
p->ping(); // Derived
}
"포인터는 Base*인데 객체는 Derived"라는 이런 불일치는 다형성의 정상적인 모습입니다. RTTI는 우리가 갑자기 묻고 싶을 때 등장합니다: "너 정말 Derived니?" 그리고 다르게 처리할 필요가 있을 때 사용됩니다.
2. dynamic_cast: 상속자를 '시도해 보는' 안전한 방법
베이스 타입의 포인터/참조로 객체를 들고 있고 특정 파생형으로 시도해서 캐스팅하려 할 때, dynamic_cast가 공식적인 안전한 방법입니다. 이 강의에서는 예외를 논의할 필요가 없는 포인터 버전을 주로 다룹니다. 포인터 버전은 아주 단순한 계약을 제공합니다: "실패하면 nullptr가 된다."
핵심 조건: 베이스 타입은 적어도 하나의 가상 함수를 가져야 하는 다형적 타입이어야 합니다. 실제로는 인터페이스에 가상 소멸자가 있는 경우가 대부분이며, 이는 우리가 항상 지켜야 할 것입니다: virtual ~I() = default.
예시:
#include <iostream>
struct Shape {
virtual ~Shape() = default; // 타입을 다형적으로 만듭니다
};
struct Circle : Shape {
int r = 10;
};
struct Square : Shape {
int a = 5;
};
void debug_print_shape(Shape* s) {
if (auto* c = dynamic_cast<Circle*>(s)) {
std::cout << "Circle r=" << c->r << "\n"; // Circle r=10
return;
}
if (auto* sq = dynamic_cast<Square*>(s)) {
std::cout << "Square a=" << sq->a << "\n"; // Square a=5
return;
}
std::cout << "Unknown shape\n"; // Unknown shape
}
여기서 규율이 중요합니다: 우리는 역참조하기 전에 nullptr인지 결과를 검사합니다. 이것이 포인터 버전의 dynamic_cast가 제공하는 "안전성"입니다.
dynamic_cast 체인이 거의 항상 나쁜 인터페이스의 증상인 이유
다음과 같이 작성했다고 상상해보세요:
"만약 이것이 FileStorage라면 이렇게 하고, MemoryStorage라면 다르게 하고, NullStorage라면 세 번째로…"
문제는 "작동하지 않는다"가 아닙니다. 문제는 여러분이 타입에 따른 디스패치를 수동으로 구현하고 있다는 점입니다. 즉, 필요한 동작 차이를 인터페이스의 가상 메서드로 표현하는 대신에 타입을 외부에서 검사하고 있습니다. 이는 두 가지 전형적인 골칫거리를 낳습니다.
첫째, 새로운 파생형을 추가하면 코드의 여러 예상치 못한 지점이 깨집니다. CloudStorage를 추가했는데, 프로젝트 전체에서 dynamic_cast<FileStorage*>를 찾아서 각 위치에 분기 하나씩 더 추가해야 할 수도 있습니다. 즉, 클라이언트 코드는 다시 구체 클래스들을 알게 됩니다.
둘째, 이런 로직은 쉽게 모순을 낳습니다: 어떤 곳에서는 새 타입을 처리하는 것을 잊고, 다른 곳에서는 다르게 처리하고, 또 다른 곳에서는 실수로 분기를 복사해 넣는 등.
정상적인 다형성과 대비되는 모습은 이렇습니다: "타입을 추측하는" 대신에 우리는 "객체에게 필요한 작업을 시킨다". 만약 동작 차이가 실제로 중요하다면, 그것은 인터페이스의 가상 메서드로 표현되어야 합니다.
3. typeid: 타입 이름이 필요할 때
때로는 객체를 파생형으로 캐스팅할 필요가 없습니다. 단지 어떤 종류의 객체가 도착했는지 알고 로그에 찍고 싶을 뿐입니다. 예를 들어 팩토리를 만들고 나서 "이런 storage가 생성되었다"라고 출력하고 싶을 때, typeid가 적절합니다.
typeid(expr)는 std::type_info 객체에 대한 참조를 반환합니다. 가장 흔히 쓰이는 것은 typeid(expr).name()입니다. 하지만 중요한 점은: name()은 "아름답고 안정적인 클래스 이름"이 아닙니다. 형식은 컴파일러와 설정에 따라 달라지고(종종 맹글링되어 있음) 사람을 위한 진단에는 괜찮지만 프로토콜이나 로직의 키로 사용하기에는 부적절합니다.
예시:
#include <iostream>
#include <typeinfo>
struct Base { virtual ~Base() = default; };
struct Derived : Base { };
int main() {
Derived d;
Base& b = d;
std::cout << typeid(b).name() << "\n"; // 예: 7Derived (컴파일러에 따라 다름)
}
여기서 Base가 다형적이라는 점이 중요한 이유는, 다형적 타입에 대해 참조/역참조로 typeid를 호출하면 동적 타입을 반영하기 때문입니다. 그렇지 않으면 내부가 Derived여도 단순히 "Base"를 얻게 될 수 있습니다.
별도 언급하자면(예외 처리를 깊게 다루지 않고): p가 == nullptr인 상태에서 폴리모픽 타입을 대상으로 typeid(*p) 를 시도하는 것은 나쁜 아이디어입니다. 최소한 프로그램의 비정상 종료를 유발할 수 있습니다. 규칙은 간단합니다: typeid(*p)를 호출하기 전에 p가 널이 아님을 확인하세요.
4. 예시: 팩토리가 어떤 Storage를 만들었는지 진단하기
작은 학습용 미니프로젝트인 콘솔 앱 TaskPad를 계속해 봅시다. 여기서 작업들은 추상화 ITaskStorage를 통해 저장됩니다. 이전 강의에서 이미 설정 파일이나 CLI에 따라 구현을 생성하는 팩토리가 있었고, 클라이언트 코드는 std::unique_ptr<ITaskStorage>로 작업한다고 가정합니다.
간단한 인터페이스를 정의해 봅시다:
#include <string>
#include <string_view>
#include <vector>
struct Task {
int id = 0;
std::string title;
};
struct ITaskStorage {
virtual ~ITaskStorage() = default;
virtual void add(Task t) = 0;
virtual std::vector<Task> list() const = 0;
};
그다음 구현들이 있습니다. 강의용이라 간단하고 의사 구현으로 둡니다:
#include <utility>
#include <vector>
struct MemoryTaskStorage : ITaskStorage {
std::vector<Task> tasks;
void add(Task t) override { tasks.push_back(std::move(t)); }
std::vector<Task> list() const override { return tasks; }
};
그리고 파일 기반 구현:
#include <string>
#include <utility>
#include <vector>
struct FileTaskStorage : ITaskStorage {
std::string path;
std::vector<Task> tasks;
explicit FileTaskStorage(std::string p) : path(std::move(p)) {}
void add(Task t) override { tasks.push_back(std::move(t)); }
std::vector<Task> list() const override { return tasks; }
};
이제 팩토리가 있다고 가정합니다:
#include <memory>
#include <string>
#include <string_view>
enum class StorageKind { memory, file };
std::unique_ptr<ITaskStorage> make_storage(StorageKind kind, std::string_view arg) {
if (kind == StorageKind::file) {
return std::make_unique<FileTaskStorage>(std::string(arg));
}
return std::make_unique<MemoryTaskStorage>();
}
이제 어떤 storage가 선택되었는지, 그리고 파일인 경우 경로가 무엇인지 진단 출력하고 싶습니다. 가장 직관적인 방법은 RTTI를 사용하는 것입니다:
#include <iostream>
void debug_print_storage(const ITaskStorage& s) {
if (auto* fs = dynamic_cast<const FileTaskStorage*>(&s)) {
std::cout << "Storage: FileTaskStorage, path=" << fs->path << "\n";
return; // Storage: FileTaskStorage, path=tasks.txt
}
std::cout << "Storage: (non-file)\n"; // Storage: (non-file)
}
왜 이게 허용되는가? 왜냐하면 이것은 진단 함수이기 때문입니다. 이 함수는 결정을 내리거나 알고리즘을 변경하거나 비즈니스 규칙을 정의하지 않습니다. 단지 개발자가 "팩토리가 무엇을 만들었는지" 볼 수 있게 도와줍니다. 이것은 드물고 로컬한 RTTI 사용의 좋은 예입니다.
5. 더 나은 방법: 진단을 위한 가상 메서드 추가
다음 성숙 단계로 나아갑니다. 구현에 대한 정보를 가끔 출력하고 싶지만 프로젝트 전체가 FileTaskStorage를 알기를 원하지 않습니다. 따라서 객체 자신이 인터페이스를 통해 스스로를 설명할 수 있게 하는 것이 더 논리적입니다.
인터페이스에 debug_describe(...) 메서드를 추가해 봅시다. 중요한 점: 이것은 진단 용도이며 도메인 로직의 일부가 아님을 미리 약속합니다. 계약은 간단합니다: 메서드는 상태를 변경하지 않아야(const) 하고, 사람이 읽을 수 있는 문자열을 출력해야 합니다.
#include <ostream>
struct ITaskStorage {
virtual ~ITaskStorage() = default;
virtual void add(Task t) = 0;
virtual std::vector<Task> list() const = 0;
virtual void debug_describe(std::ostream& out) const = 0;
};
MemoryTaskStorage에서의 구현:
#include <ostream>
struct MemoryTaskStorage : ITaskStorage {
std::vector<Task> tasks;
void add(Task t) override { tasks.push_back(std::move(t)); }
std::vector<Task> list() const override { return tasks; }
void debug_describe(std::ostream& out) const override {
out << "MemoryTaskStorage, tasks=" << tasks.size() << "\n";
// MemoryTaskStorage, tasks=0
}
};
FileTaskStorage에서의 구현:
#include <ostream>
struct FileTaskStorage : ITaskStorage {
std::string path;
std::vector<Task> tasks;
explicit FileTaskStorage(std::string p) : path(std::move(p)) {}
void add(Task t) override { tasks.push_back(std::move(t)); }
std::vector<Task> list() const override { return tasks; }
void debug_describe(std::ostream& out) const override {
out << "FileTaskStorage, path=" << path << ", tasks=" << tasks.size() << "\n";
// FileTaskStorage, path=tasks.txt, tasks=0
}
};
이제 클라이언트 코드는 이렇게 할 수 있습니다:
#include <iostream>
#include <memory>
int main() {
auto storage = make_storage(StorageKind::file, "tasks.txt");
storage->debug_describe(std::cout); // FileTaskStorage, path=tasks.txt, tasks=0
}
여기서는 더 이상 RTTI가 필요하지 않음을 주목하세요. 다시 "의도한 대로" 다형성을 사용합니다: 동작이 다르면 그것을 가상 메서드로 표현하면 됩니다.
6. RTTI가 적절한 경우와 도구 선택 방법
지금 중요한 것은 다른 극단으로 치우치지 않는 것입니다: "RTTI는 절대 금지"라는 태도는 옳지 않습니다. RTTI는 합리적인 상황에서 사용할 수 있습니다. 다만 로컬하고 의도적이며 명확한 이유가 있을 때 사용하는 것이 좋습니다.
RTTI는 인프라 코드에서 적절한 경우가 많습니다: 로깅, 디버그 커맨드, 진단 덤프, 통계, 개발 빌드의 텔레메트리 등. 이런 곳에서는 인터페이스를 로그 한 줄 때문에 확장하고 싶지 않은 경우가 많습니다. 인터페이스는 이제 모든 서브클래스가 구현해야 하는 계약이기 때문입니다.
또한 레거시와 통합할 때 RTTI는 합리적일 수 있습니다. 예를 들어 외부 API가 구체 타입 ConcreteX*을 기대하고 내부적으로는 I*를 다루는 경우, dynamic_cast는 "호환성 검사" 역할을 합니다: 객체가 기대한 타입이 아니면 작업을 정중히 거부하거나 대체 경로를 사용합니다.
세 번째 드문 경우는 테스트나 개발자 도구입니다. 예를 들어 팩토리가 특정 설정으로 정확히 FileTaskStorage를 생성하는지 확인하고 싶을 때입니다. 운영 코드에서는 이 지식이 필요 없지만 테스트나 진단에서는 유용합니다.
dynamic_cast와 typeid: 간단 비교표
| 도구 | 수행 내용 | 일반적인 결과 | 언제 적절한가 |
|---|---|---|---|
|
파생형으로 안전하게 캐스팅을 시도한다 | |
로컬하게(보통 진단이나 통합) 파생형 특정 API를 시도해야 할 때 |
|
실행 시 표현식의 타입 정보를 얻는다 | |
로그나 진단용으로 "이 객체는 대체 무엇인가"를 알고 싶을 때 |
프로젝트에서 보통 이런 식으로 보입니다:
flowchart TD
A[ITaskStorage가 있음] --> B{동작이 필요한가?}
B -->|예| C[인터페이스에 virtual 메서드 추가하거나 사용]
B -->|아니요, 진단만 필요| D{파생형 필드 접근이 필요한가?}
D -->|예| E[dynamic_cast + nullptr 검사]
D -->|아니요, 이름만 필요| F["typeid(...)를 로그에 사용"]
예, 이것은 "건강한 판단의 도식"이지 수학적 증명은 아닙니다. 그러나 실제 코드에서는 건강한 판단이 매우 경쟁력 있는 기술입니다.
7. RTTI 사용 시 흔한 실수
실수 1: RTTI를 주요 분기 로직으로 사용하는 것.
"A이면 이렇게, B이면 저렇게" 식으로 쉽게 작성할 수 있고 처음에는 모든 것이 제어되는 것처럼 보입니다. 그러나 실상은 다형성을 무효화시키고 클라이언트 코드가 모든 구현을 알게 만듭니다. 몇 번의 반복 후에는 새로운 타입을 추가할 때 극심한 고통이 옵니다. 동작에 영향을 주는 차이라면 인터페이스의 가상 메서드로 표현되어야 합니다.
실수 2: dynamic_cast가 다형적 베이스를 필요로 한다는 것을 잊는 것.
dynamic_cast는 RTTI 메타데이터에 의존하며, 이는 다형적 타입에서만 생성됩니다. 베이스 클래스에 가상 함수가 없으면 캐스팅은 컴파일되지 않거나 의미가 없습니다. 좋은 습관: 인터페이스에는 항상 virtual ~I() = default, 가 포함되어 있어 베이스가 자동으로 다형성이 되게 합니다.
실수 3: dynamic_cast 결과를 nullptr 검사 없이 역참조하는 것.
포인터 버전은 실패 시 nullptr를 반환한다는 점이 편리합니다. 그러나 dynamic_cast 직후 곧바로 p->field를 쓰면 전형적인 충돌을 야기합니다. 규칙은 단순합니다: 먼저 if (auto* d = dynamic_cast<...>(p))를 하고 그 안에서 사용하세요.
실수 4: typeid(...).name()을 안정적인 식별자로 사용하는 것.
초보자는 종종 문자열 비교를 합니다: if (typeid(x).name() == "FileTaskStorage"). 이것은 매우 신뢰할 수 없습니다: 형식의 포맷은 컴파일러에 따라 달라지고 맹글링될 수 있습니다. name()은 사람을 위한 로그 출력에는 적합하지만 프로그램 로직의 일부로 쓰기에는 적합하지 않습니다.
실수 5: RTTI를 프로젝트 전반에 걸쳐 끌고 다니는 것.
가장 건강한 RTTI 시나리오는 한 군데에 작은 함수(예: debug_print_storage)로 모아두는 것입니다. 이 함수는 애플리케이션의 주요 동작에 영향을 주지 않습니다. 만약 dynamic_cast가 파싱, 명령 실행, 계산 등의 "실제" 함수들에서 자주 보이기 시작하면, 이는 거의 항상 인터페이스가 필요한 동작을 설명하지 못하거나 책임이 뒤섞여 있다는 신호입니다.
GO TO FULL VERSION