언리얼 엔진 5

[UE5] 가비지 컬렉션

언린이 2026. 2. 23. 00:19

1. 가비지 컬렉션이 필요한 이유

C++은 메모리 관리를 개발자에게 전적으로 맡기는 언어입니다. new로 할당한 메모리는 반드시 delete로 해제해야 하며, 이 규칙을 지키지 않으면 메모리 누수가 발생합니다. 반대로 이미 해제한 메모리에 다시 접근하면 댕글링 포인터(Dangling Pointer) 문제가 생기고, 이는 즉시 크래시로 이어지거나 더 위험하게는 눈에 보이지 않는 정의되지 않은 동작(Undefined Behavior)을 유발합니다.

게임 엔진처럼 수천 개의 객체가 생성되고 파괴되는 환경에서는 이러한 수동 메모리 관리의 부담이 극도로 커집니다. 레벨을 전환하며 액터를 파괴하고, 스트리밍으로 서브레벨을 로드·언로드하고, 에디터에서 실시간으로 객체를 추가·삭제하는 상황을 모두 수동으로 관리한다면 버그 없이 동작하게 만드는 것은 사실상 불가능에 가깝습니다.

언리얼 엔진은 이 문제를 해결하기 위해 UObject 기반의 가비지 컬렉션(Garbage Collection, 이하 GC) 시스템을 도입했습니다. GC는 더 이상 참조되지 않는 UObject를 자동으로 탐지하고 메모리에서 해제합니다. 개발자는 객체의 생성에만 집중하고, 불필요해진 객체의 회수는 엔진에 맡길 수 있습니다.

2. 가비지 컬렉션의 동작 원리

2-1. 마크 앤 스윕(Mark and Sweep)

언리얼 엔진의 GC는 마크 앤 스윕(Mark and Sweep) 방식으로 동작합니다. 이 알고리즘은 두 단계로 나뉩니다.

  1. 마크 단계(Mark Phase) — 루트셋(Root Set)에서 출발하여 참조 그래프를 순회하며, 도달 가능한(Reachable) 모든 UObject에 "살아있음" 표시를 합니다.
  2. 스윕 단계(Sweep Phase) — 표시가 없는 객체, 즉 도달 불가능한(Unreachable) 객체를 메모리에서 해제합니다.

이 과정은 일정 주기(기본적으로 약 60초, gc.TimeBetweenPurgingPendingKillObjects 설정으로 조정 가능)마다 게임 스레드에서 실행됩니다. GC가 게임 스레드에서 실행되기 때문에, 함수 실행 도중에 참조하고 있는 객체가 갑자기 회수되는 일은 발생하지 않습니다.

2-2. 도달 가능성 분석(Reachability Analysis)

마크 단계의 핵심은 도달 가능성 분석입니다. GC는 루트셋에 포함된 객체들을 시작점으로 삼아, 각 객체의 UPROPERTY로 표시된 포인터와 엔진 컨테이너(TArray<UObject*>, TMap, TSet 등)에 저장된 참조를 따라가며 연결된 모든 객체를 탐색합니다.

이 탐색은 리플렉션 시스템을 통해 이루어집니다. UPROPERTY() 매크로가 붙은 포인터만 GC가 인식할 수 있는 이유가 바로 이것입니다. 리플렉션에 등록되지 않은 원시 포인터(Raw Pointer)는 GC의 참조 그래프에 포함되지 않으므로, 해당 포인터가 가리키는 객체는 다른 경로로 참조되지 않는 한 회수 대상이 됩니다.

2-3. 객체 파괴의 흐름

GC가 객체를 파괴할 때는 한 번에 모든 처리가 끝나지 않습니다. 파괴는 다음과 같은 단계로 나뉘어 진행됩니다.

  1. ConditionalBeginDestroy() — 객체의 파괴 프로세스를 시작합니다. 내부적으로 BeginDestroy()를 호출합니다.
  2. BeginDestroy() — 객체가 파괴되기 전에 필요한 정리 작업을 수행합니다. 개발자가 오버라이드하여 리소스 해제 등의 로직을 넣을 수 있습니다.
  3. FinishDestroy() — 최종적으로 객체의 메모리를 해제합니다. BeginDestroy()FinishDestroy()가 반드시 같은 프레임에서 호출되는 것은 아닙니다.

