LYSC STUDIO

LIST
Cover

주제 20: C++ 언리얼 엔진 쓰레기 수집가(UObject GC) 이해와 메모리 릭(Leak) 방지 (UE5 Engine)

·Game Design

일반적인 C++ 프로그래밍은 개발자에게 메모리 할당과 해제(new, delete)의 완벽한 통제권을 줍니다. 이는 극강의 성능을 낼 수 있지만, 메모리 누수(Memory Leak)나 허상 포인터(Dangling Pointe

C++ 언리얼 엔진 쓰레기 수집가(UObject GC) 이해와 메모리 릭(Leak) 방지

1. 서론: C++의 강력함과 언리얼 엔진의 타협

일반적인 C++ 프로그래밍은 개발자에게 메모리 할당과 해제(new, delete)의 완벽한 통제권을 줍니다. 이는 극강의 성능을 낼 수 있지만, 메모리 누수(Memory Leak)나 허상 포인터(Dangling Pointer)로 인한 치명적인 크래시를 유발하기 쉽습니다. 반면, 에픽게임즈(Epic Games)의 언리얼 엔진(Unreal Engine 5)은 대규모 게임 개발의 생산성과 안정성을 위해 C++ 위에 자체적인 가비지 컬렉션(Garbage Collection, GC) 시스템을 구축했습니다.

이 독특한 하이브리드 구조 때문에, 언리얼 프로그래머들은 순수 C++의 메모리 관리 지식뿐만 아니라, 엔진의 근간을 이루는 UObject 생태계와 GC의 동작 원리를 정확히 이해해야 메모리 릭의 지옥에서 벗어날 수 있습니다.

2. 언리얼 GC의 핵심 원리: Mark and Sweep

언리얼 엔진의 가비지 컬렉터는 기본적으로 'Mark and Sweep (표시하고 쓸어 담기)' 알고리즘을 사용합니다. 특정 주기(일반적으로 수십 초 또는 레벨 전환 시)마다 GC가 발동하여 다음의 과정을 거칩니다.

  1. Root Set(루트 셋)에서 출발: 절대로 삭제되면 안 되는 핵심 오브젝트들(예: 레벨, 게임 인스턴스, AddToRoot()가 호출된 오브젝트)의 목록인 루트 셋을 확인합니다.
  2. Mark (도달 가능성 탐색): 루트 셋에 있는 오브젝트들이 참조하고 있는(포인터로 연결된) 다른 UObject들을 재귀적으로 순회하며 '도달 가능함(Reachable)' 마크를 남깁니다. (이 때 언리얼의 리플렉션 시스템을 통해 멤버 변수들을 탐색합니다.)
  3. Sweep (메모리 해제): 메모리에 존재하는 모든 UObject 중에서 Reachable 마크가 없는 오브젝트들은 가비지(Garbage)로 간주하고 메모리에서 강제로 파괴(Destroy)합니다.

3. 메모리 릭을 유발하는 치명적인 실수들

언리얼에서 메모리 누수가 발생하거나 예기치 않은 크래시가 발생하는 가장 대표적인 안티 패턴들을 분석합니다.

3.1. UPROPERTY() 누락

클래스의 멤버 변수로 UObject* 포인터를 선언해놓고 매크로 UPROPERTY()를 붙이지 않는 실수가 가장 흔합니다. UPROPERTY()가 없으면 언리얼의 리플렉션 시스템이 해당 포인터를 인식하지 못해, GC의 Mark 단계에서 참조 관계가 끊어집니다. 그 결과, 게임 로직에서는 여전히 해당 오브젝트를 사용 중임에도 불구하고 GC가 이를 맘대로 메모리에서 날려버려 치명적인 Access Violation 크래시가 발생합니다.

3.2. 영원히 죽지 않는 강한 참조(Strong Reference)

반대로, 더 이상 필요 없는 오브젝트임에도 어딘가에서 UPROPERTY()로 참조를 잡고 있다면 이 오브젝트는 영원히 메모리에서 해제되지 않는 '진정한 의미의 메모리 릭'을 발생시킵니다. 대표적인 예가 서로를 참조하는 순환 참조(Circular Reference)입니다. A가 B를 가리키고 B가 A를 가리키면 둘 다 루트 셋에서 연결이 끊어져도 서로 살려두어 누수가 발생합니다.

3.3. 델리게이트(Delegate)와 타이머(Timer)의 잔재

이벤트 드리븐 구조에서 많이 사용하는 멀티캐스트 델리게이트나 타이머 핸들을 주의해야 합니다. 특정 액터가 델리게이트에 바인딩(Bind)을 한 후, 액터가 파괴될 때 바인딩을 해제(Unbind 또는 Remove)하지 않으면, 엔진 내부의 델리게이트 매니저가 죽은 액터의 메모리 주소를 여전히 쥐고 있게 되어 심각한 릭이나 널 포인터 크래시를 유발합니다.

4. 안전한 메모리 관리를 위한 실무 가이드라인

4.1. 약한 참조(Weak Reference)의 생활화

오브젝트의 생명 주기를 내가 직접 소유(Own)하고 관리할 것이 아니라, 단순히 '관찰(Observe)'만 해야 한다면 반드시 TWeakObjectPtr<T>를 사용해야 합니다. 약한 포인터는 GC의 Mark 단계에 영향을 주지 않으므로 해당 오브젝트가 자연스럽게 파괴될 수 있도록 놔둡니다. 사용할 때는 항상 IsValid() 체크를 통해 오브젝트가 아직 살아있는지 검증한 후 안전하게 접근할 수 있습니다.

4.2. UObject가 아닌 순수 C++ 객체의 관리

모든 구조체를 UObject로 만들 필요는 없습니다. UObject 상속 구조는 무겁습니다. 순수 C++ 클래스나 구조체를 할당할 때는 엔진이 제공하는 스마트 포인터 시스템인 TSharedPtr, TSharedRef, TUniquePtr를 적극적으로 활용해야 합니다. 이는 표준 C++11의 스마트 포인터와 유사하게 레퍼런스 카운팅 방식으로 작동하여 리플렉션 오버헤드 없이 안전한 메모리 자동 해제를 보장합니다.

5. 프로파일링: 릭 추적하기

메모리 릭이 의심될 때는 감으로 코드를 수정하지 말고 프로파일러를 켜야 합니다. 콘솔 명령어 Obj List를 통해 현재 메모리에 올라온 특정 클래스의 인스턴스 개수를 확인하고, 비정상적으로 수가 많다면 Obj Refs Name="오브젝트이름" 명령어를 통해 도대체 어떤 녀석이 이 오브젝트를 강제로 잡고(Reachable) 있는지 참조 트리를 추적해야 합니다. 최근에는 Unreal Insights 툴의 Memory Profiling 모듈을 통해 시각적이고 직관적으로 메모리 릭의 진원지를 찾아낼 수 있습니다.

6. 결론

언리얼 엔진의 가비지 컬렉터는 마법의 지팡이가 아닙니다. UObject 생명 주기에 대한 정확한 이해, 적재적소에 TWeakObjectPtr와 스마트 포인터를 배치하는 설계 능력, 그리고 델리게이트 해제를 잊지 않는 꼼꼼함이 결합될 때 비로소 장시간 구동에도 프레임 저하 없는 안정적인 AAA 게임 서버/클라이언트를 완성할 수 있습니다.

Core Loop Logic Viewer
ACTION
REWARD
UPGRADE
Awaiting Input...
이 포스트가 인디 게임 개발자 분들의 프로젝트 최적화 및 기획에 큰 도움이 되길 바랍니다.