영상을 불러오지 못했습니다

Unreal Engine UObject 가비지 컬렉션: 참조 그래프와 안전한 수명 관리

3350 단어
17 분
Unreal Engine UObject 가비지 컬렉션: 참조 그래프와 안전한 수명 관리

Unreal Engine의 UObject garbage collection을 이해하는 핵심은 “pointer가 몇 개인가”가 아니라 Root Set에서 GC가 아는 strong reference를 따라 대상에 도달할 수 있는가이다. Outer, C++ smart pointer, 파괴 요청, 강제 GC의 역할을 이 참조 그래프와 구분해야 한다.

C++ 수명 관리와 UObject GC는 함께 존재한다#

ISO C++에는 UObject 같은 tracing GC가 기본 제공되지 않는다. 대신 stack object, RAII, value type, std::unique_ptr 같은 deterministic ownership을 중심으로 설계한다. Unreal project도 이 원칙을 버리지 않는다.

대상수명 관리비고
UObject와 파생 classUnreal reachability GCAActor, UActorComponent, asset도 UObject 계열
plain C++ classRAII, value, unique/shared ownershipUObject smart pointer를 섞지 않음
UStruct value 자체owner의 C++/UObject lifetimeEnclosing field까지 reflected되거나 명시적으로 report될 때 내부 UObject field를 순회
stack local과 container valueC++ scope/ownerUObject GC가 value의 destructor를 대신하지 않음

UA prefix는 Unreal naming convention이다. prefix 자체가 GC 대상을 만드는 것이 아니라 실제 UObject inheritance와 reflection 정보가 중요하다.

Reference graph와 collection lifecycle#

GC는 root와 engine-known reference에서 시작해 strong edge를 추적한다. 도달한 object는 이번 collection에서 유지되고, 도달하지 못한 object는 unreachable이 되어 destruction lifecycle로 들어간다.

UPROPERTY TObjectPtr

static AddReferencedObjects

GC가 아는 external strong reference

수명 유지 안 함

아니오

Root Set / engine roots

도달 가능한 UObject

참조 대상 UObject

FGCObject / TStrongObjectPtr

unreflected raw T* / TWeakObjectPtr / TSoftObjectPtr

Root에서 strong path가 있는가?

이번 collection에서 유지

Unreachable

BeginDestroy

FinishDestroy / memory release

UPROPERTY TObjectPtr

static AddReferencedObjects

GC가 아는 external strong reference

수명 유지 안 함

아니오

Root Set / engine roots

도달 가능한 UObject

참조 대상 UObject

FGCObject / TStrongObjectPtr

unreflected raw T* / TWeakObjectPtr / TSoftObjectPtr

Root에서 strong path가 있는가?

이번 collection에서 유지

Unreachable

BeginDestroy

FinishDestroy / memory release

Reference count와 달리 서로를 가리키는 cycle도 root에서 끊겨 있으면 함께 수거할 수 있다. 반대로 아무 gameplay 가치가 없어도 root나 long-lived owner에서 strong path가 하나 남아 있으면 계속 유지된다. GC가 memory leak을 “항상 막아 준다”는 표현보다 unintended retention도 graph 문제라고 이해하는 편이 정확하다.

일반 collection은 reachability를 계산하는 동안 UObject processing/gameplay를 일시 중단할 수 있지만, mark work 자체는 gc.AllowParallelGC로 collector worker에 나눌 수 있다. Parallel GC와 여러 frame에 걸쳐 mark하는 incremental reachability는 서로 다른 기능이다.

UObject를 올바르게 생성하기#

UObject는 new/delete로 생성·해제하지 않는다.

상황API
UObject constructor에서 default subobjectCreateDefaultSubobject<T>()
Runtime의 일반 UObjectNewObject<T>()
Runtime ActorUWorld::SpawnActor<T>() 또는 deferred spawn
기존 object 복제DuplicateObject<T>()

CreateDefaultSubobject는 constructor-only API다. Runtime에 임의 UObject를 만드는 NewObject의 대체가 아니다.

Outer와 lifetime owner를 구분한다#

