LYSC STUDIO

LIST
Cover

C# 가비지 컬렉션(GC) 스파이크를 없애는 오브젝트 풀링 패턴

·Unity Optimization

유니티 게임 개발에서 가장 흔하게 마주하는 성능 저하 원인 중 하나는 바로 C# 가비지 컬렉션(GC)에 의한 프레임 스파이크입니다. 빈번한 메모리 할당과 해제를 방지하고 런타임 성능의 안정성을 확보하기 위한 진보된 오브젝트 풀링 패턴과 메모리 최적화 전략을 상세히 알아봅니다.

1. 가비지 컬렉터(GC) 스파이크의 실체와 프레임 드랍

유니티는 C#을 스크립팅 언어로 사용하며, C#은 개발자 대신 메모리를 관리해 주는 가비지 컬렉터(Garbage Collector, GC)를 내장하고 있습니다. GC는 힙(Heap) 메모리 영역을 스캔하여 더 이상 참조되지 않는 더미 객체(가비지)들을 찾아내고 메모리를 회수합니다. 이 자동화된 메모리 관리는 개발 생산성을 높여주지만, 실시간 렌더링이 필수인 게임에서는 치명적인 약점이 됩니다. GC가 동작하는 순간 메인 스레드가 멈추는 'Stop-the-world' 현상이 발생하기 때문입니다.

게임 플레이 도중 매 프레임 수십 개의 총알 객체를 `new` 키워드로 생성하고 파괴(Destroy)한다면, 힙 메모리에는 쓰레기가 급격히 쌓이게 됩니다. 임계치에 도달하면 유니티는 프레임 연산을 멈추고 강제로 GC를 호출하며, 이로 인해 게임이 0.1초~0.5초간 뚝 끊기는 'GC 스파이크'가 발생합니다. 액션 게임이나 리듬 게임에서 이런 끊김은 유저 경험을 바닥으로 쳐박는 최악의 버그와도 같습니다. 따라서 런타임 중에는 힙 메모리 할당(Allocation)을 0으로 만드는 것을 목표로 삼아야 합니다.

2. 오브젝트 풀링(Object Pooling)의 기본 철학

런타임 메모리 할당을 제거하기 위한 가장 정석적이고 강력한 패턴이 바로 '오브젝트 풀링(Object Pooling)'입니다. 개념은 매우 단순합니다. 게임이 시작될 때(로딩 타임) 미리 사용할 오브젝트들을 한꺼번에 잔뜩 생성해 두고 수영장(Pool)처럼 모아둡니다. 그리고 게임 플레이 도중 오브젝트가 필요해지면 새로 생성(Instantiate)하는 대신 풀에서 꺼내 활성화(SetActive(true))하여 사용합니다. 사용이 끝난 오브젝트는 파괴(Destroy)하는 대신 풀로 반환하고 비활성화(SetActive(false)) 상태로 만듭니다.

이 방식을 사용하면 인게임 런타임 도중에는 객체의 생성과 소멸이 단 한 번도 일어나지 않으므로, GC가 수거할 가비지 자체가 발생하지 않습니다. 메모리를 미리 점유한다는 단점이 있지만, 모바일 기기도 기가바이트 단위의 RAM을 탑재하는 현대 환경에서는 메모리 용량보다 CPU의 연산 일관성(프레임 방어)을 확보하는 것이 백 번 천 번 유리한 교환비입니다.

3. 진보된 커스텀 오브젝트 풀 아키텍처 설계

단순한 List나 Queue를 사용한 오브젝트 풀링은 초보적인 수준입니다. 실무에서 쓰이는 튼튼한 풀링 아키텍처는 제네릭(Generic)과 팩토리 패턴을 결합하여 다양한 프리팹을 유연하게 관리할 수 있어야 합니다. 유니티 2021 버전부터는 `UnityEngine.Pool` 네임스페이스를 통해 내장된 ObjectPool API를 제공하고 있습니다. 이를 활용하면 코드를 훨씬 간결하고 안전하게 작성할 수 있습니다.

내장 풀을 사용할 때는 오브젝트가 생성될 때(OnCreate), 풀에서 꺼내질 때(OnGet), 풀로 반환될 때(OnRelease), 풀이 가득 차 파괴될 때(OnDestroy)의 델리게이트를 명확히 정의해야 합니다. 특히 반환될 때 오브젝트의 속도(Velocity), 체력, 트레일 렌더러 등을 초기 상태로 완벽히 리셋하는 작업이 매우 중요합니다. 리셋을 빼먹으면 이전에 쐈던 총알의 이펙트가 새로 쏜 총알에 남아있는 끔찍한 버그를 초래하게 됩니다.

4. 풀링을 넘어선 메모리 최적화: Struct와 박싱(Boxing) 회피

