Part 1. UObject 시스템: 리플렉션 · UClass · CDO · GC · 참조

Semi·2026년 4월 14일

Unreal Engine 정리

목록 보기
1/3
post-thumbnail

공식 문서(Gameplay Classes, Objects, Unreal Property System Reflection, Dynamic Delegates, Unreal Build System) 기반 검증 완료 버전


이 문서를 읽기 전에 — 왜 UObject 시스템부터인가

언리얼 C++을 배우다 보면 아래 의문들이 계속 반복된다.

  • UPROPERTY() 매크로를 빠뜨리면 왜 에디터에 안 보이나?
  • 생성자에서 GetWorld()를 호출하면 왜 크래시가 나나?
  • Actor가 파괴됐는데 포인터가 nullptr이 아닌 이유는?
  • StaticClass()는 인스턴스도 없는데 어떻게 호출되나?
  • SpawnActor마다 생성자가 호출되는 것 같지 않은데, 실제로는 무슨 일이 일어나나?

이 질문들의 답이 전부 UObject 시스템 안에 있다. 이 시스템을 먼저 이해하면 그 위에 쌓이는 모든 언리얼 지식이 "왜?"라는 근거를 갖게 된다.


1. 표준 C++의 한계 — 왜 언리얼이 직접 리플렉션을 구축했는가

1-1. C++ 컴파일의 목표: 기계어로 변환

C++ 컴파일러의 목표는 소스코드를 CPU가 직접 실행할 수 있는 기계어 바이너리로 변환하는 것이다.

[소스코드]                       [컴파일 후 바이너리]

class AItem {                    mov eax, [rbp-8]
    float Speed = 300.f;   →     movss xmm0, [rip+0x1234]
    void Move() { ... }          call 0x00401A20
};                               ...

변환이 완료된 바이너리 안에는 AItem이라는 이름도, Speed라는 이름도, Move()라는 함수 이름도 남지 않는다. 컴파일러가 이름을 전부 메모리 주소와 오프셋으로 바꿔버리기 때문이다.

1-2. 표준 C++에 RTTI가 있지 않나?

표준 C++에도 RTTI(Run-Time Type Information) 라는 런타임 타입 정보 시스템이 있다. dynamic_cast<>typeid()가 RTTI를 사용한다.

Animal* pAnimal = new Cat();
Cat* pCat = dynamic_cast<Cat*>(pAnimal); // 안전한 다운캐스트 — RTTI 사용
std::cout << typeid(*pAnimal).name();    // 타입 이름 조회 → "Cat"

RTTI를 쓰면 가상 함수가 있는 클래스에 한해서 타입 이름과 상속 계층을 런타임에 확인할 수 있다. 하지만 RTTI에는 결정적인 한계가 있다.

RTTI로 할 수 있는 것RTTI로 할 수 없는 것
타입 이름 조회 (typeid().name())"이 클래스에 멤버 변수가 몇 개야?"
안전한 다운캐스트 (dynamic_cast)"이 변수의 이름이 뭐야?"
타입 동일성 비교"이 변수에 EditAnywhere가 붙어있어?"
"이 함수를 문자열 이름으로 찾아 호출해줘"

언리얼이 필요한 기능들이 전부 오른쪽에 있다. 에디터 Details 패널은 변수 이름과 메타데이터를 알아야 하고, 블루프린트는 함수를 이름으로 찾아 호출해야 하고, GC는 어떤 멤버가 UObject* 타입인지 알아야 한다.

1-3. 언리얼의 선택 — RTTI 비활성화 후 자체 시스템 구축

언리얼은 RTTI를 비활성화(-fno-rtti 옵션)하고 UHT + 매크로 + UObject 시스템의 조합으로 훨씬 강력한 리플렉션 시스템을 직접 구축했다.

표준 C++ RTTI:          타입 이름 조회, 안전한 캐스트 정도만 가능
언리얼 리플렉션 시스템: 변수 이름/타입/메타데이터, 함수 이름으로 호출,
                        GC 추적, 에디터 연동, 블루프린트 연동 전부 가능

2. UObject — 언리얼 리플렉션 시스템의 최상위 기반

2-1. UObject가 제공하는 것

기능의미
리플렉션런타임에 클래스 구조(변수·함수·타입)를 들여다볼 수 있는 능력
가비지 컬렉션 (GC)사용하지 않는 UObject 계열 객체를 엔진이 자동으로 수거
직렬화객체 상태를 파일로 저장하고 다시 로드할 수 있는 능력
CDO 생성각 클래스의 기본값을 담은 마스터 객체(CDO)를 자동으로 생성
에디터 연동Details 패널에 변수 노출, 블루프린트에서 함수 호출 등

2-2. 클래스 접두사 — UObject 상속 여부와 함께

언리얼의 접두사는 단순한 이름 규칙이 아니다. 그 클래스가 UObject 시스템 안에 있는지 여부를 즉시 알려주는 지표이기도 하다.

접두사UObject 상속설명예시
AAActor → UObject 계열. 월드에 배치될 수 있는 모든 것ACharacter, AGameMode
UUObject 직접 상속 계열 (Actor 아닌 것)UActorComponent, UGameInstance
F순수 C++ 구조체/클래스. UObject 시스템 밖FVector, FTimerHandle
T템플릿 클래스. UObject와 무관하게 동작TArray, TMap, TSubclassOf
E열거형. 단순한 정수값 집합EEndPlayReason
I인터페이스의 실제 상속용 클래스. UObject 없음IItemInterface

