나만의 작은 도서관

[TIL][C++] 250918 MMO 서버 개발 104일차: [언리얼] UObject, 모듈 로딩 과정, UClass와 Class Default Object(CDO) 본문

Today I Learn

[TIL][C++] 250918 MMO 서버 개발 104일차: [언리얼] UObject, 모듈 로딩 과정, UClass와 Class Default Object(CDO)

pledge24 2025. 9. 18. 22:45
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

[언리얼] UObject

  • UObject는 언리얼 엔진이 관리하는 특수한 객체. 언리얼이 관리해줬으면 하는 객체들은 UObject 또는 UObject의 하위 클래스를 상속받으면 된다.
  • 언리얼에서 사용하는 모든 클래스가 UObject를 상속받는 구조가 될 필요는 없기 때문에 굳이 언리얼 시스템에 등록하고 싶지 않다면 네이티브 클래스를 사용해도 된다.

 

언리얼 오브젝트(UObject)가 되었을 때 추가되는 기능들

  • CDO(Class Default Object): 객체의 초기값을 언리얼에서 관리한다.
  • Reflection : Serialize 된 객체의 구조를 런타임에서 확인할 수 있게 된다.
  • GC(Garbage Collection) 호환: 더 이상 해당 객체를 참조하는 대상이 없어질 경우(Garbage가 된 경우) 알아서 메모리를 해제해 준다.
  • Replication: 언리얼 서버를 사용하는 경우, 해당 객체에 대해 동기화를 시킬 수 있다.
  • Editor Integration: 언리얼 에디터에서 값을 편집할 수 있게 된다.(editable)
  • 그 외 기타 등등…

[언리얼] 모듈 로딩 과정

빌드 시점: “. generated.h” 파일 생성

  • UHT가 UObject 기반 클래스가 존재하는. h 파일에 가서 UPROPERTY()나 UFUNCTION()과 같은 매크로 함수들을 파싱. 파싱 된 결과물은 “. generated.h”에 보일러플레이트 코드 형태로 기록된다.
    • 참고로. generated.h에는 메타데이터가 들어있는 게 아니라, 메타데이터를 생성할 수 있는 설계도와 같은 코드가 들어있다.

엔진 구동 시점: 각 모듈(ex. Engine, CoreUObject 등)을 로드

  • 엔진이 구동되면, 언리얼은 모듈 방식으로 사용하기 때문에 사용하는 모듈을 로드한다.

 

각 모듈 로드 시: UClass와 CDO 생성

  • 모듈 로드 시, 모듈에 속한 모든 UObject 기반 클래스에 대한 UClass를 생성한다. UClass 타입 인스턴스 생성에. generated.h가 사용된다.
  • CDO의 경우 UClass 인스턴스가 생성된 이후에 생성되며, CDO는 해당 클래스의 기본값을 가지고 있는 UObject 인스턴스이다.

GENERATED_BODY의 역할?

  • GENERATED_BODY는 빌드 시점에서. generated.h 파일의 일부 코드를 삽입하는 매크로 함수이다. 즉,. generated.h 파일이 갱신된 다음 갱신된 내용 중 일부 코드가 GENRATED_BODY에 삽입되는 것이다.
  • 이렇게 삽입된 코드로 인해 매크로 함수가 붙은 멤버들이 리플렉션 시스템에 등록된다.

[언리얼] UClass와 Class Default Object(CDO)

  • 간단하게 말하여서 UClass는 UObject 기반 클래스의 메타데이터가 들어있는 인스턴스, CDO는 기본값으로 세팅된 인스턴스이다.
    • 여기서 메타데이터는 “데이터에 대한 데이터”를 의미하며, 그 정보의 특성을 설명해 주는 보조적인 정보이다. (메타데이터가 뭔지에 대해 예를 들면, 오리가 있을 때 오리가 어떻게 생겨먹었는지에 대한 설명이 적혀있는 글이라고 보면 된다.)
  • 각 UObject 기반 클래스에 대한 UClass와 CDO은 런타임에 인스턴스가 만들어지며, 모듈 로드 시점에 생성된다. 또한 이 둘은 모두 전역적으로 관리되는 객체이다.

 

