C++ RTTI 활용 (완성): TSubclassOf 스타일로 타입 기반 객체 생성 구현하기 (4탄)

예제 코드 깃허브 링크

🚀 들어가며

이번 글에서는 C++ TSubclassOf 구현을 통해
타입 정보를 기반으로 객체를 생성하는 방법을 살펴봅니다.

이전 글에서 우리는 다음의 내용을 이해하고 구현했습니다.


🎯 이번 글의 목표

이제 타입을 확인하는 수준에서 한 단계 더 나아가 보겠습니다.
이번 글의 목표는 다음과 같은 코드를 가능하게 만드는 것입니다.

TSubclassOf<Player> playerType = Player::StaticClass();
std::shared_ptr<Player> player = SpawnActor(playerType);

즉, 클래스 정보를 변수처럼 저장해두고, 나중에 그 타입으로 객체를 생성하는 구조를 만들어보겠습니다.

언리얼 엔진의 TSubclassOfSpawnActor를 단순화해서 흉내 내는 방식입니다.


🧠왜 TSubclassOf가 필요한가?

지금까지는 객체가 이미 만들어진 뒤에 타입을 확인했습니다.

if (object->Is<Player>())
{
    // Player 타입인지 확인
}

하지만 게임 엔진에서는 이런 상황도 자주 필요합니다.

  • 아직 객체는 없지만, 나중에 어떤 타입의 객체를 생성할지 저장해두고 싶다.

예를 들어 다음과 같은 경우입니다.

  • 특정 Actor 타입을 변수로 저장
  • 나중에 그 타입으로 객체 생성
  • 데이터 기반으로 생성할 객체 종류 결정
  • 팩토리 시스템 구현
  • SpawnActor 구조 구현

이때 필요한 것이 바로 “타입 정보를 값처럼 다루는 구조”입니다.


목표 구조

최종적으로는 이런 형태를 만들고 싶습니다.

TSubclassOf<Player> playerType = Player::StaticClass();
auto player = SpawnActor(playerType);

그리고 부모 타입으로도 받을 수 있어야 합니다.

TSubclassOf<CraftObject> objectType = Player::StaticClass();
auto object = SpawnActor(objectType);

PlayerCraftObject의 자식이므로, TSubclassOf<CraftObject>에 저장될 수 있습니다.

반대로 전혀 관계없는 타입은 저장되지 않아야 합니다.


🧩TypeInfo에 생성 함수 추가하기

기존 TypeInfo는 타입 이름과 부모 타입만 가지고 있었습니다.

이번에는 여기에 객체 생성 함수까지 추가합니다.

#pragma once

#include <memory>

// 전방 선언.
class CraftObject;
class TypeInfo
{
public:
    using CreateFunc = std::shared_ptr<CraftObject>(*)();

public:
    TypeInfo(const char* name, const TypeInfo* parent, CreateFunc createFunc)
        : className(name), parentType(parent), createFunc(createFunc)
    {
    }

    const char* GetName() const
    {
        return className;
    }

    bool IsChildOf(const TypeInfo* other) const
    {
        for (const TypeInfo* current = this; current; current = current->parentType)
        {
            if (current == other)
            {
                return true;
            }
        }

        return false;
    }

    std::shared_ptr<CraftObject> CreateInstance() const
    {
        if (!createFunc)
        {
            return nullptr;
        }

        return createFunc();
    }

private:
    const char* className = nullptr;
    const TypeInfo* parentType = nullptr;
    CreateFunc createFunc = nullptr;
};

핵심은 이 부분입니다.

using CreateFunc = std::shared_ptr<CraftObject>(*)();

이 함수 포인터는 CraftObject 계열 객체를 생성하는 함수를 가리킵니다.

즉, TypeInfo가 이제 단순한 타입 정보가 아니라 “객체를 생성할 수 있는 타입 정보”가 됩니다.

아래 코드는 실제 언리얼 엔진의 UClass 클래스 내부에 있는 함수 포인터 선언 부분입니다. (언리얼 엔진의 Class.h 헤더 부분)

언리얼 엔진도 예제와 비슷하게 생성 전용 함수를 함수 포인터로 선언해두고 이를 활용해 객체를 생성합니다.

