[Unreal Engine] Reflection System - Generation 1

Imeamangryang·2025년 6월 24일

Unreal Reflection System

목록 보기
2/10
post-thumbnail

출처 : staticJPL - Unreal-Engine-Core-Documentation

Generation


Reflection and Type Systems

리플렉션은 런타임에 타입 정보를 획득하는 시스템입니다. 이 타입 정보는 객체와 연관된 정적 세부 정보를 제공합니다. 가비지 컬렉션은 클래스 객체를 생성할 때 리플렉션이 없어도 정상적으로 동작할 수 있습니다. 타입 시스템은 런타임에 타입을 얻는 과정을 의미하며, 타입 정보를 활용해 객체를 역으로 생성하거나 속성을 읽고 수정할 수 있습니다. 현재 C++은 리플렉션 타입 시스템을 지원하지 않으며(C++ RTTI 제외), C#은 이를 지원합니다.

C# 리플렉션 예시와 주요 포인트:

  1. Assembly: DLL을 의미하며, 일반적으로 어셈블리와 연관됩니다.
  2. Module: 어셈블리 내부의 하위 모듈 구성을 나타냅니다.
  3. Type: 클래스 객체를 설명하며, 객체 타입에 대한 포괄적인 정보를 제공합니다. 타입들은 BaseType, DeclaringType 등 속성을 통해 서로 관계를 맺을 수 있습니다.
  4. ConstructorInfo: 타입 내 생성자를 설명하며, 특정 생성자 호출이 가능합니다.
  5. EventInfo: 타입에 정의된 이벤트를 설명하며, 언리얼 엔진의 델리게이트와 유사합니다.
  6. FieldInfo: 타입의 필드를 설명하며, C++ 멤버 변수와 같아 동적으로 값을 읽고 수정할 수 있습니다.
  7. PropertyInfo: 타입의 프로퍼티를 설명하며, C++의 get/set 메서드 조합과 유사합니다. 획득 후 프로퍼티 값을 접근할 수 있습니다.
  8. MethodInfo: 타입의 메서드를 설명합니다. 획득 후 동적으로 메서드 호출이 가능합니다.
  9. ParameterInfo: 메서드의 각 파라미터를 설명합니다.
  10. Attributes: 타입에 추가적인 기능을 부여하는 특성으로, C++에는 없으며 클래스에 정의된 메타데이터로 이해할 수 있습니다.

Dynamic_Cast in C++

dynamic_cast 연산자는 기반 클래스 포인터나 참조가 파생 클래스 객체를 가리킬 때, 이를 파생 클래스의 포인터나 참조로 변환하는 데 사용됩니다. 이 연산자는 가상 함수가 포함된 클래스에만 적용됩니다. 참조 변환이 실패하면 bad_cast 예외가 발생하고, 포인터 변환이 실패하면 null이 반환됩니다.

내부적으로 dynamic_cast는 가상 함수 테이블에 저장된 타입 정보를 활용해 기반 클래스 포인터가 실제로 파생 클래스 객체를 가리키는지 판단합니다. 주 목적은 런타임에 객체 포인터가 특정 하위 클래스의 인스턴스인지 확인하는 것입니다.


언리얼 엔진의 UHT 솔루션

언리얼 헤더 툴(UHT)은 언리얼 엔진에서 현재 사용되는 솔루션입니다.

초기 언리얼 엔진은 C++로 자체 리플렉션 시스템을 구현해야 했습니다. 이후 버전에서는 언리얼 헤더 툴의 로직이 더 나은 라이브러리 지원을 위해 C# 언어로 이전되었습니다.

이 설계의 주요 장점은 C++ 코드에 단순히 빈 태그 매크로를 추가하는 것만으로 비교적 작은 변경만으로도 리플렉션 기능을 도입할 수 있다는 점입니다. 이를 통해 프로그래머는 원래의 클래스 선언 구조를 해치지 않고 메타데이터와 코드를 자연스럽게 연결할 수 있습니다.

