공식 문서에도 있고, 각종 블로그 에도 있고 많은 곳에 UClass, UObject 설명이 있지만, 굳이 덧붙일려고 한다. 아마 오류도 있을 수 있고, UE5.5.0 에서는 또 바뀌어 있을지도 모르지만, 그래도 정리해 보고자 한다.
이번 포스팅에서는 언리얼 엔진에서
에 대해서 다뤄 보기로 한다. 다른 포스팅에서도 그렇지만 여기서도 여러분이 기본적인 UCLASS(...), UFUNCTION(...), UPROPERTY(...) 의 코딩 방법은 알 고 있다고 가정한다. Code 가 좀 나오지만 그리 어렵지는 않으니 눈으로 한번 살펴보자.
UClass 는 특정 UObject 의 내부 정보를 읽기/쓰기/순회/호출 에 필요한 메타데이터를 제공하는데 주 목적이 있다. 공식 문서도 하도 여러군데 흩어져 있어서 따로 링크를 달지 않겠지만 UClass 의 주목적은 누가 뭐라고 해도 이런 Reflection 기능의 제공이다. C++에서는 기본적으로 지원하지 않으니 말이다. Reflection 기능의 사전적 정의를 먼저 살펴보자.
위에서 '클래서 생성' 은 BlueprintClass 생성이 담당하고 있다고 봐도 되겠다. Runtime 에서 언리얼엔진에서 Class 를 생성해서 사용하는 경우가 있을까... 싶긴하다.
Reflection을 한국 말로 굳이 번역하자면 '반영' 정도가 될 듯한데 딱 부러지게 맞는 용어가 아닌듯 하다. 그냥 리플렉션으로 받아 들이자.
Class UMyParent
UCLASS()
class EKUE54_API UMyParent : public UObject
{
GENERATED_BODY()
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 MyNumA = 1;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 MyNumB = 2;
UFUNCTION(BlueprintCallable)
int32 GetAddedNumA(int32 v);
};
Class UMyChild
UCLASS()
class EKUE54_API UMyChild : public UMyParent
{
GENERATED_BODY()
};
위의 Sample Code 가 있다고 했을때, MyNumA 를 String 기반으로 가져 올때는 다음과 같이 하면 된다.
void PrintMyNumA()
{
// UMyParent 클래스의 인스턴스가 있다고 가정
UMyParent* ParentInstance = NewObject<UMyParent>();
// 현재 클래스를 가져옴
UClass* Class = UMyParent::StaticClass();
// 'MyNumA'라는 이름의 프로퍼티를 찾음
FProperty* FoundProperty = Class->FindPropertyByName(FName("MyNumA"));
if (FoundProperty)
{ // 프로퍼티가 int32 형인지 확인
if (FIntProperty* IntProperty = CastField<FIntProperty>(FoundProperty))
{ // 이 클래스의 인스턴스에서 MyNumA 값 가져오기
int32 MyNumAValue = IntProperty->GetPropertyValue_InContainer(ParentInstance);
// 값을 로그로 출력
UE_LOG(LogTemp, Warning, TEXT("MyNumA Value: %d"), MyNumAValue);
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Property 'MyNumA' not found."));
}
}
// UPROPERTY 순회 예제
void PrintUProperties(UObject* Object)
{
if (Object == nullptr)
return;
UClass* Class = Object->GetClass();
for (TFieldIterator<FProperty> PropIt(Class); PropIt; ++PropIt)
{
FProperty* Property = *PropIt;
FString PropertyName = Property->GetName();
// 예제: int32 또는 float 값 가져오기
if (FIntProperty* IntProp = CastField<FIntProperty>(Property))
{
int32 Value = IntProp->GetPropertyValue(IntProp->ContainerPtrToValuePtr<void>(Object));
UE_LOG(LogTemp, Log, TEXT("%s: %d"), *PropertyName, Value);
}
else if (FFloatProperty* FloatProp = CastField<FFloatProperty>(Property))
{
float Value = FloatProp->GetPropertyValue(FloatProp->ContainerPtrToValuePtr<void>(Object));
UE_LOG(LogTemp, Log, TEXT("%s: %f"), *PropertyName, Value);
}
else
{
UE_LOG(LogTemp, Log, TEXT("Property %s is not int or float"), *PropertyName);
}
}
}
void PrintClassProperties(UClass* Class)
{
if (Class == nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("Class is null!"));
return;
}
// ChildProperties로부터 필드 순회
for (FField* Field = Class->ChildProperties; Field != nullptr; Field = Field->Next)
{ // 필드 이름 출력
UE_LOG(LogTemp, Log, TEXT("Field Name: %s"), *Field->GetName());
// 필드가 FProperty 타입이면 추가 정보를 출력
if (FProperty* Property = CastField<FProperty>(Field))
{
UE_LOG(LogTemp, Log, TEXT("Property Type: %s"), *Property->GetCPPType());
}
}
}
stirng 기반으로 GetAddedNumA 호출은 이와 같이 하면 된다.
void CallGetAddedNumA()
{
// UMyParent 클래스의 인스턴스 생성
UMyParent* ParentInstance = NewObject<UMyParent>();
// 현재 클래스의 UClass 정보 가져오기
UClass* Class = UMyParent::StaticClass();
// 'GetAddedNumA' 함수 찾기
UFunction* Function = Class->FindFunctionByName(FName("GetAddedNumA"));
if (Function)
{ // 파라미터 구조체 준비 (인자와 반환값을 위한 구조체)
struct FMyParams
{
int32 InputValue;
int32 ReturnValue; // 함수가 반환할 값을 저장
};
FMyParams Params;
Params.InputValue = 10; // 전달할 인자 값 설정
// 함수 호출: 파라미터 구조체를 전달하고, 함수는 반환값을 해당 구조체에 채워 넣음
ParentInstance->ProcessEvent(Function, &Params);
// 반환값을 로그로 출력
UE_LOG(LogTemp, Warning, TEXT("Returned Value: %d"), Params.ReturnValue);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("Function 'GetAddedNumA' not found."));
}
}
위 와 같은 동작이 C++ 에서 어떻게 가능한걸까? 그건 class 멤버 변수 와 함수를 해당 class 에서 상대 주소값(offset) 을 계산해두고 this+offset 기반으로 값을 읽고(메모리에서), 함수를 호출 할 수 있기 때문이다. 아래 예제를 살펴보자.
Source Code
#include <iostream>
#include <cstdint>
class MyClass {
public:
int a;
int b;
// 멤버 함수: c + a + b 출력
void PrintSum(int c) {
std::cout << "Sum: " << (c + a + b) << std::endl;
}
// static 멤버: 변수 a, b의 오프셋과 PrintSum 함수 포인터
static size_t offset_a;
static size_t offset_b;
static void (MyClass::* PrintSumOffset)(int);
};
// static 변수 정의
size_t MyClass::offset_a = offsetof(MyClass, a);
size_t MyClass::offset_b = offsetof(MyClass, b);
void (MyClass::* MyClass::PrintSumOffset)(int) = &MyClass::PrintSum;
int main() {
// MyClass 인스턴스 생성
MyClass MyObj;
MyObj.a = 10;
MyObj.b = 20;
// 포인터를 이용해 MyObj의 a, b 값을 가져오기
int* pA = reinterpret_cast<int*>(reinterpret_cast<uint8_t*>(&MyObj) + MyClass::offset_a);
int* pB = reinterpret_cast<int*>(reinterpret_cast<uint8_t*>(&MyObj) + MyClass::offset_b);
std::cout << "Value of a: " << *pA << std::endl; // a의 값 출력
std::cout << "Value of b: " << *pB << std::endl; // b의 값 출력
// PrintSum 함수를 호출 (포인터를 통해서)
(MyObj.*(MyClass::PrintSumOffset))(5); // c=5 인자로 전달
return 0;
}
Output
Value of a: 10
Value of b: 20
Sum: 35
일견 이상해 보일 수도 있지만 사실 이상할 건 없다. C++ Class 를 컴파일 하면 어셈블리 코드가 this 포인터에서 Offset 을 더해서 작동하게 원래 만들어지는데 그걸 C++ Source 단에서 직접 코딩한 것 뿐이다.
똑같진 않지만, 언리얼에서 FField 에서 값을 가져오는 부분을 살펴보자. 위 'Unreal Reflection Usage' 에서 int32 MyNumAValue = IntProperty->GetPropertyValue_InContainer(ParentInstance); 부분을 따라가면 아래 Code가 나온다.
UE_5.4/Engine/Source/Runtime/CoreUObject/Public/UObject/UnrealType.h
FORCEINLINE void* ContainerVoidPtrToValuePtrInternal(void* ContainerPtr, int32 ArrayIndex) const
{
checkf((ArrayIndex >= 0) && (ArrayIndex < ArrayDim), TEXT("Array index out of bounds: %i from an array of size %i"), ArrayIndex, ArrayDim);
check(ContainerPtr);
if (0)
{
// in the future, these checks will be tested if the property is NOT relative to a UClass
check(!GetOwner<UClass>()); // Check we are _not_ calling this on a direct child property of a UClass, you should pass in a UObject* in that case
}
return (uint8*)ContainerPtr + Offset_Internal + static_cast<size_t>(ElementSize) * ArrayIndex;
}
위에서 ContainerPtr 가 UObject 의 Pointer 이고 Offset_Internal 이 각 FField(여기서는 FIntProperty) 에 저장되어 있는 UClass 에서의 해당 변수 Offset Pointer 이다. ArrayIndex 는 배열 타입일 경우 몇 번쨰 인자인가 하는 것으로 여기서는 '0' 이라고 보면 된다.
결국 각 FField 가 UPROPERTY 에 있는 변수에 대해 'Name', 'Type', 'OfssetPointer' 를 가지고 있고 각 UObject 에 해당하는 UClass 의 Instance 가 FField 목록을 가지고 있다고 보면 된다.
언리얼이 실제로 저 값을 어떻게 채워서 UClass Instance 를 제공하는지는 아래에서 살펴기로 하고, 우선은 '아 저렇게 돌겠구나...' 하는 마음의 위안을 삼자. 이제 실제로 언리얼 에서 어떻게 구성되어 있는지 대략적으로 살펴보자.
우선 UObject, UClass 및 각종 Field 들의 관계를 간략하게 살펴보자. 아래 Diagram 중 UMyParent 와 UMyChild 는 우리가 Custom 하게 상속 받은 Class 로 가정한다.