NewObject<T>(Outer)Outer는 object name path, containment, serialization context에 중요하다. 그러나 Outer가 임의 child를 자동으로 strong-reference하는 일반 lifetime-owner 계약은 아니다.

GcOwner.h
#pragma once
#include "UObject/Object.h"
#include "UObject/ObjectPtr.h"
#include "UObject/UObjectGlobals.h"
#include "GcOwner.generated.h"
UCLASS()
class MYGAME_API UGcChild final : public UObject
{
GENERATED_BODY()
};
UCLASS()
class MYGAME_API UGcOwner final : public UObject
{
GENERATED_BODY()
public:
void CreateChild()
{
Child = NewObject<UGcChild>(this);
}
void ReleaseChild()
{
Child = nullptr;
}
private:
UPROPERTY(Transient)
TObjectPtr<UGcChild> Child;
};

this를 Outer로 정하는 것과 Child를 살리는 strong edge는 별개다. 위에서는 도달 가능한 owner의 reflected Child property가 target을 유지한다. Child = nullptr 뒤 다른 strong path가 없다면 target은 다음 collection에서 수거 대상이 될 수 있다.

UObject pointer 결정표#

Persistent reference와 function-local view를 같은 type으로 강제하지 않는다.

의도선택Keepalive주의
parameter, return, 짧은 localraw T*없음lifetime invariant가 scope 안에서 명확해야 함
reflected persistent strong fieldUPROPERTY() TObjectPtr<T>있음owner도 reachable이어야 함
장기 non-owning cacheTWeakObjectPtr<T>없음매 사용 전에 Get/IsValid
path 기반 on-demand assetTSoftObjectPtr<T>없음load 완료 뒤 별도 strong ownership 필요
non-UObject owner나 짧은 pinTStrongObjectPtr<T>있음항상 strong, hidden retention과 cycle 주의
UObject의 special unreflected edgestatic AddReferencedObjects조건부owner가 reachable할 때 outgoing edge를 report
non-UObject native ownerFGCObject있음global registration과 주소 안정성 필요
정말 전역인 manual rootAddToRoot / RemoveFromRoot있음remove 누락은 사실상 permanent retention

Persistent reflected field를 왜 TObjectPtr로 쓰는지는 TObjectPtr에서 UFUNCTION, container, serialization과 함께 설명했다.

Bare TObjectPtr는 자동 strong reference가 아니다#

TObjectPtr wrapper만 member로 선언해서는 GC가 자동으로 찾지 못한다. 도달 가능한 UCLASS/USTRUCTUPROPERTY graph에 있거나, custom reference collector가 명시적으로 report해야 한다. Container도 element뿐 아니라 container member 자체가 reflected되어야 한다.

UPROPERTY()
TObjectPtr<UObject> Current;
UPROPERTY()
TArray<TObjectPtr<UObject>> History;

UE 5.4부터 experimental incremental reachability는 TObjectPtr assignment write barrier를 사용한다. 그래서 incremental mode에서는 non-editor build라고 wrapper가 raw pointer와 완전히 같은 assignment cost와 type property를 가진다고 단정할 수 없다.

Weak, soft, strong pointer의 역할#

TWeakObjectPtr#

Weak pointer는 target을 살리지 않는다. Target이 수거되거나 destruction 상태가 되면 Get()이 null을 반환할 수 있으므로 사용할 때마다 확인한다.

Game thread의 짧은 use라면 다음처럼 쓴다.

if (UObject* Object = WeakObject.Get())
{
UseObject(Object);
}

UE 5.5+에서 lifetime을 잠시 고정해야 한다면 Pin()으로 strong guard를 얻을 수 있다.

#include "UObject/StrongObjectPtrTemplates.h"
#include "UObject/WeakObjectPtrTemplates.h"
if (TStrongObjectPtr<UObject> Pinned = WeakObject.Pin())
{
// Pinned가 살아 있는 동안 GC lifetime은 유지된다.
// UObject API 자체의 thread safety는 별도 계약이다.
UseObject(Pinned.Get());
}

Pin이 object method를 worker-thread-safe하게 만들지는 않는다.

TSoftObjectPtr#