언리얼 헤더 툴의 영감을 이해하기 위해 아래 이미지는 Clang Compilation Driver를 보여줍니다. 요약하면, 컴파일러 드라이버는 CPP 파일을 토큰으로 파싱하며, 이는 어휘 분석(Lexical Analysis) 과정의 일부입니다. 이후 추상 구문 트리(AST)를 순회하여 소스 코드를 "LLVM 중간 표현"으로 생성하고, 이후 특정 플랫폼의 툴체인으로 컴파일됩니다.

컴파일러 이론을 너무 깊이 파고들지는 않겠지만, 일반적인 어휘 분석기(lexical analyzer) 아키텍처는 아래 이미지와 같습니다.

Token(토큰): 소스 코드에서 정보 단위를 나타내는 문자 시퀀스입니다.

Pattern(패턴): 토큰이 사용하는 설명 방식을 패턴이라 합니다.

Lexeme(렉심): 토큰의 패턴과 일치하는 소스 코드 내 문자 시퀀스로, 토큰의 인스턴스라고도 합니다.

이제 이러한 개념을 바탕으로 언리얼 엔진 도구의 기본을 살펴볼 수 있습니다.


UHT

UHT의 실행 경로는 다음과 같이 일반화할 수 있습니다.

  1. UBT가 모듈의 메이크파일을 생성할 때, 이 메이크파일이 UHT에 전달되어 매니페스트 파일을 생성합니다. 매니페스트에는 모듈의 BaseDirectory, OutputDirectory, ClassHeaders, Public Headers 등 정보가 담깁니다.
  2. 모든 모듈은 "Classes Folder"에 대한 절대 경로를 가지고 있으며, 마찬가지로 Public Folders는 "PublicUObjectHeaders" 폴더에 위치합니다. 같은 원리로 Private은 "PrivateUObjectHeaders" 폴더에 위치합니다.
  3. 모듈 준비가 끝나면, UHT는 엔진이 사용하는 패키지 외부 계층 구조를 반영하는 UPackage를 생성합니다. 이 패키지는 UObjects를 자신의 Outer와 함께 캡슐화합니다. 이 시점에서 "헤더가 파서를 위해 준비"됩니다.
  4. Unreal의 렉서(lexar) 파서는 문자열 데이터를 토큰(기호와 식별자)으로 분할합니다. 토크나이저는 {에서 };까지의 데이터 구조를 스코프하여 괄호 사이의 모든 내용을 파싱합니다. 헤더 파서는 UHT 타입별로 특정 파서를 위임하며, 각 매크로 타입(UClass, UStruct, UProperty, UInterface, UFunction 등)에 대해 별도의 파서가 존재합니다.
  5. 토큰들은 심볼 테이블로 해석되며, 심볼 테이블은 Engine, Source 두 개의 서브 테이블을 포함합니다.
  6. 심볼 테이블이 타입으로 "채워진(populated)" 후 "해결(resolved)"됩니다. 여기서 resolved란 각 UHT 타입이 고유하게 처리되는 것을 의미합니다. 각 UHT 타입이 해결될 때 고유 처리를 위한 함수 델리게이트가 호출됩니다. 헤더가 모두 해결되면 Exporter가 실행될 준비가 됩니다.
  7. Exporter는 Export Factory를 생성하고, 특정 Exporter의 Run 델리게이트를 호출합니다. 흥미롭게도 Epic은 자신만의 Exporter를 플러그인으로 실행할 수 있는 훅 포인트를 제공합니다. 이를 통해 엔진과 함께 자체 코드 생성 시스템을 UHT와 병행해 구현할 수 있습니다.
  8. CodeGen은 타입 시스템 코드를 generated.h, gen.cpp 파일로 생성하는 전용 Exporter이며, 이 파일들은 Intermediate 폴더에 저장됩니다.

UHT.cs 파일을 보면 이 프로세스가 잘 구현되어 있고, 전체 흐름을 명확하게 확인할 수 있습니다.