UClass 가 UObject 와 쌍으로 가장 자주 사용하게 되는 Class 이고 Blueprint로 만들어지는 Class 를 대변하는 UBlueprintGeneratedClass 의 부모 Class 이다. 여기서는 Reflection 기능을 거의 제공해주는 UStruct에 초점을 맞추기 때문에 자세히 살펴 보지는 않겠다. GetSuperClass 의 경우 아래와 같이 되어 있다는 정도만 알고 넘어가자.
/** Returns parent class, the parent of a Class is always another class */
UClass* GetSuperClass() const
{
return (UClass*)GetSuperStruct();
}
...
/** Struct this inherits from, may be null */
UStruct* GetSuperStruct() const
{
return SuperStruct;
}
UBluerpintGeneratedClass 는 UBlueprint 에서 만들어지는 Class 이다. 이 내용은 UBlueprint with _C 를 다룰 다른 포스팅에서 자세히 살펴보기로 한다.
실제적으로 Reflection 기능을 제공하는 Class 이다. 간략히 살펴보면
FField 를 상속 받아 UPROPERTY(...) 에 해당하는 변수 정보 메타데이터를 가지고 있다고 보면 된다.
UField 계열과 FField 계열 두가지가 있는데, UPROPERTY(...) 계열이 UE5.1 쯔음 해서 UField -> FField 계열로 변경 되었다. 참고하도록 하자.
UObject 를 상속받은 UMyObject Class 를 만들었다고 했을때, 엔진이 구동 되면 UClass Instance 인 'MyObject: UClass' 와 UMyObject 의 ClassDefaultObject(이하 CDO) 인 'Default__MyObject: UMyObject' 가 생성된다.
MyObject->GetClass(), UMyObject::StaticClass() 로 'UClass' instance 를 가져 올 수 있고 해당 UClass Instance 로 부터 'GetDefaultObject()' 함수를 통해 CDO 에 접근 할 수 있다.
Class Default Object 의 목적은 말 그대로 Class 의 Default 값을 제공 하는 것이다. 언리얼 엔진에서 CDO 값은 어떻게 결정될까? 우선 Class 멤버변수 선언부, 생성자 상속 구조를 통해서 결정되고, Config 로 채운다움, BlueprintGeneratedClass 에서는 Data값으로 다시 덮어 씌워진다. 이 Default 값은, Editor 에서 변수 값이 변경 되었을때 기본값으로 Back 하는 기능(특정 Instance 와의 차이점 비교) 과 UObject Instance 를 NewObject 로 생성하지 않고, UClass 에서 CDO 를 통해 값을 가져 갈 수 있는 기능등을 제공 한다.
예를들어, 'B_MyBlueprint' 라는 Blueprint 를 만들었다면 해당 'B_MyBlueprint' 의 Package 를 로딩하고 'UClass B_MyBlueprint' 을 읽어 온 다음 CDO 를 통해 값을 가져 갈 수 있다.
또한 Config 로 비슷한 동작을 수행할 수 있다.
UMyConfig
#include "MyConfig.h"
#include "Engine/Engine.h"
#include "Misc/ConfigCacheIni.h"
UCLASS(config = Game)
class MYPROJECT_API UMyConfig : public UObject
{
GENERATED_BODY()
public:
// Configuration value
UPROPERTY(config)
int32 MyConfigValue;
};
DefaultGame.ini
[/Script/MyProject.MyConfig]
MyConfigValue=42
void PrintConfigValue()
{
// Get the CDO (Class Default Object) of UMyConfig
UMyConfig* DefaultConfig = GetDefault<UMyConfig>();
// Retrieve the config value
int32 ConfigValue = DefaultConfig->MyConfigValue;
// Log the value using UE_LOG
UE_LOG(LogTemp, Log, TEXT("Config Value from UMyConfig: %d"), ConfigValue);
}

