1. RTTI(RunTime Type Information)
C++ 에는 다양한 연산자를 지원한다. 예전 포스팅: 형 변환(Type Casting)과 캐스팅 연산자에서 짧게 언급한 적이 있는데, 그중 눈여겨볼 것은 단연 dynamic_cast라 할 수 있다.
dynamic_cast는 런타임 타입 검사를 수행해 안전한 다운캐스팅을 시도하는 캐스팅 연산자이다. 부모 클래스 포인터가 가리키는 인스턴스가 부모로부터 파생된 자식 혹은 자손 클래스 타입일 경우, 해당 파생 클래스 타입의 포인터를 반환한다. 아니라면 nullptr를 반환한다. 이러한 동작 방식으로 인해 안전한 다운캐스팅이 보장된다.
여기서 언급되는 '런타임 타입 검사'가 바로 RTTI에 의해 수행된다. RTTI는 프로그램 실행 중에 객체의 실제 동적 타입을 식별하고, 해당 타입 정보를 알아내는 메커니즘이다. RTTI는 주로 다형성(polymorphism)을 기반으로, dynamic_cast처럼 부모 클래스의 포인터나 참조가 실제로는 어떤 파생 클래스를 가리키고 있는지 확인해야 할 때 사용된다.
이전 포스팅: 다형성(Polymorphism)과 가상 함수(Virtual Function)에서 다형성의 개념과 원리에 대해 자세히 알아봤는데, C++에서는 클래스에 하나 이상의 virtual 함수(가상 소멸자 포함)를 선언해야 컴파일러가 가상 함수 테이블 정보를 넣으면서 런타임에 타입 정보를 확인할 수 있다. 다시 말해 C++의 RTTI는 이러한 다형적(Polymorphic) 클래스여야 엄밀하게 동작한다.
class Player
{
public:
Player()
{
_hp = 100;
}
virtual void Move() { cout << "Player::Move()\n"; }
virtual void Die() { cout << "Player::Die()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
Knight()
{
_stamina = 100;
}
void Move() override { cout << "Knight::Move()\n"; }
public:
int _stamina;
};

이전 포스팅 내용의 일부인데, 그림처럼 virtual 함수가 1개 이상 포함된 다형적 클래스 Player와 이를 상속받은 Knight는 내부적으로 가상 함수 테이블(vftable) 정보가 존재하여 적절한 메서드를 호출할 수 있게 된다.
dynamic_cast는 이러한 가상 함수 테이블을 이용해 캐스팅을 수행한다. 그림에는 표시되어 있지 않지만, 내부적으로 type_info 구조체 정보가 추가로 존재한다. 해당 정보를 추적하면 클래스의 이름과 부모 클래스의 타입을 획득할 수 있다. 변환하고자 하는 타입(파생 클래스)의 type_info에 있는 부모 클래스 정보를 확인하여 상속 관계가 일치하면 포인터를 반환해 준다.
class AActor
{
public:
AActor() = default;
virtual ~AActor() = default;
}
class ACharacter : public AActor
{
public:
ACharacter() = default;
~ACharacter() override = default;
}
int main()
{
ACharacter* Character = new ACharacter;
AActor* Actor = Character;
std::cout << typeid(Actor).name() << std::endl;
std::cout << typeid(*Actor).name() << std::endl;
std::cout << typeid(Character).name() << std::endl;
std::cout << typeid(*Character).name() << std::endl;
std::cout << std::endl;
if (ACharacter* CastedCharacter = dynamic_cast<ACharacter*>(Actor))
{
std::cout << "Actor is a Character" << std::endl;
}
else
{
std::cout << "Actor is NOT a character" << std::endl;
}
...
return 0;
}

위는 다형적 클래스에서 typeid와 dynamic_cast의 동작 예시 코드이다. ACharacter 타입 인스턴스를 Character라는 변수에 넣은 뒤, AActor* 포인터 변수 Actor에도 복사해 둔 상태이다.
- 포인터 변수 Actor의 타입 이름은 AActor*
- 포인터 변수 Actor가 들고 있는 인스턴스(즉, Character)의 타입 이름은 ACharacter
- 포인터 변수 Character의 타입 이름은 ACharacter*
- 포인터 변수 Character가 들고 있는 인스턴스(즉, Character)의 타입 이름은 ACharacter
- 포인터 변수 Actor는 ACharacter* 타입의 인스턴스를 가리키므로 dynamic_cast는 성공적으로 형 변환에 성공
1. RTTI의 오버헤드
RTTI는 유용한 기능이지만 몇 가지 오버헤드가 존재한다.
- 컴파일러는 모든 다형적 클래스의 타입 정보를 저장해야 하며, 이 정보를 가리키는 포인터 또한 들고 있어야 한다.
- RTTI를 이용하는 dynamic_cast의 경우 런타임에 클래스 상속 계층 구조를 거슬러 올라가며 타입을 확인하기 때문에, 컴파일 타임에 캐스팅을 수행하는 static_cast보다 훨씬 느리다.
- 또한 위에서 확인한 typeid 또한 런타임에 타입 정보를 조회하기 때문에 오버헤드가 발생한다.
이러한 이유로 게임 엔진과 같은 일부 애플리케이션의 경우 RTTI 기능을 끄기도 한다. 다만 이렇게 되면 RTTI 기반으로 동작하는 typeid와 dynamic_cast는 사용할 수 없게 된다. 이러한 경우 부모에서 자식으로의 다운 캐스팅을 어떤 방식으로 구현할 수 있을까?
2. Enum을 이용한 다운 캐스팅
간단한 방법으로는 enum을 이용하는 것이다. 특정 다형적 클래스와 이를 상속받는 자식 클래스들에 대한 정보를 enum 변수로 표현하고, 생성자에서 해당 enum 변수를 세팅하게 하는 것이다.
enum class EActorType { Actor, Character };
class AActor
{
public:
AActor(EActorType InType) : Type(InType) {}
virtual ~AActor() = default;
EActorType Type;
}
class ACharacter : public AActor
{
public:
ACharacter() : AActor(EActorType::Character) {}
~ACharacter() override = default;
}
int main()
{
ACharacter* Character = new ACharacter;
AActor* Actor = Character;
if(Actor->Type == EActorType::Character)
{
ACharacter* CastedCharacter = static_cast<ACharacter*>(Actor);
...
}
return 0;
}
위 코드를 보면 Actor 변수의 Type 값을 확인해, Character인 경우 컴파일 타임 캐스팅 연산자인 static_cast를 이용해 ACharacter*으로 캐스팅하는 것을 볼 수 있다. 실제로 일부 게임 서버에서 dynamic_cast의 성능 문제를 극복하고자 이러한 enum 방식을 사용하기도 한다고 한다.
다만 이 방식은 상속 관계의 클래스가 새로 추가될 때마다 매번 enum 값을 수정해야 한다는 단점이 있다. 또한 위의 Actor와는 전혀 별개의 새로운 다형적 클래스가 생성되면 별개의 enum class를 추가해야 하니, enum 정보가 특정 클래스와 강하게 결합될 수밖에 없는 구조이기도 하다.
근본적으로 RTTI를 완전히 대체하려면 Reflection이란 개념을 도입해야 한다.
2. Reflection
Reflection은 프로그램이 런타임에 자기 자신의 구조와 타입을 인식하고 조작할 수 있는 능력을 의미한다. 리플렉션 기능이 존재하면 객체의 실제 타입/이름/상속관계 확인이나 클래스 내 필드/메서드 확인, 특정 이름으로 메서드 호출, 메타 데이터 확인 등 다양한 동작을 수행할 수 있다. 실제로 Java나 C#, Python은 언어 차원에서 Reflection 기능이 제공된다. 반면 C++에서는 RTTI를 통해 Reflection의 다양한 기능 중 '타입 확인' 정도의 매우 제한적인 기능만 제공한 것이라 볼 수 있다.
그렇다면, Reflection 시스템은 어떻게 C++ RTTI 메커니즘이 제공하던 런타임에 타입 정보를 식별할 수 있을까? 한 번 언리얼 엔진을 떠올려보자.

언리얼 엔진 환경에서 게임 개발을 위해 스크립트를 작성하다 보면 UCLASS(), UPROPERTY()와 같은 매크로를 자주 접하게 된다. 해당 매크로들은 언리얼 엔진에서 제공하는 기능으로, 언리얼 리플렉션 시스템을 위해 사용된다.
C++는 언어 차원에서 Reflection 기능을 지원하지 않기 때문에, 언리얼 엔진에서는 RTTI 기능을 끄고 자체적인 리플렉션 시스템을 구축했다. 시스템을 도입한 목적은 다양하지만, 여기서는 이러한 리플렉션 메커니즘이 어떻게 런타임에 타입을 식별할 수 있는지에 집중해 보자.
기본적으로 C++의 RTTI는 런타임에 타입을 찾는 방식인 반면, 언리얼의 리플렉션은 컴파일 타임에 타입 정보를 등록하고, 런타임에 이를 활용하는 방식이다. UCLASS, UPROPERTY와 같은 매크로들을 사용하게 되면 UHT(Unreal Header Tool)이 빌드 시점에 타입 정보가 담긴 메타데이터 테이블을 자동 생성하여, 런타임에 클래스 이름/부모 관계/필드 정보 등을 조회할 수 있게 되고 이를 기반으로 캐스팅을 수행할 수 있게 되는 원리이다.
언리얼 리플렉션 시스템은 매우 크고 복잡한 구조로 이루어져 있다. 따라서 여기서는 간단하게 메타 데이터를 나타내는 특별한 클래스를 이용해 타입 정보 식별 및 캐스팅을 수행해 보는 정도의 원리를 확인해 보겠다.
#pragma once
#include <string>
#include <type_traits> // For std::is_void_v
// Represents the C++ class itself at runtime (the "meta-class")
class UClass
{
public:
// Constructor
UClass(const std::string& InName, UClass* InSuperClass);
// Returns the name of the class
const std::string& GetName() const { return Name; }
// Returns the UClass of the parent class
UClass* GetSuperClass() const { return SuperClass; }
// Checks if this class is a child of (or the same as) the given class
bool IsA(const UClass* Other) const;
private:
std::string Name;
UClass* SuperClass;
};
template<typename T>
inline UClass* GetSuperClass()
{
if constexpr (std::is_void_v<T>)
{
return nullptr;
}
else
{
return T::StaticClass();
}
}
// For child classes that inherit from another reflected class.
#define GENERATED_BODY(ClassName, ParentName) \
public: \
using Super = ParentName; \
static UClass* StaticClass() \
{ \
static UClass StaticClassInstance(#ClassName, GetSuperClass<Super>()); \
return &StaticClassInstance; \
} \
virtual UClass* GetClass() const \
{ \
return ClassName::StaticClass(); \
} \
\
static_assert(std::is_void_v<Super> || \
std::is_base_of_v<class UObject, Super>, \
"Super must be void or derive from UObject");
#include "Class.h"
UClass::UClass(const std::string& InName, UClass* InSuperClass)
: Name(InName), SuperClass(InSuperClass)
{
}
bool UClass::IsA(const UClass* Other) const
{
if (Other == nullptr)
{
return false;
}
for (const UClass* Temp = this; Temp != nullptr; Temp = Temp->GetSuperClass())
{
if (Temp == Other)
{
return true;
}
}
return false;
}
먼저 메타 데이터 정보를 들고 있는 UClass란 이름의 클래스이다. Java에서 'Class' 클래스에 대응되는 개념이라 생각하면 된다. 메타 데이터는 간단하게 이름 정보와 자신의 부모 클래스 정보를 들고 있으며, 상속 관계에 있는지 검사할 수 있는 IsA 메서드를 제공한다.
그리고 이러한 메타 데이터 클래스를 쉽게 사용할 수 있도록 GENERATED_BODY라는 매크로를 하나 제공한다. 해당 클래스 자체에 대한 정보를 반환해 주는 StaticClass 메서드, 인스턴스의 실제 타입 정보를 반환해 주는 GetClass 메서드를 제공한다.
#pragma once
#include "Class.h"
// Base class for all objects that can use our custom reflection system.
class UObject
{
GENERATED_BODY(UObject, void)
public:
virtual ~UObject() = default;
template<typename ClassType>
bool IsA() const
{
return GetClass()->IsA(ClassType::StaticClass());
}
template<typename ClassType>
ClassType* Cast()
{
return IsA<ClassType>() ? static_cast<ClassType*>(this) : nullptr;
}
template<typename ClassType>
const ClassType* Cast() const
{
return IsA<ClassType>() ? static_cast<const ClassType*>(this) : nullptr;
}
};
리플렉션 시스템에 들어갈 모든 클래스들의 최상위 부모 클래스를 나타내는 UObject 클래스이다. Java나 C#에서는 기본적으로 모든 클래스가 object라는 이름의 최상단 클래스로부터 파생되지만, C++에서는 그런 최상단 클래스의 개념이 없다. 따라서 리플렉션 시스템에 들어가려면 해당 클래스로부터 상속을 받아야 한다.
UObject부터는 클래스의 최상단에 GENERATED_BODY 매크로를 반드시 선언한다. (자신의 타입, 부모 클래스 타입) 정보를 넘겨줌으로써 위에서 본 StaticClass, GetClass 메서드로부터 적절한 정보를 획득할 수 있게 된다. 또한 UClass 메서드를 이용해 상속 관계인지 파악하는 IsA 메서드와 형 변환을 수행하는 Cast 메서드를 지원한다. 여기서 C++의 dynamic_cast가 대체된다.
class AActor : public UObject
{
GENERATED_BODY(AActor, UObject)
public:
AActor() {}
virtual void Tick() {}
};
class APawn : public AActor
{
GENERATED_BODY(APawn, AActor)
public:
APawn() {}
void Possess() {}
};
class ACharacter : public APawn
{
GENERATED_BODY(ACharacter, APawn)
public:
ACharacter() {}
void CharacterAction() {}
};
테스트를 위해 위와 같이 UObject - AActor - APawn - ACharacter 상속 구조를 설계해 보자.
void PrintClassInfo(UObject* Object)
{
// Get the UClass from the instance
UClass* ObjectClass = Object->GetClass();
std::cout << "--- Object Instance Info ---" << std::endl;
std::cout << "Instance Type: " << ObjectClass->GetName() << std::endl;
// Demonstrate IsA<> and Cast<>
std::cout << " IsA<ACharacter>(): " << (Object->IsA<ACharacter>() ? "true" : "false") << std::endl;
std::cout << " IsA<APawn>(): " << (Object->IsA<APawn>() ? "true" : "false") << std::endl;
std::cout << " IsA<AActor>(): " << (Object->IsA<AActor>() ? "true" : "false") << std::endl;
std::cout << " IsA<UObject>(): " << (Object->IsA<UObject>() ? "true" : "false") << std::endl;
if (ACharacter* Character = Object->Cast<ACharacter>())
{
std::cout << " Cast to ACharacter successful!" << std::endl;
Character->CharacterAction();
}
// Demonstrate GetSuperClass() by walking up the hierarchy
std::cout << " Inheritance Hierarchy: ";
for (UClass* Super = ObjectClass; Super != nullptr; Super = Super->GetSuperClass())
{
std::cout << Super->GetName() << (Super->GetSuperClass() ? " -> " : "");
}
std::cout << std::endl << std::endl;
}
클래스 정보를 출력해 보는 용도로 함수 하나를 정의했다. UObject를 상속받은 아무 인스턴스나 넘겨받으면 다음과 같은 작업을 수행한다.
- 해당 인스턴스의 타입명을 출력한다.
- IsA를 이용해 상속 관계를 파악해 본다. 또한 Character로 Cast 가능하다면 특별히 한 줄 더 출력한다.
- 부모 클래스를 타고 올라가며 상속 계층을 출력해 본다.
해당 함수에 차례대로 ACharacter 인스턴스, APawn 인스턴스, AActor 인스턴스를 넘겨보면 다음과 같이 정상적으로 출력되는 것을 확인할 수 있다.

// Demonstrate StaticClass()
std::cout << "--- StaticClass() Demo ---" << std::endl;
UClass* PawnClass = APawn::StaticClass();
std::cout << "Statically retrieved class name: " << PawnClass->GetName() << std::endl;
std::cout << "Is APawn an AActor? " << (PawnClass->IsA(AActor::StaticClass()) ? "true" : "false") << std::endl;
std::cout << "Is APawn a ACharacter? " << (PawnClass->IsA(ACharacter::StaticClass()) ? "true" : "false") << std::endl;
마지막으로 StaticClass 메서드를 이용해 APawn에 대한 정보 자체를 얻어서 타입명과 상속 관계에 대한 테스트를 진행해 본다. 마찬가지로 정상적으로 출력되는 것을 확인할 수 있다.

3. 마무리
주제 상 언리얼 리플렉션 시스템의 극히 일부만 소개했지만, 사실 이러한 타입 정보 획득뿐만 아니라 정말 다양한 기능을 수행한다. 블루프린트와 에디터에 변수가 노출되는 것도 해당 시스템이 있어서 가능한 기능이고, 직렬화, 네트워크 복제, 가비지 컬렉션 등 언리얼 엔진의 근간을 이루는 수많은 기능들이 해당 시스템을 통해 구현되고 있다. 다만 위에서 간접적으로 언급됐지만, UObject를 상속받지 않은 일반 C++ 클래스는 구조적으로 리플렉션 시스템이 적용될 수 없다. 더 자세한 내용을 확인하고 싶다면 이 분의 게시글(C++ RTTI와 언리얼 리플렉션 시스템)을 확인해 봐도 좋겠다.
예시로 언리얼 엔진을 들었지만, 다른 언어(Java, C# 등)에서 제공하는 원리도 크게 다르지 않은 것으로 알고 있다. Java의 경우. class 파일에 메타 데이터 정보가 담겨있고, 이를 기반으로 getClass() 메서드 같은 기능을 사용할 수 있는 것으로 알고 있다. 그래서 이 글을 보고 리플렉션에 대한 개념을 대략적으로라도 알아갔으면 좋겠다.
사실 해당 내용은 모 회사 기술 면접에서 본인이 질문받은 내용 중 일부였다. 마지막에 리플렉션 시스템이 어떻게 런타임에 타입 정보를 획득하는지에 대해서 똑바로 대답하지 못해 참으로 아쉽다...
'Computer Science > Programming Language' 카테고리의 다른 글
| [C++] 메모리 관리 2: 메모리 할당(Allocator) (6) | 2025.08.13 |
|---|---|
| [C++] 메모리 관리 1: 스마트 포인터 (6) | 2025.08.05 |
| [C++] 형 변환(Type Casting)과 캐스팅 연산자 (0) | 2025.05.24 |
| [C++] 다형성(Polymorphism)과 가상 함수(Virtual Function) (2) | 2025.05.19 |