언리얼/언리얼 C++

언리얼과 유니티 GC 비교하기

강목근 2025. 3. 9. 11:01

GC란 무엇인가?

GC, 엔진으로 개발을 하고 있다면(로우 레벨에서 직접 메모리를 관리하는 것이 아니라면..) 반드시 들어봤을 개념이고 공부했을 개념이다.

 

GC의 뜻은 Garbage Collection으로, 단어 뜻 그대로, 쓰레기 수거의 역할을 한다. 

프로그램이 실행되는 중에 더 이상 참조되지 않는 객체를 자동으로 탐지하고, 해당 객체가 점유한 메모리를 회수하는 메모리 관리 기법이다.

 

이걸 왜 사용하냐면, 전통적인 수동 메모리 관리 방식은 개발자가 명시적으로 메모리의 할당과 해제를 수행해야 하므로 메모리 누수(Memory Leak)나 Dangling Pointer와 같은 치명적인 버그가 발생할 수 있기 때문에.. 최대한 이런 일이 발생되지 않게 하기 위해서 나온 개념이다.

 

즉, 자동으로 메모리를 회수하는 기법이다. (개발자가 메모리를 관리하는 데에 부담을 덜 수 있도록 하기 위함.)

 

대표적인 게임 엔진인 유니티와 언리얼에서도 당연하게 이 GC를 사용하고 있다.

 

이번 포스트에서는 유니티와 언리얼에서 사용하는 GC를 공부하고, 비교할 것이다.


유니티의 GC - Boehm GC

Mono

내가 사용하는 버전은 유니티의 2022.3.xx 버전이다. 

이는 Mono 런타임을 기반으로 하는 버전이다.