StepReadManifestFile(manifestFilePath);  
StepPrepareModules();  
StepPrepareHeaders();  
StepParseHeaders();  
StepPopulateTypeTable();  
StepResolveInvalidCheck();  
StepBindSuperAndBases();  
RecursiveStructCheck();  
StepResolveBases();  
StepResolveProperties();  
StepResolveFinal();  
StepResolveValidate();  
StepCollectReferences();  
TopologicalSortHeaderFiles();
// 참조 디렉터리를 삭제 중이라면, 해당 작업이 완료될 때까지 대기
if (_referenceDeleteTask != null)  
{  
	Logger.LogTrace("Step - Waiting for reference output to be cleared.");  
	_referenceDeleteTask.Wait();  
}  
  
Logger.LogTrace("Step - Starting exporters.");  
StepExport();

또한, CURRENT_FILE_ID와 EVENT_PARAMS 매크로가 정의되어 있습니다.
FNativeClassHeaderGenerator::FNativeClassHeaderGenerator(const UPackage, const TSet<FUnrealSourceFile>&, FClasses&, bool)

ObjectMacros.h 내부에는 IDE의 구문 강조 및 자동 완성 정보를 제공하기 위해 UC 네임스페이스가 정의되어 있습니다.

namespace UC
{
// UCLASS 매크로에서 사용할 수 있는 유효한 키워드들
enum
{
/// 이 키워드는 에디터에서 클래스가 표시되는 액터 그룹을 설정하는 데 사용됩니다.
classGroup,
/// 이 클래스를 인스턴스화할 때 항상 지정된 클래스의 Outer를 갖도록 선언합니다. 이는 하위 클래스에 상속되며, 오버라이드하지 않는 한 유지됩니다.
Within, /* =OuterClassName */
/// 이 클래스를 블루프린트에서 변수로 사용할 수 있는 타입으로 노출합니다.
BlueprintType,
/// 이 클래스를 블루프린트에서 변수로 사용할 수 없도록 합니다.
NotBlueprintType,
/// 이 클래스를 블루프린트 생성 시 허용되는 기본 클래스로 노출합니다. 기본값은 NotBlueprintable이며, 상속 시 변경될 수 있습니다.
Blueprintable,
/// 이 클래스를 블루프린트 생성 시 허용되지 않는 기본 클래스로 지정합니다. 기본값은 NotBlueprintable이며, 상속 시 변경될 수 있습니다.
NotBlueprintable,
...

Object Reflection Type System Structure

앞서 언급했듯이, 언리얼의 오브젝트 시스템 설계는 타입 시스템에서 출발합니다. 언리얼은 C# 기반의 Unreal Header Tool(UHT)을 활용해 리플렉션 정보를 수집하고 코드를 생성합니다. 이 리플렉션 과정의 이해는 UObject의 생성과 활용, 특히 블루프린트, 가비지 컬렉션, 직렬화 등 다양한 시스템과의 연계 방식을 파악하는 데 중요한 통찰을 제공합니다.

코드가 아닌 데이터 파일로 리플렉션 정보를 생성하지 않는 핵심 이유는 동기화에 있습니다. 리플렉션 데이터를 C++ 코드로 생성하면, 컴파일 시점에 바이너리와의 정합성을 보장할 수 있습니다. 이는 패키지의 정적 초기화와 동적 링크 과정이 UClass와 UObject의 리플렉션 계층 구조 생성과 밀접하게 연결되어 있을 때 특히 중요합니다.

간단한 Hello 클래스 리플렉션 예시

#include "Hello.generated.h"

UCLASS()
class Hello
{
public:
	UPROPERTY()
	int Count;
	UFUNCTION()
	void Say();
};

tructure Diagram

간단히 말해, 모든 프로그램은 타입과 중첩 함수로 구성됩니다. 타입에는 Enum, struct, class가 있으며, 클래스 내부에는 필드, 함수, 그리고 서브타입(중첩 타입)을 정의할 수 있습니다.

색상 체계:

  • 노란색: 선언부(UStruct, UEnum, UClass, UScriptStruct, UFunction 등)
  • 초록색: UProperty, 필드 정의를 나타냄
  • 주황색: UField, 다음 필드를 가리키는 포인터로 연결 리스트 순회에 사용
  • 보라색: UMeta, UPackage 정보를 보관
  • 빨간색: 최상위 UObject 타입

타입은 개념적으로 두 가지로 나눌 수 있습니다: 집합 타입(Aggregate Type)과 원자 타입(Atomic Type)

