유니티(Unity) 가비지 컬렉션(GC) 스파이크 방지와 메모리 풀링(Object Pooling) 구현 가이드

유니티(Unity) 가비지 컬렉션(GC) 스파이크 방지와 메모리 풀링(Object Pooling) 구현 가이드

모바일 및 PC 게임 개발에서 부드러운 60fps 또는 120fps 프레임을 유지하는 것은 사용자 경험(UX)과 직결되는 필수 엔지니어링 과제입니다. 하지만 게임 플레이 도중 총알이 대량으로 발사되거나 화려한 파티클 이펙트가 터지는 순간 화면이 순간적으로 뚝뚝 끊기는 스터터링(Stuttering, 버벅임) 현상을 자주 경험하게 됩니다. 이러한 프레임 드랍의 주범은 바로 가비지 컬렉터(Garbage Collector)의 일시 정지(Stop-The-World) 현상'GC 스파이크(GC Spike)'입니다. 유니티 C# 환경에서 힙 메모리 할당을 최소화하고, 게임 엔진의 성능을 극한으로 끌어올리기 위한 가비지 컬렉션의 동작 메커니즘과 필수 안티패턴 해결책, 그리고 고성능 메모리 풀링(Object Pooling)의 실전 구현 기법을 상세히 다룹니다.

게임 개발 및 성능 최적화 프로그래밍 환경
▲ 유니티 게임 런타임 메모리 관리 및 프레임 레이트 최적화

1. 유니티 힙(Heap) 메모리와 GC 스파이크의 발생 원인

유니티의 스크립팅 런타임(Mono 또는 IL2CPP)은 관리형 메모리(Managed Memory)를 사용합니다. 값 형식(Value Type, 구조체나 기본 데이터형)은 주로 스택(Stack)에 할당되어 스코프를 벗어나면 즉시 해제되지만, 참조 형식(Reference Type, 클래스 인스턴스, 배열, 문자열 등)은 관리형 힙(Managed Heap)에 할당됩니다.

1.1 Boehm-Demers-Weiser GC와 세대(Generation)가 없는 유니티의 한계

일반적인 .NET 런타임은 세대별(Generational) GC를 사용하여 단명하는 객체를 빠르게 수거하지만, 전통적인 유니티의 Boehm GC는 세대 구분이 없는 비압축(Non-compacting) 마크 앤 스윕(Mark & Sweep) 방식으로 동작합니다.

  • Stop-The-World: 가비지 컬렉터가 실행될 때 모든 게임 스레드의 실행을 일시적으로 중단하고 살아있는 모든 객체를 순회(Mark)한 뒤 미참조 메모리를 해제(Sweep)합니다. 이 과정에서 프레임 타임이 급증하며 화면이 멈춥니다.
  • 메모리 단편화(Memory Fragmentation): 유니티 힙은 메모리를 압축(Compaction)하여 빈 공간을 모으지 않기 때문에, 작은 메모리 할당과 해제가 반복되면 힙 사이사이에 빈 구멍(Pothole)이 생깁니다. 큰 연속 메모리를 요구할 때 빈 공간이 있어도 들어가지 못해 힙 크기 자체가 계속해서 비가역적으로 팽창(Heap Expansion)하게 됩니다.
  • 점진적 가비지 수집(Incremental GC): 최신 유니티 버전에서는 GC 작업을 여러 프레임으로 분할하는 기능이 도입되었으나, 근본적인 GC Alloc(힙 할당) 자체를 없애지 않으면 여전히 오버헤드가 누적됩니다.

2. C# 코딩에서 흔히 발생하는 GC 유발 안티패턴과 개선법

매 프레임 호출되는 Update() 함수나 이벤트 핸들러 내부에서 무심코 작성한 코드가 치명적인 GC Alloc을 유발합니다. 실무에서 반드시 수정해야 할 핵심 안티패턴들을 정리합니다.

구분 / 기능 GC 유발 안티패턴 (지양) 최적화 패턴 (권장)
문자열(String) 조작 text.text = "Score: " + score;
(매 프레임 임시 문자열 힙 생성)
StringBuilder 재사용 또는 ZString 라이브러리 활용
코루틴(Coroutine) 대기 yield return new WaitForSeconds(0.1f);
(매 루프마다 YieldInstruction 객체 생성)
미리 생성한 인스턴스를 멤버 변수에 캐싱하여 재사용
물리 쿼리(Physics) Physics.RaycastAll(...)
(반환 시마다 RaycastHit[] 배열 새로 할당)
Physics.RaycastNonAlloc(..., results) 사용으로 배열 재사용
LINQ 및 람다 클로저 list.Where(x => x.hp > 0).ToList();
(내부 열거자 할당 및 클로저 클래스 생성)
전통적인 for 루프 직접 순회 및 람다 캡처 배제
박싱(Boxing) Dictionary<MyEnum, int> (구버전)
또는 Debug.Log($"Val: {intVal}")
제네릭 인터페이스(IEquatable<T>) 구현 및 구조체 박싱 차단

3. 고성능 오브젝트 풀링(Object Pooling) 아키텍처 및 C# 구현

게임에서 총알, 이펙트, 몬스터, UI 항목처럼 빈번하게 나타났다 사라지는 객체를 매번 Instantiate()Destroy()로 처리하면 네이티브 C++ 엔진 계층과 관리형 힙 양쪽 모두에 극심한 부하를 줍니다. 오브젝트 풀링(Object Pooling)은 객체를 미리 정해진 수량만큼 생성해 두고, 필요할 때 활성화(Active)하여 사용한 뒤 반환(Inactive)하여 재활용하는 디자인 패턴입니다.