UClass의 용도는?

  • UClass는 메타데이터를 담고 있는 컨테이너로써, 1) 리플렉션 정보를 저장, 2) 프로퍼티 목록 관리, 3) 함수 목록 관리 등에 사용된다. 코드로 보면 아래와 같다.
// 런타임에 클래스 정보 조회 가능해짐
UClass* ActorClass = AMyActor::StaticClass(); // 참고로 얘는 GENERATED_BODY가 삽입한 함수이다
FString ClassName = ActorClass->GetName(); // 클래스 이름은? -> "MyActor"
UClass* ParentClass = ActorClass->GetSuperClass();  // 상위 클래스는? -> AActor 클래스

// 클래스의 모든 프로퍼티(멤버 변수) 조회 가능
for(FProperty* Property : TFieldRange<FProperty>(ActorClass))
{
	FString PropName = Property->GetName();    // 변수 이름은?
	FString PropType = Property->GetCPPType(); // 변수 타입은?
}

// 클래스의 함수를 이름으로 조회 가능
UFunction* Function = ActorClass->FindFunctionByName(TEXT("MyFunction"));
if (Function)
{

}

// 런타임에 오브젝트 생성
UObject* NewObject = NewObject<UObject>(GetTransientPackage(), ActorClass);
AActor* NewActor = GetWorld()->SpawnActgor(ActorClass); // 생성한 오브젝트 스폰
  • 위와 같이 네이티브에서도 메타데이터에 접근할 수 있지만, 가장 요긴하게 쓰이는 건 아무래도 리플렉션 시스템에 의해 에디터에서도 실시간으로 클래스 정보를 볼 수 있다는 것일 거다. (컴파일해서 바이너리 덩어리가 된 상태에선 당연하게도 함수나 변수의 이름이 더 이상 남아있지 않는다. 그럼에도 에디터에서 이름으로 볼 수 있는 이유는 UClass가 가지고 있는 메타데이터와 리플렉션 시스템 때문이다.)

 

+) 추가글. StaticClass가 아니더라도 UObject 기반 클래스는 자기 자신에 대한 UClass 정보를 항상 보유하고 있다.

  • UObject 기반 클래스는 자기 자신에 대한 UClass 정보를 항상 보유합니다. 내부적으로 UObjectBase에 UClass* ClassPrivate 멤버가 있으며, 이를 통해 런타임에 GetClass() 같은 멤버 함수를 호출할 수 있다.
  • 따라서, 아래 코드와 같이 자기 자신의 UClass 메타데이터를 알 수 있다.
void UMyObject::Test()
{
    UClass* MyClass = GetClass();

    // 클래스 이름 출력
    UE_LOG(LogTemp, Log, TEXT("Class Name: %s"), *MyClass->GetName());

    // 특정 UProperty나 Function 탐색
    UFunction* Func = MyClass->FindFunctionByName(TEXT("SomeFunction"));
    if (Func)
    {
        UE_LOG(LogTemp, Log, TEXT("Found Function: %s"), *Func->GetName());
    }
}

 

 

CDO의 용도는?

  • CDO는 클래스의 기본값을 저장하는 템플릿 오브젝트로, 언리얼에서는 새 인스턴스 생성 시 CDO를 복제해서 만든다. (대충 CDO가 인스턴스 생성 때마다 사용된다는 뜻)
  • 언리얼은 왜 이런 방식을 선택했는가? 한다면 아래와 같은 이유 때문이다. (가장 큰 이유는 “초기화 비용 최적화”)

 

이유 1) 초기화 비용 최적화

  • 클래스의 모든 기본값, 컴포넌트 구조, 프로퍼티 설정을 CDO에서 미리 완료
  • 새 인스턴스 생성이 CDO의 메모리를 복사해서 만들면 됨.
  • 이렇게 CDO를 복제하는 방식을 사용하면 복잡한 초기화 로직을 매번 실행하지 않아도 되어 인스턴스 생성 코스트 감소

이유 2) 메모리 효율성

  • 모든 기본값이 CDO에 저장되어 있으므로 중복 저장되는 경우를 방지할 수 있음
  • 인스턴스는 CDO와 다른 값만 저장한다.