  • 집합 타입(Aggregate Type): C++에서 여러 개의 서로 다른 타입의 값을 하나의 이름 아래 묶는 타입입니다. 집합 타입의 객체는 여러 하위 객체(예: struct, 배열 등)를 포함할 수 있으며, 이 하위 객체 역시 다른 집합 타입일 수 있습니다. 집합 타입의 멤버는 점(.) 또는 인덱스([]) 표기법으로 접근합니다.
  • 원자 타입(Atomic Type): C++에서 더 이상 분해할 수 없는 단일 값을 나타내는 타입입니다. int, float, char 등과 같이 데이터 조작의 기본 단위가 되는 타입입니다. 원자 타입은 더 작은 의미 있는 단위로 나눌 수 없으며, 산술 및 논리 연산의 직접적인 대상이 됩니다.

Aggregate Types

  • UFunction: 함수의 입력 및 출력 파라미터로 속성만 가질 수 있습니다.

  • UScriptStruct: C++의 POD(Plain Old Data) 구조체와 유사하며, 속성만 포함할 수 있습니다. 언리얼 엔진에서는 리플렉션, 직렬화, 네트워크 복제 등을 지원하는 "경량" UObject 역할을 합니다. 일반 UObject와 달리 가비지 컬렉터의 관리 대상이 아니므로 메모리 할당과 해제를 직접 관리해야 합니다. UScriptStruct는 UStruct를 상속합니다.

  • UClass: 속성과 함수 모두를 포함할 수 있으며, 가장 자주 사용되는 타입입니다.

Atomic Types

  • UEnum: 일반적인 열거형(enum)과 enum class를 모두 지원합니다.
  • 기본 타입(Basic Types): int, FString 등과 같은 타입은 별도의 선언 없이 사용 가능하며, 각각에 대응하는 UProperty의 하위 클래스로 지원됩니다.
  • Super Struct: UStruct 상속 계층의 루트로, Super Struct는 부모/최상위 역할을 합니다. C++의 UStruct 매크로로 생성된 타입 데이터는 UScriptStruct로 표현되며, 이는 모든 집합 타입을 UStruct 기반으로 통합합니다.

UInterface

  • C++의 가상 클래스처럼, UInterface는 여러 인터페이스를 상속할 수 있습니다. 단, 함수만 포함해야 합니다.

  • 일반 클래스는 UObject를 상속해야 하며, UInterface는 특별한 클래스이지만 여전히 UClass에 저장됩니다.

FProperty

필드의 타입 인스턴스는 언리얼 엔진에서 필드의 타입을 나타냅니다. Unreal의 Property 시스템은 다양한 하위 카테고리를 가지며, FProperty는 템플릿을 통해 여러 하위 클래스로 인스턴스화됩니다. 이 타입은 FField 클래스를 상속합니다.

Link()가 호출되면 FField는 Int32 Offset을 할당받습니다.

이 과정에 대한 자세한 내용은 문서 후반부 또는 UObject Construction & Post Initialization Document에서 더 깊이 다룹니다.

이 설계는 Blueprint Virtual Machine에서 UMyObject의 구체적인 타입을 몰라도 복사/할당이 가능하도록 해줍니다.

Struct는 커스텀 생성자를 가지며, TArray는 Memcopy로 복사할 수 없습니다. 이를 해결하기 위해 FProperty는 InitializeValue_Container, CopyCompleteValue와 같은 가상 함수를 제공합니다.

UMetaData

UMetaData는 TMap<FName, FString> 형태의 키-값 쌍으로, 오직 에디터에서만 사용됩니다. 이 쌍은 에디터에 분류, 표시 이름, 안내 메시지 등의 정보를 제공합니다.

FField

FField는 리플렉션 데이터 객체의 기반 클래스입니다.

"FField"라는 이름은 선언이든 정의든 타입 시스템 내에서 필드로 간주될 수 있음을 의미합니다.

참고: FField는 최근에 도입된 타입입니다. 엔진 소스를 살펴보면, 블루프린트와의 호환성을 위해 UField와의 브릿지 계층이 존재함을 알 수 있습니다. 앞으로 Epic은 UProperty에서 FProperty로의 전환을 통해 사용 비용을 낮추려는 의도를 가지고 있습니다.

FField 기반 클래스의 의도는 무엇인가요?

Dazhao는 UProperty, UStruct, UEnum을 직접 UObject에서 상속받는 것이 직관적으로 보일 수 있지만, 그렇게 하지 않는 명확한 이유가 있다고 설명합니다:

