🚀 들어가며
이전 글에서는 Unreal Engine이 왜 C++ 기본 RTTI 대신 자체 Reflection 시스템을 사용하는지 살펴봤습니다.
언리얼은 단순 타입 확인을 넘어서
- 에디터
- 블루프린트
- 직렬화
- 네트워크 복제
- 가비지 컬렉션(GC)
같은 엔진 전체 기능과 연결되기 때문에, 자체적인 메타데이터 시스템이 필요했습니다.
언리얼의 메타 시스템을 이해하는 과정에서 아래 질문을 해볼 수 있습니다.
❓ Unreal Engine의 GC는 어떻게 UObject를 추적할까?
언리얼 엔진을 공부하다 보면 메모리 관리 측면에서 다음의 말을 들어보셨을 겁니다.
- UPROPERTY 안 붙이면 위험하다
- GC가 추적하지 못한다
- UObject가 갑자기 nullptr 된다
처음에는 다소 이상하게 느껴질 수 있습니다.
왜냐하면 C++에서는 보통
- new
- delete
- std::shared_ptr
같은 방식으로 메모리를 관리하기 때문입니다.
그런데 언리얼 엔진은 조금 다릅니다.
언리얼의 UObject 시스템은 👉 Reflection 시스템과 연결된 자체 GC 구조로 동작합니다.
이번 글에서는
- 언리얼 GC가 왜 필요한지
- GC가 UObject를 어떻게 추적하는지
- 왜 UPROPERTY가 중요한지
- 왜 raw pointer만으로는 부족한지
정리해보겠습니다.
🧠 C++는 기본적으로 GC가 없습니다
일반적인 C++에서는 객체 생명주기를 개발자가 직접 관리합니다.
Player* player = new Player(); delete player;
또는 스마트 포인터를 사용합니다.
std::shared_ptr<Player> player = std::make_shared<Player>();
즉 기본적으로
- 누가 객체를 소유하는가?
- 언제 삭제할 것인가?
를 개발자가 직접 결정합니다.
하지만 게임 엔진에서는 객체 관계가 매우 복잡해집니다.
예를 들어
Actor ├── Component ├── Material ├── Animation ├── UI Widget └── 다른 UObject 참조
수많은 객체가 서로 연결됩니다.
그리고 게임 진행 및 게임 개발 과정에서
- 레벨 전환
- 블루프린트 생성
- 에디터 수정
- 런타임 Spawn
등이 계속 일어납니다.
이 상황에서 모든 객체의 생명주기를 수동 관리하는 것은 매우 어렵습니다.
그래서 Unreal Engine은 UObject 계열 객체에 대해 👉 자체 Garbage Collection 시스템을 사용합니다.
🎯 언리얼 GC의 핵심 아이디어
지금까지 설명한 Unreal Engine GC 구조를 그림으로 정리해보면 다음과 같습니다.
핵심은 언리얼 GC는 Reflection 시스템과 UPROPERTY 정보를 기반으로 도달 가능한 UObject를 추적한다는 점입니다.