class UClass : public UStruct
{
...
    // 언리얼 엔진에서 객체를 생성할 때 사용하는 함수 포인터 선언 부분.
    typedef void (*ClassConstructorType)(const FObjectInitializer&);
...
}

⚙️CraftObject 수정하기

루트 클래스인 CraftObjectStaticClass()를 가져야 합니다.

#pragma once

#include "TypeInfo.h"

class CraftObject
{
public:
    CraftObject() = default;
    virtual ~CraftObject() = default;

    static const TypeInfo* StaticClass()
    {
        static TypeInfo typeInfo("CraftObject", nullptr, nullptr);
        return &typeInfo;
    }

    virtual const TypeInfo* GetClass() const
    {
        return CraftObject::StaticClass();
    }

    template<typename T>
    bool Is() const
    {
        return GetClass()->IsChildOf(T::StaticClass());
    }
};

CraftObject는 루트 타입입니다.

따라서 부모 타입은 없습니다

static ::TypeInfo typeInfo("CraftObject", nullptr, nullptr);

🎯TYPE_DECLARATIONS 매크로 수정하기

이제 각 클래스는 자기 자신을 생성하는 함수를 등록해야 합니다.

이 매크로는 언리얼 엔진에서 언리얼 오브젝트 계열의 클래스를 생성할 때 추가하는 GENERATED_BODY() 매크로의 역할을 합니다.
구체적인 동작 매커니즘은 차이가 있지만, 큰 틀에서 하는 역할은 동일합니다.

즉, 특정 오브젝트 계열에서 동일하게 지원해야하는 기능(반복적인 코드)을 추가해주는 역할을 합니다.

#define TYPE_DECLARATIONS(Type, ParentType)                                                \
    using super = ParentType;                                                              \