  1. 데이터 타입의 통합: 모든 데이터 타입(UField 등)에 공통 기반 클래스를 두면, 배열을 통해 모든 타입의 데이터를 참조하고 순회하기 쉬워집니다. 이 통합은 정의의 원래 순서를 유지하고, UClass가 생성한 타입 데이터와 원본 코드 간의 일관성을 보장하는 데도 도움이 됩니다.
  2. 메타데이터 부착: 모든 선언/정의(UProperty, UStruct, UEnum)는 추가 메타데이터(UMetaData)로 확장될 수 있습니다. 이를 공통 기반 클래스에 두면 관리가 간편해집니다.
  3. 확장성: UField와 같은 공통 기반 클래스가 있으면, 예를 들어 각 필드의 선언을 출력하는 Print 메서드와 같은 기능을 가상 함수로 추가하고, 필요에 따라 하위 클래스에서 오버라이드할 수 있습니다. 즉, 기능 확장이 용이합니다.

FField가 왜 UObject를 상속해야 할까요?

  1. 가비지 컬렉션(GC): 필수는 아님. 타입 데이터는 한 번 할당되면 해제되지 않아야 하며, 현재 GC는 타입 시스템을 통해 객체 참조 순회를 지원합니다.
  2. 리플렉션: 일부 적용 가능.
  3. 에디터 통합: 블루프린트에서 함수 변수 생성 등 통합 편집에 사용됩니다.
  4. 클래스 디폴트 오브젝트(CDO): 필요 없음. 타입 데이터는 보통 한 종류당 한 개만 존재하며, CDO는 객체에 사용됩니다.
  5. 직렬화: 매우 중요. 블루프린트 등에서 생성된 타입 데이터를 보존하는 데 필요합니다.
  6. 네트워크 복제: 크게 유용하지 않음. 현재 네트워크 복제는 타입 데이터를 활용합니다.
  7. 원격 프로시저 호출(RPC): 영향 없음.
  8. 자동 속성 갱신: 필요 없음. 타입 데이터는 자주 변경되지 않습니다.
  9. 통계: 선택 사항.

특히 직렬화는 리플렉션이 제공하는 가장 중요한 기능으로 간주됩니다.

핵심 원리는 불필요한 요소와 필수 기능을 결합하되, 모든 데이터 타입이 UObject를 상속하도록 통일하는 것입니다. 이렇게 하면 직렬화 등 작업에서 별도의 기능 세트를 두 번 구현할 필요가 없습니다. 이 방식이 완전히 순수해 보이지 않을 수 있지만, 장점이 단점을 상회합니다. 객체에서는 Instance->GetClass()로 UClass 객체를 얻을 수 있고, UClass 자체에서 GetClass()를 호출하면 UClass가 반환되어 객체와 타입 데이터를 쉽게 구분할 수 있습니다.

In Summary

  • UStruct: FField의 연결 리스트를 포함하며, 제약이 적어 확장성이 높습니다. 현재는 중첩 타입이 없지만, UStruct는 중첩 타입과 속성 모두를 포함할 수 있는 구조입니다. UFunction은 속성만 가질 수 있고, UScriptStruct는 오직 속성만 포함하며 함수는 가질 수 없습니다. UStruct의 UStruct* SuperStruct 포인터는 상속받은 기반 클래스를 참조하는 데 사용됩니다.

  • MetaData: 컴파일된 클래스에 포함되어 부팅 시 언팩 및 설정됩니다. 일반적으로 메타데이터는 에디터에서만 주로 사용되며, 런타임에서는 활용 빈도가 낮습니다.

profile
언리얼 엔진 주니어 개발자 입니다.

0개의 댓글