T, E, I, F 접두사는 전부 UObject를 상속받지 않는다. GC 관리 대상이 아니고, 리플렉션 시스템도 직접 적용되지 않는다.

2-3. F 접두사 클래스 — 왜 UPROPERTY 없이 스택에서 쓸 수 있는가

이것을 이해하려면 먼저 메모리 저장 위치의 차이를 알아야 한다.

스택(Stack) vs 힙(Heap)

[스택]
함수 안에서 선언한 지역 변수. 함수가 끝나면 자동으로 사라짐.
값 자체가 변수 공간에 직접 들어있음.

int32 Score = 0;      // 스택에 int 값 직접 저장 (4바이트)
float Speed = 300.f;  // 스택에 float 값 직접 저장 (4바이트)
FVector Position;     // 스택에 float 3개 직접 저장 (12바이트)

[힙]
new / NewObject() / SpawnActor()로 동적 할당한 것.
직접 delete 하거나 GC가 해제해줘야 함.

UObject* Obj = NewObject<UObject>(); // 힙에 UObject 생성, 포인터만 로컬 변수

UObject 계열이 반드시 힙에 있어야 하는 이유

GC가 객체를 관리하려면 "어디에 뭐가 있는지"를 알아야 한다. GC는 힙에 생성된 UObject들을 전역 오브젝트 배열(GUObjectArray)로 추적한다. 그래서 UObject 계열은 반드시 NewObject()SpawnActor()로 힙에 생성해야 하고, 스택에 값으로 선언할 수 없다.

// ❌ 불가능 — UObject 계열은 스택에 선언할 수 없음
UActorComponent Comp;  // 컴파일 에러 또는 심각한 버그

// ✅ 올바른 방식 — 힙에 생성, GC가 관리
UActorComponent* Comp = NewObject<UActorComponent>(this);

F 계열은 왜 스택에 선언할 수 있는가

FVector, FTimerHandle 같은 F 계열은 UObject를 상속받지 않는다. GC의 전역 오브젝트 배열에 등록되지 않는다. 그냥 일반 C++ 구조체와 똑같이 동작한다.

FVector Position;   // 스택에 float 3개(12바이트)가 직접 저장됨
float Speed;        // 스택에 float 1개(4바이트)가 직접 저장됨

float SpeedFVector Position의 차이는 크기뿐이다. 둘 다 힙 할당이 없고, GC가 추적할 이유도 없다.

그러면 F 계열 멤버 변수에 UPROPERTY()를 붙이는 경우는?

UCLASS()
class AMyActor : public AActor
{
    UPROPERTY(EditAnywhere)   // ← GC 추적 목적 아님. 에디터 노출 목적
    float Speed = 300.f;

    UPROPERTY(EditAnywhere)   // ← 동일. 에디터에서 조정 가능하게
    FVector StartPosition;

    UPROPERTY()               // ← GC 추적이 주목적
    UOtherActor* RefActor;
};

UPROPERTY()의 역할은 두 가지다. float, FVector 같은 값 타입 멤버에 붙이면 에디터 노출 목적이고, UObject* 포인터 멤버에 붙이면 GC 추적 목적이 된다. 둘을 구분해서 이해하면 혼동이 없다.


3. 빌드 시스템 — 프로젝트 · 플러그인 · 모듈

3-1. 세 개념의 포함 관계

프로젝트 (Project)           .uproject 파일로 정의
    ├── 내 모듈              SpartaProject.Build.cs
    └── 플러그인 (Plugin)    .uplugin 파일로 정의
            ├── 모듈 A       각각 .Build.cs 보유
            └── 모듈 B

모듈 (Module)

공식 문서:

"UE4 is split into many modules. Each module has a .build.cs file that controls how it is built."

모듈은 빌드의 기본 단위다. 하나의 .Build.cs 파일을 가지고, 기본적으로 하나의 DLL로 컴파일된다. 기능적으로 연관된 C++ 코드의 묶음이다.

SpartaProject 모듈
→ Source/SpartaProject/SpartaProject.Build.cs
→ 컴파일 결과: UnrealEditor-SpartaProject.dll

모듈은 Public/(외부에 노출할 헤더)과 Private/(내부 구현) 폴더로 구분된다.

플러그인 (Plugin)

공식 문서:

"In UE4, Plugins are collections of code and data that developers can easily enable or disable within the Editor on a per-project basis."

플러그인은 하나 이상의 모듈과 에셋을 묶어 재사용 가능한 단위로 패키징한 것이다. 에디터에서 켜고 끌 수 있고, 여러 프로젝트에서 공유할 수 있다.

EnhancedInput 플러그인 (언리얼 내장)
→ EnhancedInput.uplugin
    └── EnhancedInput 모듈 (런타임)
    └── EnhancedInputEditor 모듈 (에디터 전용)

프로젝트 (Project)

.uproject 파일로 정의되는 최상위 단위. 어떤 플러그인을 사용할지, 어떤 모듈이 포함되는지를 선언한다. SpartaProject.uproject가 여기에 해당한다.

의존성 방향의 규칙

공식 문서에 따르면 의존성 방향에 규칙이 있다.

엔진 모듈    ← 어디서나 의존 가능 (최상위)
플러그인 모듈 ← 엔진 모듈에 의존 가능, 프로젝트 모듈에는 의존 불가
프로젝트 모듈 ← 플러그인·엔진 모듈 모두 의존 가능 (최하위)

이유: 엔진은 특정 프로젝트 없이도 빌드될 수 있어야 하기 때문