(iOS와 같은 일부 플랫폼에서 IL2CPP를 통해 C#코드를 C++로 변경하기도 한다. 다만 이번 포스트에서는 다루지 않겠다.)

 

Mono는 .NET Framework의 오픈 소스 구현체로, 유니티에서 C# 스크립트를 실행하는 런타임 환경이다.

유니티가 다양한 플랫폼에서 동작할 수 있는 가장 큰 이유가 바로 이 Mono를 사용하기 때문이다. (MonoBehaviour와 같은 것을 많이 봤을 것이다.)

간단히 설명하자면 오픈 소스 개발자들이 윈도우 이외의 환경에서도 .NET Framework를 사용하기 위해서 만든 프로젝트이다.

 

유니티는 Mono의 초창기 버전에서부터 개발된 엔진이다. 때문에 GC 방식이 과거 Mono와 동일한데..


많은 오해 - 유니티가 SGen(세대별 가비지 컬렉션)이 아니었다고?

생각보다.. 대학 후배도 그렇고 취업을 준비하는 유니티 개발자들에게 많이 듣는 얘기이다.

"너 유니티 GC에 대해 아니?"
"아 그럼요, 유니티는 메모리 관리를 위해 이를 세대로 나누어 0세대, 1세대, 2세대로... SOH와 LOH가..."

 

이런, 공식 문서를 제대로 보지 않은 것이 분명하다.

https://docs.unity3d.com/kr/2023.2/Manual/performance-incremental-garbage-collection.html

 

점진적 가비지 컬렉션 - Unity 매뉴얼

점진적 가비지 컬렉션(GC)은 가비지 컬렉션 프로세스를 여러 프레임에 분산시킵니다. 이는 Unity에서 가비지 컬렉션 동작의 기본값입니다.

docs.unity3d.com

분명하게도, 해당 문서에서는 유니티의 가비지 컬렉터가 Boehm GC를 사용한다고 말하고 있다.

 

특히나 Boehm과 SGen 이 두 방식은 정말 다른 방식인데, 이에 대해선 뒤에 더 설명하는 것으로 하고..

도대체 왜 이런 오해가 생기는 것일까?

 

가장 큰 이유는 아마.. .NET Framework나 .NET Core와 혼동한 게 아닐까 싶다.

혹은.. C#과 .NET이 동일하다고 생각한 것일수도 있고...

 

보통 유니티를 공부한다고 하는 친구들을 보면, "Unity C# GC", "C# GC"와 같은 키워드로 구글링을 하곤 한다.

그리고 많은 블로그에서, "C#에서 GC는 SGen 방식을 사용한다."라고 하는 것을 볼 수 있다.

 

이건, 잘못된 말이다. 

정확히 설명하자면, 뭔가 이상한 말이다.

C#은 언어이고, GC는 실행 환경(Runtime)에 따라 달라지는 개념이다.

 

즉, GC가 어떻게 동작하는지 결정하는 건 C#을 실행하는 환경에 따라 달라진다는 것이다.

.NET Framework, .NET Core, Mono 중 어느 것을 사용하는지에 따라 GC의 방식이 변하게 된다.

 

그런데 또 찾아보면 이런 말이 나온다.

https://www.mono-project.com/docs/advanced/garbage-collector/sgen/

 

Generational GC | Mono

Generational GC This document discusses the internals of SGen. If you are interested on how to use SGen and how to tune it for your own application workloads, see the document Working With SGen. For a few releases, Mono has shipped with a new garbage colle

www.mono-project.com

어라? 선배님, 모노의 GC는 세대별 GC인 SGen이 맞잖아요! 그래서 이렇게 문서에서도 설명하는 거 아닌가요?

 

이 말은 맞는 말이지만, 결론적으로 유니티의 GC가 SGen이라는 것은 틀린 말이다.

아까도 말했듯이, 유니티는 Mono의 초창기 버전에서부터 개발된 엔진이다.

 

그리고 Mono는 초기에 Boehm GC 방식을 사용했다.

그러니 이런 오해가 생길 수밖에 없는 것이다. (최초에 내가 공부할 때도 굉장히 혼동이 많이 됐던 부분이다.)

 

자 그러니 헷갈리지 말자.

  1. C#이 .Net이라 생각하는 일반적인 인식 (공식적으로 C#이 .NET을 위한 주요 언어이고, 관련 자료가 많으니 오해할 수 있다.)
  2. Mono가 발전하면서 바뀐 GC 정책 (과거에는 Boehm, 지금은 SGen...)
  3. 유니티 문서의 설명 부족 (직접 찾아보지 않는 이상, 관련 문서에서 "우리는 .NET과 다른 GC를 씁니다"라는 강조사항이 없어 알기가 쉽지 않음)
  4. IL2CPP로 인한 오해 (C# 코드를 C++, 즉 네이티브 코드로 변환하니 .NET의 GC를 그대로 사용하겠지? 라는 오해를 함. 실제로는 Boehm GC를 그대로 사용)

알기 쉽게 정리했으니 이제 다르다는 것을 인지했을 것이다.

유니티는 정식 .NET 환경이 아니라, Mono 기반의 독립적인 런타임으로 봐야 한다.

 


오케이 알겠어, 그래서 Boehm은 뭐하는 GC인데?

드디어 이걸 설명할 차례가 왔다.

우선 Boehm GC는 마크 앤 스위프(Mark-and-Sweep) 알고리즘을 기반으로 한 보수적 GC에 해당한다.

차근차근 보자.

 

Mark-and-Sweep

1단계 - 마크 단계 (Mark Phase)

마크 단계는 객체가 살아있는지 회수해야 하는 대상인지 판단하는 단계이다. (식별 단계)

 

가장 처음으로 하는 것은 바로 "루트 세트(Root Set)"를 찾는 것이다.

루트 세트가 무엇인가 하면, 프로그램에서 시작점이 되는 메모리 영역을 뜻한다.

예를 들면, 스택, 레지스터, 전역 변수 등에서 접근 가능한 참조를 시작점으로 삼는 것이다.

 

이후에 스캔을 진행하는데, Boehm은 스캔을 "보수적"으로 한다.

이게 무슨 말이냐면, 정확한 타입 정보를 모르는 채로 스캔을 진행한다는 뜻이다.

메모리 블록을 스캔하면서 "포인터처럼 보이는 값"을 추적한다.

 

그리고 여기에서, 큰 문제점이 하나 발생한다.

0x12345678이라는 정수 값이 있다고 해보자. 이 값이 정말 "우연하게도" 특정 객체가 위치한 메모리 주소와 일치하면? GC는 "아, 이 객체 아직 참조되고 있구나?" 라고 판단을 해버린다.

그러니까.. 정수를 포인터로 착각하는 문제가 발생하는 것이다.

 

아무튼, 이런 경우를 포함해서 마크 단계에서는 "도달 가능한 객체"에 마크 비트를 설정하여 "이 객체는 아직 살아있음"을 표시한다.

 

2단계 - 스위프 단계 (Sweep Phase)

스위프 단계는 마크 단계에서 마크 비트가 설정되지 않은 객체들을 메모리에서 해제하는 단계이다.

이때, 해제된 메모리는 "프리 리스트"에 추가된다.

 

프리 리스트가 무엇인가?

동적으로 메모리 할당을 하기 위해서 해제된 메모리를 다음 메모리 할당 때 먼저 사용하기 위해 이를 관리하는 것이다.

 

이런 Boehm GC는 타입 정보가 필요 없기 때문에 메모리를 직접 관리하던 레거시 C++ 프로젝트 등에 결합하기가 쉽다.

또한 다양한 플랫폼(윈도우, 리눅스, 맥 등...)에서도 안정성이 입증되어 대체적으로 쓰기 쉽다.

 

다만 유니티가 이 Boehm GC를 쓰면서 아주 큰 문제를 가지고 있는데...

우선, 언제 GC가 작동하는지 예측하기가 어렵다.

 

일단 위에서 말했듯, 보수적 방식이기 때문에 "진짜 필요 없는 객체"를 해제하지 않아 메모리에 계속 남아있어 이상한 타이밍에 GC가 호출되거나 할 수 있다.

 

다른 방식이 결정적 실행 방식으로 작동하는 것에 비해, Boehm은 비결정적 방식으로 동작한다. 

"메모리 할당 임계값 초과" 혹은 "시스템 메모리 부족"과 같은 상황에 실행되고는 하는데, 이 임계값들은 동적으로 변동하기 때문에 더더욱 예측하기가 어렵다.

 

또한, SGen에 비해 전체 힙을 한 번에 검사해야 하며, 객체의 수명이 길건 짧건 모두 동일하게 처리하기 때문에 실행 자체가 굉장히 불규칙적이게 된다.


GC Spike

이런 이유로, GC Spike가 발생하기 쉽다.

더 정확히 말하자면, GC Spike는 Boehm GC가 Stop-the-World(메모리 해제를 위해 전체 프로그램을 중단)하는 방식이기 때문에, GC가 오래 걸리면 오래 걸릴수록 프로그램이 멈춰 히치가 발생한다.

 

GC Spike는 GC가 실행될 때 눈에 띄게 게임이 멈추거나 성능이 저하되는 것을 의미한다.

 

어떤 코드에서 발생할까? 단순한 예를 들어보자.

void Update()
{
    GameObject particle = new GameObject("Particle");
    particle.AddComponent<ParticleSystem>();
    Destroy(particle, 1f); // 1초 후 파괴
}

게임에서 파티클을 생성한다고 해보자...

저 파티클은 매 프레임 생성되었고, Destroy 함수로 인해 1초 뒤에 파괴된다.

이렇게 된다면 힙이 빠르게 채워지고, GC가 실행되면서 20ms 이상의 스파이크가 발생한다.

 

그래서 대부분, 오브젝트 풀링과 같은 기법을 사용한다.

public class ParticlePool : MonoBehaviour
{
    private List<GameObject> pool = new List<GameObject>();
    private int poolSize = 50;

    void Start()
    {
        for (int i = 0; i < poolSize; i++)
        {
            GameObject particle = new GameObject("Particle_" + i);
            particle.AddComponent<ParticleSystem>();
            particle.SetActive(false);
            pool.Add(particle);
        }
    }

    public GameObject GetParticle()
    {
        foreach (var p in pool)
        {
            if (!p.activeInHierarchy)
            {
                p.SetActive(true);
                return p;
            }
        }
        return null; // 풀 확장 로직 추가 가능
    }
}

이렇게 되면 GC 호출의 빈도가 상당히 많이 줄어들게 된다.

 

결국 뭐가 되었든 GC Spike를 경계하기 위해 개발자의 회피 전략이 꼭 필요하다는 것이다.

이런 이유로 애초에 객체 생성을 최대한 덜 하기 위해 재사용 되는 것들은 거의 캐싱해서 사용하는 습관이 생기기도 한다.


유니티에선 저런 걸 그냥 두나요?

아니다. 유니티에서도 여러 노력을 하고 있다.

 

개발자가 가비지 컬렉션의 동작 방식을 제어할 수 있도록 여러 모드를 제공하고 있는데, 각각 "점진적 가비지 컬렉션", "비활성화된 점진적 가비지 컬렉션", "자동 가비지 컬렉션 비활성화"로 나뉜다.

 

https://docs.unity3d.com/kr/2021.2/Manual/performance-garbage-collector.html

 

가비지 컬렉터 개요 - Unity 매뉴얼

Unity에서는 가비지 컬렉터를 사용하여 애플리케이션과 Unity에서 더 이상 사용하지 않는 오브젝트로부터 메모리를 회수합니다. 스크립트가 관리되는 힙에 할당하려고 하지만 할당을 수용할 수

docs.unity3d.com

 

점진적 가비지 컬렉션

점진적 가비지 컬렉션 옵션은 현재 유니티의 기본 설정 값이다.

"마크 단계"를 여러 프레임에 걸쳐 나누어 실행하는 방식이다.

 

만약, 객체가 1,000,000개 존재하는 게임이라고 해보자. 한 프레임에 이걸 전부 검사하려면 상당히 오래 걸리지 않겠는가..? 

때문에 이를 스캔하는 작업을 작은 단위로 분할하여 프레임 끝 부분의 여유 시간(예를 들면 모니터가 새 프레임을 표시할 수 있는 시점까지 대기하는 VSync-수직동기화가 활성화된 경우가 있겠다.) 을 활용해 진행한다.

 

GC로 인한 "단일 프레임"의 부하를 줄이기 위한 방식이다.

이걸 사용하는 것만으로도 GC Spike에 대한 부담이 훨씬 줄어든다.

 

또한, 자동으로 분산해주기 때문에 개발자가 뭘 할 필요도 없다. 그냥 기능 활성화만 하면 된다.

출처 - https://docs.unity3d.com/kr/2023.2/Manual/performance-incremental-garbage-collection.html

 

다만 전체 GC 시간 자체는 증가하게 된다. 아무래도 프레임을 기다리고, 그 여유시간에 진행하다 보니...

 

또한 점진적 모드는 "쓰기 장벽(write barriers)"를 사용한다. 이는 객체의 참조가 변경될 때마다 가비지 컬렉터가 해당 객체를 다시 검사해야 하는지를 추적하는 메커니즘으로, 약간의 런타임 오버헤드가 발생할 수 있다.

 

만약 참조가 자주 변경되는 프로젝트라면 쓰기 장벽으로 인해 가비지 컬렉터가 동일한 객체를 반복적으로 검사할 가능성이 있으므로, 주의해야 한다.

 

비활성화된 점진적 가비지 컬렉션

이건 기존 방식과 동일하게 한 프레임에서 GC 작업을 모두 처리하는 방식이다.

작업 분산이 되지 않으므로 여전히 GC Spike에 취약하다.

 

장점은.. GC에 걸리는 총 시간이 짧다.

 

그럼 이걸 어디에 사용할까?

메모리 사용량이 극도로 적어 GC Spike가 아예 티가 나지 않아서 그냥 쓰는 프로젝트

씬 전환이 중심이 되어 수동으로 씬 전환 타이밍에 GC를 실행시켜 눈에 보이지 않는 타이밍에 프레임을 사용하는 프로젝트

등이 있겠다.

 

자동 가비지 컬렉션 비활성화

이 방식은 개발자가 GarbageCollector.GCMode API를 통해 가비지 컬렉션의 실행 시점을 완전히 제어할 수 있는 모드이다.

 

유니티의 자동 가비지 컬렉션이 비활성화 되며, 개발자가 직접  System.GC.Collect()를 호출해 수동으로 가비지 컬렉션을 진행해야 한다.

 

이 경우에는, GC 실행 시점을 명확하게 알 수 있기에 성능 최적화 및 프로파일링에 있어서 큰 강점을 갖는다.

또한 GC Spike를 아예 안보이게 씬 전환이나 로딩 씬에서 사용하여 처리할 수 있다.

 

다만 역시.. 이 경우에는 유니티의 강점인 "생산성"에 영향이 있을 수 밖에 없다.

개발자가 직접 관리해야 하므로 메모리 관리에 부담을 느끼게 될 것이다. 코드 설계 및 유지보수가 어려워질 것이다.

 

결국에.. 글을 작성하는 내가 봐도 점진적 가비지 컬렉션 모드를 사용하지 않을까 싶다.

누가 뭐라고 해도 결국 유니티의 장점은 "생산성"이니...

 


언리얼의 GC - 하이브리드 GC

UObject

최근 내 언리얼 글에서도 나왔던 개념이다. 언리얼은 UObject라는 클래스 계층을 통해 객체를 생성 및 관리한다는 것을 바로 이전 글에서도 설명했다.

https://woodroot.tistory.com/36

 

언리얼 감각 되살리기 - 유니티랑 언리얼 비교하기

유니티 개발자로 살아간지 반년이 넘었다. 이제 슬슬 언리얼을 다시 공부하지 않으면 아예 관련된 내용을 전부 잊을 지경이다.가능한 범위에서 이론적으로 학습하고, 간단한 프로그램을 제작

woodroot.tistory.com

관심이 있으면 참고하자.

 

그리고 이와 관련된 리플렉션 시스템도 GC와 연관이 있다.

이에 대해서 자세히 설명해보고자 한다.

 

언리얼은 참조 카운팅과 Mark-and-Sweep GC를 결합한 하이브리드 방식의 GC를 사용한다.

이를 통해서 순환 참조와 같은 전통적인 문제를 해결함과 동시에 GC에 대한 예측 가능성과 안정성을 높인다.


리플렉션이 무슨 상관이 있지?

우선, 기억을 되짚어보자.

 

UObject는 언리얼 엔진 내 대부분의 게임 플레이 관련 클래스가 전부 상속받는 클래스라 했다.

그리고, UCLASS, UPROPERTY등의 매크로를 통해 엔진이 각 객체와 필드(프로퍼티)를 리플렉션에 등록한다.

 

왜 리플렉션이 중요할까?

언리얼의 GC는 UObject와 그 하위 클래스들이 보유한 UPROPERTY 정보를 기반으로 "어떤 객체가 다른 객체를 참조하고 있는지"를 추적한다.

이 과정을 통해 언리얼 엔진은 "정확한 참조 그래프"를 구성하므로, Boehm의 보수적 스캔 방식에 비해 잘못된 객체를 착각하는 문제도 발생하지 않는다.

 

아.. 이런 생각을 하면 안되는데 벌써 처음부터 이미 유니티의 Boehm보다 좋은 것처럼 느껴진다...


참조 카운팅

이전 글에서 작성했던 부분이다.

우선 위 글을 읽어보면, UObject는 참조 카운팅 방식으로 관리되고 있음을 알 수 있다.

이는 기본적인 C++ 스마트 포인터와 동일한 방식이다. TSharedPtr이나 TWeakPtr등의 기능을 제공한다.

 

근데 생각해보자, UObject 기반 객체에 TSharedPtr을 사용하는가?

아니다. 그 이유는 UObject가 엔진의 Mark-and-Sweep GC에 의해 관리되어야 하며, 단순 참조 카운팅만으로는 엔진 내부의 라이프사이클과 충돌할 수 있기 때문이다.

 

그렇지 않는다면 "순환 참조 문제"가 발생할 수 있다.

UCLASS()
class A : public UObject {
public:
    UPROPERTY() class B* RefToB;
};

UCLASS()
class B : public UObject {
public:
    UPROPERTY() class A* RefToA;
};

A* ObjA = NewObject<A>();
B* ObjB = NewObject<B>();
ObjA->RefToB = ObjB;
ObjB->RefToA = ObjA;

 

위 코드를 보자. 누가봐도 문제가 많아 보이는 순환 참조 코드이다..

저걸 이제 그냥 두고 GC에게 맡기는 경우..

루트 세트에서 도달할 수 없는 경우, 엔진은 마크 단계에서 두 객체를 “도달 불가능” 상태로 인식하고 스위프 단계에서 해제한다.
만약 둘 중 하나가 여전히 전역적으로 참조되고 있다면(루트 세트에 연결), 해당 객체만 살아남고, 다른 객체는 해제될 수 있다.

완전히 해결하고자 하면 "TWeakObjectPtr"을 사용하자.

UCLASS()
class B : public UObject {
public:
    TWeakObjectPtr<A> WeakRefToA; // 약한 참조로 변경
};

참조 카운팅이 증가하지 않고 GC에서도 강한 연결로 취급하지 않아 객체 해제가 원활해진다.


언리얼의 Mark-and-Sweep

Mark-and-Sweep은 아까 위에서 Boehm을 설명할 때 자세히 설명했으니, 여기선 언리얼이 뭐가 다른지만 설명하겠다.

 

루트 세트

언리얼의 레벨, 서브시스템, 월드 등 엔진 내부가 지정한 "최상위 객체"들이 GC의 시작점으로 설정된다.

 

마크 단계

UPROPERTY 매크로를 통해 객체의 메타데이터가 생성되므로, 엔진은 이미 "누가 UObject인지"를 정확히 알고 있다.

따라서 보수적 스캔과 달리 '포인터처럼 보이는 값'을 모두 추적할 필요가 없고, 그냥 UObject만 처리하면 된다.

 

스윕 단계

엔진은 마크되지 않은 객체를 해제하여 UObject 전용 메모리 풀 혹은 힙에 반환한다.

이 해제 과정도 그냥 단순하게 delete로 해제하는 게 아니라, 일단 객체 소멸자(Destructor)를 먼저 호출하여 객체가 스스로 정리할 수 있도록 한다.

이를 통해 순환 참조 문제를 해결하고, 정확한 GC가 가능해진다.

 

이후에?

언리얼은 모든 UObject의 생애 주기를 관리하기 위해 내부적으로 유지하는 GUObjectArray를 가지고 있는데, GC가 진행된 이후 삭제된 객체들을 제거하여 목록을 최신 상태로 유지하는 작업을 진행한다.

또한 일부 객체는 PendingKill 상태로 남아있을 수 있기 때문에.. 특정 조건(예를 들어 레벨이 완전히 언로드 될 때)에만 이를 제거한다.

 


그럼 C++ 객체들은?

맞다. 지금까지 설명한 언리얼의 GC는 "UObject"를 대상으로만 수행되기 때문에, 우리가 현업에서 생성해서 사용하는 전통적인 C++ 객체들은 GC의 대상이 되지 않는다.

 

결국 직접 관리를 해야 한다는 것인데..

언리얼에서 제공해주는 스마트 포인터를 이용해서 관리를 직접 해야 한다.

혹은, 정말 자신 있는 경우에 new와 delete를 사용하는 방법도 있다. 그러나 굉장히 위험한 방법이다.

struct FMyData
{
    int32 Value;
};

TSharedPtr<FMyData> DataPtr = MakeShared<FMyData>();
DataPtr->Value = 42;
// 참조가 사라지면 자동 해제
TUniquePtr<FMyData> UniqueData = MakeUnique<FMyData>();
UniqueData->Value = 100;
// 스코프를 벗어나면 자동 해제

그것도 아니라면? UObject랑 엮어서 관리하면 된다. C++ 객체를 UObject 안에 멤버로 넣어서 UObject가 정리될 때 C++ 객체도 같이 정리되게끔 한다.

 

결국 지금 팀이 어떻게 프로그래밍 하고 있고, C++ 객체에 대한 규정이 어떤지, 메모리 관리를 어떻게 하기를 권장하는지를 따져보고 본인 상황에 맞게 관리하면 될 것이다.

 

개인적으로는 최대한 UObject를 쓰는 쪽으로 설계하는 게 좋지 않을까 생각이 들긴 하지만, 대규모 언리얼 개발에 참여해본 경험이 없어 이에 대한 확답을 내리기는 어려울 것 같다.


가비지 컬렉터 퍼포먼스 튜닝

언리얼 엔진에서도 GC의 성능을 높이기 위해 세세하게 조정할 수 있는 여러 설정을 제공한다.

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/unreal-object-handling-in-unreal-engine

위 링크에서 자세히 볼 수 있다.

바로 위 링크가 출처이다.

Time Between Purging Pending Kill Objects

GC가 메모리를 해제하는 주기를 조절할 수 있다. 기본 값은 60초이다.

 

Create GC UObject Clusters

이 옵션은 서로 참조 관계에 있는 UObject들을 "클러스터"라는 단위로 묶어서 관리한다.

묶여있는 클러스터는 하나의 객체로 취급할 수 있기 때문에 도달 가능성을 판단하는데 시간이 절약된다.

특정 액터와 그가 가지고 있는 컴포넌트들 같이 강하게 연결된 객체를 한 번에 처리하여 성능이 개선된다.

다만 클러스터의 항목 전부를 한 프레임에 판단하므로 너무 클 경우 문제가 된다.

 

Merge GC Clusters

간단하게 설명하면 클러스터들끼리 합쳐서 하나의 커다란 클러스터로 만드는 것이다.

즉, GC가 처리할 클러스터의 절대적인 수가 줄어들어서 효율을 높이는 옵션인데..

마찬가지로 클러스터의 크기가 너무 클 수 있으므로 조심해야 한다.

 

이외에도

액터와 가지고 있는 컴포넌트를 자동으로 하나의 클러스터로 묶어주는 Actor Clustering Enabled

블루프린트에서 생성된 객체도 클러스터로 관리할 수 있게 해주는 Blueprint Clustering Enabled

특정 객체를 GC의 대상에서 제외시키는 Use Disregard For GC

클러스터의 최소 객체 수를 설정하여 그보다 작은 클러스터는 병합되거나 해제되게 하는  Min GC Cluster Size

객체의 BeginDestroy 함수를 여러 프레임에 걸쳐 호출하여 대량에 객체가 한 번에 소멸되거나 할 때 유용한 Incremental Begin Destroy Enabled

등이 있다.

 

그리고, 아까 위에서 장황하게 설명한 "점진적 가비지 컬렉션" 또한 언리얼도 가능하다.

조사하면서 알았는데, 5.4버전부터 추가된 "실험적 기능"이라고 한다. 문서도 5.4부터 존재한다..

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/incremental-garbage-collection-in-unreal-engine

유니티의 점진적 가비지 컬렉션과 목적은 동일하고, 그 안에 GC가 다른 것이므로 설명은 생략하고 문서 링크만 남기도록 하겠다.


결론

언리얼과 유니티 GC의 비교

항목 언리얼 엔진 유니티 엔진
GC 방식 정확한 Mark-and-Sweep 방식과 참조 카운팅을 결합한 하이브리드 방식 Boehm-Demers-Weiser
보수적 Mark-and-Sweep 방식
메모리 관리 대상 UObject와 그 파생 클래스만 관리
순수 C++ 객체는 따로 관리해야 한다.
C# 스크립트에서 생성되는
관리되는(Managed) 객체를 관리
세대 구분 여부 세대 구분이 없이 모든 객체를 단일 세대에서 관리한다.
GC 실행 시점 주기적으로 자동 실행
개발자 수동 호출 가능
메모리 할당 임계값을 초과하거나 시스템 메모리가 부족할 때 실행
개발자 수동 호출 가능
개발자 제어 가능성 수동 GC 호출, GC 주기 조정, 클러스터링 설정 등 세밀한 제어 가능 수동 GC 호출이 가능하지만 그외의 제어 가능한 설정이 제한적
성능 영향 참조 카운팅으로 해제되고, GC가 보조로 주기적으로 실행되어 GC Spike 발생이 적은 편이다. GC를 실행할 경우 전체 힙을 검사하여 저사양 게임이더라도 GC Spike가 잦은 편이다.
점진적 GC 지원 지원하며, 마크 작업을 여러 프레임에 걸쳐 분산 처리를 한다.
GC 최적화 기법 클러스터링, Disregard for GC 등 언리얼 자체에서 제공해주는 옵션을 이용하여 최적화 가능 오브젝트 풀링(최신 버전에 기능으로 추가됨. 이전에는 구현), 값 타입 활용, 메모리 프로파일링 등을 통해 개발자가 직접 최적화를 진행해야 함
메모리 위험 순환 참조 시 메모리 누수가 발생할 수 있으나 그에 대한 대책을 충분히 마련해둠 보수적 스캔으로 필요 없는 객체가 남아있을 수 있다.
GC 실행 빈도 조절 설정할 수 있다. 설정할 수 없다.
메모리 관리의 복잡성, 난이도, 생산성 UObject 외의 C++ 객체는 개발자가 직접 관리해야 하고, UPROPERTY를 명시적으로 사용하는 등 개발자가 해야 하는 부분이 비교적 많다.
비교적 생산성이 낮다
C# 스크립트의 모든 "객체 타입"은 GC에서 관리하여 신경을 아예 쓰지 않아도 안전하다.
비교적 생산성이 좋다.
실시간 게임에서의 안정성 GC의 타이밍을 예측하기 쉬워 안정적으로 GC Spike를 일으키지 않고 관리하기 좋다. 예측이 불가능해 결국 GC Spike에 대해 프로파일링을 하게 된다.

 

내가 이전 게시글에서도 말했던 거지만, 항상 느끼는 것은 똑같은 것 같다.

이전 게시글을 작성하며 느낀 유니티와 언리얼의 방향성 차이

유니티는 Boehm GC를 통해 접근성과 개발 속도를 강조했고, 초보자나 빠른 프로토타입 제작에 적합하게 설계됐다.

언리얼 엔진은 하이브리드 GC로 안정성을 우선시하며, 대규모 프로젝트와 고품질 게임 제작에 강점을 보인다.

 

다만 유니티 개발을 계속 하면서, 모바일 게임의 발열 및 부하를 낮추기 위해 프로파일링을 해봤던 입장으로는...

Boehm을 빨리 버렸으면 좋겠다. 물론 .NET Core로 완전 바꾸겠다 선언했지만, 그게 언제가 될지 모르니..

 

마지막으로 원래 위 내용에 포함하려 했던 주제를 간략하게 소개하고 글 마치도록 하겠다. (출처와 근거가 명확하다 판단이 들지 않아 밑으로 빼게 됐다. 쓴 게 아깝기도 하고.. ㅠ)

 

더보기

Unity가 오래된 Mono를 쓰게 된 배경과 왜 바꾸지 못하는지

Unity는 오래전(대략 Unity 3.x ~ 4.x 시절)부터 Mono 런타임을 커스텀 수정한 버전을 사용해왔다.

그 당시에 Mono에서 제공하던 것이 Boehm이었고, 유니티는 이를 다양한 플랫폼에 이식하면서 안정성을 검증하고, 본인들 엔진에 맞게 개량해왔다.

동시에, 최신 Mono를 사용하기 위한 Xamarin과의 라이선스 비용에 문제가 있었다는 얘기도 있었다.

https://stackoverflow.com/questions/46574407/unitys-garbage-collector-why-non-generational-and-non-compacting

 

물론 이는 Microsoft가 Xamarin을 인수하고, Mono가 .NET Foundation에 기여되면서 장벽이 많이 완화되었다 카더라...

 

그리고 여기서부터는 예측인데, GC 교체를 위해선 단순하게 Mono 런타임만 바꾸면 되는 게 아니고, 에디터-런타임 간 직렬화 시스템, 멀티 플랫폼 빌드 파이프라인, 기존 에셋 호환성 등 엔진 전반에 걸친 대규모 수정이 필요할 것으로 생각된다.

 

넥슨이 마비노기를 언리얼로 이식한다 했을 때 대부분의 사람들이 "그게 정말 가능할까?" 생각을 했던 만큼, 엔진을 대대적으로 수정하는 건 굉장히 매우 어려운 일이다. (물론 예시는 엔진을 아예 옮기는 거지만..)

 

다만 이제 그런 부담이 있으니 대규모 엔진 수정과 그에 대한 QA를 진행하는 데에 있어서 기술적, 인력적 부담이 크지 않았을까 생각된다.

 

아무튼, .NET Core로 완전히 바꾼다 얘기 했으니 이제 기다리기만 하면 되지 않을까.. 6에도 적용되지 않은게 조금 아쉽지만..