포스트

언리얼 UBT·UHT·리플렉션·CDO·GC 사슬

UPROPERTY() 한 줄을 끝까지 따라가면

따로 외우면 다섯 개지만 실제로는 한 줄기다. UBT가 빌드를 지휘하고 UHT가 매크로를 읽어 리플렉션 데이터를 만들고, 그 위에서 CDO 복사와 GC 추적이 돈다. CDO 런타임 수정 실습까지.

언리얼 UBT·UHT·리플렉션·CDO·GC 사슬

언리얼이 표준 C++ 위에 얹어 둔 시스템들 — UBT, UHT, 리플렉션, CDO, GC — 은 따로 외우면 다섯 개지만 실제로는 하나의 사슬이다. UBT가 빌드를 지휘하고, UHT가 매크로를 읽어 리플렉션 데이터를 만들고, 그 데이터 위에서 CDO 복사와 GC 추적이 돈다. 이 글에서는 그 사슬을 순서대로 따라가며 UPROPERTY() 한 줄이 왜 그렇게 많은 일을 하는지를 이야기하려 한다. 마지막에는 CDO를 런타임에 바꿔 이후 스폰되는 인스턴스만 달라지는지 로그로 확인한 실습이 붙는다.

UBT와 UHT — 빌드 파이프라인

빌드의 첫 관문은 UBT(Unreal Build Tool)다. 가장 먼저 실행되어 전체 프로젝트 구조를 파악하고 플랫폼별 환경을 설정한다.

1
UBT 실행 → 환경 설정 → UHT 실행 → 컴파일 → 링크

UBT가 하는 일을 정리하면 이렇다.

  • 플러그인 및 모듈 검색 — Plugins, Modules 디렉터리 탐색
  • .Target.cs 파일 분석 — 빌드 대상(Editor/Game/Server 등) 결정
  • .Build.cs 파일 처리 — 모듈별 의존성 및 컴파일 설정
  • 플랫폼 호환성 확인 — Win64, Mac, Linux 등
  • 개발 환경 자동 구성 — VS/Xcode 프로젝트 파일 생성

여기서 눈에 띈 건 .Build.cs.Target.csC# 파일이라는 점이다. UBT는 이 파일들을 Rules Assembly(공유 라이브러리)로 컴파일한 뒤 실행한다. 빌드 규칙이 단순 텍스트가 아니라 실행 가능한 C# 코드이기 때문에 조건분기나 루프 같은 로직을 쓸 수 있다. .Build.cs가 없으면 모듈 자체가 인식되지 않는다.

1
2
3
4
5
6
7
8
9
10
11
public class MyGame : ModuleRules
{
    public MyGame(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
        PublicDependencyModuleNames.AddRange(new string[]
        {
            "Core", "CoreUObject", "Engine", "InputCore"
        });
    }
}

UBT가 빌드 도구라면, UHT(Unreal Header Tool)는 컴파일 전에 실행되는 헤더 분석 도구다. .h 파일에서 UCLASS, UPROPERTY, UFUNCTION, USTRUCT, UENUM 매크로를 읽어 메타데이터(리플렉션 정보)를 생성한다.

파일설명
MyObject.generated.hhelper 함수 및 thunk 함수 포함 (헤더별 1개)
MyObject.gen.cpp리플렉션 데이터 실체 구현 (헤더별 1개)
[Module].generated.inl모듈 단위 리플렉션 데이터 통합 (모듈별 1개)

생성된 리플렉션 데이터는 바이너리와 함께 컴파일되므로 구식화(stale)되는 일이 없다.

1
2
3
4
5
6
7
8
9
10
11
UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()  // UHT가 generated.h를 삽입하는 자리
public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    int32 Health = 100;

    UFUNCTION(BlueprintCallable)
    void TakeDamage(int32 Amount);
};

GENERATED_BODY()가 UHT가 generated.h를 삽입하는 자리라는 것, 이게 없으면 컴파일 에러가 난다는 것, UPROPERTY/UFUNCTION이 없으면 에디터·블루프린트에서 아예 보이지 않는다는 것을 여기서 정리했다.


리플렉션 시스템

리플렉션은 런타임에 클래스/변수/함수의 타입 정보에 접근하는 기능이다. C++ 표준에는 리플렉션이 없기 때문에, 언리얼은 UHT + 매크로로 자체 구현했다. 위의 빌드 파이프라인과 이어 보면 흐름이 이렇게 된다.