게임 프로그래밍 및 알고리즘 최적화 그래픽
▲ 오브젝트 풀링을 통한 메모리 재사용과 가비지 컬렉션 할당 억제

3.1 Unity 2021+ 공식 내장 `UnityEngine.Pool.ObjectPool<T>` 활용

과거에는 Queue<GameObject>List<GameObject>를 직접 구현했으나, Unity 2021부터는 엔진 공식 네임스페이스에 고도로 최적화된 ObjectPool<T> 제네릭 클래스가 제공됩니다.

using UnityEngine;
using UnityEngine.Pool;


public class Bullet : MonoBehaviour
{
    private IObjectPool<Bullet> pool;
    private float lifeTime = 2.0f;
    private float currentTimer;


    public void SetPool(IObjectPool<Bullet> targetPool)
    {
        pool = targetPool;
    }


    private void OnEnable()
    {
        currentTimer = 0f;
    }


    private void Update()
    {
        // 총알 이동 로직
        transform.Translate(Vector3.forward * (20f * Time.deltaTime));


        currentTimer += Time.deltaTime;
        if (currentTimer >= lifeTime)
        {
            // Destroy 대신 풀에 반환 (Release)
            pool.Release(this);
        }
    }
}

3.2 풀 매니저(Pool Manager) 구현 가이드

아래는 ObjectPool<T>를 관리하는 풀 발사기 스크립트의 전체 구현 예시입니다.

using UnityEngine;
using UnityEngine.Pool;


public class BulletSpawner : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private int defaultCapacity = 50;
    [SerializeField] private int maxPoolSize = 200;


    private IObjectPool<Bullet> bulletPool;


    private void Awake()
    {
        bulletPool = new ObjectPool<Bullet>(
            createFunc: CreateBullet,
            actionOnGet: OnGetBullet,
            actionOnRelease: OnReleaseBullet,
            actionOnDestroy: OnDestroyBullet,
            collectionCheck: true, // 중복 반환 검사
            defaultCapacity: defaultCapacity,
            maxSize: maxPoolSize
        );
    }


    private Bullet CreateBullet()
    {
        Bullet bullet = Instantiate(bulletPrefab, transform);
        bullet.SetPool(bulletPool);
        return bullet;
    }


    private void OnGetBullet(Bullet bullet)
    {
        bullet.gameObject.SetActive(true);
    }


    private void OnReleaseBullet(Bullet bullet)
    {
        bullet.gameObject.SetActive(false);
    }


    private void OnDestroyBullet(Bullet bullet)
    {
        Destroy(bullet.gameObject);
    }


    public void Shoot(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = bulletPool.Get();
        bullet.transform.position = position;
        bullet.transform.rotation = rotation;
    }
}

4. 유니티 프로파일러(Profiler)를 통한 GC Alloc 추적 및 검증법

메모리 최적화의 완성은 추측이 아닌 정밀한 측정에서 시작됩니다. 유니티 에디터 상단 메뉴의 Window > Analysis > Profiler(단축키 Ctrl + 7)를 실행하여 실시간 할당을 분석합니다.

  1. CPU Usage 모듈 활용: 뷰 모드를 Hierarchy로 변경한 후 GC Alloc 컬럼 헤더를 클릭하여 내림차순으로 정렬합니다. 매 프레임 단 1Byte라도 GC Alloc이 발생하는 스크립트를 즉시 식별할 수 있습니다.
  2. Deep Profile 활성화: 일반 프로파일링에서 추적되지 않는 내부 중첩 메서드나 프로퍼티 호출까지 상세히 추적하여 어느 라인에서 힙 할당이 발생하는지 확인합니다. (주의: 에디터 프레임 레이트가 일시적으로 저하될 수 있으므로 병목 확인 후 비활성화 권장)
  3. Memory Profiler 패키지 설치: Package Manager를 통해 Memory Profiler 패키지를 설치하면, 힙 덤프 스냅샷을 찍어 어떤 클래스 인스턴스가 메모리를 점유하고 있고 단편화가 얼마나 진행되었는지 시각적으로 분석할 수 있습니다.

5. 결론 및 유니티 메모리 최적화 5대 실무 수칙

가비지 컬렉션 스파이크는 게임의 몰입감을 해치고 스토어 리뷰 평점을 깎아먹는 대표적인 원인입니다. 안정적인 상용 게임 서비스를 위해 다음 5가지 수칙을 개발 가이드라인으로 삼으시길 권장합니다.

  • 반복 생성 객체 풀링 필수: 투사체, 이펙트, 데미지 폰트 등 1회성 객체는 100% ObjectPool<T>로 전환합니다.
  • 런타임 Instantiate/Destroy 금지: 인게임 플레이 도중 씬 로딩 이외의 동적 생성 및 파괴를 엄격히 제한합니다.
  • Update() 내부 Alloc 제로화: 매 프레임 실행되는 루프에서 new 키워드, 문자열 연결, 임시 배열 생성을 완전히 제거합니다.
  • NonAlloc 물리 API 표준화: RaycastAll 대신 RaycastNonAlloc, OverlapSphereNonAlloc을 전면 도입합니다.
  • 빌드 타겟 실기기 프로파일링: 에디터뿐만 아니라 실제 안드로이드(Android) 및 iOS 디바이스를 연결하여 모바일 환경의 GC 오버헤드를 정기적으로 측정합니다.

Post a Comment

다음 이전