특정 시점에서 Object Instance 관계를 살펴보자. 위 Diagram 에서 'ObjectName: ObjectType' 으로 표기 되어 있다.
NewObject<UMyChild>() 를 통해 'ChildObj' 라는 이름으로 Instance 를 생성 했다.그런데, 실제로는 여기에 한가지가 빠져 있다. 위의 Class Diagram 을 다시 보고 오자. UClass 도 UObject Child Class 인걸 알 수 있다. 'UObject' 에서 GetClass() 를 하면 해당하는 UClass Instance 를 얻어 올 수 있다. 그러면 MyChild: UClass Instance 에서 GetClass() 를 하면? 'Class: UClass' 가 나온다. 그기서 GetSuperClass() 를 하면? 'Struct: UClass' 가 나온다.
그럼 Unreal 에서는 UClass 의 내용을 어떻게 채워 놓는걸까? 이게 여러분들이 익히 잘 알고 있는 Unreal Build Tool(이하 UBT) 과 Unreal Header Tool(이하 UHT) 의 역할이고 이중 특히 UHT 가 UCLASS, UPROPERTY, UFUNCTION 을 파싱해서 메타데이터를 생성하는 Code를 Generated 한다. 다른 부분도 그렇지만 이 부분은 특히 필자도 자세히 알지는 못한다. 그래도 맛은 보고 가자.
Sample Code 'UMyParent' 에 있는 내용중 UMyParent::StaticClass() 코드가 어떻게 만들어 지는지 대략 살펴보고 , MyNumA, MyNumB, GetAddedNumA 의 정보가 UClass Instance 에 어떻게 기입 되는지 살펴보자.
당연히 이 마법같은 일은 아래 3개가 조합으로 이루어 진다. GENERATED_BODY 에서 기본 골격을 #define Macro 조합(이라고 쓰고 떡칠이라고 읽는다) 으로 뼈대를 만들고, 세부 뼈대 내용을 generated 된 코드로 채워 넣는 식이다. Project 이름이 EkUe54 일 경우 generated 된 코드는 아래 폴더에 만들어진다. EkUe54/Intermediate/Build/Win64/UnrealEditor/Inc/EkUe54/UHT/
GENERATED_BODY() GENERATED_BODY() 는 UE_5.4/Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h 에
GENERATEDBODY(...) BODY_MACRO_COMBINE(CURRENT_FILE_ID,,LINE,_GENERATED_BODY);
이렇게 되어있는데, Source File 이름과 라인수를 조합해서 유니크한 이름의 GENERATED_BODY 골격을 만들어 낸다. 이 중 소스 파일이름과 LINE 넘버는 C++ Compiler 가 컴파일 시에 대체 시켜 준다. 어떻게 보면 목적 Code 를 바로 만들어 주는 C++ 의 몇 안되는 장점 중 하나다.
MyParent.generated.h 에 보면
#define FID_ProjectC_EkUe54_Source_EkUe54_MyParent_h_15_GENERATED_BODY \
PRAGMA_DISABLE_DEPRECATION_WARNINGS \
public: \
FID_ProjectC_EkUe54_Source_EkUe54_MyParent_h_15_RPC_WRAPPERS_NO_PURE_DECLS \
FID_ProjectC_EkUe54_Source_EkUe54_MyParent_h_15_INCLASS_NO_PURE_DECLS \
FID_ProjectC_EkUe54_Source_EkUe54_MyParent_h_15_ENHANCED_CONSTRUCTORS \
private: \
PRAGMA_ENABLE_DEPRECATION_WARNINGS
가 만들어져 있는데 따라서 MyParent.h 에 있는 GENERATED_BODY() 는 위 내용으로 대체 된다.
INCLASS_NO_PURE_DECLS 은 아래와 같이 되어있고
#define FID_ProjectC_EkUe54_Source_EkUe54_MyParent_h_15_INCLASS_NO_PURE_DECLS \
private: \
static void StaticRegisterNativesUMyParent(); \
friend struct Z_Construct_UClass_UMyParent_Statics; \
public: \
DECLARE_CLASS(UMyParent, UObject, COMPILED_IN_FLAGS(0), CASTCLASS_None, TEXT("/Script/EkUe54"), NO_API) \
DECLARE_SERIALIZER(UMyParent)
DECLARE_CLASS 는 ObjectMacros.h 에
#define DECLARE_CLASS( TClass, TSuperClass, TStaticFlags, TStaticCastFlags, TPackage, TRequiredAPI ) \
private: \
TClass& operator=(TClass&&); \
TClass& operator=(const TClass&); \
TRequiredAPI static UClass* GetPrivateStaticClass(); \
public: \
/** Bitwise union of #EClassFlags pertaining to this class.*/ \
static constexpr EClassFlags StaticClassFlags=EClassFlags(TStaticFlags); \
/** Typedef for the base class ({{ typedef-type }}) */ \
typedef TSuperClass Super;\
/** Typedef for {{ typedef-type }}. */ \
typedef TClass ThisClass;\
/** Returns a UClass object representing this class at runtime */ \
inline static UClass* StaticClass() \
{ \
return GetPrivateStaticClass(); \
} \
...
로 되어 있다. 짜잔... 드디어 UMyParent::StaticClass() 코드가 어디서 만들어 지는지 찾게 되었다.
이 부분은 필자도 자세히 살펴 보지는 못했지만 위의 설명을 참고하면 어떤 동작들을 하는지 감을 잡기엔 충분 하리라 본다. 세부 내용은 엔진 업데이트마다 수시로 바뀐다. MyParent.gen.cpp 에 보면
static constexpr UECodeGen_Private::FMetaDataPairParam NewProp_MyNumA_MetaData[] = {
{ "Category", "MyParent" },
{ "ModuleRelativePath", "MyParent.h" },
};
static constexpr UECodeGen_Private::FMetaDataPairParam NewProp_MyNumB_MetaData[] = {
{ "Category", "MyParent" },
{ "ModuleRelativePath", "MyParent.h" },
};
...
static const UECodeGen_Private::FIntPropertyParams NewProp_MyNumA;
static const UECodeGen_Private::FIntPropertyParams NewProp_MyNumB;
static const UECodeGen_Private::FPropertyParamsBase* const PropPointers[];
static UObject* (*const DependentSingletons[])();
static constexpr FClassFunctionLinkInfo FuncInfo[] = {
{ &Z_Construct_UFunction_UMyParent_GetAddedNumA, "GetAddedNumA" }, // 3525413116
};
...
const UECodeGen_Private::FIntPropertyParams Z_Construct_UClass_UMyParent_Statics::NewProp_MyNumA = { "MyNumA", nullptr, (EPropertyFlags)0x0020080000000005, UECodeGen_Private::EPropertyGenFlags::Int, RF_Public|RF_Transient|RF_MarkAsNative, nullptr, nullptr, 1, STRUCT_OFFSET(UMyParent, MyNumA), METADATA_PARAMS(UE_ARRAY_COUNT(NewProp_MyNumA_MetaData), NewProp_MyNumA_MetaData) };
const UECodeGen_Private::FIntPropertyParams Z_Construct_UClass_UMyParent_Statics::NewProp_MyNumB = { "MyNumB", nullptr, (EPropertyFlags)0x0020080000000005, UECodeGen_Private::EPropertyGenFlags::Int, RF_Public|RF_Transient|RF_MarkAsNative, nullptr, nullptr, 1, STRUCT_OFFSET(UMyParent, MyNumB), METADATA_PARAMS(UE_ARRAY_COUNT(NewProp_MyNumB_MetaData), NewProp_MyNumB_MetaData) };
const UECodeGen_Private::FPropertyParamsBase* const Z_Construct_UClass_UMyParent_Statics::PropPointers[] = {
(const UECodeGen_Private::FPropertyParamsBase*)&Z_Construct_UClass_UMyParent_Statics::NewProp_MyNumA,
(const UECodeGen_Private::FPropertyParamsBase*)&Z_Construct_UClass_UMyParent_Statics::NewProp_MyNumB,
};
가 있다. 시간될 때 한번 살펴 보기 바란다. C++ 에서는 class 밖에 있는 global 한 변수는 모듈이 올라 올때 초기화 되는것을 알아두자.