3. 루트셋(Root Set)과 객체를 살려두는 방법

3-1. 루트셋이란

루트셋은 GC가 도달 가능성 분석을 시작하는 출발점 객체들의 집합입니다. 루트셋에 포함된 객체는 GC에 의해 절대 회수되지 않으며, 이 객체들로부터 참조 체인을 따라 도달할 수 있는 모든 객체 역시 회수되지 않습니다.

일반적으로 개발자가 루트셋을 직접 관리할 필요는 없습니다. 엔진이 내부적으로 월드(UWorld), 레벨(ULevel), 게임 인스턴스(UGameInstance) 등 핵심 객체를 루트셋에 등록해 놓고, 이들이 액터를 참조하고 액터가 컴포넌트를 참조하는 식으로 참조 체인이 자연스럽게 형성되기 때문입니다.

3-2. UPROPERTY()를 이용한 강한 참조

UObject를 살려두는 가장 기본적이고 권장되는 방법은 UPROPERTY() 매크로가 붙은 포인터에 객체를 저장하는 것입니다.

다음은 UPROPERTY()를 사용하여 UObject에 대한 강한 참조를 유지하는 예제입니다.

UCLASS()
class MYGAME_API AMyActor : public AActor
{
    GENERATED_BODY()

public:
    // GC가 이 참조를 추적합니다. CachedData가 살아있는 한 회수되지 않습니다.
    UPROPERTY()
    TObjectPtr<UMyDataAsset> CachedData;

    // 컨테이너 안의 UObject 포인터도 UPROPERTY가 있으면 추적됩니다.
    UPROPERTY()
    TArray<TObjectPtr<UMyItem>> Inventory;

    void LoadData()
    {
        // NewObject로 생성한 UObject를 UPROPERTY 멤버에 저장
        CachedData = NewObject<UMyDataAsset>(this);
    }
};

위의 코드에서 CachedDataInventory는 모두 UPROPERTY() 매크로가 붙어 있으므로, GC의 참조 그래프에 포함됩니다. AMyActor 인스턴스가 살아있는 한, 이 멤버들이 가리키는 객체는 회수되지 않습니다. 또한 해당 객체가 GC에 의해 파괴되면, UPROPERTY() 포인터는 자동으로 nullptr로 설정됩니다.

UE5에서는 UPROPERTY() 포인터에 원시 포인터(UMyDataAsset*) 대신 TObjectPtr<UMyDataAsset>을 사용하는 것이 권장됩니다. TObjectPtr은 지연 로딩(Lazy Loading) 지원과 향후 도입될 증분 GC(Incremental GC)와의 호환성을 위해 설계되었습니다.

3-3. AddToRoot()를 이용한 루트셋 직접 등록

특수한 상황에서는 UObject를 루트셋에 직접 추가해야 할 수도 있습니다. UPROPERTY()를 사용할 수 없는 경우, 예를 들어 전역 변수나 싱글턴 패턴으로 관리되는 UObject가 그 예입니다.

다음은 AddToRoot()를 사용하여 객체를 루트셋에 등록하는 예제입니다.

UMySubsystem* Subsystem = NewObject<UMySubsystem>();

// 루트셋에 추가하여 GC가 회수하지 못하게 합니다.
Subsystem->AddToRoot();

// ... 사용이 끝난 후 반드시 루트셋에서 제거해야 합니다.
Subsystem->RemoveFromRoot();

위의 코드에서 AddToRoot()를 호출하면 해당 객체가 루트셋에 포함되어 GC의 회수 대상에서 제외됩니다. 그러나 사용이 끝난 후에 반드시 RemoveFromRoot()를 호출하여 루트셋에서 제거해야 합니다. 이를 잊으면 메모리 누수가 발생하며, 에디터를 종료할 때 크래시가 발생할 수도 있습니다.

AddToRoot()는 최후의 수단으로만 사용해야 합니다. GC 관련 크래시가 발생한다고 해서 무작정 AddToRoot()를 호출하는 것은 근본적인 문제를 감추는 것일 뿐, 해결이 아닙니다. 대부분의 경우 UPROPERTY() 참조가 누락되어 있는 것이 원인이므로, 참조 구조를 먼저 점검하는 것이 올바른 접근입니다.