3-2. 모듈은 DLL만을 의미하는가

기본적으로 모듈 하나 = DLL 하나지만, 정확히는 "모듈 = 빌드 단위" 가 맞다.

개발 빌드:     모듈별 DLL → 라이브 코딩(메모리 패치)이 가능한 이유
배포(Shipping): 모놀리식 실행파일 → 단일 .exe 안에 전부 포함

3-3. 전체 빌드 타임라인

[1단계] UBT (Unreal Build Tool)
   → .uproject, .uplugin 읽어 프로젝트/플러그인/모듈 구조 파악
   → 각 모듈의 .Build.cs 읽어 의존성·컴파일 옵션 결정
   → 플랫폼 환경 판단 (Win64, Mac 등)
   → 헤더 변경 감지 시 UHT 실행

[2단계] UHT (Unreal Header Tool)
   → .h 파일 스캔하여 UCLASS, UPROPERTY, UFUNCTION 등 마커 탐지
   → 클래스마다 MyClass.generated.h 자동 생성
     · StaticClass() 구현
     · UClass 초기화/등록 코드
     · FProperty 등록 코드
     · SuperClass 연결 코드

[3단계] C++ 컴파일러
   → GENERATED_BODY() 위치에 .generated.h 코드 삽입
   → 전체 C++ 컴파일
   → 모듈별 DLL 생성

[4단계] 엔진 실행 시
   → 모듈(DLL) 로드
   → 등록 함수 실행 → UClass 객체 생성
   → CDO 생성 (생성자 호출 — 이 시점에만)

.generated.h는 항상 헤더 파일의 마지막 #include 여야 한다. UHT가 그 위에 있는 include들이 처리됐다고 전제하고 코드를 생성하기 때문이다.

헤더(.h)를 수정하면 UHT 재실행 + 전체 리빌드가 발생한다. 초기화 로직은 가급적 .cpp 생성자 안에서 처리하면 빌드 시간을 줄일 수 있다.

3-4. 리플렉션 매크로들의 역할

매크로들은 UHT에게 "여기 처리해야 할 게 있어"라고 알리는 마커다. UHT가 마커를 보고 .generated.h를 생성하고, GENERATED_BODY()가 그 내용을 클래스 안에 삽입한다.

// UCLASS() — 이 클래스를 리플렉션 시스템에 등록
UCLASS()
class MYPROJECT_API AMyActor : public AActor { GENERATED_BODY() };
// 주요 지정자: Blueprintable, NotBlueprintable, BlueprintType, Abstract

// UPROPERTY() — 이 멤버 변수를 리플렉션에 등록
UPROPERTY(EditAnywhere)       // 블루프린트 기본값 + 레벨 인스턴스 모두 편집
UPROPERTY(EditDefaultsOnly)   // 블루프린트 기본값에서만 편집
UPROPERTY(EditInstanceOnly)   // 레벨에 배치된 인스턴스에서만 편집
UPROPERTY(VisibleAnywhere)    // 보이지만 편집 불가 (컴포넌트에 권장)
UPROPERTY(BlueprintReadWrite) // 블루프린트에서 읽기/쓰기
UPROPERTY(BlueprintReadOnly)  // 블루프린트에서 읽기만

// UFUNCTION() — 이 함수를 리플렉션에 등록
UFUNCTION(BlueprintCallable)             // 블루프린트에서 호출 가능
UFUNCTION(BlueprintPure)                 // 실행 핀 없는 순수 Getter
UFUNCTION(BlueprintImplementableEvent)   // C++ 선언, 블루프린트에서 구현
UFUNCTION(BlueprintNativeEvent)          // C++ 기본 구현, BP에서 오버라이드 가능

// USTRUCT() — 구조체를 리플렉션에 등록
USTRUCT(BlueprintType)
struct FMyStruct : public FTableRowBase { GENERATED_BODY() };
// F 접두사, UObject 상속 없음, GC 관리 대상 아님

// UENUM() — 열거형을 리플렉션에 등록
UENUM(BlueprintType)
enum class EMyType : uint8 { TypeA UMETA(DisplayName="A"), TypeB };
// E 접두사

3-5. FProperty — 이름이 F 접두사인 이유

UPROPERTY() 매크로와 FProperty 클래스는 서로 다른 것이다.

UPROPERTY() 매크로  →  우리가 코드에 쓰는 마커. UHT에게 "이 변수를 등록해" 알림.
FProperty 클래스   →  UHT가 그 마커를 보고 엔진 내부에 생성하는 메타데이터 객체.
                      우리가 직접 만들거나 다루는 것이 아님.

UE4 시절: UProperty (UObject 계열)

UE4에서는 UPROPERTY() 마커가 있는 변수마다 UProperty 객체가 생성됐다. UPropertyUObject를 상속받았기 때문에 GC 추적 대상이었다.

"In UE4, properties were UObjects (UProperty subclasses). A class with 100 properties meant 100 extra GC-tracked objects."

UPROPERTY() 100개 → UProperty 객체 100개 → GC 추적 대상 100개 추가
→ 멤버 변수가 많을수록 GC 부담 증가

UE5: FProperty (FField 계열, UObject 아님)

"UE5 moved properties to FField, a lighter-weight node that is not a UObject. Properties are owned directly by their parent UStruct and linked via FField::Next."

UE5에서 FPropertyUObject가 아닌 FField를 상속받는다. GC 추적 대상이 아니다.

UPROPERTY() 100개 → FProperty 객체 100개 → GC 추적 대상 아님
→ 멤버 변수가 아무리 많아도 GC 부담 없음

