동적으로 할당한 메모리를 자동으로 해제하여 메모리 누수 등의 문제에서 자유로워질 수 있는 GC(Garbage Collector)라는 메모리 관리 기술은 개발에서 매우 유용하다.
수많은 오브젝트의 상호작용으로 만들어지는 게임에서 이러한 메모리 관리는 더더욱 빛을 발하는데, 때문에 상용화된 엔진 대부분은 고유의 GC 기술을 내장하고 있다. C# 스크립팅을 사용하는 Unity 엔진은 .NET의 엔진을 더 가공한 형태의 GC를, 또다른 대표적인 게임 엔진 Unreal도 C++에서는 지원하지 않았던 GC 기술을 추가하였다.
이번 포스팅에선 과연 게임 엔진에서 사용하는 GC는 어떻게 작동하며, 대표적인 두 엔진의 GC는 무슨 특징이 있을지 알아볼 것이다.
기본적으로 Unity의 GC는 C#의 GC를 기반으로 Mark and sweep 알고리즘을 활용한 가비지 컬렉션을 수행한다. 메모리 할당이 필요할 때 힙에 충분한 공간이 없으면 GC가 실행되는데, 이 GC에 대한 작업을 수행하는 프로세스를 선택할 수 있다.
점진적 가비지 컬렉션
오버헤드가 큰 가비지 컬렉션 작업을 여러 프레임에 분할하여 수행한다. 이를 통해 게임이 잠시 멈춘 상태에서 가비지 컬렉션을 수행하는 stop-to-world 방식의 가비지 컬렉션에서 벗어날 수 있다.
이 방식은 Unity의 기본 GC 설정이며, [Edit
- Project Settings
- Player
- Configuration
]의 Use Incremental GC
필드를 해제하여 비점진적 가비지 컬렉션(stop-to-world)으로 전환할 수 있다.
점진적 가비지 컬렉션을 사용하면 게임 도중 전체적인 프레임이 안정화되지만, 오브젝트의 레퍼런스가 수시로 변경되면 엔진에선 삭제할 오브젝트를 검사하는 마킹 단계를 다시 수행해야 하므로 문제를 초래할 수 있다. 작업 조각 사이에 레퍼런스의 변동이 크지 않은 경우 이 가비지 컬렉션 방식이 유용하다.
비점진적 가비지 컬렉션
프레임을 분할하지 않고 연속으로 가비지 컬렉션 작업을 끝내는 기본적인 가비지 컬렉션 방식이다. GC가 힙을 검사하기 위해 어플리케이션의 실행을 일시중지한다.
자동 가비지 컬렉션 비활성화
C# 스크립트에서
GarbageCollector.GCMode = GarbageCollector.Mode.Manual;
와 같은 코드를 추가하여 자동 가비지 컬렉션을 비활성화하고 System.GC.Collect()
함수로 직접 Full GC를 해줄 수 있다. 다양한 가비지 컬렉션 모드에 대해선 이 문서를 참고하자.
UE의 GC는 기본적으로 Mark and sweep 알고리즘을 사용한다고 밝혀져 있다. 그와 함께 Modern C++의 스마트 포인터를 사용할 수 있어 필요한 경우 직접 메모리 관리에 참여할 수 있다.
리플렉션
또한 리플렉션이 있는 오브젝트만이 GC의 영향을 받는데, 기본 타입 변수 포인터를 만들고 동적으로 할당하는 부분은 GC가 책임지지 않는다. 이때 활용할 수 있는 것이 스마트 포인터이고, 네이티브한 부분에선 스마트 포인터로, UObject에 대해선 GC로 메모리 관리를 한다고 할 수 있다.
만약 UPROPERTY
매크로를 활용하지 않은 일반 변수를 GC 아래에 두고 싶다면 액터와 같은 UObject에 변수를 선언하면 된다. 리플렉션을 가지는 해당 오브젝트가 GC에 의해 처리될 때 소유한 변수들을 함께 처리해준다.
수동 가비지 컬렉션
Unity의 System.GC.Collect()
와 같이 직접 GC를 실행할 수도 있다. 이때 내부 로직에선 GC에서 처리할 오브젝트가 아니라고 판단했지만 사용자가 직접 GC에 의해 수집되도록 오브젝트를 등록할 수 있다. UObject는 ConditionalBeginDestroy()
, Actor는 DestroyActor()
함수를 이용해 GC에 등록할 수 있다. 유의할 점은 이 함수들은 GC에 의해 수집되도록 등록하는 것 뿐 실제 GC를 호출하는 것은 아니다.
원하는 타이밍에 GC를 호출할 땐 World::ForceGarbageCollection(bool bFullPurge)
함수를 사용할 수 있다.
클러스터(Clustering)
UE에선 가비지 컬렉션의 단위를 오브젝트가 아닌 클러스터로 변경할 수 있다. 클러스터는 관련 있는 여러 오브젝트를 하나의 것으로 취급하는 개념으로, 각 오브젝트를 개별로 검사하고 처리하지 않고 클러스터에 포함된 요소를 조금이라도 사용하고 있는지를 검사하고 한 번에 처리하기 때문에 GC로 인한 메모리 교란(churn)이나 총 시간을 감소시킬 수 있다. 이 옵션은 키고 끌 수 있다.
클러스터 병합(Cluster Merge)라는 기능도 있는데, 기본적으로 클러스터는 단일 오브젝트를 기준으로 한다. 특정 오브젝트가 다른 오브젝트의 레퍼런스를 가지고 있더라도 참조하는 오브젝트에 기본으로 설정되는 클러스터를 변경하지 않는다. 하지만 클러스터 병합 옵션이 설정되면 레퍼런스를 가지고 있는 오브젝트를 해당 오브젝트의 클러스터로 병합시킨다. 이 경우 클러스터의 크기가 대체적으로 커질 수 있고, 그만큼 메모리에 많은 오브젝트가 남을 가능성이 커 일반적으로 효율적인 방법이 아니다. 하지만 단일 참조로 이루어진 오브젝트들을 관리할 경우가 많을 땐 성능을 향상시키는 데 도움을 줄 수 있다.
해시 테이블
GC에서 검사할 UObject의 주소를 해시 테이블로 관리하여 빠르고 편리하게 접근할 수 있는 시스템을 활용한다.
▶ [Unity Document] 가비지 컬렉터 개요
▶ [Unity Document] 자동 메모리 관리 이해
▶ [UE Document] 언리얼 오브젝트 처리
▶ [UE Document] 액터의 생명 주기
▶ [UE Forum] Garbage Collector Internals
▶ [UE4] Garbage Collector Overview
▶ [UE4] 언리얼 오브젝트의 기능