UE5 Garbage Collection

에크까망·2024년 9월 27일

들어가기 전에

  • 단순화 하기 위해서 문체를 단정적으로 사용하지만 지극히 개인 의견일 뿐 입니다.
  • 본문과 관련해서 오류 지적이나 의견 있으시면 꼭 댓글 부탁드립니다.
  • 글 작성 시점은 2024/09 입니다.
  • 글 작성 기준은 UE5.4.4 입니다.
  • UClass Reflection Posting을 먼저 보신 것으로 가정 합니다

들어가며

들어가기에 앞서 용어 정리가 필요하다. 'GC' 된다고 하는 것은 필요 없는 UObject 가 정리 된다는 것을 뜻한다. 기본적으로 UPROPERTY(...) 로 제대로 Referencing 하고 있으면 'GC' 가 되지 않는다. 가끔 UPROPERTY 에 있어야 GC대상이 된다고 하는 분들이 있는데 엄밀히 말해서는 반대 이야기다. UPROPERTY 등으로 Referencing 이 되어 있지 않으면 해당 UObject 는 GC당하는데 Pointer 변수값이 nullptr 로 변경되지 않아서 다른 문제가 생긴다.

이 포스팅에서 다음에 초점을 두어 본다.

  • UnrealEngine 에서 GC 관리 대상
  • C++ 로 직접 구현된 GC 의 장점
  • 다른 Programming Language GC 와의 차이
  • IsValid(object)
  • UCLASS 도 UObject 인데 GC 가 될까 안될까?
  • UObject 가 GC되지 않게 하는 방법(with Referencing 방식)의 종류
  • USTRUCT 에서의 GC
  • World 와 AActor

UE GC를 설명할때 매우 중요한게 TWeakObjectPtr<> 인데 이 건 다른 포스팅에서 따로 다룬다.

Unreal GC 기본 방식

Mark & Sweep

이 내용은 공식문서나 인터넷 상의 여러 Article 에 잘 설명되어 있으므로 여기서는 간략히 살펴만 보고 넘어가자.

UnrealEngine 은 Mark and Sweep 방식으로 Garbage Collection(이하 GC) 를 수행한다. Root 로 명시된 Object 로 부터 Reflection 기능을 기반으로 Object Refrence Property 및 추가 함수 기능을 이용해서 Rechable Object 를 찾는다. 실제로 수행은 반대인데 처음에 Global Object Array 에서 Object Flag 에 Unreachable 을 한번에 모조리 마킹해두고, Referencing 될떄마다 Unreachable 을 지운 다음, Sweep 에서 Unreachable 이 살아있는 Object 를 제거한다.

기본 동작

생각해 보면 알 수 있듯이 Mark 동작을 먼저하고, Sweep 동작은 이후에 진행해도 로직 상으로는 문제가 없다. 다만 최적화를 구현함에 있어 세부적인 방식은 Multi-Thread 까지 동원해서 수시로 변경되고 있다(볼때마다 달라져 있더라... 는 개인적인 후문이).

이상한 코딩만 하지 않으면 한번 Unrechable 로 판명된 Object가 다시 Reachable 이 될 수 는 없다.

UClass GC

Marking 방식 및 GC되지 않게 하는 방식은 정말 많은데 짚고 넘어갈 것은 Native(C++ Code)에 있는 Class 는 GC대상이 아니라는 것이다. 당연한거 아냐? 라고 생각하셨으면 'UClass Reflection' 포스팅을 잘 안 보신거다.

UCLASS 도 UObject 이기 떄문에 기본적으로 관리 대상이다. UClass, UClass-CDO 및 그기서 Referencing 되고 있는 Object도 당연히 관리 대상이다.

그럼 Blueprint 로 만들어진 UClass 는? Referencing 하지 않으면 당연히 날아간다(GC된다). Blueprint 로 만든 Object 를 Referencing 만 해도 해당 UCLASS 는 참조 되므로 잘 인식하지 못하고 사용할 수 있는데 날아 갈 수 있다는걸 항상 주의해야 한다.

GC 관리 대상

오직 모든 UObject Instance 만 Garbage Collection 관리 대상이다. '모든 UObject' 을 기억해 두자.

C++ 로 직접 구현된 GC 의 장점

관리 대상을 프로그래머가 지정할 수 있다

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되게 할 수 있다