변하지 않은 것: UPROPERTY()가 제공하는 에디터 노출·GC 추적 기능은 그대로다. GC는 FProperty의 정보를 읽어 "이 멤버가 UObject* 타입이다" 를 파악하고 해당 포인터가 가리키는 UObject를 여전히 추적한다.

변한 것: 그 기능을 구현하는 내부 클래스의 상속 구조만 UProperty(UObject)FProperty(FField)로 바뀐 것이다.

결론: FProperty의 F는 다른 F 접두사(FVector, FTimerHandle 등)와 동일한 F다. "UObject가 아닌 순수 C++ 계열"임을 나타낸다.


4. UClass — 클래스를 표현하는 메타 객체

4-1. UClass란 무엇인가

UCLASS() 매크로가 붙은 클래스마다 UClass 객체가 하나씩 존재한다. UClass는 클래스 인스턴스(붕어빵)가 아니라 클래스 자체의 설계도(붕어빵 틀)를 표현하는 메타 객체다.

UClass 안에 담긴 것설명
클래스 이름런타임에 클래스 이름 문자열로 접근 가능
SuperClass 포인터부모 클래스의 UClass를 가리킴. Cast<>가 동작하는 근거
FProperty 링크드 리스트UPROPERTY()로 등록된 변수들의 메타데이터
UFunction 목록UFUNCTION()으로 등록된 함수들
CDO 포인터이 클래스의 Class Default Object 주소

4-2. UClass 간 SuperClass 연결 — Cast<>가 동작하는 원리

BP_BigCoinItem UClass
    └─ SuperClass → ACoinItem UClass
                     └─ SuperClass → ABaseItem UClass
                                      └─ SuperClass → AActor UClass
                                                       └─ ... → UObject UClass
// Cast<ABaseItem>(SomeActor) 동작 방식:
// SomeActor->GetClass() 에서 시작
// → SuperClass 포인터를 타고 올라가면서
// → ABaseItem의 UClass가 나오면 → 캐스트 성공, 포인터 반환
// → UObject까지 올라가도 못 찾으면 → nullptr 반환

// IsA<ABaseItem>(), IsChildOf() 모두 동일한 체인 순회로 동작

4-3. StaticClass() vs GetClass() — 목적과 결과의 차이

둘 다 UClass*를 반환하지만 언제, 어떤 상황에서 쓰는지가 완전히 다르다.

공식 블로그:

"You can get the UClass for a reflected C++ type by writing UTypeName::StaticClass(), and you can get the type for a UObject instance using Instance->GetClass()"

StaticClass()GetClass()
호출 방식AMyActor::StaticClass()myActor->GetClass()
인스턴스 필요 여부❌ 없어도 됨✅ 이미 존재하는 인스턴스 필요
결정 시점컴파일 타임에 타입 결정런타임에 실제 타입 결정
반환값UClass*UClass*

StaticClass()가 인스턴스 없이 호출 가능한 이유

StaticClass()는 UHT가 .generated.h에 생성한 static 함수다. C++에서 static 함수는 클래스 이름으로 직접 호출 가능하다. 이 함수 안에는 모듈 로드 시 이미 생성된 UClass 객체의 포인터가 캐시되어 있어 인스턴스와 완전히 무관하게 항상 같은 UClass*를 반환한다.

목적의 차이

// StaticClass() — 인스턴스가 없을 때, 클래스 자체를 값처럼 전달할 때
GetWorld()->SpawnActor<AActor>(ABigCoinItem::StaticClass(), Location, Rotation);
DefaultPawnClass = ASpartaCharacter::StaticClass();
if (SomeActor->IsA(ABaseItem::StaticClass())) { ... }

// GetClass() — 이미 있는 인스턴스의 런타임 실제 타입을 알아야 할 때
UClass* ActualClass = SomeActor->GetClass();
FString ClassName = ActualClass->GetName(); // "BP_BigCoinItem" 같은 이름 문자열
AActor* Clone = GetWorld()->SpawnActor<AActor>(Original->GetClass(), ...);
UE_LOG(LogTemp, Warning, TEXT("%s"), *Actor->GetClass()->GetName());

핵심 차이 — 다형성 상황에서 결과가 달라진다

ABigCoinItem* coin = SpawnActor<ABigCoinItem>(...);
AActor* ptr = coin; // AActor*로 업캐스팅해서 들고 있음

// StaticClass()는 선언 타입 기준 (컴파일 타임에 결정)
AActor::StaticClass()->GetName()        // "Actor"
ABigCoinItem::StaticClass()->GetName()  // "BigCoinItem"

// GetClass()는 인스턴스의 실제 타입 기준 (런타임에 결정)
ptr->GetClass()->GetName()              // "BP_BigCoinItem" (실제 타입)

// ptr은 AActor*로 선언되어 있지만
// GetClass()는 실제로 할당된 인스턴스의 타입을 런타임에 반환함
// → 이것이 GetClass()가 필요한 이유

4-4. UClass · CDO 생성 시점

UClass가 생성되는 순간 CDO도 항상 함께 만들어진다. 클래스 종류에 따라 이 시점이 다르다.

클래스 종류UClass + CDO 생성 시점비고
C++ 네이티브 클래스엔진 시작 시 DLL/모듈 로드 시점항상 메모리에 있음. 레벨 로드 이전에 이미 완료.
블루프린트 클래스해당 .uasset이 메모리에 처음 로드되는 시점필요할 때만 로드, 안 쓰면 언로드 가능
C++을 상속받은 BPBP .uasset 로드 시점BP CDO = C++ CDO 복사 + BP 설정값 적용

