들어가기에 앞서 용어 정리가 필요합니다. 'GC'된다고 하는 것은 필요 없는 UObject가 정리된다는 것을 의미합니다. 기본적으로 UPROPERTY(...)로 제대로 Referencing하고 있으면 'GC'가 되지 않습니다. 가끔 UPROPERTY에 있어야 GC 대상이 된다고 하시는 분들이 있는데, 엄밀히 말하면 반대의 이야기입니다. UPROPERTY 등으로 Referencing이 되어 있지 않으면 해당 UObject는 GC당하는데, Pointer 변수 값이 nullptr로 변경되지 않아서 다른 문제가 발생합니다.
이 포스팅에서는 다음 항목들에 초점을 두어 살펴보겠습니다.
UE GC를 설명할 때 매우 중요한 것이 TWeakObjectPtr<>인데, 이 부분은 다른 포스팅에서 따로 다루겠습니다.
이 내용은 공식 문서나 인터넷 상의 여러 Article에 잘 설명되어 있으므로, 여기서는 간략히 살펴보고 넘어가겠습니다.
UnrealEngine은 Mark and Sweep 방식으로 Garbage Collection(이하 GC)을 수행합니다. Root로 명시된 Object로부터 Reflection 기능을 기반으로 Object Reference Property 및 추가 함수 기능을 이용해서 Reachable Object를 찾습니다. 실제 수행은 반대 방향으로 이루어지는데, 처음에 Global Object Array에서 Object Flag에 Unreachable을 한꺼번에 마킹해두고, Referencing될 때마다 Unreachable을 지운 다음, Sweep에서 Unreachable이 살아있는 Object를 제거합니다.
생각해 보시면 알 수 있듯이, Mark 동작을 먼저 하고 Sweep 동작은 이후에 진행해도 로직상으로는 문제가 없습니다. 다만 최적화를 구현함에 있어 세부적인 방식은 Multi-Thread까지 동원해서 수시로 변경되고 있습니다(볼 때마다 달라져 있더라는 개인적인 후문이 있습니다).
이상한 코딩만 하지 않으시면, 한번 Unreachable로 판명된 Object가 다시 Reachable이 될 수는 없습니다.
Marking 방식 및 GC되지 않게 하는 방식은 정말 많지만, 짚고 넘어갈 것은 Native(C++ Code)에 있는 Class는 GC 대상이 아니라는 점입니다. '당연한 거 아냐?'라고 생각하셨다면 'UClass Reflection' 포스팅을 잘 보지 않으신 것입니다.
UCLASS도 UObject이기 때문에 기본적으로 관리 대상입니다. UClass, UClass-CDO 및 거기서 Referencing되고 있는 Object도 당연히 관리 대상입니다.
그럼 Blueprint로 만들어진 UClass는 어떨까요? Referencing하지 않으면 당연히 날아갑니다(GC됩니다). Blueprint로 만든 Object를 Referencing만 해도 해당 UCLASS는 참조되므로 잘 인식하지 못하고 사용할 수 있는데, 날아갈 수 있다는 점을 항상 주의하셔야 합니다.
오직 모든 UObject Instance만 Garbage Collection 관리 대상입니다. '모든 UObject'를 기억해 두시기 바랍니다.
UnrealEngine은 C++로 Garbage Collection(이하 GC) 기능을 직접 만들어 두었습니다. C# 같은 Managed Memory 언어가 아닌 C++을 쓰면서 GC를 직접 제공하면 어떤 이점이 있을까요? 가장 큰 장점은 프로그래머가 원하는 대상에 한해서 GC 관리를 받을 수 있다는 점입니다.
프로그래머가 원하는 대상에 한해서 GC 관리를 받을 수 있다는 것은, GC 수행 시간을 줄일 수 있고 필요한 부가 Memory 또한 줄일 수 있다는 의미입니다. Managed Memory 기반 GC 언어들은 모든 Object가 GC 대상이기 때문에 상당한 부하가 따릅니다.
stop-the-world는 GC가 수행될 때 All-Stop시키고 수행함으로써 모든 연산이 순간 멈춘다는 뜻으로, GC 방식의 가장 큰 단점입니다. UE는 직접 구현함으로써 그 대상을 줄이고 최적화할 수 있습니다. 그래도 과도하게 UObject를 만들면 멈칫하긴 합니다.
Referencing되고 있음에도 임의로 GC 대상이 되게 할 수 있습니다. UE에서 대표적인 방식이 AActor::Destroy(...)인데, 예전 Code에서는 MarkPendingKill 계열 Flag를 사용했으나 근래에는 UObject::MarkAsGarbage()를 통한 RF_MirroredGarbage로 변경된 것으로 보입니다. 여기서는 Referencing되고 있어도 GC될 수 있게 할 수 있다는 점만 명심해 두시기 바랍니다.
Unity 경험이 있으신 분의 경우, Unity도 임의로 destroy할 수 있고 destroy 이후에 if (obj == null)로 확인하면 true라고 생각하시는 분이 계실 수도 있습니다.
Unity의 내부 C#은 Mono 등에서 여러 가지로 변경되었고, GC 관리 방식도 공식 .net framework과는 다릅니다. 하지만 기본 개념은 같습니다. 즉, 임의로 Object를 파괴할 수 없다는 뜻입니다(dispose로 일부 내용을 해제하는 것과는 다르게, Object 완전 파괴의 경우에 해당합니다).
그럼 위에서의 null 확인은 어떻게 이루어질까요? Destroy(gameObject)를 하게 되면 해당 gameObject는 World에서 빠지게 되고, operator== 재정의로 World 소속 여부를 물어서 true/false 판정을 하게 됩니다. if(obj == null)이 참이라고 해서, 다른 gameObject에서 변수로 참조하고 있으면 해당 gameObject의 C#-Object는 파괴되지 않습니다(혹시 custom c#으로 인해 이 부분에 변경이 있었다면 꼭 댓글 부탁드립니다).
UE에서 Object 유효성을 검사하는 가장 중요한 함수가 IsValid인데, 내부 구현이 상당히 자주 변경되어 왔습니다. 하지만 기본적인 작동 방식은 크게 변하지 않았습니다.
/**
* Test validity of object
*
* @param Test The object to test
* @return Return true if the object is usable: non-null and not pending kill or garbage
*/
FORCEINLINE bool IsValid(const UObject *Test)
{
return Test && FInternalUObjectBaseUtilityIsValidFlagsChecker::CheckObjectValidBasedOnItsFlags(Test);
}
struct FInternalUObjectBaseUtilityIsValidFlagsChecker
{
FORCEINLINE static bool CheckObjectValidBasedOnItsFlags(const UObject* Test)
{ // Here we don't really check if the flags match but if the end result is the same
checkSlow(GUObjectArray.IndexToObject(Test->InternalIndex)->HasAnyFlags(EInternalObjectFlags::Garbage) == Test->HasAnyFlags(RF_MirroredGarbage));
return !Test->HasAnyFlags(RF_MirroredGarbage);
}
};
여기서 IsValid가 하는 일은 nullptr인지, Garbage 대상으로 체크되어 있는지 두 가지를 확인한다는 점을 명심하시기 바랍니다.
Test 대상이 되는 UObject* Test 변수 값은 생성 후 아래 3가지 상태를 가진다고 할 수 있습니다.
이것이 TObjectPtr이 UPROPERTY로 제대로 지정되어 있어야 하는 이유입니다. 즉, UObject* 변수의 값이 가리키는 Object가 GC되면 메모리는 해제(엄밀히 말해서 재사용 대상)되므로, 해당 변수 값이 반드시 null로 바뀌어야 합니다. 이 동작을 위해서라도 해당 변수는 Reflection 대상 및 수동으로 관리 대상이 되어야 합니다.
그렇지 않을 경우 일어날 수 있는 최악의 참사를 생각해 보겠습니다.
UMyObject* Target에 Object가 할당되었습니다. 여기서 임의로 그 주소를 0xAA00이라 하겠습니다.0xAA00은 재사용 대상이 되었습니다. 하지만 Target 변수 값은 그대로 0xAA00입니다.0xAA00이 MemoryPool로부터 재사용되어 해당 주소로 UOtherObject가 할당되었습니다.IsValid(Target)을 물어보면 당연히 유효하다고 반환합니다. null도 아니고 garbage flag도 켜져 있지 않기 때문입니다. 심지어 Internal-Index(TWeakObjectPtr에서 살펴보겠습니다)도 당연히 유효합니다.0xAA00을 UMyObject 타입으로 사용하게 됩니다.하지만 다행히 대부분의 경우, GC되고 난 다음 위 Target 변수를 사용하면 바로 Crash가 발생합니다. 그렇지만 최악의 경우는 항상 염두에 두시기 바랍니다.
UObject를 AddToRoot 등으로 Root로 등록하면 GC Referencing 출발점이 되고 GC당하지 않습니다. 그럼 Root로부터 출발해서 Reachable로 판명할 수 있게 하는 Referencing 방법들을 살펴보겠습니다.
대표적인 방법으로, Reflection 기능으로 참조가 이루어집니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyActor.generated.h"
UCLASS()
class MYGAME_API AMyActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AMyActor();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// TObjectPtr를 UPROPERTY로 선언하여 GC가 추적 가능하게 설정
UPROPERTY()
TObjectPtr<class AOtherActor> OtherActorPtr;
// AOtherActor와 상호작용하는 함수
void InteractWithOtherActor();
};
IsValid에서도 언급했지만, 반드시 UPROPERTY()로 선언되어 있어야 함을 잊지 마시기 바랍니다. 특히 AActor의 경우 Referencing하고 있어도 Destroy로 임의로 GC될 수 있기 때문에 꼭 UPROPERTY()로 선언해야 합니다(사실은 TWeakObjectPtr이 대부분의 경우 더 적절합니다).
그럼 USTRUCT는 어떻게 관리될까요? 아래 코드를 살펴보겠습니다.
#pragma once
#include "CoreMinimal.h"
#include "OtherActor.h" // AOtherActor 클래스 포함
#include "MyStruct.generated.h"
// USTRUCT 정의
USTRUCT(BlueprintType)
struct MYGAME_API FMyStruct
{
GENERATED_BODY()
// TObjectPtr 변수를 UPROPERTY로 선언
UPROPERTY()
TObjectPtr<AOtherActor> OtherActorPtr;
// 생성자
FMyStruct()
: OtherActorPtr(nullptr) // 초기값을 nullptr로 설정
{
}
};
언리얼에서 GC 관리 대상은 UObject입니다. 따라서 USTRUCT는 관리 대상이 아닙니다. 그렇다면 위 코드 안에 있는 OtherActorPtr가 제대로 참조되게 하려면 어떻게 해야 할까요? 해당 USTRUCT가 UPROPERTY로 UObject에서 참조되어야 합니다. USTRUCT는 UObject와 달리 기본적으로 '0'으로 초기화되지 않는다는 점도 주의하시기 바랍니다.
정리하면, UObject만 GC 관리 대상이므로 바로 참조는 되지 않지만, USTRUCT도 Reflection Code를 만들어내기 때문에, 자신이 참조받게 되면 자신이 참조하는 내용을 연결할 수 있습니다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MyStruct.h" // FMyStruct 포함
#include "MyActor.generated.h"
UCLASS()
class MYGAME_API AMyActor : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AMyActor();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
// FMyStruct을 UPROPERTY로 선언하여 GC 및 리플렉션에 사용 가능하게 함
UPROPERTY()
FMyStruct MyStructProperty;
// AOtherActor와 상호작용하는 함수
void InteractWithOtherActor();
};
UObject에서 아예 출발하지 않고 Reflection도 사용하지 않으면서 Referencing할 수 있는 방법 중 하나가 FGCObject를 상속받는 것입니다.
FGCObject 코드를 보면 생성자에서 Global GGCObjectReferencer에 등록하는 것을 확인할 수 있습니다.
FGCObject
/**
* This class provides common registration for garbage collection for
* non-UObject classes. It is an abstract base class requiring you to implement
* the AddReferencedObjects() method.
*/
class FGCObject
{
public:
/**
* The static object referencer object that is shared across all
* garbage collectible non-UObject objects.
*/
static COREUOBJECT_API UGCObjectReferencer* GGCObjectReferencer;
/** Initializes the global object referencer and adds it to the root set. */
static COREUOBJECT_API void StaticInit();
/**
* Tells the global object that forwards AddReferencedObjects calls on to objects
* that a new object is requiring AddReferencedObjects call.
*/
FGCObject()
{
RegisterGCObject();
}
구현은 다음과 같이 하시면 됩니다.
#pragma once
#include "CoreMinimal.h"
#include "UObject/GCObject.h"
struct FMyReference : public FGCObject
{
public:
// UObject 참조
UObject* MyReferencedObject;
FMyReference()
: MyReferencedObject(nullptr)
{
}
// FGCObject 인터페이스: GC 시스템에 참조된 객체를 등록
virtual void AddReferencedObjects(FReferenceCollector& Collector) override
{
if (MyReferencedObject)
{
// Unreal Engine의 가비지 컬렉터에게 참조된 UObject를 추가
Collector.AddReferencedObject(MyReferencedObject);
}
}
// 필요한 경우 구조체의 메모리 정리를 위한 함수
virtual FString GetReferencerName() const override
{
return TEXT("FMyReference");
}
};
여기서 중요한 것은 void AddReferencedObjects(FReferenceCollector& Collector)에서 어떤 임의의 조건에 따라서(예를 들어 짝수 번 호출 때마다) Collector.AddReferencedObject(MyReferencedObject);를 하면 안 된다는 점입니다. 설마 그렇게 하시는 분은 없겠지만, 만약 그렇게 하면 어떤 일이 일어날까요? Referencing Chain이 끊어지는 것은 당연한 일이고, MyReferencedObject 변수 값이 GC된 시점에 null로 변경되지 않게 됩니다.
주의하시기 바랍니다. FGCObject::AddReferencedObject(...)가 아닙니다. 가장 대표적인 사용처는 AActor에서 찾아볼 수 있습니다. Super::AddReferencedObject를 통해 부모 클래스까지 올라가는 것을 확인할 수 있습니다. UObject 계열에서 Reflection에 의존하지 않고 Referencing할 수 있는 방법입니다. 또한 Collector를 일반 함수로 전달해서 Non-UObject나 Non-FGCObject 계열에서도 Referencing을 걸 수 있습니다.
ObjectMacros.h에 있는 struct FUObjectCppClassStaticFunctions로 등록되고, GarbageCollector에서 수집할 때 해당 Object의 Class 기반으로 호출됩니다.
AActor
static ENGINE_API void AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
void AActor::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{
AActor* This = CastChecked<AActor>(InThis);
Collector.AddStableReferenceSet(&This->OwnedComponents);
#if WITH_EDITOR
if (This->CurrentTransactionAnnotation.IsValid())
{ This->CurrentTransactionAnnotation->AddReferencedObjects(Collector);
}
#endif
Super::AddReferencedObjects(InThis, Collector);
}
World 상의 AActor에 대한 Referencing도 AddReferencedObjects를 통해서 이루어집니다.
void ULevel::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
{
ULevel* This = CastChecked<ULevel>(InThis);
// Let GC know that we're referencing some AActor objects
if (FPlatformProperties::RequiresCookedData() && GActorClusteringEnabled && This->bGarbageCollectionClusteringEnabled && This->bActorClusterCreated)
{ Collector.AddStableReferenceArray(&This->ActorsForGC);
} else
{
Collector.AddStableReferenceArray(&This->Actors);
}
Super::AddReferencedObjects( This, Collector );
}
Console 명령으로 gc.ForceCollectGarbageEveryFrame 1을 입력하면 매 프레임 GC를 수행합니다. GC 관련 오류(변수 설정 잘못 등)가 의심스러운 타이밍에 입력하시면 디버깅에 큰 도움이 됩니다.