1. 리플렉션이 필요한 이유
C++은 기본적으로 런타임에 클래스나 구조체가 어떤 멤버를 갖고 있는지 알아낼 수 있는 방법을 제공하지 않습니다. C++ 표준에 포함된 RTTI(Run-Time Type Information)는 typeid와 dynamic_cast 정도의 제한적인 기능만 지원하며, "이 클래스에 어떤 프로퍼티가 있고, 각각의 타입은 무엇인가?"와 같은 질문에는 답할 수 없습니다.
그런데 게임 엔진은 런타임에 클래스 정보를 반드시 알아야 합니다. 에디터의 디테일 패널에 프로퍼티를 표시하려면 해당 클래스에 어떤 변수가 있는지 알아야 하고, 저장·로딩(시리얼라이제이션)을 하려면 객체가 가진 데이터를 순회할 수 있어야 합니다. 네트워크 리플리케이션에서 변경된 프로퍼티만 골라서 전송하는 것도 마찬가지입니다. 가비지 컬렉션이 참조를 추적해서 사용하지 않는 객체를 자동으로 회수하는 것 역시 리플렉션 없이는 불가능합니다.
언리얼 엔진은 이러한 문제를 해결하기 위해 자체적인 리플렉션 시스템(Reflection System), 즉 프로퍼티 시스템(Property System)을 구축했습니다. 이 시스템 덕분에 C++로 작성된 클래스, 구조체, 함수, 변수 정보를 런타임에 조회하고 조작할 수 있으며, 이를 기반으로 에디터 통합, 블루프린트 연동, 시리얼라이제이션, 가비지 컬렉션, 네트워크 리플리케이션 등 엔진의 핵심 시스템이 작동합니다.
2. 리플렉션 시스템의 구조
2-1. 옵트인(Opt-in) 방식
언리얼 엔진의 리플렉션 시스템은 옵트인 방식으로 동작합니다. 모든 C++ 타입이 자동으로 리플렉션에 포함되는 것이 아니라, 개발자가 명시적으로 매크로를 사용하여 표시한 타입과 멤버만 리플렉션 데이터에 포함됩니다. 이 설계에는 명확한 이유가 있습니다. 리플렉션 데이터를 생성하고 유지하는 데는 메모리와 처리 비용이 발생하므로, 필요한 항목만 선택적으로 노출함으로써 성능 부담을 최소화할 수 있습니다.
리플렉션에 사용되는 핵심 매크로는 다음과 같습니다.
UCLASS()— 클래스를 리플렉션 시스템에 등록합니다.USTRUCT()— 구조체를 리플렉션 시스템에 등록합니다.UENUM()— 열거형을 리플렉션 시스템에 등록합니다.UPROPERTY()— 멤버 변수를 리플렉션 시스템에 등록합니다.UFUNCTION()— 멤버 함수를 리플렉션 시스템에 등록합니다.GENERATED_BODY()— 엔진이 생성한 보일러플레이트 코드를 클래스 또는 구조체 내부에 삽입합니다.
2-2. 타입 계층 구조
리플렉션 시스템 내부의 타입 계층은 다음과 같은 구조를 갖습니다.
UField
├── UStruct (집합 구조의 기본 클래스)
│ ├── UClass — UCLASS로 표시된 C++ 클래스
│ ├── UScriptStruct — USTRUCT로 표시된 C++ 구조체
│ └── UFunction — UFUNCTION으로 표시된 C++ 함수
├── UEnum — UENUM으로 표시된 C++ 열거형
└── UProperty (FProperty) — UPROPERTY로 표시된 멤버 변수
UClass는 자식으로 함수(UFunction)와 프로퍼티(FProperty)를 모두 가질 수 있지만, UFunction과 UScriptStruct는 프로퍼티만 자식으로 가질 수 있습니다.
런타임에 특정 타입의 리플렉션 데이터에 접근하려면 다음과 같은 방법을 사용합니다.
// UCLASS로 표시된 클래스의 UClass 가져오기
UClass* MyActorClass = AMyActor::StaticClass();
// USTRUCT로 표시된 구조체의 UScriptStruct 가져오기
UScriptStruct* MyStructInfo = FMyStruct::StaticStruct();
// 인스턴스로부터 UClass 가져오기
UClass* InstanceClass = MyActorInstance->GetClass();
위 코드에서 StaticClass()와 StaticStruct()는 GENERATED_BODY() 매크로에 의해 자동으로 생성되는 정적 함수입니다. 이 함수들을 통해 컴파일 타임에 결정된 리플렉션 데이터를 런타임에 조회할 수 있습니다.
2-3. 언리얼 헤더 툴(UHT)과 코드 생성 과정
리플렉션 데이터는 컴파일 시점에 언리얼 헤더 툴(Unreal Header Tool, UHT)에 의해 생성됩니다. 전체 빌드 과정은 다음과 같은 두 단계로 진행됩니다.
- UBT(Unreal Build Tool) 가 프로젝트의 헤더 파일을 스캔하여 리플렉션 매크로가 포함된 모듈을 찾습니다.
- 해당 헤더가 마지막 컴파일 이후 변경되었다면, UHT를 호출하여 헤더를 파싱하고 리플렉션 데이터를 생성합니다.
UHT는 파싱 결과를 바탕으로 두 종류의 파일을 생성합니다.
<FileName>.generated.h— 클래스별 보일러플레이트 선언 (StaticClass, 썽크 함수 등)<FileName>.gen.cpp— 리플렉션 데이터 정의 (프로퍼티 등록, 메타데이터 등)
이렇게 생성된 코드는 일반 C++ 소스로 컴파일되므로, 리플렉션 데이터가 바이너리에 직접 포함됩니다. 따라서 오래되거나 불일치하는 리플렉션 데이터가 로드될 가능성이 원천적으로 차단됩니다. 이 점은 외부 메타데이터 파일을 별도로 관리하는 방식에 비해 상당한 안정성을 제공합니다.
GENERATED_BODY() 매크로는 내부적으로 <FileName>_h_<LineNumber>_GENERATED_BODY와 같은 형태로 확장됩니다. 예를 들어 MyActor.h 파일의 12번째 줄에 클래스가 선언되어 있다면, MyActor_h_12_GENERATED_BODY로 확장되어 해당 위치에 맞는 생성 코드를 삽입합니다.
3. UCLASS — 클래스 리플렉션
UCLASS 매크로는 UObject를 상속받는 클래스를 언리얼 엔진의 리플렉션 시스템에 등록합니다. 다음은 기본적인 사용 예시입니다.
// MyCharacter.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h" // 반드시 마지막 #include로 배치
UCLASS(Blueprintable)
class MYPROJECT_API AMyCharacter : public ACharacter
{
GENERATED_BODY() // 반드시 클래스 본문의 첫 줄에 위치
public:
AMyCharacter();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float MaxHealth;
UFUNCTION(BlueprintCallable, Category = "Combat")
void Attack();
};
위 코드에서 주목해야 할 규칙은 세 가지입니다. 첫째, #include "MyCharacter.generated.h"는 반드시 해당 파일의 마지막 #include 문이어야 합니다. 둘째, GENERATED_BODY()는 클래스 본문의 첫 줄에 위치해야 합니다. 셋째, MYPROJECT_API와 같은 모듈 API 매크로를 클래스 선언에 포함해야 다른 모듈에서 해당 클래스에 접근할 수 있습니다.
주요 UCLASS 지정자
UCLASS 매크로에는 클래스의 동작 방식을 제어하는 다양한 지정자(Specifier)를 전달할 수 있습니다.
// 블루프린트에서 이 클래스를 상속받아 새로운 블루프린트 클래스를 만들 수 있습니다.
UCLASS(Blueprintable)
// 추상 클래스로 지정하여 직접 인스턴스를 생성할 수 없게 합니다.
// 다른 클래스의 부모 클래스로만 사용할 수 있습니다.
UCLASS(Abstract)
// 이 클래스를 에디터의 "클래스 선택" 목록에서 숨깁니다.
UCLASS(NotBlueprintable)
// 에디터 및 블루프린트의 다양한 위치에 표시될 카테고리를 지정합니다.
UCLASS(Blueprintable, meta=(BlueprintSpawnableComponent))
위 코드처럼 지정자를 조합하면 클래스가 에디터와 블루프린트에서 어떻게 노출되고 사용되는지를 세밀하게 제어할 수 있습니다.
4. UPROPERTY — 프로퍼티 리플렉션
UPROPERTY 매크로는 멤버 변수를 리플렉션 시스템에 등록합니다. 이를 통해 에디터 노출, 시리얼라이제이션, 가비지 컬렉션 참조 추적, 네트워크 리플리케이션 등이 가능해집니다.
4-1. 에디터 노출 지정자
에디터에서 프로퍼티를 어떻게 표시하고 편집할 수 있는지를 제어하는 지정자는 크게 Edit 계열과 Visible 계열로 나뉩니다.
UCLASS(Blueprintable)
class MYPROJECT_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 블루프린트 기본값과 레벨에 배치된 인스턴스 모두에서 편집 가능합니다.
UPROPERTY(EditAnywhere, Category = "Stats")
float MaxHealth;
// 블루프린트 기본값에서만 편집 가능하고, 개별 인스턴스에서는 편집할 수 없습니다.
UPROPERTY(EditDefaultsOnly, Category = "Stats")
float BaseAttackPower;
// 레벨에 배치된 개별 인스턴스에서만 편집 가능합니다.
UPROPERTY(EditInstanceOnly, Category = "Stats")
FString CharacterNickname;
// 에디터에서 값을 볼 수 있지만 편집할 수는 없습니다.
// 컴포넌트 포인터에 주로 사용합니다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
UStaticMeshComponent* MeshComp;
};
위 코드에서 EditAnywhere는 가장 넓은 범위의 편집 권한을 부여하고, EditDefaultsOnly와 EditInstanceOnly는 각각 기본값과 인스턴스로 범위를 제한합니다. VisibleAnywhere는 읽기 전용으로 표시하므로, 컴포넌트 포인터처럼 외부에서 임의로 변경하면 안 되는 값에 적합합니다.
실제로 사용해보면 EditAnywhere를 모든 프로퍼티에 붙이고 싶은 유혹이 있지만, 이는 의도를 넘어서는 편집 권한을 열어두는 것입니다. 기본적으로 EditDefaultsOnly를 사용하고, 인스턴스별 수정이 실제로 필요한 경우에만 EditAnywhere로 확장하는 것이 좋습니다.
4-2. 블루프린트 노출 지정자
에디터 디테일 패널에서의 편집과 블루프린트 그래프에서의 접근은 별개입니다. 블루프린트 그래프에서 변수를 읽거나 쓰려면 별도의 지정자가 필요합니다.
// 블루프린트에서 읽기와 쓰기 모두 가능합니다.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float CurrentHealth;
// 블루프린트에서 읽기만 가능하고 쓰기는 불가능합니다.
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Stats")
float ArmorRating;
위 코드처럼 BlueprintReadWrite를 사용하면 블루프린트에서 Get과 Set 노드를 모두 사용할 수 있고, BlueprintReadOnly를 사용하면 Get 노드만 사용할 수 있습니다.
4-3. 네트워크 및 기타 지정자
// 서버에서 클라이언트로 값이 자동 복제됩니다.
UPROPERTY(Replicated)
int32 PlayerScore;
// 값이 변경되었을 때 지정된 콜백 함수가 호출됩니다.
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
// 시리얼라이제이션에서 제외되어 저장·로딩 시 값이 초기화됩니다.
UPROPERTY(Transient)
float TimeSinceLastHit;
위 코드에서 Replicated는 네트워크 멀티플레이어 환경에서 해당 프로퍼티를 자동으로 서버에서 클라이언트로 복제합니다. ReplicatedUsing은 값이 변경될 때 지정된 함수를 자동으로 호출해주므로, UI 갱신이나 이펙트 재생 등의 후처리에 유용합니다. Transient는 해당 값을 저장 대상에서 제외하여, 임시 계산 값이나 캐시 데이터에 적합합니다.
5. UFUNCTION — 함수 리플렉션
UFUNCTION 매크로는 멤버 함수를 리플렉션 시스템에 등록합니다. 이를 통해 블루프린트에서 호출하거나, 네트워크 RPC(Remote Procedure Call)로 사용하거나, 콘솔 명령어로 실행할 수 있게 됩니다.
5-1. 블루프린트 연동 지정자
UCLASS(Blueprintable)
class MYPROJECT_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// C++에서 구현하고, 블루프린트에서 호출할 수 있습니다.
UFUNCTION(BlueprintCallable, Category = "Combat")
void StartAttack();
// C++에서 구현하지 않고, 블루프린트에서만 구현합니다.
// C++ 헤더에 선언만 하고 .cpp에 본문을 작성하지 않습니다.
UFUNCTION(BlueprintImplementableEvent, Category = "Combat")
void OnAttackFinished();
// C++에서 기본 구현을 제공하되, 블루프린트에서 재정의할 수 있습니다.
// .cpp에서는 함수명_Implementation으로 본문을 작성합니다.
UFUNCTION(BlueprintNativeEvent, Category = "Combat")
void OnDamageTaken(float DamageAmount);
// const 함수에 BlueprintCallable을 사용하면
// 블루프린트에서 실행 핀 없는 퓨어 노드로 표시됩니다.
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Stats")
float GetHealthPercent() const;
};
위 코드에서 BlueprintCallable은 가장 기본적인 지정자로, C++ 함수를 블루프린트 그래프에서 호출 가능한 노드로 만듭니다. BlueprintImplementableEvent는 C++에서 선언만 하고 구현은 블루프린트에 맡기는 방식입니다. BlueprintNativeEvent는 C++에서 기본 동작을 구현하면서도 블루프린트에서 필요에 따라 재정의할 수 있어, 가장 유연한 확장 패턴을 제공합니다.
BlueprintNativeEvent를 사용할 때는 반드시 .cpp 파일에서 _Implementation 접미사를 붙인 함수명으로 본문을 작성해야 합니다.
// MyCharacter.cpp
void AMyCharacter::OnDamageTaken_Implementation(float DamageAmount)
{
// C++ 기본 구현
CurrentHealth -= DamageAmount;
if (CurrentHealth <= 0.f)
{
Die();
}
}
위 코드처럼 OnDamageTaken이 아니라 OnDamageTaken_Implementation으로 함수 본문을 작성합니다. 블루프린트에서 이 함수를 재정의하면 블루프린트 버전이 호출되고, 재정의하지 않으면 C++ 기본 구현이 호출됩니다.
5-2. 네트워크 RPC 지정자
멀티플레이어 환경에서 서버와 클라이언트 간에 함수를 원격으로 호출하려면 네트워크 지정자를 사용합니다.
// 클라이언트에서 호출하면 서버에서 실행됩니다.
// WithValidation을 반드시 함께 사용해야 합니다.
UFUNCTION(Server, Reliable, WithValidation)
void ServerRequestAttack(FVector TargetLocation);
// 서버에서 호출하면 소유 클라이언트에서 실행됩니다.
UFUNCTION(Client, Unreliable)
void ClientPlayHitEffect(FVector HitLocation);
// 서버에서 호출하면 서버 자신과 모든 클라이언트에서 실행됩니다.
UFUNCTION(NetMulticast, Reliable)
void MulticastPlayExplosionEffect();
위 코드에서 Server 지정자를 사용하는 함수는 반드시 WithValidation을 함께 사용해야 합니다. 이 경우 _Validate 접미사가 붙은 검증 함수도 함께 구현해야 합니다.
// MyCharacter.cpp
// 요청의 유효성을 검증합니다. false를 반환하면 함수 실행이 차단됩니다.
bool AMyCharacter::ServerRequestAttack_Validate(FVector TargetLocation)
{
// 비정상적인 거리의 공격 요청을 차단합니다.
return TargetLocation.Size() < 10000.f;
}
// 검증을 통과한 경우에만 실행됩니다.
void AMyCharacter::ServerRequestAttack_Implementation(FVector TargetLocation)
{
// 서버에서 실제 공격 로직을 처리합니다.
PerformAttack(TargetLocation);
}
위 코드처럼 _Validate 함수에서 false를 반환하면 해당 RPC 호출이 차단됩니다. 이는 클라이언트의 부정 조작을 방지하는 데 필수적인 보안 메커니즘입니다.
RPC 함수는 반환값을 가질 수 없습니다. 또한 Reliable과 Unreliable 중 하나를 선택해야 합니다. Reliable은 전달이 보장되므로 게임 상태에 영향을 주는 중요한 호출에 사용하고, Unreliable은 전달이 보장되지 않지만 대역폭을 절약할 수 있으므로 이펙트 재생처럼 누락되어도 치명적이지 않은 호출에 사용합니다.
5-3. 콘솔 명령어 지정자
// 게임 실행 중 콘솔(~ 키)에서 직접 호출할 수 있습니다.
UFUNCTION(Exec)
void GodMode();
위 코드처럼 Exec 지정자를 사용하면 게임 콘솔에서 함수명을 입력하여 직접 호출할 수 있습니다. 디버깅이나 치트 명령 구현에 유용하지만, Exec은 PlayerController, GameMode, CheatManager 등 특정 클래스에서만 동작한다는 점에 주의해야 합니다.
6. USTRUCT — 구조체 리플렉션
USTRUCT 매크로는 C++ 구조체를 리플렉션 시스템에 등록합니다. UCLASS로 표시된 클래스와 달리, USTRUCT로 표시된 구조체는 가비지 컬렉션의 대상이 아니며 UObject를 상속받지 않습니다. 대신 가볍고 값 복사가 가능한 데이터 컨테이너로 사용됩니다.
USTRUCT(BlueprintType)
struct FCharacterStats
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float MaxHealth = 100.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float AttackPower = 10.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
float MoveSpeed = 600.f;
};
위 코드에서 BlueprintType 지정자를 사용하면 이 구조체를 블루프린트에서 변수 타입으로 사용할 수 있습니다. 구조체 내부의 멤버에도 UPROPERTY를 사용하여 에디터 노출과 시리얼라이제이션을 활성화할 수 있습니다.
이 구조체를 클래스에서 사용하는 예시는 다음과 같습니다.
UCLASS(Blueprintable)
class MYPROJECT_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// FCharacterStats 구조체를 멤버 변수로 사용합니다.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Character")
FCharacterStats Stats;
};
위 코드처럼 구조체를 프로퍼티로 선언하면, 에디터의 디테일 패널에서 구조체 내부의 각 멤버를 펼쳐서 개별적으로 편집할 수 있습니다. 관련된 데이터를 하나의 구조체로 묶으면 코드의 가독성이 높아지고, 함수 파라미터로 전달할 때도 편리합니다.
7. UENUM — 열거형 리플렉션
UENUM 매크로는 C++ 열거형을 리플렉션 시스템에 등록합니다. 이를 통해 블루프린트에서 열거형 값을 사용하거나, 에디터에서 드롭다운 메뉴로 표시할 수 있습니다.
UENUM(BlueprintType)
enum class ECharacterState : uint8
{
Idle UMETA(DisplayName = "대기"),
Walking UMETA(DisplayName = "걷기"),
Running UMETA(DisplayName = "달리기"),
Attacking UMETA(DisplayName = "공격"),
Dead UMETA(DisplayName = "사망")
};
위 코드에서 BlueprintType을 사용하면 블루프린트에서 이 열거형을 변수 타입으로 사용할 수 있습니다. UMETA(DisplayName = "...")는 에디터에서 표시되는 이름을 지정합니다. 기본적으로 열거형 값의 이름이 그대로 표시되는데, 한국어나 좀 더 직관적인 이름으로 변경하고 싶을 때 UMETA를 활용합니다.
열거형의 기본 타입은 uint8을 사용하는 것이 일반적입니다. 블루프린트에서 사용하려면 반드시 uint8 기반이어야 합니다.
UCLASS(Blueprintable)
class MYPROJECT_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "State")
ECharacterState CurrentState;
UFUNCTION(BlueprintCallable, Category = "State")
void ChangeState(ECharacterState NewState)
{
CurrentState = NewState;
// 상태에 따른 로직 처리...
}
};
위 코드처럼 열거형을 프로퍼티와 함수 파라미터에 사용하면, 에디터에서는 드롭다운 메뉴로 상태를 선택할 수 있고, 블루프린트에서는 열거형 값을 직관적으로 사용할 수 있습니다.
8. 런타임 리플렉션 활용
리플렉션 시스템은 단순히 에디터와 블루프린트를 위한 것만이 아닙니다. C++ 코드에서 런타임에 리플렉션 데이터를 직접 조회하고 활용할 수 있습니다.
void InspectObject(UObject* Obj)
{
if (!Obj) return;
UClass* ObjClass = Obj->GetClass();
// 클래스 이름을 출력합니다.
UE_LOG(LogTemp, Log, TEXT("클래스 이름: %s"), *ObjClass->GetName());
// 이 클래스의 모든 프로퍼티를 순회합니다.
for (TFieldIterator<FProperty> PropIt(ObjClass); PropIt; ++PropIt)
{
FProperty* Property = *PropIt;
UE_LOG(LogTemp, Log, TEXT(" 프로퍼티: %s (타입: %s)"),
*Property->GetName(),
*Property->GetCPPType());
}
// 특정 클래스의 서브클래스인지 확인합니다.
if (ObjClass->IsChildOf(ACharacter::StaticClass()))
{
UE_LOG(LogTemp, Log, TEXT("이 객체는 ACharacter의 서브클래스입니다."));
}
}
위 코드에서 TFieldIterator를 사용하면 클래스에 등록된 모든 프로퍼티를 런타임에 순회할 수 있습니다. IsChildOf를 사용하면 클래스 상속 관계를 런타임에 확인할 수 있습니다. 이러한 기능은 에디터 확장 도구, 커스텀 시리얼라이저, 디버깅 유틸리티 등을 구현할 때 유용합니다.
9. 일반 C++과의 비교
9-1. 공통점
C++ 표준에도 RTTI(Run-Time Type Information)라는 런타임 타입 정보 기능이 존재합니다. typeid 연산자로 객체의 타입 이름을 확인하거나, dynamic_cast로 안전한 다운캐스팅을 수행할 수 있습니다. 언리얼 엔진의 리플렉션 시스템도 이와 마찬가지로 런타임에 타입 정보를 조회한다는 목적을 공유합니다.
9-2. 차이점
그러나 C++ 표준의 RTTI는 타입 이름 확인과 다운캐스팅 정도만 가능한 매우 제한적인 기능입니다. 반면 언리얼의 리플렉션 시스템은 훨씬 풍부한 정보를 제공합니다.
| 기능 | C++ 표준 RTTI | 언리얼 리플렉션 시스템 |
|---|---|---|
| 타입 이름 확인 | O | O |
| 안전한 다운캐스팅 | O (dynamic_cast) |
O (Cast<>) |
| 프로퍼티 목록 조회 | X | O |
| 프로퍼티 값 읽기/쓰기 | X | O |
| 함수 목록 조회 | X | O |
| 런타임 함수 호출 | X | O (ProcessEvent) |
| 클래스 계층 구조 탐색 | 제한적 | O |
| 시리얼라이제이션 | X | 자동 지원 |
| 가비지 컬렉션 | X | 자동 지원 |
특히 언리얼 엔진은 dynamic_cast 대신 자체적인 Cast<> 템플릿 함수를 제공합니다. Cast<>는 리플렉션 시스템의 클래스 계층 정보를 활용하여 dynamic_cast보다 더 빠르게 동작합니다.
// C++ 표준 RTTI 방식 (언리얼에서는 사용하지 않습니다)
AMyCharacter* Character = dynamic_cast<AMyCharacter*>(SomeActor);
// 언리얼 엔진의 Cast 방식 (리플렉션 기반, 더 빠릅니다)
AMyCharacter* Character = Cast<AMyCharacter>(SomeActor);
위 코드처럼 언리얼 프로젝트에서는 dynamic_cast 대신 반드시 Cast<>를 사용해야 합니다. Cast<>는 리플렉션 데이터를 활용하므로 RTTI가 비활성화된 빌드에서도 정상 동작하며, 성능도 우수합니다.
또한 C++23부터 표준에 정적 리플렉션(Static Reflection)이 논의되고 있지만, 현재까지 완전히 확정되지 않았습니다. 언리얼 엔진은 이미 UHT를 통한 코드 생성 방식으로 이 문제를 해결하고 있으며, C++ 표준의 리플렉션이 도입되더라도 언리얼 자체의 리플렉션 시스템이 대체될 가능성은 낮습니다. 엔진 고유의 기능(가비지 컬렉션, 블루프린트 연동 등)과 깊이 통합되어 있기 때문입니다.
10. 유니티 엔진과의 비교
10-1. 유사 기능
유니티는 C# 언어의 내장 리플렉션 기능(System.Reflection 네임스페이스)을 그대로 활용합니다. C#은 관리형 언어로서 모든 타입이 기본적으로 리플렉션을 지원하므로, 별도의 매크로나 코드 생성 도구 없이도 런타임에 타입 정보를 조회할 수 있습니다. 유니티의 인스펙터(Inspector)에 변수를 표시하려면 단순히 [SerializeField] 또는 public 접근자를 사용하면 됩니다.
10-2. 차이점
언리얼 엔진의 리플렉션 시스템은 컴파일 타임에 UHT가 코드를 생성하는 방식이므로, 리플렉션 데이터가 바이너리에 직접 포함됩니다. 반면 유니티의 C# 리플렉션은 순수한 런타임 동작이므로, 리플렉션 호출 시마다 추가적인 메모리 할당과 가비지 컬렉션 부하가 발생합니다. 이는 특히 모바일 플랫폼에서 프레임 저하의 원인이 될 수 있습니다.
또한 유니티에서는 모든 C# 타입이 자동으로 리플렉션에 포함되지만, 언리얼에서는 개발자가 UPROPERTY, UFUNCTION 등의 매크로로 명시적으로 선택한 항목만 포함됩니다. 이 옵트인 방식 덕분에 불필요한 리플렉션 데이터가 생성되지 않아 메모리 사용량을 절약할 수 있습니다.
10-3. 언리얼의 강점
언리얼 리플렉션 시스템의 가장 큰 강점은 엔진의 핵심 시스템과 긴밀하게 통합되어 있다는 점입니다. 가비지 컬렉션은 UPROPERTY로 표시된 참조를 자동으로 추적하고, 네트워크 리플리케이션은 리플렉션 데이터를 활용하여 변경된 프로퍼티만 효율적으로 전송합니다. 블루프린트 시스템 역시 리플렉션 데이터를 기반으로 C++ 함수와 변수를 노드로 노출합니다. 이 모든 것이 하나의 통합된 시스템 위에서 동작하므로, 개발자가 각 기능을 개별적으로 설정할 필요 없이 매크로 하나로 여러 시스템에 동시에 등록됩니다.
11. 주의사항
11-1. generated.h 인클루드 순서
<ClassName>.generated.h는 반드시 해당 헤더 파일의 마지막 #include 문이어야 합니다. 이 규칙을 지키지 않으면 컴파일 에러가 발생합니다.
// 올바른 예시
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h" // 반드시 마지막
// 잘못된 예시 — 컴파일 에러 발생
#include "CoreMinimal.h"
#include "MyActor.generated.h"
#include "GameFramework/Actor.h" // generated.h 뒤에 다른 #include가 있으면 안 됩니다.
11-2. UHT의 한계
UHT는 완전한 C++ 파서가 아닙니다. C++의 주요 문법은 이해하지만, 일부 복잡한 구문에서 혼란을 일으킬 수 있습니다. 특히 #if / #ifdef 전처리기 지시문을 UPROPERTY나 UFUNCTION이 붙은 멤버 주변에 사용하면 문제가 발생할 수 있습니다. WITH_EDITOR와 WITH_EDITORONLY_DATA만 예외적으로 지원됩니다.
// 문제가 발생할 수 있는 예시
#if SOME_DEFINE
UPROPERTY(EditAnywhere)
float SomeValue; // SOME_DEFINE이 false인 설정에서 컴파일 에러가 발생합니다.
#endif
// 안전한 예시
#if WITH_EDITORONLY_DATA
UPROPERTY(EditAnywhere)
float EditorOnlyValue; // WITH_EDITORONLY_DATA는 UHT가 인식합니다.
#endif
11-3. 리플렉션의 성능 비용
리플렉션은 무료가 아닙니다. 리플렉션 시스템에 등록된 각 타입과 멤버는 메타데이터를 유지하기 위한 메모리를 소비합니다. 모든 변수에 무분별하게 UPROPERTY를 붙이거나 모든 함수에 UFUNCTION을 붙이는 것은 불필요한 오버헤드를 만듭니다. 에디터에 노출할 필요도, 블루프린트에서 접근할 필요도, 시리얼라이제이션이나 가비지 컬렉션이 필요하지도 않은 멤버에는 매크로를 붙이지 않는 것이 좋습니다.
11-4. 지원되지 않는 타입
리플렉션 시스템이 지원하는 C++ 타입에는 제한이 있습니다. 기본 타입(int32, float, bool, FString 등)과 TArray, TMap, TSet, TSubclassOf, TSoftObjectPtr 등 특정 템플릿 타입만 지원됩니다. std::vector, std::map 등의 STL 컨테이너는 UPROPERTY로 표시할 수 없습니다. 또한 중첩된 컨테이너(예: TArray<TArray<int32>>)도 지원되지 않습니다.
11-5. UObject와 메모리 관리
UCLASS로 표시된 클래스의 인스턴스는 반드시 NewObject<>() 또는 CreateDefaultSubobject<>()를 통해 생성해야 합니다. C++ 표준의 new 연산자를 사용하면 리플렉션 시스템과 가비지 컬렉션에 등록되지 않아 메모리 관리 문제가 발생합니다.
// 올바른 예시
UMyComponent* Comp = NewObject<UMyComponent>(this);
// 잘못된 예시 — 가비지 컬렉션이 이 객체를 인식하지 못합니다.
UMyComponent* Comp = new UMyComponent();
리플렉션 시스템은 언리얼 엔진의 거의 모든 핵심 기능이 의존하는 근간 시스템입니다. UCLASS, UPROPERTY, UFUNCTION 등의 매크로를 정확히 이해하고 적절히 사용하면, 에디터 통합, 블루프린트 연동, 네트워크 리플리케이션, 시리얼라이제이션, 가비지 컬렉션이 자연스럽게 따라옵니다. 새로운 클래스를 만들 때 이 매크로들을 올바르게 배치하는 습관을 들이면, 언리얼 엔진의 강력한 인프라를 최대한 활용할 수 있습니다.
'언리얼 엔진 5' 카테고리의 다른 글
| [UE5] 언리얼 오브젝트 생성과 소멸 (0) | 2026.02.26 |
|---|---|
| [UE5] 컨테이너(TArray, TMap, TSet) (1) | 2026.02.24 |
| [UE5] 액터와 컴포넌트 최적화 (0) | 2026.02.23 |
| [UE5] 가비지 컬렉션 (0) | 2026.02.23 |