Referencing 되고 있음에도 임의로 GC 대상이 되게 할 수 있다. UE 에서 대표적인 방식이 AActor::Destroy(...) 인데 예전 Code 에서는 MarkPendingKill 계열 Flag를 사용 했으나 근래에 UObjecT::MarkAsGarbage() 를 통한 RF_MirroredGarbage 로 변경 된듯 하다. 여기서는 Referencing 되고 있어도 GC 될 수 있게 할 수 있다는 점만 명심하자.

그럼 Unity Destroy(gameObject) 는?

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#으로 인해 이부분에 변경이 있었으면 꼭 댓글 부탁 드립니다).

IsValid(uobject) 가 안전하게 Check할 수 있는 이유

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개의 상태를 가진다고 할 수 있다.

  1. Valid-Pointer
  2. 가비지 대상으로 확인되고 예약
  3. Null-Pointer

이게 TObjectPtr 이 UPROPERTY 로 제대로 지정 되어 있어야 하는 이유이다. 즉, UObject* 변수의 값이 가르키는 Object 가 GC가 되면 메모리는 해제(엄밀히 말해서 재사용 대상) 되므로 해당 변수 값이 반드시 null 로 바뀌어야 한다. 이 동작을 위해서라도 해당 변수는 Reflection 대상 및 수동으로 관리 대상이 되어야 한다.

아닐 경우 일어 날 수 있는 최악의 참사를 생각해 보자.

  1. UMyObject* Target 에 Object 가 할당 되었다. 여기서 임의로 그 주소를 0xAA00 이라 하자.
  2. Target 이 가리키는 MyObject 가 GC 되어서 파괴 되었고, 메모리 0xAA00 은 재사용 대상이 되었다. 하지만 Target 변수 값은 그대로 0xAA00 이다.
  3. UOtherObject 을 새로 생성 했는데 0xAA00 이 MemoryPool 로 부터 재사용 되어 해당주소로 UOtherObject 가 할당 되었다.
  4. 이 상태에서 IsValid(Target) 을 물어보면 당연히 유효하다고 한다. null 도 아니고 garbage flag 도 안 켜져 있기 때문이다. 심지어 Internal-Index(TWeakObectPtr 에서 살펴보자) 도 당연히 유효하다.
  5. 사용자는 UOtherObject 가 할당된 0xAA00 을 UMyObject 타입으로 사용한다.
  6. 게임은 요단강을 건너게 되는데, 언제 건너갔는지 알기 힘들다.

하지만 다행히 대부분의 경우 GC되고 난 다음 위 Target 변수를 사용하면 바로 Crash 가 난다. 그렇지만 최악의 경우는 항상 염두해 두자.

Referencing 방법

UObject 를 AddToRoot 등으로 Root 로 등록하면 GC Referencing 출발점이 되고 GC당하지 않는다. 그럼 Root 로 부터 출발 해서 Reachable 로 판명할 수 있게 하는 Referencing 방법들을 살펴보자.

UPROPERTY

대표적인 방법으로 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

그럼 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 는 관리 대상이 아니다. 그럼 위 Code 안에 있는 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();
};

FGCObject

UObject 에서 아예 출발하지 않고 Reflection 도 사용하지 않으면서 Referencing 할 수 있는 방법중 하나가 FGCObject 를 상속 받는 것이다.

FGCObject Code 를 보면 생성자에서 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 로 변경되지 못.한.다.

static void AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)

일단 주의하자. FGCObject::AddReferencedObject(...) 가 아니다. 제일 대표적인 사용처는 AActor 에서 찾아 볼 수 있다. Super::AddReferencedObject 를 통해 부모 클래스 까지 올라가는 것을 볼 수 있다. UObject 계열에서 Refelction 에 의존하지 않고 Referencing 할 수 있는 방법이다. 또 Collector 를 일반 함수로 던져서 Non-UObject 나 Non-FGCObject 계열에서도 Rerferencing 걸 수 있다.

ObjectMactros.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);  
}

ULevel::AddReferencedObjects

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 );  
}

디버깅 Tip 하나

Console 명령으로 gc.ForceCollectGarbageEveryFrame 1 을 하면 매 프레임 GC 를 수행한다. GC 관련 오류 (변수 설정 잘못 등)가 의심 스러운 타이밍에 입력하면 디버깅에 큰 도움이 된다.

profile
Game Client Programmer

0개의 댓글