빈번한 객체 생성/파괴가 유발하는 Garbage Collection 스파이크를 원천 차단하기 위한 커스텀 오브젝트 풀링 설계와, 이를 C# Job System과 연동하여 멀티스레딩 성능을 극대화하는 방법을 다룹니다.
도입 및 개요
유니티 프로그래밍을 편안하게 만들어주는 C#의 가비지 컬렉터(Garbage Collector, GC)는 양날의 검입니다. 매니지드(Managed) 환경에서는 메모리 할당과 해제를 시스템이 알아서 관리해주기 때문에 개발 생산성이 크게 높아집니다. 하지만 60FPS나 120FPS를 안정적으로 유지해야 하는 실시간 액션 게임이나 리듬 게임에서 GC는 언제 터질지 모르는 시한폭탄과 같습니다. 게임 플레이 도중 Instantiate()를 통해 적 객체, 총알 파티클, 데미지 텍스트 등을 무수히 생성하고, 화면 밖으로 나갔을 때 Destroy()로 파괴하는 행위를 반복하면, C# 힙(Heap) 메모리에는 더 이상 사용되지 않지만 해제되지는 않은 '가비지(Garbage)' 데이터들이 차곡차곡 쌓이게 됩니다. 메모리가 일정 한계치에 도달하면 유니티는 메인 스레드의 모든 실행을 강제로 멈추고(Stop-The-World) 쓰레기를 청소하는 가비지 컬렉션을 수행합니다. 이 순간 10~50ms 이상의 멈춤 현상, 즉 프레임 스파이크(Spike)가 발생하며 플레이어는 심각한 렉(Lag)을 경험하게 됩니다.
이러한 GC 스파이크를 원천 차단하는 가장 고전적이면서도 확실한 디자인 패턴이 바로 '오브젝트 풀링(Object Pooling)'입니다. 오브젝트 풀링의 기본 철학은 '재활용'입니다. 게임이 로딩되는 씬 초기화 단계에서 미리 필요한 개수만큼의 총알이나 적 오브젝트를 Instantiate()하여 비활성화(SetActive(false)) 상태로 큐(Queue)나 리스트(List) 자료구조에 담아둡니다(풀을 채웁니다). 게임 플레이 중 객체가 필요해지면 새로 생성하는 것이 아니라 풀에서 하나를 꺼내어 위치를 초기화하고 활성화하여 사용합니다. 수명이 다한 객체는 Destroy()하지 않고 다시 비활성화한 뒤 풀에 반환합니다. 이렇게 하면 게임 루프(런타임) 도중에는 단 한 건의 동적 메모리 할당이나 해제도 발생하지 않으므로 가비지가 생성되지 않으며, GC가 개입할 여지조차 사라져 프레임 레이트가 콘크리트처럼 단단하게 고정됩니다. 최신 유니티 버전에서는 UnityEngine.Pool 네임스페이스를 통해 ObjectPool<T> 클래스를 기본 내장 API로 제공하므로, 개발자가 커스텀 풀을 바닥부터 작성할 필요 없이 매우 안전하고 최적화된 풀링을 쉽게 구현할 수 있습니다.
하지만 진정한 하드코어 최적화는 여기서 한 걸음 더 나아가 오브젝트 풀링과 C# Job System을 결합할 때 비로소 완성됩니다. 수천 개의 총알을 풀링하여 생성 비용을 없앴다고 하더라도, 매 Update마다 수천 개의 총알이 자신의 위치를 갱신하기 위해 메인 스레드에서 무거운 연산을 수행한다면 여전히 CPU 바운드 병목을 피할 수 없습니다. 이때 C# Job System과 Burst Compiler를 도입하면 상황이 역전됩니다. 풀에서 꺼낸 오브젝트들의 위치, 속도, 수명 데이터를 NativeArray와 같은 언매니지드(Unmanaged) 네이티브 컬렉션으로 동기화합니다. 그런 다음 위치를 갱신하고 충돌 범위를 계산하는 수학적 연산을 IJobParallelFor 인터페이스를 구현한 구조체(Job)에 위임합니다.
이 Job을 워커 스레드(Worker Threads)에 스케줄링(Schedule)하면, 멀티코어 CPU의 모든 코어가 동시에 활성화되어 수천 개의 객체 연산을 완벽히 병렬로 분산 처리합니다. 메인 스레드(MonoBehaviour Update)는 단지 Job이 완료되기를 기다렸다가 최종 결과 위치만 Transform에 적용해주면 됩니다. 특히 Burst Compiler가 적용된 Job은 C# 코드를 고도로 최적화된 어셈블리어 기계 코드로 변환하고, SIMD(Single Instruction Multiple Data) 명령어를 사용하여 한 번의 CPU 사이클에 여러 개의 데이터를 동시에 연산해냅니다. 결과적으로 객체 풀링으로 메모리 할당 오버헤드를 0으로 만들고, Job System으로 CPU 연산 오버헤드를 메인 스레드에서 완전히 분리해내는 궁극의 아키텍처가 탄생하게 됩니다. 이는 모바일 디바이스의 배터리 소모와 발열을 드라마틱하게 줄여주는 최고의 엔지니어링 접근법입니다.
using UnityEngine;
using UnityEngine.Pool;
public class BulletPoolManager : MonoBehaviour
{
[SerializeField] private GameObject bulletPrefab;
private ObjectPool pool;
void Start()
{
pool = new ObjectPool(
createFunc: () => Instantiate(bulletPrefab),
actionOnGet: obj => obj.SetActive(true),
actionOnRelease: obj => obj.SetActive(false),
actionOnDestroy: obj => Destroy(obj),
collectionCheck: true,
defaultCapacity: 100,
maxSize: 1000
);
}
public void FireBullet(Vector3 position)
{
GameObject bullet = pool.Get();
bullet.transform.position = position;
}
}