레벨 로드 흐름과의 연결

이 차이는 레벨 로드 경로를 이해하는 데 직결된다.

UClass와 CDO는 클래스당 딱 하나만 존재한다. 같은 BP Actor를 레벨에 100개 배치해도 UClass와 CDO는 하나이고, 인스턴스만 100개가 생긴다.


5. CDO (Class Default Object) — 공식 문서 기반 완전 정리

5-1. CDO란 무엇인가

공식 문서:

"The CDO is essentially a default 'template' Object, generated by the class constructor and unmodified thereafter."

"This constructor initializes the Class Default Object (CDO), which is the master copy on which future instances of the class are based."

CDO는 클래스 생성자 실행으로 만들어지는 기본값 마스터 객체다. 생성 이후에는 변경되지 않으며 읽기 전용으로 간주해야 한다.

5-2. UClass와 CDO의 관계

UClass와 CDO는 항상 쌍으로 존재한다. UClass가 생성되면 엔진은 즉시 CDO를 만든다. 이 흐름은 클래스 종류와 무관하게 동일하다.

[C++ 클래스]
엔진 시작 시 DLL 로드
    ↓
UClass 생성 (클래스 구조 정보가 담긴 메타 객체)
    ↓
CDO 생성 — GetDefaultObject() 호출 → 생성자 실행 → 기본값이 채워진 인스턴스
    ↓
UClass.ClassDefaultObject 포인터에 CDO 주소 저장

[BP 클래스]
.uasset 파일 로드 시
    ↓
UClass 생성 (C++ SuperClass 포인터 연결 포함)
    ↓
CDO 생성 — C++ CDO 복사 + BP 에디터 설정값 적용
    ↓
UClass.ClassDefaultObject 포인터에 CDO 주소 저장

UClass = 설계도 ("이 클래스의 변수·함수·부모가 무엇인지" 기술)
CDO    = 마스터 샘플 ("생성자를 실행했을 때 변수들의 초기값이 무엇인지" 기록)

5-3. SpawnActor 시 CDO 복사 흐름

공식 문서:

"Any initialization code written into the constructor will be applied to the CDO, and will therefore be copied to any new instances of the object created properly within the engine, as with CreateNewObject or SpawnActor."

SpawnActor<AMyActor>() 호출
    ↓
1. 새 메모리 공간 할당
2. CDO를 기반으로 프로퍼티 복사
   · 단순 값 타입(int, float) → 메모리 복사에 가까운 방식
   · 복잡한 타입(UObject*, TArray) → InitProperties()로 안전하게 복사
3. PostInitProperties() 호출
4. 월드에 등록 → PostActorCreated()
5. BeginPlay() 호출

정상적인 SpawnActor/NewObject 경로에서는 C++ 생성자가 재호출되지 않는다. CDO를 복사해서 인스턴스를 만들기 때문이다.

이것이 CDO의 존재 이유다. SpawnActor마다 생성자 코드를 처음부터 실행하지 않고 이미 완성된 CDO를 복사하기 때문에 CPU 부하가 낮고 빠르다.

5-4. CDO 계층 구조와 PIE 인스턴스 관계

[UClass 계층]
BP_BigCoinItem UClass → SuperClass → ACoinItem UClass → ... → UObject UClass

[CDO 계층]
ABaseItem CDO (생성자로 만들어진 기본값)
    └─ BP_BigCoinItem CDO = ABaseItem CDO 복사 + BP 에디터 설정값 덮어씀

[PIE 인스턴스] — 플레이 버튼을 눌렀을 때
BP_BigCoinItem CDO
    └─ 인스턴스A = CDO 복사 + 달라진 값만 별도 보관
    └─ 인스턴스B = CDO 복사 + 달라진 값만 별도 보관
    └─ 인스턴스C = ...

수천 개의 인스턴스가 있어도
공통 기본값은 CDO 하나에만 저장.
각 인스턴스는 CDO와 달라진 값만 별도로 들고 있음.

PIE 종료 시: 인스턴스들만 파괴, UClass와 CDO는 그대로 유지
PIE 재시작 시: 다시 CDO 복사 → 새 인스턴스 생성

5-5. 생성자 안에서 해도 되는 것 vs 안 되는 것

구분이유
✅ 기본값 설정 (Speed = 300.f)CDO 생성 시 기록, 모든 인스턴스에 복사됨
CreateDefaultSubobject<T>()CDO에 컴포넌트 구조를 등록. 생성자 밖에서 호출 불가.
ConstructorHelpers::FObjectFinder에셋을 한 번만 로드해서 CDO에 캐시
GetWorld() 사용CDO 생성 시점에 월드가 존재하지 않을 수 있음
SpawnActor() 호출동일한 이유. 월드 의존 코드는 BeginPlay()에서
❌ 델리게이트 바인딩 (AddDynamic)CDO도 생성자를 통해 만들어지므로 CDO가 이벤트에 응답하는 문제 발생. BeginPlay()에서 해야 한다.

5-6. 델타 직렬화 — 에디터 "되돌리기 화살표"의 원리

직렬화 시 CDO와 달라진 값만 저장된다. CDO와 동일한 값은 저장하지 않아 파일 크기와 직렬화 비용을 줄인다. 에디터 Details 패널의 "값 되돌리기" 화살표가 CDO의 기본값으로 리셋하는 이유가 이것이다.