// 기존 방식이라면 매번 실행
AMyActor::AMyActor() {
    // 복잡한 컴포넌트 생성
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    // 많은 프로퍼티 설정
    Health = 100.0f;
    Speed = 600.0f;
    // 기타 초기화...
}

// CDO 방식: 위 초기화는 CDO에서 한 번만, 이후는 복사

 

이유 3) 런타임 성능 향상

  • 초기화 비용 최적화와 연계되는 이야기. 액터를 생성하는 비용이 적어지면, 당연히 동적으로 액터를 스폰할 때 비용이 줄어들어 성능이 올라간다.

 

추가 팁) UObject의 생성자는 CDO만 호출한다.

  • CDO라는 개념을 알게 되었다면 위 문장의 이유를 쉽게 이해할 수 있을 것이다.
  • CDO가 만들어진 다음, 다음 인스턴스 생성 때부터 전부 메모리 복사로 인스턴스가 만들어지니 클래스의 생성자를 호출하는 대상도 CDO 밖에 없다.
    • 즉, 실제 게임 플레이에서 생성자 코드는 사용되지 않는다.
  • 인스턴스마다 다른 초기화를 하고 싶다면, Init이나 BeginPlay를 사용하면 된다.

 

CDO를 이용한 UObject 인스턴스 생성 함수 2개

  • 모든 UObject 인스턴스는 CDO로 생성되는데, 이때 생성하는 위치에 따라 사용하는 함수가 2개로 갈린다. 첫 번째는 CreateDefaultSubobject로 생성자에서만 사용하며, 두 번째는 NewObject로 런타임에 동적으로 생성할 때 사용한다.

 

CreateDefaultSubobject

// 생성자에서
AMyActor::AMyActor()
{
    // CDO를 통한 컴포넌트 생성
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
}
  • CreateDefaultSubobject는 대충 위와 같이 사용하는데, 이 경우는 UObject가 UObject를 멤버로 가지고 있는, 즉, 포함 관계에 놓여있을 때 사용하는 함수이다.
  • 포함 관계에 있다고 무조건 생성자에서 할당할 필요는 없다. 나중에 NewObject 함수를 이용해 메모리를 할당할 수도 있지만, 생성자에서 할당하면 클래스 정의 시점에서 정적 구조를 설정할 수 있다는 장점이 있다. 이 경우 아래의 장점도 추가된다.
    • 메모리 효율성: CDO는 한 번만 생성되어 모든 인스턴스가 공유
    • 에디터 통합: 블루프린트 에디터에서 기본값 설정 가능
    • 직렬화: CDO의 속성은 자동으로 직렬화되어 저장

 

주의) CreateDefaultSubobject는 액터나 컴포넌트의 생성자에서만 사용 권장

  • CreateDefaultSubobject는 액터나 컴포넌트가 아닌 UObject 타입인 경우 오류는 발생하지 않지만 정상적으로 메모리 복사가 이루어지지 않을 수도 있다고 한다. 따가서, GameInstance와 같은 액터도, 컴포넌트도 아닌 UObject의 경우 CreateDefaultSubobject는 사용하면 안 된다.

 

실제로 nullptr을 반환했던 코드

UP1GameInstance::UP1GameInstance() { 
	InventoryHelper = CreateDefaultSubobject<UInventory>(TEXT("InventoryComponent")); 
	EquippedGearHelper = CreateDefaultSubobject<UEquippedGear>(TEXT("EquippedGearComponent")); 
}

 

 

NewObject

// 일반 함수에서
void AMyActor::SpawnDynamicObject()
{
    // 런타임 동적 생성
    UMyObject* DynamicObject = NewObject<UMyObject>(this);
}
  • NewObject는 런타임에 메모리를 할당하는 방식으로, 게임 실행 도중에 객체를 생성한다는 특징이 있다. 이 경우 아래와 같은 장점이 있다.
    • 가비지 컬렉션: NewObject로 생성된 객체는 GC 시스템에 의해 관리
    • 유연성: 조건에 따라 다른 타입의 객체 생성 가능
    • 소유권 관리: Outer 객체를 명시하여 소유권 체계 구축

 

이 둘의 핵심 차이점은?

  • CDO: 클래스 정의 시점의 정적 구조 설정
  • NewObject: 런타임의 동적 객체 생성