Soft pointer는 asset path와 weak semantics를 제공한다. Sync/async load 시점을 선택할 수 있지만 load가 끝난 object를 계속 살려 두지는 않는다. FStreamableManager가 completion callback까지 hard reference를 잡더라도, 이후에는 도달 가능한 UPROPERTY() TObjectPtr<T>나 명시적으로 관리되는 handle에 넘겨야 한다.

TStrongObjectPtr#

TStrongObjectPtr는 UPROPERTY 없이 GC가 아는 strong reference를 만들 수 있다. 비-UObject owner나 짧은 pin에는 유용하지만 persistent reflected member의 기본 선택은 아니다.

  • 생성·해제와 reference tracking 비용이 있다.
  • GC graph/debugging에서 source가 일반 UPROPERTY보다 덜 명확할 수 있다.
  • UObject가 자기 자신이나 서로를 strong pointer로 잡는 cycle은 wrapper destructor에 도달하지 못해 영구 retention이 될 수 있다.
  • TStrongObjectPtrUPROPERTY type이 아니다.

Native owner에서 reference를 report하기#

Non-UObject owner는 FGCObject#

#include "UObject/GCObject.h"
#include "UObject/Object.h"
#include "UObject/ObjectPtr.h"
#include "UObject/UObjectGlobals.h"
class FNativeOwner final : public FGCObject
{
public:
void SetObject(UObject* InObject)
{
Object = InObject;
}
void AddReferencedObjects(FReferenceCollector& Collector) override
{
Collector.AddReferencedObject(Object);
}
FString GetReferencerName() const override
{
return TEXT("FNativeOwner");
}
private:
TObjectPtr<UObject> Object;
};

FGCObject는 non-UObject native owner용이다. UObject와 동시에 derive하거나, 주소가 이동할 수 있는 TArray<FNativeOwner> 같은 value container에 넣지 않는다. 필요하면 TUniquePtr<FNativeOwner>로 native wrapper의 주소를 안정적으로 소유한다. 이 TUniquePtr는 UObject가 아니라 wrapper의 C++ lifetime을 관리한다.

UObject의 특별한 unreflected edge는 static collector#

일반 field는 UPROPERTY가 가장 읽기 쉽다. Engine-level special case에서만 UObject의 static AddReferencedObjects를 사용한다. 이 함수는 owner를 독립적으로 root에 올리지 않는다. Owner가 이미 reachable해서 collector가 방문할 때, owner에서 나가는 추가 edge를 report한다.

ManualReferenceOwner.h
#pragma once
#include "UObject/Object.h"
#include "UObject/ObjectPtr.h"
#include "ManualReferenceOwner.generated.h"
UCLASS()
class MYGAME_API UManualReferenceOwner final : public UObject
{
GENERATED_BODY()
public:
static void AddReferencedObjects(
UObject* InThis,
FReferenceCollector& Collector);
void SetReference(UObject* InObject)
{
NativeReference = InObject;
}
private:
TObjectPtr<UObject> NativeReference; // intentionally unreflected
};
ManualReferenceOwner.cpp
#include "ManualReferenceOwner.h"
#include "UObject/UObjectGlobals.h"
void UManualReferenceOwner::AddReferencedObjects(
UObject* InThis,
FReferenceCollector& Collector)
{
UManualReferenceOwner* This =
CastChecked<UManualReferenceOwner>(InThis);
Collector.AddReferencedObject(This->NativeReference, This);
Super::AddReferencedObjects(InThis, Collector);
}

Reference 누락은 조기 수거를, Super 누락은 base-class reference 파손을 만든다. Incremental reachability write barrier 때문에 manually reported storage도 TObjectPtr를 사용한다.

파괴를 요청하는 API와 GC destruction을 구분한다#

일반 UObject를 더 이상 쓰지 않는다면 strong reference를 해제하고 GC에 맡긴다. ConditionalBeginDestroy()를 gameplay destruction이나 “GC 힌트”로 직접 호출하지 않는다. 이 함수는 GC가 이미 파괴하기로 결정한 object의 destruction lifecycle을 진행하는 내부 경계다.

대상정상적인 외부 요청실제 memory release
일반 UObjectstrong reference 해제이후 GC lifecycle
AActorDestroy()end-of-tick 처리 후 GC 가능 상태, 이후 GC
UActorComponentDestroyComponent()unregister/cleanup 뒤 GC 가능 상태, 이후 GC