5-7. CDO를 직접 수정하면 안 되는 이유

// 교육 목적으로만 — 실제 사용 금지
AMyActor* CDO = GetMutableDefault<AMyActor>();
CDO->Health = 200;
// → 이후 SpawnActor로 생성되는 모든 인스턴스가 Health = 200으로 시작
// → 이미 존재하는 인스턴스에는 영향 없음
// → 런타임에 CDO 수정 = 예측 불가한 동작의 원인

6. GC (Garbage Collection) — Mark and Sweep

6-1. GC가 필요한 이유

표준 C++에서는 new로 메모리를 할당하면 반드시 delete로 직접 해제해야 한다. 빠뜨리면 메모리 누수, 잘못된 시점에 해제하면 댕글링 포인터로 크래시가 난다. 언리얼의 GC는 UObject 계열 객체의 메모리를 자동으로 관리해서 이 문제를 해결한다.

6-2. Mark and Sweep 전체 흐름

[1단계] Mark (마킹)
루트 객체(UWorld, UGameInstance 등)에서 시작
→ UPROPERTY()로 선언된 멤버 포인터들을 하나씩 타고 들어감
→ 참조 체인을 따라가며 살아있는 객체들에 "마킹" 표시
→ 이 단계에서는 아무것도 지우지 않음

[2단계] Sweep (스윕)
마킹 안 된 객체 = 어떤 UPROPERTY 체인에도 연결 안 됨
→ 고립 객체로 판정 → 수거 대상 확정
→ 이 객체를 가리키는 UPROPERTY 포인터들을 nullptr로 처리
  (마킹 단계에서 "누가 이 객체를 가리키는지" 이미 파악했기 때문에 가능)

[3단계] Destroy (제거)
BeginDestroy() 호출 → 리소스 해제 시작
FinishDestroy() 호출 → 메모리에서 완전히 제거

GC는 게임 스레드에서 실행된다. 함수 실행 도중에 GC가 객체를 수거하지 않는다.

6-3. UPROPERTY() 유무에 따른 GC 동작 — 정확한 시나리오

멤버 변수가 UObject* 타입(또는 파생 클래스 포인터)인 경우, UPROPERTY() 유무에 따라 GC 동작이 완전히 달라진다.

UCLASS()
class AMyActor : public AActor
{
    UPROPERTY()
    UOtherObject* SafeRef;   // GC가 추적함

    UOtherObject* DangerRef; // GC가 이 포인터의 존재를 모름
};

SafeRef — UPROPERTY() 있음, 안전

1. GC Mark 단계에서 SafeRef를 타고 UOtherObject 인스턴스를 마킹
2. "누군가 참조 중" → 수거 안 함, 인스턴스 살아있음
3. 만약 수거 대상이 되면 (다른 모든 참조가 사라진 경우):
   GC가 SafeRef 포인터를 nullptr로 처리해줌
4. if (SafeRef) → false → 안전하게 처리 가능

DangerRef — UPROPERTY() 없음, 댕글링 포인터 위험

1. GC Mark 단계에서 DangerRef의 존재 자체를 모름
2. DangerRef가 가리키는 인스턴스, 다른 UPROPERTY 참조도 없으면
   "고립 객체"로 판정 → Sweep 단계에서 수거 확정
3. GC가 DangerRef 포인터의 위치를 몰라서 nullptr로 못 밀어줌
4. DangerRef에 예전 메모리 주소값이 그대로 남음 (이미 해제된 메모리)
5. if (DangerRef) → true  ← 주소값이 0이 아니니까
6. DangerRef->Func() → 이미 해제된 메모리 접근 → 크래시 (댕글링 포인터)

이것이 댕글링 포인터 문제의 본질이다. 해제된 메모리 주소를 가리키는 포인터를 역참조했을 때 크래시가 난다.

6-4. ⚠️ UE5의 GC 동작 변경 예정

Epic Games 내부 개발자(Ari Arnbjörnsson) 발언:

"The GC will no longer clear UPROPERTY + RawPtr/TObjectPtr references (even for Actors) but instead mark them as garbage (MarkAsGarbage()) and not GC them. The only way to clear the memory will be to null the reference or use weak pointers."

UE4에서는 GC 수거 시 UPROPERTY 포인터를 자동으로 nullptr로 밀어줬다. UE5에서는 이 동작이 변경될 예정이다. 지금부터 IsValid() 체크를 습관화해야 한다.

6-5. 유효성 체크 3단계

방법동작권장 상황
if (Obj)주소값이 nullptr(0)인지만 확인댕글링 포인터를 잡지 못함. 단독 사용 비권장.
IsValid(Obj)nullptr + PendingKill(삭제 대기) 상태 확인일반적인 상황에서 권장.
Obj->IsValidLowLevel()해당 주소에 실제 UClass 정보가 있는지 확인매우 의심스러운 상황에서만. 오버헤드 큼.

7. 델리게이트와 AddDynamic

7-1. 언리얼 델리게이트 종류

공식 문서:

"Dynamic delegates can be serialized, their functions can be found by name, and they are slower than regular delegates."

구분DynamicNon-Dynamic
함수 찾는 방식이름(문자열)으로 탐색 (리플렉션 사용)함수 포인터로 직접 연결
직렬화✅ 가능❌ 불가
블루프린트 연동✅ 가능❌ 불가
속도느림 (런타임 탐색)빠름
바인딩 대상 함수UFUNCTION() 필수불필요
바인딩 방법AddDynamic, BindDynamicAddUObject, BindUObject