위 구조처럼 Unreal Engine의 Garbage Collection은 단순 메모리 해제가 아니라, UObject 참조 그래프를 순회하는 방식으로 동작합니다.
특히
- UPROPERTY 기반 참조 추적
- Reachability 분석
- Mark & Sweep 구조
- TWeakObjectPtr 기반 안전한 약한 참조
구조가 서로 연결되면서 엔진 전체 객체 생명주기를 관리하게 됩니다.
🧩 Reachability(도달 가능성)
GC에서 가장 중요한 개념은 Reachability입니다.
예를 들어
GameInstance
└── PlayerController
└── Character
└── Weapon
이렇게 연결되어 있다면
- GameInstance가 살아있고
- PlayerController를 참조하고 있으며
- Character를 참조하고 있고
- Weapon까지 연결되어 있다면
Weapon도 살아있는 객체로 판단됩니다.
즉
루트 객체 ↓ 참조 순회 ↓ 도달 가능한 UObject 표시 ↓ 나머지 제거
흐름으로 동작합니다.
🎮 그런데 GC는 객체를 어떻게 찾을까?
여기서 가장 중요한 질문이 등장합니다.
❓ GC는 UObject 참조를 어떻게 알 수 있을까?
예를 들어
class AMyActor : public AActor
{
public:
UObject* Target;
};
이 코드를 보면, GC 입장에서는 문제가 있습니다.
- Target이 UObject인지
- 단순 포인터인지
- GC 추적 대상인지
알 수 없습니다.
왜냐하면 C++ 컴파일 이후에는 👉 일반 원시 포인터(raw pointer)는 단순 주소 값일 뿐이기 때문입니다.
즉
0x12345678
같은 주소만 존재합니다.
GC는
- 이 포인터가 UObject인지
- 참조 관계인지
- 추적해야 하는지
자동으로 알 수 없습니다.
그래서 등장하는 것이 바로 👉 UPROPERTY() 매크로입니다.
🔥 UPROPERTY()가 중요한 이유
언리얼에서는 UObject 참조 변수를 보통 이렇게 선언합니다.
UPROPERTY() UObject* Target;
이 순간 중요한 일이 발생합니다.
Reflection 시스템 등록 ↓ GC 추적 가능 ↓ 직렬화 가능 ↓ 에디터 노출 가능
즉, UPROPERTY는 단순 매크로가 아닙니다.
👉 “이 멤버는 엔진이 관리해야 하는 UObject 참조”라고 선언하는 것입니다.
🧠 Reflection 시스템과 GC의 연결
이전 글에서 설명했던 Reflection 시스템이 여기서 다시 등장합니다.
언리얼은 UPROPERTY 정보를 기반으로
- 현재 클래스가 어떤 UObject를 참조하는지
- 어떤 멤버를 순회해야 하는지
알 수 있습니다.
개념적으로는 이런 느낌입니다.
UClass └── Property 목록 보유 Property └── UObject 포인터 정보 보유
GC는 이 정보를 이용해 객체 그래프를 순회합니다.
즉
루트 UObject ↓ UPROPERTY 순회 ↓ 참조 UObject 발견 ↓ 계속 순회
형태입니다.
🎯 원시 포인터(raw pointer)가 위험한 이유
이제 왜 원시 포인터(raw pointer)가 위험한지 이해할 수 있습니다.
예를 들어
UObject* Target;
이렇게만 작성하면, GC는 이 포인터를 알 수 없습니다.
즉
GC 입장: "이 UObject를 아무도 참조하지 않는구나"
라고 판단할 수 있습니다.
결과적으로
- GC가 객체 제거
- 포인터는 여전히 이전 주소 유지
- 댕글링 포인터(Dangling Pointer) 문제 발생
문제가 생길 수 있습니다.
즉, 포인터는 살아있는데 실제 UObject는 이미 제거되는 문제가 발생할 수 있습니다.
⚠️ 그래서 이런 코드가 위험합니다
class AMyActor : public AActor
{
public:
UObject* Target;
};
겉보기에는 문제 없어 보이지만
- GC가 추적하지 못하고
- 객체가 제거될 수 있으며
- Target은 죽은 객체 주소를 들고 있을 수 있습니다
그래서 언리얼 엔진에서는
UPROPERTY() UObject* Target;
형태로 UPROPERTY() 매크로를 붙여서 UObject 타입의 변수를 선언합니다.
🧩 GC는 실제로 어떻게 순회할까?
언리얼 엔진의 가비지 컬렉터는 훨씬 더 복잡한 매커니즘으로 동작하지만,
매우 단순화하면 Mark & Sweep 방식에 가까운 동작 매커니즘을 갖습니다.
흐름은 대략 이렇습니다.
1. 루트 객체 찾기
엔진이 반드시 살아있다고 판단하는 객체들이 있습니다.
주로
- World
- GameInstance
- Level
- PlayerController
이 객체들이 시작점입니다.
2. 참조 순회
GC는 각 UObject의 UPROPERTY를 검사합니다.
Object ↓ UPROPERTY 목록 확인 ↓ 참조 UObject 발견 ↓ 재귀적으로 순회
3. Reachable 표시
도달 가능한 객체는 살아있는 것으로 표시합니다.
Reachable = true
4. Sweep 단계
끝까지 순회한 뒤에도 도달 불가능한 UObject는 제거 대상이 됩니다.
즉
참조 없음 ↓ GC 제거 가능
상태가 됩니다.
🎮 왜 std::shared_ptr를 안 사용할까?
언리얼 엔진을 사용하다보면 이 질문도 자주 등장합니다.
❓ 그냥 std::shared_ptr 쓰면 안 되나?
이 질문에 대한 답은 생각보다 단순하지 않습니다.
언리얼 엔진이 UObject에 대해 shared_ptr 기반 구조를 사용하지 않는 이유는 여러 가지가 있습니다.
1. 순환 참조 문제
shared_ptr는 순환 참조에 취약합니다.
A → B B → A
서로 참조하면 참조 카운트가 0이 되지 않습니다.
게임 엔진에서는 객체 관계가 매우 복잡하기 때문에, 순환 참조 관리 비용이 커질 수 있습니다.
2. 엔진 전체와 통합되어야 함
언리얼 엔진은 단순하게 메모리 관리만 하는 것이 아닙니다.
언리얼 엔진의 GC는
- Reflection
- 에디터
- 직렬화
- 블루프린트
- 네트워크 복제
와 모두 연결됩니다.
즉 UObject 시스템은 엔진 전체 메타 시스템과 연결된 객체 구조라고 할 수 있습니다.
3. UObject는 특별한 객체 시스템이다
언리얼에서 UObject는 일반 C++ 객체와 다릅니다.
예를 들어:
- 이름(Name)
- Outer
- Class 정보
- Reflection 정보
- GC 정보
를 내부적으로 가질 수 있습니다.
즉, 단순 shared_ptr 기반 객체보다 훨씬 복잡한 엔진 객체입니다.
🧠 TWeakObjectPtr는 왜 필요할까?
GC 구조를 이해하면 이것도 자연스럽게 연결됩니다.
가끔은
- 객체를 참조는 하고 싶지만
- GC 생존에는 영향 주고 싶지 않은 경우
가 있습니다.
이때 사용하는 것이
TWeakObjectPtr
입니다.
TWeakObjectPtr은
- 객체가 살아있는지 확인 가능
- GC 제거 여부 감지 가능
- Dangling Pointer 방지 가능
특징을 가집니다.
즉, GC 기반 UObject 환경에 맞춘 Weak Pointer라고 볼 수 있습니다.
🎯 핵심 정리
- Unreal Engine은 UObject 계열 객체를 GC 기반으로 관리합니다.
- GC는 Reachability(도달 가능성)를 기준으로 객체를 판단합니다.
- UPROPERTY는 GC가 UObject 참조를 추적할 수 있도록 Reflection 시스템에 등록합니다.
- raw pointer만 사용하면 GC가 참조를 알 수 없어 위험할 수 있습니다.
- 언리얼 GC는 Reflection 시스템과 깊게 연결되어 있습니다.
- TWeakObjectPtr는 GC 환경에서 안전한 약한 참조를 제공합니다.
🧩 마무리
처음 언리얼을 공부하면
- 왜 UPROPERTY를 붙여야 하지?
- 왜 raw pointer가 위험하지?
- 왜 UObject는 특별하지?
같은 부분이 굉장히 낯설게 느껴질 수 있습니다.
하지만 내부 구조를 이해해보면, 이 모든 것이 Reflection + GC + 엔진 객체 시스템으로 연결되어 있다는 것을 알 수 있습니다.
즉 언리얼의 UObject는 단순 C++ 객체가 아니라
- 엔진이 추적하고
- 에디터가 이해하고
- GC가 관리하는
특별한 객체 시스템입니다.
이 구조를 이해하면 UPROPERTY()가 단순 문법이 아니라, 엔진 전체 구조와 연결된 핵심 요소라는 점이 보이기 시작합니다.