3-4. FGCObject를 이용한 참조 관리

UObject를 상속받지 않는 일반 C++ 클래스가 UObject에 대한 참조를 유지해야 하는 경우에는 FGCObject를 상속받아 AddReferencedObjects()를 오버라이드하는 방법을 사용합니다.

다음은 FGCObject를 상속받아 UObject 참조를 보호하는 예제입니다.

class FMyManager : public FGCObject
{
public:
    // 일반 C++ 클래스이므로 UPROPERTY를 사용할 수 없습니다.
    UMyConfig* Config;

    FMyManager()
    {
        Config = NewObject<UMyConfig>();
    }

    // GC에게 이 객체가 Config를 참조하고 있음을 알립니다.
    virtual void AddReferencedObjects(FReferenceCollector& Collector) override
    {
        Collector.AddReferencedObject(Config);
    }

    // FGCObject를 상속받으면 반드시 구현해야 하는 함수입니다.
    virtual FString GetReferencerName() const override
    {
        return TEXT("FMyManager");
    }
};

위 코드처럼 FGCObject를 상속받고 AddReferencedObjects()에서 참조를 등록하면, GC가 도달 가능성 분석을 수행할 때 해당 UObject를 살아있는 것으로 인식합니다. FGCObject는 비-UObject 시스템(예: Slate 위젯, 커스텀 매니저 클래스 등)에서 UObject를 안전하게 참조해야 할 때 유용합니다.

4. TWeakObjectPtr의 역할

4-1. 약한 참조(Weak Reference)란

앞서 살펴본 UPROPERTY(), AddToRoot(), FGCObject는 모두 강한 참조(Strong Reference) 방식으로, 참조가 유지되는 한 GC가 대상 객체를 회수하지 못합니다. 그런데 모든 참조가 강한 참조일 필요는 없습니다.

예를 들어, 어떤 NPC 캐릭터가 현재 바라보고 있는 타겟 액터를 추적한다고 가정해 봅니다. NPC가 타겟을 참조한다고 해서 타겟이 절대 파괴되면 안 되는 것은 아닙니다. 타겟이 다른 이유로 파괴되었다면, NPC는 그저 "타겟이 없어졌구나"라고 인식하고 다음 행동으로 넘어가면 됩니다. 이런 상황에서 강한 참조를 사용하면 타겟이 파괴되지 못하는 문제가 발생합니다.

TWeakObjectPtr은 이러한 약한 참조(Weak Reference) 를 제공합니다. 대상 객체를 가리키되, GC가 해당 객체를 회수하는 것을 막지 않습니다. 대신, 객체가 파괴된 후에 접근하면 nullptr을 반환하여 안전하게 유효성을 검증할 수 있습니다.

4-2. TWeakObjectPtr 사용법

다음은 TWeakObjectPtr을 사용하여 타겟 액터를 약하게 참조하는 예제입니다.

UCLASS()
class MYGAME_API AMyNPC : public ACharacter
{
    GENERATED_BODY()

public:
    // 약한 참조: 이 포인터는 타겟의 GC를 막지 않습니다.
    TWeakObjectPtr<AActor> CurrentTarget;

    void SetTarget(AActor* NewTarget)
    {
        CurrentTarget = NewTarget;
    }

    void TickAI()
    {
        // IsValid()로 대상이 아직 살아있는지 확인합니다.
        if (CurrentTarget.IsValid())
        {
            FVector TargetLocation = CurrentTarget->GetActorLocation();
            // 타겟 방향으로 이동하는 로직
            MoveToLocation(TargetLocation);
        }
        else
        {
            // 타겟이 파괴되었으므로 순찰 상태로 전환합니다.
            ReturnToPatrol();
        }
    }
};

위의 코드에서 CurrentTargetTWeakObjectPtr이므로 타겟 액터의 수명에 영향을 주지 않습니다. 타겟이 Destroy()로 파괴되거나 GC에 의해 회수되면, IsValid()false를 반환하여 안전하게 처리할 수 있습니다.