OnComponentBeginOverlapDECLARE_DYNAMIC_MULTICAST_DELEGATE로 선언된 Dynamic 델리게이트다. 그래서 AddDynamic을 쓰고, 바인딩 대상 함수에 UFUNCTION()이 필요하다.

7-2. AddDynamic이 하는 일

AddDynamic은 실제로 매크로다. 내부 동작을 풀어쓰면:

Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);

// 매크로가 하는 일:
// 1. &ABaseItem::OnItemOverlap 함수 포인터에서 함수 이름 문자열 추출
//    → "OnItemOverlap"
// 2. 리플렉션 시스템에서 이름으로 UFunction 탐색
//    → UFUNCTION() 매크로로 등록된 함수만 찾을 수 있음
// 3. 찾은 UFunction + this(객체 포인터)를 델리게이트 리스트에 추가
// 4. 이벤트 발생 시 this->OnItemOverlap(...) 형태로 호출

이름으로 찾는 것이 핵심이다. 컴파일 타임에 주소로 직접 연결하는 Non-Dynamic 방식과 달리, Dynamic은 런타임에 리플렉션을 통해 함수를 이름으로 탐색한다. 그래서 UFUNCTION()이 없으면 리플렉션에 등록이 안 되어 바인딩 실패하고, 이름으로 탐색하기 때문에 "Dynamic"이라는 이름이 붙었다.

7-3. this를 명시적으로 써야 하는 이유

Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
//                                              ↑
//                                          객체 포인터

C++에서 멤버 함수 포인터는 단독으로 호출할 수 없다. 반드시 "어떤 객체에서 호출할지"를 함께 지정해야 한다.

void (ABaseItem::*FuncPtr)(...) = &ABaseItem::OnItemOverlap;
// FuncPtr만으로는 호출 불가. 반드시 객체가 있어야 함:
(this->*FuncPtr)(...);

델리게이트는 "나중에 이벤트 발생 시 호출할 함수"를 지금 등록하는 것이다. 등록 시점에 "어떤 객체에서 호출할지"를 모르면 나중에 호출할 수 없다. 그래서 this를 명시적으로 함께 저장한다.

7-4. 생성자에서 AddDynamic을 쓰면 안 되는 이유

생성자에서 AddDynamic 바인딩
    → CDO 생성 시에도 생성자 실행
    → CDO에도 OnComponentBeginOverlap 이벤트 바인딩됨
    → CDO가 Overlap 이벤트에 응답하기 시작 (예기치 않은 동작)
    → UPROPERTY 델리게이트면 직렬화 문제까지 발생 가능

올바른 위치: BeginPlay() 또는 PostInitializeComponents()

8. 참조 시스템 — 강한 참조 · 약한 참조 · 소프트 참조

8-1. 전체 비교표

방식GC 막음로드 보장종류주요 용도
UPROPERTY() + 포인터강한일반 UObject 멤버 변수
TSubclassOf<T>강한클래스 타입 참조 (하드 레퍼런스)
TSoftClassPtr<T>소프트클래스 지연 로딩, 대량 에셋
TSoftObjectPtr<T>소프트오브젝트 인스턴스 지연 로딩
TWeakObjectPtr<T>약한순환 참조 방지, 임시 참조
TObjectPtr<T>강한UE5 권장 방식 (헤더 UPROPERTY 선언)
TSharedPtr<T>강한UObject 아닌 클래스 (F 접두사 등)
TWeakPtr<T>약한TSharedPtr의 약한 참조 쌍

TWeakObjectPtr 사용 패턴

TWeakObjectPtr<UMyObject> WeakRef;

// GC 수거를 막지 않음. 수거 시 자동으로 무효화됨.
if (WeakRef.IsValid())
{
    WeakRef->DoSomething();  // 유효한 경우에만 안전하게 접근
}

raw UObject* 포인터와의 핵심 차이

raw UObject* (UPROPERTY 없음):
→ GC가 인스턴스를 수거해도 포인터에 예전 주소가 그대로 남음
→ 댕글링 포인터 → 역참조 시 크래시

TWeakObjectPtr<T>:
→ UPROPERTY 없이도 GC 후 자동으로 무효화됨
→ IsValid()가 false를 반환 → 안전하게 처리 가능
→ 이것이 raw UObject*보다 TWeakObjectPtr을 사용해야 하는 이유

TWeakObjectPtr은 내부적으로 GUObjectArray의 인덱스와 SerialNumber를 저장한다. GC가 인스턴스를 수거하면 그 슬롯의 SerialNumber가 무효화되고, IsValid()가 이것을 감지해서 false를 반환한다. 포인터를 직접 들고 있지 않기 때문에 댕글링 포인터가 발생하지 않는다.

8-2. TSubclassOf\<T> — 컴파일 타임 타입 안전 장치

// ✅ 가능 — ASpartaCharacter는 ACharacter → APawn 계열
DefaultPawnClass = ASpartaCharacter::StaticClass();

// ❌ 컴파일 에러 — AGameMode는 APawn 계열이 아님
DefaultPawnClass = ASpartaGameMode::StaticClass();

// 암시적 변환: .Get() 없이 바로 SpawnActor에 전달 가능
TSubclassOf<AActor> ItemClass = ABigCoinItem::StaticClass();
GetWorld()->SpawnActor<AActor>(ItemClass, ...);

8-3. TSubclassOf vs TSoftClassPtr — 하드 vs 소프트 레퍼런스

