언리얼 UBT·UHT·리플렉션·CDO·GC 사슬
UPROPERTY() 한 줄을 끝까지 따라가면
따로 외우면 다섯 개지만 실제로는 한 줄기다. UBT가 빌드를 지휘하고 UHT가 매크로를 읽어 리플렉션 데이터를 만들고, 그 위에서 CDO 복사와 GC 추적이 돈다. CDO 런타임 수정 실습까지.
언리얼이 표준 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.cs가 C# 파일이라는 점이다. 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.h | helper 함수 및 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 | 클래스 메타데이터 |
| struct | UScriptStruct | 구조체 메타데이터 |
| function | UFunction | 함수 메타데이터 |
| enum | UEnum | 열거형 메타데이터 |
| member variable | FProperty | 멤버 변수 메타데이터 (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 Health | 100 | CDO 기본값(100)에서 복사 |
| ActorA Modi | 50 | 인스턴스 값만 변경 |
| CDO Init Health | 100 | CDO 원본값 |
| CDO Modfi Health | 200 | CDO 자체를 200으로 수정 |
| ActorB Init Health | 200 | CDO가 200인 후 생성 → 200 복사 |
| ActorA Current HP | 50 | 이미 생성된 인스턴스는 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를 런타임에 수정하면 이후 스폰되는 인스턴스에만 영향을 준다는 것을 로그 실습으로 확인했다.