1
2
3
4
5
UBT 실행
  └→ UHT 실행: UCLASS/UPROPERTY/UFUNCTION 매크로 분석
         └→ MyClass.generated.h 생성 (리플렉션 데이터 포함)
               └→ 컴파일러가 generated.h 포함해 컴파일
                     └→ 런타임: 리플렉션 시스템 활성화

C++ 원본이 UE 내부에서 어떤 타입으로 표현되는지도 배웠다.

C++ 원본UE 타입역할
class (UObject 상속)UClass클래스 메타데이터
structUScriptStruct구조체 메타데이터
functionUFunction함수 메타데이터
enumUEnum열거형 메타데이터
member variableFProperty멤버 변수 메타데이터 (UE5에서 UProperty → FProperty 리네임)

이 사슬에서 가장 와닿은 부분은 UPROPERTY() 한 줄이 담당하는 일의 범위다.

1
2
3
4
5
6
7
8
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 Health = 100;
// ↑ 이 한 줄이:
// 1. 에디터 디테일 패널 노출
// 2. 블루프린트에서 읽기/쓰기 가능
// 3. GC 추적 대상 등록
// 4. 직렬화(저장/로드) 자동 처리
// 5. CDO → 인스턴스 복사 시 값 전달

C++ 표준 RTTI와 비교하면 UE 리플렉션이 왜 별도로 존재하는지 분명해진다. RTTI는 에디터·GC 연동이 불가능하고, 언리얼에서는 아예 비활성화되어 있다. UE에서는 자체 리플렉션이 권장(사실상 필수)이다.


CDO — Class Default Object

CDO는 UObject 클래스마다 하나씩 존재하는 기본값 보관소다. 붕어빵 틀(CDO)에서 붕어빵(인스턴스)을 찍어내는 관계로 이해하면 된다. 엔진 시작 시 모듈이 로드되면서 CDO가 생성되고(이때 생성자가 딱 한 번 호출된다), 이후 인스턴스들은 CDO에서 값을 복사해 만들어진다.

1
2
UBT → UHT → 컴파일 → 엔진 시작 시 CDO 생성 (생성자 딱 한 번 호출)
Module Loading → CDO Created → Instances Copy from CDO

CDO에 접근하는 방법은 세 가지다.

1
2
3
4
5
6
7
8
// 공식 방법 — StaticClass()를 통한 접근
ATestMyActor* CDO = Cast<ATestMyActor>(ATestMyActor::StaticClass()->GetDefaultObject());

// 헬퍼 함수 — 수정 가능
ATestMyActor* MutableCDO = GetMutableDefault<ATestMyActor>();

// 헬퍼 함수 — 읽기 전용
const ATestMyActor* ReadOnlyCDO = GetDefault<ATestMyActor>();

C++ 클래스의 CDO 기본값은 C++ 생성자에서 오고 컴파일 시점에 고정되지만, 블루프린트 클래스의 CDO는 .uasset 파일에서 오고 블루프린트 에디터에서 수정·저장된다.

CDO가 존재하는 이유는 최적화다.

원리설명
메모리 절약공통 기본값을 CDO 하나에만 저장
빠른 초기화생성자 호출 없이 메모리 복사로 즉시 초기화
델타 직렬화CDO와 차이점만 기록·전송 → 파일 용량·네트워크 효율 개선

코드 실습 — CDO를 런타임에 수정하면?