TWeakObjectPtrUPROPERTY() 매크로 없이도 동작합니다. 이는 TWeakObjectPtr이 내부적으로 GUObjectArray(엔진의 전역 UObject 배열)의 인덱스와 시리얼 번호를 저장하는 방식으로 구현되어 있기 때문입니다. 객체에 직접 접근하는 것이 아니라, 전역 배열에서 해당 인덱스의 객체가 여전히 유효한지(시리얼 번호가 일치하는지)를 확인하는 것이므로, 이미 해제된 메모리에 접근하는 위험이 없습니다.

4-3. 람다와 비동기 콜백에서의 활용

TWeakObjectPtr이 특히 유용한 상황 중 하나는 람다(Lambda)나 비동기 콜백에서 UObject를 참조할 때입니다. 콜백이 실행되는 시점에는 원래 객체가 이미 파괴되어 있을 수 있으므로, 원시 포인터를 캡처하면 댕글링 포인터가 발생할 위험이 있습니다.

다음은 비동기 콜백에서 TWeakObjectPtr을 안전하게 사용하는 예제입니다.

void AMyActor::RequestAsyncData()
{
    // this를 직접 캡처하지 않고, 약한 참조로 변환하여 캡처합니다.
    TWeakObjectPtr<AMyActor> WeakSelf = MakeWeakObjectPtr(this);

    AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [WeakSelf]()
    {
        // 비동기 작업 수행...
        // 작업 완료 후 게임 스레드로 돌아와서 결과를 적용합니다.

        AsyncTask(ENamedThreads::GameThread, [WeakSelf]()
        {
            // Pin()으로 강한 참조를 획득하여 안전하게 접근합니다.
            if (TStrongObjectPtr<AMyActor> StrongSelf = WeakSelf.Pin())
            {
                StrongSelf->OnDataReceived();
            }
            // Pin()이 실패하면 객체가 이미 파괴된 것이므로 아무것도 하지 않습니다.
        });
    });
}

위 코드처럼 MakeWeakObjectPtr()로 약한 참조를 만들고, 콜백 내부에서 Pin()을 호출하면 TStrongObjectPtr을 획득할 수 있습니다. Pin()이 성공하면 해당 스코프 안에서는 객체가 GC에 의해 회수되지 않으므로 안전하게 사용할 수 있습니다. 이 패턴은 UE5에서 워커 스레드와 게임 스레드 간에 UObject를 안전하게 전달할 때 권장되는 방식입니다.

5. 포인터 종류별 비교

각 포인터 타입이 GC와 어떻게 상호작용하는지 정리하면 다음과 같습니다.

포인터 타입 객체 수명 유지 파괴 시 자동 nullptr 비고
UPROPERTY() TObjectPtr<T> O O 가장 권장되는 방식
UPROPERTY() T* O O UE4 스타일, 동작은 동일
원시 포인터 T* (UPROPERTY 없음) X X 댕글링 위험, 사용 자제
TWeakObjectPtr<T> X O 비소유 참조에 적합
TStrongObjectPtr<T> O - 비-UObject 클래스에서 소유 참조 시 사용
AddToRoot() O - 최후의 수단

6. 일반 C++과의 비교

6-1. 공통점

언리얼 엔진의 GC와 일반 C++의 스마트 포인터(std::shared_ptr, std::weak_ptr)는 근본적으로 같은 문제, 즉 메모리 수명 관리의 자동화를 목표로 합니다. UPROPERTY() 포인터가 강한 참조로 객체를 살려두는 역할은 std::shared_ptr의 참조 카운팅과 유사하고, TWeakObjectPtr이 대상의 유효성만 확인하고 수명에 개입하지 않는 역할은 std::weak_ptr과 개념적으로 같습니다.

6-2. 차이점

그러나 동작 방식은 근본적으로 다릅니다.

일반 C++의 std::shared_ptr 는 참조 카운팅(Reference Counting) 방식입니다. 마지막 shared_ptr이 소멸되는 즉시 대상 객체가 파괴됩니다. 파괴 시점이 예측 가능하다는 장점이 있지만, 순환 참조(Circular Reference)가 발생하면 참조 카운트가 0이 되지 않아 메모리 누수로 이어집니다.

// 일반 C++ — 순환 참조 시 메모리 누수 발생
struct Node
{
    std::shared_ptr<Node> Next;  // 서로를 참조하면 영원히 해제되지 않음
};