AActor::Destroy()는 latent이며 보통 tick 끝에 처리된다. Gameplay 연결 해제는 EndPlay, native resource release는 BeginDestroy/FinishDestroy override의 contract에 맞춘다. 모든 override에서 필요한 Super 호출을 보존한다.

Null test와 UObject validity도 구분한다.

if (IsValid(Object))
{
UseObject(Object);
}

IsValid는 null과 garbage/pending-destruction 상태를 함께 확인할 때 유용하다. 그러나 이미 수명이 끝난 untracked raw address를 safe handle로 바꾸지는 않는다. Long-lived non-owning field에는 TWeakObjectPtr를 사용해야 한다.

GC cluster는 root가 아니다#

Cluster는 여러 object를 reachability unit으로 묶어 per-object scanning 비용을 줄이는 고급 최적화다. Cluster root가 global Root Set이라는 뜻은 아니다.

  • Cluster root 자격은 CanBeClusterRoot, member 자격은 CanBeInCluster, 실제 구성은 CreateCluster와 project setting에 달려 있다.
  • Cluster member 하나가 외부 strong reference로 reachable하면 cluster 전체가 유지될 수 있다.
  • 큰 cluster나 merged cluster는 over-retention과 일괄 unhash/destruction hitch를 만들 수 있다.
  • “같은 level”이나 “subobject”라는 사실만으로 무조건 자동 cluster가 된다고 가정하지 않는다.

먼저 Unreal Insights와 GC analytics로 root traversal, cluster, destruction 비용을 측정하고, 실제 object graph와 lifetime이 함께 움직이는 대상을 묶는다.

DisregardForGC는 gameplay opt-in이 아니다#

Disregard pool은 초기 load 단계의 engine/bootstrap object를 cooked-build permanent pool로 다루는 최적화다. CloseDisregardForGC() 뒤 생성되는 일반 gameplay object를 “평생 쓰는 Blueprint”라는 이유로 임의 등록하는 일상적인 수명 관리 API가 아니다.

일반적인 게임플레이 코드에서는 root, UPROPERTY, weak/soft reference를 기준으로 객체 수명을 먼저 설계하고, MaxObjectsNotConsideredByGC 같은 permanent-pool 설정은 엔진 시작 단계의 메모리 검사가 필요한 고급 조정으로 분리한다.

Parallel, incremental, destruction 설정을 분리한다#

이 설정들은 모두 pause와 관련 있어 보이지만 서로 다른 단계를 제어한다.

Setting나누는 작업
gc.AllowParallelGC한 collection의 reachability work를 여러 collector thread로 분할
gc.AllowIncrementalReachabilitymark/reachability를 여러 frame으로 분할
gc.AllowIncrementalGatherunreachable object gather를 여러 frame으로 분할
gc.IncrementalBeginDestroyEnabledBeginDestroy processing을 time slice
gc.MultithreadedDestructionEnabled지원되는 object memory free를 worker thread에서 실행

하나를 켰다고 나머지가 자동으로 켜지거나 thread-safe해지는 것은 아니다.

점진적 도달 가능성 분석은 Experimental 기능이다#

UE 5.4는 incremental reachability와 incremental gather를 Experimental로 도입했다. mark와 unreachable object gather를 여러 frame으로 나눠 긴 pause를 줄이는 대신, write barrier와 target별 검증이 필요하다.

공식 예제는 아래처럼 설정한다.

[ConsoleVariables]
gc.AllowIncrementalReachability=1
gc.AllowIncrementalGather=1
gc.IncrementalReachabilityTimeLimit=0.002

0.002는 2 ms의 예시 budget일 뿐 보편적인 권장값이나 보장된 hard limit가 아니다. 실제 frame 비용을 측정해 project와 target에 맞게 조정한다.

활성화하려면 아래 조건부터 만족해야 한다.

  1. GC-exposed UPROPERTY raw UObject pointer를 TObjectPtr로 전환한다.
  2. UObject static AddReferencedObjectsFGCObject storage도 TObjectPtr로 바꾼다.
  3. Async/task code의 object lifetime과 API thread safety를 별도로 검사한다.
  4. PIE뿐 아니라 target server/client build에서 stress test한다.
  5. GC verification과 reference tracking으로 barrier 누락을 찾는다.