// TSubclassOf (하드 레퍼런스)
// DataTable에 TSubclassOf로 클래스 100개 참조 시
// → DataTable 로드 시점에 100개 클래스 전부 RAM에 올라옴

// TSoftClassPtr (소프트 레퍼런스)
// → 경로(문자열)만 저장. 참조 대상은 로드되지 않음
TSoftClassPtr<AActor> SoftClass;
UClass* Actual = SoftClass.Get();             // 로드됐으면 반환, 아니면 nullptr
UClass* Forced = SoftClass.LoadSynchronous(); // 강제 동기 로드 (게임 흐름 잠시 멈춤)
// RequestAsyncLoad() → 비동기 로드 (게임 흐름 유지, 실무 권장)

8-4. TObjectPtr\<T> — UE5 권장 방식

// UE4 스타일
UPROPERTY(VisibleAnywhere)
UCameraComponent* CameraComp;

// UE5 권장 스타일
UPROPERTY(VisibleAnywhere)
TObjectPtr<UCameraComponent> CameraComp;
// · 헤더의 UPROPERTY 선언에만 사용
// · .cpp 내 함수에서는 raw pointer 그대로 사용
// · 배포 빌드에서는 raw pointer와 동일하게 동작
// · 에디터에서 Lazy Loading 등 추가 기능 지원

8-5. 참조 방식 선택 기준

상황권장 방식
일반 UObject 멤버 변수 (헤더)TObjectPtr<T> + UPROPERTY() (UE5 권장)
에디터에서 에셋 지정, 즉시 사용TSubclassOf<T> 또는 UPROPERTY() + 포인터
대량 에셋, 메모리 최적화 필요TSoftClassPtr<T> / TSoftObjectPtr<T>
GC 수거를 막으면 안 되는 임시 참조 / 순환 참조 방지TWeakObjectPtr<T>
UObject 아닌 외부 클래스 (F 접두사 등)TSharedPtr<T> / TWeakPtr<T>

핵심 요약

  • 표준 C++ 컴파일 후 클래스 구조 정보는 기계어로 사라진다. RTTI가 일부 타입 정보를 제공하지만 변수 이름·메타데이터·함수 이름 탐색은 불가능하다. 언리얼은 RTTI를 비활성화하고 자체 리플렉션 시스템을 구축했다.

  • 모듈은 빌드의 기본 단위다. 개발 빌드에서는 모듈별 DLL로 컴파일되어 라이브 코딩이 가능하고, 배포(Shipping) 빌드에서는 모놀리식 실행파일로 합쳐진다. 플러그인은 하나 이상의 모듈과 에셋을 묶은 재사용 단위이고, 프로젝트는 최상위 단위다.

  • T, E, I, F 접두사 클래스는 UObject를 상속받지 않는다. GC 관리 대상이 아니며 스택에서 값 타입으로 사용할 수 있다. F 계열 멤버에 UPROPERTY()를 붙이는 경우는 에디터 노출 목적이다.

  • UPROPERTY() 매크로는 UHT가 FProperty 객체를 생성하도록 하는 마커다. UE4에서 UProperty(UObject 계열)였던 것이 UE5에서 FProperty(FField 계열)로 변경되어 GC 추적 부담이 줄었다. UPROPERTY()가 제공하는 에디터 노출·GC 추적 기능은 그대로다.

  • UObject* 타입 멤버에 UPROPERTY()가 없으면 GC가 해당 포인터를 추적하지 못한다. GC가 인스턴스를 수거해도 포인터에 예전 주소가 남아 댕글링 포인터가 된다.

  • UClass는 설계도, CDO는 마스터 샘플이다. UClass 생성 시 CDO가 항상 함께 만들어진다. C++ 클래스는 엔진 시작 시 DLL 로드와 함께, BP 클래스는 .uasset이 처음 로드되는 시점에 UClass + CDO가 생성된다. SpawnActor는 CDO를 복사해서 인스턴스를 만들기 때문에 생성자는 재호출되지 않는다. UClass와 CDO는 클래스당 딱 하나만 존재하며 인스턴스 수와 무관하다.

  • 생성자 안에서 GetWorld(), SpawnActor(), AddDynamic()을 쓰면 안 된다. CDO 생성 시점에는 월드가 없고, 델리게이트 바인딩은 CDO까지 이벤트에 응답하는 문제를 일으킨다.

  • StaticClass()는 인스턴스 없이 클래스 자체를 값처럼 전달할 때, GetClass()는 존재하는 인스턴스의 런타임 실제 타입을 알아야 할 때 사용한다. 다형성 상황에서 AActor*로 들고 있어도 GetClass()는 실제 파생 타입을 반환한다.

  • AddDynamic은 런타임에 리플렉션을 통해 함수를 이름으로 탐색하는 Dynamic 델리게이트 전용 매크로다. UFUNCTION()이 필수이고, BeginPlay()에서 바인딩해야 한다.

  • GC는 Mark and Sweep 방식이다. UE5에서 UPROPERTY 포인터 자동 nullptr 처리 동작이 변경될 예정이므로 IsValid() 체크를 습관화해야 한다.

profile
기반이 되는 강의를 진행하고, Ai와 다시 한 번 탐구하고 정리하여 포스팅합니다. 목적 자체가 저의 학습을 위함이기 때문에, 시리즈 관련 포스팅은 규칙적으로 나오지 않을 수 있습니다. 양해 부탁드립니다. (_ _)

2개의 댓글

comment-user-thumbnail
2026년 4월 14일

잘 읽었습니다.

1개의 답글