언리얼 엔진의 GC는 마크 앤 스윕 방식입니다. 루트셋에서 도달 가능한지 여부만 판단하므로, 순환 참조가 있더라도 루트셋에서 도달할 수 없으면 관련 객체 전부를 회수합니다. 즉, 순환 참조로 인한 메모리 누수가 구조적으로 발생하지 않습니다.

또한 일반 C++의 스마트 포인터는 어떤 객체에든 사용할 수 있지만, 언리얼 엔진의 GC는 UObject를 상속받은 클래스에만 적용됩니다. UObject를 상속받지 않는 일반 C++ 클래스는 GC의 관리 대상이 아니므로, 필요에 따라 std::shared_ptr이나 언리얼의 TSharedPtr을 별도로 사용해야 합니다.

7. 유니티 엔진과의 비교

7-1. 유사 기능

유니티 엔진 역시 가비지 컬렉션을 통해 메모리를 자동 관리합니다. 유니티는 C#의 .NET GC(세대별 가비지 컬렉션)를 그대로 사용하며, MonoBehaviourScriptableObject 등 관리 객체(Managed Object)의 수명을 자동으로 관리합니다.

7-2. 차이점

유니티의 GC는 C# 런타임의 세대별 GC로, 관리 힙(Managed Heap) 위의 모든 C# 객체를 대상으로 합니다. 반면 언리얼의 GC는 UObject 계층 구조에 한정된 마크 앤 스윕 GC입니다. 이 차이는 몇 가지 실질적인 결과를 낳습니다.

유니티에서는 Destroy()를 호출한 네이티브 객체(게임 오브젝트, 컴포넌트)의 C# 래퍼가 즉시 GC되지 않아, null 체크가 직관적이지 않은 경우가 있습니다. 유니티의 UnityEngine.Object== 연산자를 오버라이드하여 네이티브 객체가 파괴되었으면 null과 같다고 판정하지만, C# 레벨에서는 객체가 아직 살아있는 "가짜 null" 상태가 존재합니다.

7-3. 언리얼의 강점

언리얼 엔진은 네이티브 C++ 레벨에서 GC를 직접 제어하므로, 개발자가 GC 타이밍과 동작을 세밀하게 조율할 수 있습니다. gc.TimeBetweenPurgingPendingKillObjects와 같은 콘솔 변수를 통해 GC 주기를 조정하거나, ForceGarbageCollection()으로 수동 GC를 트리거하는 것이 가능합니다. 또한 UE5에서는 증분 도달 가능성 분석(Incremental Reachability Analysis) 이 실험적으로 도입되어, 대규모 오픈 월드에서 GC로 인한 프레임 히치를 줄이는 방향으로 발전하고 있습니다.

유니티의 .NET GC는 범용적이고 사용하기 쉽지만, 게임 특화 최적화가 제한적입니다. 특히 GC 발생 시점을 예측하기 어렵고, GC가 발생하면 관리 힙 전체를 대상으로 수집이 이루어지므로 프레임 히치에 취약합니다. 언리얼의 GC는 UObject 시스템에 특화된 설계 덕분에, 게임에 최적화된 수명 관리가 가능합니다.

8. 액터와 컴포넌트의 특수한 수명 관리

액터(AActor)와 액터 컴포넌트(UActorComponent)는 일반 UObject와 수명 관리 방식이 다릅니다. 이들은 UPROPERTY() 참조가 없더라도 소속된 레벨이나 소유 액터로부터 암묵적으로 참조되어 GC가 회수하지 않습니다.

액터는 소속 레벨(ULevel)이 ULevel::AddReferencedObjects()를 오버라이드하여 자신에게 소속된 액터 목록을 GC에 알려주기 때문입니다. 마찬가지로 컴포넌트는 소유 액터가 참조를 관리합니다. 따라서 액터를 명시적으로 파괴하려면 Destroy()를 호출해야 하며, 단순히 참조를 끊는 것만으로는 파괴되지 않습니다.

다음은 액터를 명시적으로 파괴하는 예제입니다.

void AMyGameMode::RemoveEnemy(AEnemyCharacter* Enemy)
{
    if (IsValid(Enemy))
    {
        // Destroy()를 호출하면 다음 GC 사이클에서 메모리가 해제됩니다.
        Enemy->Destroy();
    }
}

