나만의 작은 도서관
[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: 런타임의 동적 객체 생성