말로만 들으면 헷갈려서 직접 로그를 찍어 확인했다. 인스턴스를 스폰하고, 인스턴스 값을 바꾸고, CDO 자체를 바꾼 뒤 다시 스폰하는 순서다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void AMyGameModeBase::BeginPlay()
{
    Super::BeginPlay();

    ATestMyActor* ActorA = GetWorld()->SpawnActor<ATestMyActor>(); // CDO 기본값 100 복사
    UE_LOG(LogTemp, Warning, TEXT("ActorA Init Health : %d"), ActorA->Health);  // 100

    ActorA->Health = 50;  // 인스턴스만 변경, CDO 무관
    UE_LOG(LogTemp, Warning, TEXT("ActorA Modi : %d"), ActorA->Health);  // 50

    ATestMyActor* MyActorCDO = GetMutableDefault<ATestMyActor>();
    UE_LOG(LogTemp, Warning, TEXT("CDO Init Health : %d"), MyActorCDO->Health);  // 100

    MyActorCDO->Health = 200;  // CDO 수정
    UE_LOG(LogTemp, Warning, TEXT("CDO Modfi Health : %d"), MyActorCDO->Health);  // 200

    ATestMyActor* ActorB = GetWorld()->SpawnActor<ATestMyActor>(); // CDO가 200이므로 200 복사
    UE_LOG(LogTemp, Warning, TEXT("ActorB Init Health : %d"), ActorB->Health);  // 200

    UE_LOG(LogTemp, Warning, TEXT("ActorA Current HP : %d"), ActorA->Health);  // 50 (변화 없음)
}
로그출력값이유
ActorA Init Health100CDO 기본값(100)에서 복사
ActorA Modi50인스턴스 값만 변경
CDO Init Health100CDO 원본값
CDO Modfi Health200CDO 자체를 200으로 수정
ActorB Init Health200CDO가 200인 후 생성 → 200 복사
ActorA Current HP50이미 생성된 인스턴스는 CDO 변경 영향 없음

즉 CDO 수정은 이후에 스폰되는 인스턴스에만 영향을 주고, 이미 생성된 인스턴스는 그대로다.

주의: 같은 PIE 세션 내에서 CDO 수정은 이후 SpawnActor에 영향을 줌. PIE 재실행 시 SpawnActor는 생성자 기본값을 사용하지만, GetMutableDefault가 반환하는 CDO 객체에는 이전 수정값이 남아있을 수 있음 → 상태 불일치 발생 가능


언리얼 GC 작동방식

언리얼의 GC(Garbage Collection, 쓰지 않는 객체를 자동 수거하는 메모리 관리)는 Tracing GC (Mark & Sweep) 방식이다. UObject를 상속한 객체만 GC 대상이고, UPROPERTY()로 선언된 포인터만 GC가 추적한다 — 리플렉션 데이터가 있어야 참조 그래프를 그릴 수 있기 때문이다.

단계설명
1. Mark (표시)Root Set에서 시작해 UPROPERTY로 연결된 UObject를 reference graph로 탐색, 도달 가능한 객체에 “살아있음” 표시
2. Sweep (정리)표시되지 않은 UObject를 메모리에서 해제

실행 주기는 기본 30~60초 간격이고, 가용 메모리에 따라 더 자주 돌 수 있다. 기존 GC는 모든 UObject를 한 번에 순회해서 오브젝트가 많으면 프레임 히치(순간적인 프레임 끊김)가 생겼는데, UE5의 Incremental GC는 여러 프레임에 걸쳐 점진적으로 수행해 히치를 줄였다. ForceGarbageCollection()으로 직접 호출할 수도 있지만 역시 히치를 주의해야 한다.

1
2
3
4
5
6
7
8
9
10
UCLASS()
class AMyActor : public AActor
{
    GENERATED_BODY()
public:
    UPROPERTY()  // GC가 이 포인터를 추적 → 수거 안 됨
    UMyComponent* SafeComp;

    UMyComponent* DangerousComp;  // UPROPERTY 없음 → GC가 수거할 수 있음!
};

UPROPERTY() 없는 포인터는 GC가 참조를 모르기 때문에 객체가 수거된 뒤 댕글링 포인터가 된다. 수거 여부는 IsValid()로 런타임에 확인하고, AddToRoot() / RemoveFromRoot()로 수거를 강제로 막거나 풀 수 있다.


new 연산자의 내부 동작, UE_LOG와 화면 출력의 차이, 언리얼 C++ 코드 컨벤션처럼 이 사슬과 상관없이 함께 적어 둔 메모는 주제에서 벗어나 덜어냈다. new의 3단계 동작은 new vs malloc에 따로 정리해 두었다.

핵심 요약 — UBT → UHT → 리플렉션 → CDO/GC로 이어지는 언리얼 빌드 사슬이 잡혔다. UPROPERTY() 한 줄이 에디터 노출·GC 추적·직렬화·CDO 복사까지 담당하고, CDO를 런타임에 수정하면 이후 스폰되는 인스턴스에만 영향을 준다는 것을 로그 실습으로 확인했다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.