위 코드처럼 액터는 Destroy()를 호출하여 파괴 마킹을 한 후, 다음 GC 사이클에서 실제로 메모리가 회수됩니다. Destroy() 호출 직후에는 IsPendingKillPending()true를 반환하며, 해당 액터에 대한 UPROPERTY() 포인터들은 GC 수행 시 자동으로 nullptr로 설정됩니다.

9. 주의사항

9-1. 원시 포인터로 UObject를 저장하지 않기

UPROPERTY() 없이 원시 포인터(UObject*)에 UObject를 저장하면, GC는 해당 참조를 전혀 인식하지 못합니다. 객체가 GC에 의해 회수되어도 포인터 값은 그대로 남아 있어 댕글링 포인터가 됩니다. 이 포인터를 역참조하면 정의되지 않은 동작이 발생하며, 더 위험한 경우 해당 메모리 주소에 다른 객체가 할당되어 "엉뚱한 객체에 접근하는" 버그가 생길 수 있습니다.

// ❌ 잘못된 예 — GC가 이 참조를 모릅니다
UMyObject* DangerousPtr = NewObject<UMyObject>();
// 다른 곳에서 이 객체에 대한 참조가 없으면 GC가 회수해버림
// DangerousPtr은 댕글링 포인터가 됨

// ✅ 올바른 예 — UPROPERTY로 GC에 알림
UPROPERTY()
TObjectPtr<UMyObject> SafePtr;
// SafePtr에 저장된 객체는 이 참조가 유지되는 한 회수되지 않음

9-2. TWeakObjectPtr을 UPROPERTY 대용으로 사용하지 않기

TWeakObjectPtr은 객체의 수명을 연장하지 않습니다. 만약 어떤 객체를 직접 소유하고 수명을 관리해야 하는 상황이라면 반드시 UPROPERTY() 포인터를 사용해야 합니다. TWeakObjectPtr만 가리키고 있는 객체는 다른 강한 참조가 없으면 GC가 회수합니다.

9-3. AddToRoot() 남용 금지

GC 관련 크래시가 발생했을 때 원인을 분석하지 않고 AddToRoot()로 해결하려는 것은 매우 위험한 습관입니다. AddToRoot()는 루트셋에 영구적으로 등록하는 것이므로, RemoveFromRoot()를 호출하지 않으면 메모리 누수가 발생합니다. 또한 에디터 환경에서 PIE(Play In Editor) 세션이 종료될 때 루트셋에 남아있는 객체로 인해 크래시가 발생할 수 있습니다.

9-4. GC는 게임 스레드에서만 실행됩니다

가비지 컬렉션의 도달 가능성 분석은 게임 스레드에서 수행됩니다. 따라서 다른 스레드에서 UObject에 직접 접근하거나 생성·파괴하는 것은 안전하지 않습니다. 워커 스레드에서 UObject를 참조해야 할 때는 TWeakObjectPtr을 사용하고, 실제 접근 시에는 Pin()을 통해 게임 스레드에서 안전하게 접근하는 패턴을 사용해야 합니다.

9-5. NewObject로만 UObject를 생성하기

UObject는 반드시 NewObject<T>(), SpawnActor<T>(), CreateDefaultSubobject<T>() 등 언리얼 엔진이 제공하는 생성 함수로만 만들어야 합니다. C++의 new 연산자를 사용하면 UObject가 GC 시스템에 등록되지 않아 관리 대상에서 벗어나며, 이로 인해 예측 불가능한 크래시가 발생합니다.

가비지 컬렉션은 언리얼 엔진에서 UObject의 수명을 자동으로 관리하는 핵심 시스템입니다. UPROPERTY() 매크로를 통해 강한 참조를 유지하고, TWeakObjectPtr로 비소유 참조를 안전하게 관리하며, 루트셋의 개념을 이해하면 GC와 관련된 대부분의 문제를 예방할 수 있습니다. 대규모 프로젝트에서 메모리 관련 버그 없이 안정적인 시스템을 구축하려면, 모든 UObject 포인터가 GC에 의해 추적되고 있는지 항상 확인하는 습관을 들이는 것이 중요합니다.