public:                                                                                    \
    static std::shared_ptr<CraftObject> CreateObject()                                     \
    {                                                                                      \
        return std::make_shared<Type>();                                                   \
    }                                                                                      \
                                                                                           \
    static const ::TypeInfo* StaticClass()                                                 \
    {                                                                                      \
        static ::TypeInfo typeInfo(#Type, ParentType::StaticClass(), &Type::CreateObject); \
        return &typeInfo;                                                                  \
    }                                                                                      \
                                                                                           \
    virtual const ::TypeInfo* GetClass() const override                                    \
    {                                                                                      \
        return Type::StaticClass();                                                        \
    }

객체를 생성하는 관점에서 핵심은 아래 코드입니다.

static std::shared_ptr<CraftObject> CreateObject()
{
    return std::make_shared<Type>();
}

각 타입은 자기 자신을 생성하는 정적 함수를 가집니다.

그리고 StaticClass()에서 이 생성 함수를 TypeInfo에 등록합니다.

static ::TypeInfo typeInfo(#Type, ParentType::StaticClass(), &Type::CreateObject);

이제 TypeInfo는 다음 정보를 모두 갖게 됩니다.

  • 타입 이름
  • 부모 타입
  • 객체 생성 함수

이렇게 객체 생성 함수를 포인터로 미리 저장해두면, 타입 정보만 있어도 나중에 객체를 생성할 수 있습니다.


예제 클래스 작성

#pragma once

#include "Core/CraftObject.h"

class Actor : public CraftObject
{
    TYPE_DECLARATIONS(Actor, CraftObject)
};

class Player : public Actor
{
    TYPE_DECLARATIONS(Player, Actor)
};

이제 Player::StaticClass() 함수는 Player의 타입 정보(TypeInfo)를 반환합니다.
이 정보에는 Player 클래스를 식별할 수 있는 정보 뿐만 아니라 객체를 생성할 수 있는 함수 정보도 저장되어 있습니다.


TSubclassOf 구현하기

이제 TSubclassOf를 구현해보겠습니다.

#include <type_traits>

// 전방 선언.
class CraftObject;
template<typename T,
    typename = std::enable_if_t<std::is_base_of_v<CraftObject, T>>>
class TSubclassOf
{
public:
    TSubclassOf() = default;

    TSubclassOf(const ::TypeInfo* typeInfo)
    {
        if (typeInfo && typeInfo->IsChildOf(T::StaticClass()))
        {
            this->typeInfo = typeInfo;
        }
    }

    const ::TypeInfo* Get() const
    {
        return typeInfo;
    }

    bool IsValid() const
    {
        return typeInfo != nullptr;
    }

private:
    const ::TypeInfo* typeInfo = nullptr;
};

TSubclassOf는 커스텀 RTTI를 지원하는 클래스를 대상으로 합니다.
따라서 여기에서 중요한 부분은 템플릿의 조건입니다.

typename = std::enable_if_t<std::is_base_of_v<CraftObject, T>>

이 코드는 T 타입이 CraftObject의 자식 타입일 때만 TSubclassOf를 사용할 수 있게 조건을 설정합니다.

따라서 다음 코드는 가능합니다.

TSubclassOf<Player> playerType;

Player->Actor->CraftObject의 계층을 따르기 때문입니다.

하지만, CraftObject 상속 계층에 없는 타입, 예를 들어 int 등의 타입으로 TSubclassOf를 사용하려고 하면 오류가 발생합니다.

TSubclassOf<int> wrongType; // 컴파일 오류

이렇게 템플릿을 사용할 때 원하는 조건을 설정하면 컴파일 단계에서 잘못된 사용을 막을 수 있습니다.


생성 가능한 타입인지 검사하기

생성자에서는 전달된 typeInfoT의 자식 타입인지 확인합니다.

if (typeInfo && typeInfo->IsChildOf(T::StaticClass()))
{
    this->typeInfo = typeInfo;
}

다음 코드는 유효합니다.

TSubclassOf<CraftObject> objectType = Player::StaticClass();

PlayerCraftObject의 자식이기 때문입니다.

다음 코드도 유효합니다.

TSubclassOf<Player> playerType = Player::StaticClass();

하지만 만약 EnemyPlayer가 서로 다른 타입이라면, 다음과 같은 코드는 저장되지 않습니다.

TSubclassOf<Player> playerType = Enemy::StaticClass();

이 경우 EnemyPlayer의 자식이 아니므로 내부 typeInfonullptr 상태로 남습니다.

언리얼 엔진에서도 TSubclassOf의 잘못된 상속 계층에 있는 클래스 정보를 저장하려고 하면 nullptr가 저장됩니다.


SpawnActor 구현하기

이제 TSubclassOf를 이용해서 객체를 생성해보겠습니다.

template<typename T,
    typename = std::enable_if_t<std::is_base_of_v<CraftObject, T>>>
std::shared_ptr<T> SpawnActor(TSubclassOf<T> classType)
{
    if (!classType.IsValid())
    {
        return nullptr;
    }

    std::shared_ptr<CraftObject> object = classType.Get()->CreateInstance();

    if (!object)
    {
        return nullptr;
    }

    return std::static_pointer_cast<T>(object);
}

SpawnActor 함수에서도 T타입이 CraftObject 계열일 때만 사용이 가능하도록 제한했습니다.

typename = std::enable_if_t<std::is_base_of_v<CraftObject, T>>

그리고 내부에서는 TypeInfo가 저장하고 있는 생성 함수를 호출합니다.

std::shared_ptr<CraftObject> object = classType.Get()->CreateInstance();

마지막으로 반환 타입에 맞게 형변환합니다.

이미 앞에서 계층을 확인했기 때문에 빠르게 형변환을 위해 static 스타일의 형변환을 처리합니다.

return std::static_pointer_cast<T>(object);

사용 예제

이제 원하던 형태로 사용할 수 있습니다.

TSubclassOf<Player> playerType = Player::StaticClass();
std::shared_ptr<Player> player = SpawnActor(playerType);

부모 타입으로 저장하는 것도 가능합니다.

TSubclassOf<CraftObject> objectType = Player::StaticClass();

std::shared_ptr<CraftObject> object = SpawnActor(objectType);

이 구조의 핵심은 다음입니다.

  • 타입 정보를 변수처럼 저장하고, 나중에 그 타입으로 객체를 생성한다.

언리얼 엔진의 TSubclassOf와 비교

언리얼 엔진에서도 비슷한 개념을 사용합니다.

TSubclassOf<AActor> ActorClass;

이 변수는 AActor 또는 그 자식 클래스 타입을 저장할 수 있습니다.

이후 SpawnActor 같은 함수에서 해당 타입을 기반으로 객체를 생성합니다.

물론 언리얼 엔진의 동작 매커니즘은 훨씬 더 복잡합니다.

언리얼 엔진은 여기에 다음 기능들을 함께 연결합니다.

  • UClass
  • 리플렉션
  • 가비지 컬렉션
  • 에디터 속성
  • 직렬화(Serialization)
  • 블루프린트

이번 글에서 구현한 코드는 이중에서 가장 핵심이 되는 아이디어만 단순화한 것입니다.

  • 클래스 정보를 저장하고, 그 클래스 정보로 객체를 생성한다.

이 구조의 한계

현재 구조는 기본 생성자 기반입니다.

return std::make_shared<Type>();

따라서 생성자 인자가 필요한 타입은 바로 생성할 수 없습니다.

예를 들어 다음과 같은 생성자는 현재 구조에서 직접 지원하지 않습니다.

Player(int hp, float speed);

이 문제를 해결하려면 다음과 같은 확장이 필요합니다.

  • 별도의 팩토리 시스템
  • 생성 파라미터 구조체
  • 타입별 등록 시스템
  • ObjectInitializer 패턴
  • 템플릿 기반 생성 함수 확장

하지만 블로그 예제 단계에서는 기본 생성자 기반 구조만으로도 TSubclassOf의 핵심 개념을 이해하기에 충분합니다.

또한, 언리얼 엔진도 예제와 마찬가지로 UObject 계열의 객체는 생성자 형태가 제한되어 있습니다.


핵심 정리

이번 글에서는 언리얼 엔진의 TSubclassOf를 흉내 내는 구조를 직접 구현해봤습니다.

아래는 핵심 내용을 정리한 것입니다.

  • TypeInfo에 생성 함수 포인터를 추가
  • StaticClass()에서 타입 정보(TypeInfo)를 반환
  • TSubclassOf<T>는 특정 부모 타입 계열(CraftObject)의타입 정보만 저장
  • std::enable_if_t와 std::is_base_of_v로 잘못된 타입의 사용을 방지
  • SpawnActor 함수는 저장된 타입 정보를 이용해 객체를 생성

결국 이 구조는 단순한 타입 체크를 넘어섭니다.

  • 런타입 타입 정보(RTTI)가 객체 생성 시스템으로 확장되는 순간입니다.

마무리

RTTI는 단순히 “이 객체가 어떤 타입인가?”를 확인하는 기능으로 끝나지 않습니다.

타입 정보를 잘 설계하면 다음 단계로 확장할 수 있습니다.

  • 객체 생성
  • 팩토리 패턴
  • 데이터 기반 생성
  • 에디터 연동
  • 직렬화
  • 리플렉션

이런 구조들이 모여 게임 엔진의 런타임 시스템을 구성합니다.


한 단계 더 나아가고 싶다면

이 시리즈에서 다룬 RTTI 구조는 실제 게임 엔진 내부를 이해하는 데 중요한 기반이 됩니다.

특히 타입 정보, 객체 생성, 레벨 관리, 액터 구조는 서로 분리된 개념이 아니라 하나의 흐름으로 연결됩니다.

이러한 구조를 처음부터 직접 설계하고 구현해보는 과정은 생각보다 많은 시행착오를 필요로 합니다.

상용 엔진을 사용하는 데에서 한 단계 더 나아가, 엔진의 내부 구조를 직접 만들어보고 싶으시다면 아래 강의를 참고해보셔도 좋습니다.

👉 게임 엔진 구조를 더 깊이 이해하고 싶다면

아래 강의를 통해 직접 구현해보는 것을 추천드립니다.

C++로 만드는 게임 엔진 프레임워크 강의

👉 C++로 만드는 게임 엔진 프레임워크 강의 바로가기

단순히 사용하는 것을 넘어서,
엔진이 어떻게 동작하는지 이해하는 데 초점을 맞춘 내용입니다.

직접 구현해보는 경험은 분명 큰 도움이 됩니다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다