Manual GC는 마지막 수단이다#

Automatic GC를 기본으로 둔다.

  • gc.CollectGarbageEveryFrame N은 매 N frame마다 collection/full-purge request를 넣는 debug stress command다. Production optimization이 아니다.
  • GEngine->ForceGarbageCollection(true)는 그 line에서 즉시 memory를 비우지 않는다. 다음 가능한 시점에 full purge를 요청한다.
  • CollectGarbage(...) 직접 호출은 다른 thread의 UObject lock을 기다리고 큰 hitch를 만들 수 있다.

Manual collection이 정당한 경우는 loading screen처럼 hitch를 숨길 수 있거나, 측정 결과 page swapping/OOM 위험 직전에 collection하는 편이 낫다는 증거가 있을 때다. Routine gameplay tick에서 강제하지 않는다.

성능 개선 순서는 다음이 안전하다.

  1. Unreal Insights와 gc.DumpAnalyticsToLog로 mark, gather, purge, cluster 비용을 구분한다.
  2. 불필요한 persistent strong edge와 root를 제거한다.
  3. 짧게 쏟아지는 transient UObject 생성 churn을 줄이거나 적절히 pool한다.
  4. UObject가 제공하는 reflection, serialization, replication, editor 기능이 필요 없는 data만 value/plain C++ type으로 옮긴다.
  5. Cluster와 incremental setting은 target별 측정 뒤 적용한다.

“Blueprint macro가 function보다 runtime UObject를 항상 더 많이 만든다”거나 “C++로 옮기면 GC object가 자동으로 줄어든다”는 식의 일반화는 editor graph node와 cooked runtime object를 혼동한다. 실제 runtime allocation을 profile한다.

최종 점검표#

  • 대상이 UObject인지 plain C++ value인지 먼저 구분했는가?
  • Root에서 target까지 의도한 strong path를 그릴 수 있는가?
  • Persistent strong field는 UPROPERTY() TObjectPtr<T>인가?
  • Outer를 owner-to-child keepalive로 오해하지 않는가?
  • Long-lived non-owning field는 TWeakObjectPtr인가?
  • Soft load 완료 뒤 필요한 strong ownership을 넘겼는가?
  • TStrongObjectPtr의 hidden retention과 cycle을 검토했는가?
  • Custom AddReferencedObjects가 모든 edge와 Super를 처리하는가?
  • 일반 UObject에 ConditionalBeginDestroy()를 직접 호출하지 않는가?
  • Actor와 Component는 각각 public Destroy API를 쓰는가?
  • Cluster를 root나 자동 subobject group으로 오해하지 않는가?
  • Disregard pool을 gameplay opt-in으로 사용하지 않는가?
  • Parallel mark, incremental mark, BeginDestroy slicing, memory free를 구분했는가?
  • Incremental mode 전 모든 GC-reported pointer를 TObjectPtr로 전환했는가?
  • Forced GC는 측정된 loading/memory boundary에만 있는가?

참고 자료#

글 공유하기

이 글이 도움이 되셨다면 다른 사람과 공유해 주세요!

Unreal Engine UObject 가비지 컬렉션: 참조 그래프와 안전한 수명 관리
https://hon454.github.io/posts/garbage-collection-in-unreal-engine/
작성자
Jihoon Jeon
게시일
2025-09-14
라이선스
CC BY-NC-SA 4.0

댓글

Profile Image of the Author
Jihoon Jeon
안녕하세요! 서브컬처와 자전거를 사랑하는 개발자 전지훈입니다.
공지사항
제 블로그에 오신 것을 환영합니다! 예시 공지입니다.
카테고리
태그
최신 일상
사이트 통계
게시글
46
카테고리
10
태그
139
총 단어 수
103,271
운영 일수
0
최근 활동
0일 전
사이트 정보
빌드 플랫폼
GitHub Actions
블로그 버전
Firefly v6.16.3
라이선스
CC BY-NC-SA 4.0