오브젝트 풀링으로 프리팹 생성 스파이크를 잡았다 하더라도, 코드 내부의 사소한 습관들이 여전히 힙 가비지를 생성할 수 있습니다. 대표적인 것이 바로 클래스(Class)와 구조체(Struct)의 차이를 간과하는 것입니다. 클래스는 참조 타입(Reference Type)으로 무조건 힙에 할당되어 GC의 대상이 됩니다. 반면 구조체는 값 타입(Value Type)으로 스택(Stack) 메모리에 할당되었다가 함수가 끝나면 즉시 소멸되므로 GC에 영향을 주지 않습니다. 수식 계산이나 임시 데이터 저장에는 반드시 Struct를 사용해야 합니다.

또한 박싱(Boxing)과 언박싱(Unboxing)을 경계해야 합니다. 값 타입인 int나 float를 object 타입으로 변환하거나, 인터페이스로 캐스팅하는 과정에서 묵시적으로 힙 메모리 할당이 발생(박싱)합니다. 문자열 조합 또한 매우 위험합니다. `string result = "Score: " + score.ToString();` 같은 코드는 매 프레임 호출될 경우 엄청난 가비지를 생성하므로, StringBuilder를 사용하거나 UI 갱신을 변동이 있을 때만 하도록 옵저버 패턴(이벤트)으로 개선해야 합니다.

5. 프로파일러를 통한 가비지 추적과 최적화의 생활화

결국 메모리 누수와 GC 스파이크를 완벽히 통제하려면 유니티 프로파일러(Profiler)와 친구가 되어야 합니다. CPU 프로파일러에서 'GC Alloc' 컬럼을 켜고 게임을 실행해 보면, 매 프레임 어느 스크립트의 몇 번째 줄에서 얼마나 많은 바이트의 메모리가 힙에 쓰레기로 버려지고 있는지 적나라하게 확인할 수 있습니다. 심층 프로파일링(Deep Profiling) 기능을 켜면 콜스택 깊숙한 곳의 범인까지 색출해 냅니다.

LINQ의 무분별한 사용, foreach 루프 내부에서의 클로저(Closure) 발생, GetComponent의 매 프레임 호출 등은 프로파일러가 가장 흔하게 잡아내는 주범들입니다. 클라이언트 프로그래머는 코드를 짤 때 단순히 기능이 동작하는 것에 만족해선 안 됩니다. 메모리가 할당되는 내부 동작 원리를 머릿속에 그리며 작성해야 합니다. 오브젝트 풀링과 메모리 최적화는 게임의 질감을 완전히 다르게 만드는 가장 실전적인 '장인정신'입니다.

Performance & FPS Simulator
Current FPS
60.0
Implementation C# / Unity
// [C#] UnityEngine.Pool을 활용한 안전한 총알 오브젝트 풀링 구현
using UnityEngine;
using UnityEngine.Pool;

public class BulletPoolManager : MonoBehaviour
{
    [SerializeField] private Bullet _bulletPrefab;
    private IObjectPool _pool;

    void Awake()
    {
        // 풀 초기화: 생성, 획득, 반환, 파괴 시의 콜백 정의
        _pool = new ObjectPool(
            createFunc: () => Instantiate(_bulletPrefab),
            actionOnGet: (bullet) => { 
                bullet.gameObject.SetActive(true);
                bullet.ResetState(); // 중요: 상태 리셋
            },
            actionOnRelease: (bullet) => bullet.gameObject.SetActive(false),
            actionOnDestroy: (bullet) => Destroy(bullet.gameObject),
            collectionCheck: true,  // 이미 반환된 객체의 중복 반환 방지 검사
            defaultCapacity: 50,
            maxSize: 200
        );
    }

    public Bullet SpawnBullet(Vector3 position, Quaternion rotation)
    {
        Bullet b = _pool.Get();
        b.transform.SetPositionAndRotation(position, rotation);
        b.Init(this); // 풀 반환을 위해 매니저 참조 전달
        return b;
    }

    public void ReturnBullet(Bullet bullet)
    {
        _pool.Release(bullet);
    }
}

GC 스파이크는 쾌적한 게임플레이를 방해하는 가장 고질적이고 짜증나는 요인입니다. 많은 유저들이 '최적화가 엉망이다'라고 평가하는 게임들의 상당수가 렌더링 병목이 아닌 C# 메모리 관리 실패로 인한 프레임 튀김 현상을 겪고 있습니다.

오브젝트 풀링 패턴은 선택이 아닌 필수입니다. 유니티가 제공하는 최신 ObjectPool API를 적극 도입하고, 코드 전반에 걸쳐 힙 메모리 할당을 극도로 억제하는 습관을 들여야 합니다. Struct의 활용, 박싱 회피, 문자열 연산 최적화는 기본 소양입니다.

완벽하게 통제된 메모리 환경 속에서 단 한 번의 프레임 드랍 없이 매끄럽게 돌아가는 게임을 완성했을 때의 성취감은 이루 말할 수 없습니다. 프로파일러의 'GC Alloc' 수치를 0으로 만드는 그날까지, 메모리와의 치열한 사투를 벌이는 개발자들을 응원합니다.