Object Pooling으로 프레임 드랍 줄이기

반복 생성과 삭제에서 발생하는 할당 비용과 가비지 컬렉션 부담을 줄이는 Object Pooling의 원리와 구현 방법을 정리합니다. Unity 예제를 중심으로 풀의 크기, 반납 규칙, 과도한 풀링을 피하는 기준까지 다룹니다.

✓ 복사 완료!
챕터프로파일링 & 병목 분석
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Object Pooling으로 프레임 드랍 줄이기

Object Pooling이 필요한 이유

게임에서는 총알, 피격 이펙트, 적, 피해 숫자처럼 짧은 시간에 여러 번 나타났다 사라지는 객체가 많습니다. 이런 객체를 필요할 때마다 생성하고 곧바로 파괴하면 메모리 할당과 초기화 비용이 누적됩니다. 일정 시점에는 가비지 컬렉션이 실행되어 프레임 시간이 갑자기 길어질 수 있습니다.

Object Pooling은 자주 재사용하는 객체를 미리 만들어 두고 필요할 때 꺼내 쓰고 사용이 끝나면 파괴하지 않고 다시 보관하는 기법입니다. 생성과 파괴의 횟수를 줄여 프레임 시간의 급격한 변동을 완화하는 것이 핵심입니다.

flowchart LR
    A[효과 요청] --> B{비활성 객체가 있는가?}
    B -- 예 --> C[풀에서 꺼내 활성화]
    B -- 아니오 --> D{확장 허용 여부}
    D -- 예 --> E[새 객체 생성 후 활성화]
    D -- 아니오 --> F[요청 무시 또는 가장 오래된 객체 재사용]
    C --> G[재생 또는 동작]
    E --> G
    G --> H[사용 종료]
    H --> I[비활성화 후 풀에 반납]

풀링은 평균 프레임 시간을 무조건 낮추는 만능 기법이라기보다 반복적인 할당과 해제로 생기는 순간적인 비용을 관리하는 방법입니다. 따라서 먼저 프로파일러로 생성 빈도와 메모리 할당을 확인하는 편이 좋습니다.

생성과 파괴가 만드는 비용

Instantiate와 Destroy는 단순히 C# 객체 하나를 만들고 지우는 작업보다 많은 일을 수행합니다. 프리팹의 컴포넌트를 초기화하고 활성 상태 변화에 따른 콜백을 호출하며 렌더링·물리·애니메이션 시스템에 등록하거나 해제할 수 있습니다. 이후 관리 메모리가 늘어나면 가비지 컬렉션이 실행될 가능성도 커집니다.

특히 다음 상황은 풀링 후보가 되기 쉽습니다.

  • 자동 무기에서 연사되는 투사체와 탄피
  • 반복 재생되는 폭발, 피격, 발자국 효과
  • 전투 중 짧게 표시되는 피해 숫자와 UI 알림
  • 짧은 주기로 등장하고 퇴장하는 다수의 적

투사체와 폭발 이펙트를 재사용하는 게임 오브젝트 풀의 동작 예시

반대로 한 스테이지에서 한두 번만 등장하는 보스나 무거운 연출 오브젝트까지 모두 풀에 넣을 필요는 없습니다. 사용 빈도가 낮은 객체를 미리 많이 만들어 두면 시작 로딩 시간과 상주 메모리만 늘어날 수 있습니다.

Unity에서 간단한 풀 만들기

Unity 2021 이상에서는 UnityEngine.Pool.ObjectPool<T>를 사용할 수 있습니다. 아래 예제는 GameObject를 생성하고 꺼내고 반납하는 기본 구조입니다.

using UnityEngine;
using UnityEngine.Pool;

public sealed class ProjectilePool : MonoBehaviour
{
    [SerializeField] private GameObject projectilePrefab;
    [SerializeField] private int defaultCapacity = 32;
    [SerializeField] private int maxSize = 128;

    private IObjectPool<GameObject> pool;

    private void Awake()
    {
        pool = new ObjectPool<GameObject>(
            CreateProjectile,
            OnGetProjectile,
            OnReleaseProjectile,
            OnDestroyProjectile,
            collectionCheck: true,
            defaultCapacity,
            maxSize);
    }

    public GameObject Spawn(Vector3 position, Quaternion rotation)
    {
        GameObject projectile = pool.Get();
        projectile.transform.SetPositionAndRotation(position, rotation);
        projectile.GetComponent<Projectile>().Initialize(pool);
        return projectile;
    }

    private GameObject CreateProjectile()
    {
        return Instantiate(projectilePrefab, transform);
    }

    private static void OnGetProjectile(GameObject projectile)
    {
        projectile.SetActive(true);
    }

    private static void OnReleaseProjectile(GameObject projectile)
    {
        projectile.SetActive(false);
    }

    private static void OnDestroyProjectile(GameObject projectile)
    {
        Destroy(projectile);
    }
}

ObjectPool<T>는 최대 크기에 도달한 뒤 반납된 객체를 보관하지 못하면 OnDestroy 콜백을 호출합니다. 즉, 풀을 쓰더라도 최대 크기와 과부하 상황의 동작을 명확히 정해야 합니다.

객체를 반드시 반납하는 구조 만들기

풀링에서 가장 흔한 문제는 객체를 꺼낸 뒤 반납하지 않는 것입니다. 시간이 지날수록 풀은 비어 있고 새 객체만 계속 만들어져 풀링 효과가 사라집니다. 객체의 수명 종료 지점에서 반납을 보장해야 합니다.

using UnityEngine;
using UnityEngine.Pool;

public sealed class Projectile : MonoBehaviour
{
    [SerializeField] private float lifeTime = 2f;

    private IObjectPool<GameObject> ownerPool;
    private float remainingLife;

    public void Initialize(IObjectPool<GameObject> pool)
    {
        ownerPool = pool;
        remainingLife = lifeTime;
    }

    private void Update()
    {
        remainingLife -= Time.deltaTime;

        if (remainingLife <= 0f)
        {
            ReturnToPool();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        ReturnToPool();
    }

    private void ReturnToPool()
    {
        if (ownerPool == null || !gameObject.activeSelf)
        {
            return;
        }

        ownerPool.Release(gameObject);
    }
}

OnTriggerEnter와 수명 만료가 같은 프레임에 모두 호출될 수 있으므로 중복 반납을 막는 방어 코드가 필요합니다. collectionCheck는 개발 중 이 문제를 찾는 데 도움이 되지만 실제 출시 빌드에서의 사용 여부는 프로젝트 정책과 성능 측정을 기준으로 결정합니다.

재사용 전에 상태를 초기화하라

활성화와 비활성화만으로는 충분하지 않습니다. 이전 사용에서 남은 상태가 다음 사용에 섞이지 않도록 초기화해야 합니다.

확인할 상태

  • Rigidbody의 속도와 각속도
  • 파티클 재생 시간과 잔여 입자
  • 트레일 렌더러의 이전 궤적
  • 애니메이터 파라미터와 현재 상태
  • 코루틴, 타이머, 이벤트 구독
  • 체력, 대상 참조, 충돌 무시 설정

예를 들어 투사체가 물리력을 쓴다면 풀에서 가져올 때 속도를 초기화하거나 새 발사 속도를 설정해야 합니다.

public void Launch(Vector3 velocity)
{
    Rigidbody body = GetComponent<Rigidbody>();
    body.linearVelocity = velocity;
    body.angularVelocity = Vector3.zero;
}

Unity 버전에 따라 Rigidbody의 속성 이름이 다를 수 있습니다. 사용하는 Unity 버전의 API를 확인하고 이전 버전에서는 해당 버전이 제공하는 속도 API를 사용해야 합니다.

풀 크기는 어떻게 정할까

초기 풀 크기는 동시에 화면에 존재할 수 있는 최대 개수를 기준으로 잡습니다. 예를 들어 초당 10발을 발사하고 투사체의 최대 수명이 2초라면 다른 조건이 없다면 동시에 필요한 투사체는 대략 20개입니다. 여기에 순간적인 발사 증가나 지연을 고려한 여유분을 더합니다.

필요 풀 크기≈초당 생성 수×최대 생존 시간+여유분필요\ 풀\ 크기 \approx 초당\ 생성\ 수 \times 최대\ 생존\ 시간 + 여유분

이 값은 출발점일 뿐입니다. 실제 게임에서는 명중 즉시 반납되는 비율, 여러 발사체 종류, 일시 정지 중 누적되는 요청 같은 조건이 달라집니다. 개발 빌드에서 풀의 현재 활성 수와 최대 활성 수를 기록해 실제 플레이 데이터를 바탕으로 조정하는 편이 안전합니다.

풀을 무한정 확장하면 전투 중 프레임 드랍은 줄일 수 있어도 메모리 사용량이 계속 커질 수 있습니다. 반대로 최대 크기를 너무 작게 잡으면 중요한 이펙트나 투사체가 사라질 수 있습니다. 게임 규칙에 따라 요청을 무시할지, 가장 오래된 시각 효과를 재사용할지, 우선순위가 낮은 연출만 생략할지를 정해야 합니다.

풀링이 오히려 문제를 만드는 경우

풀링에는 상주 메모리와 초기화 복잡도라는 비용이 있습니다. 따라서 다음 경우에는 신중해야 합니다.

  • 객체가 드물게 생성되어 재사용 이점이 작은 경우
  • 프리팹 하나가 매우 무거워 많은 인스턴스를 미리 만들기 어려운 경우
  • 매번 서로 다른 리소스를 동적으로 붙여야 해 초기화 비용이 큰 경우
  • 객체 수명과 반납 책임이 불명확해 누수나 중복 반납이 발생하기 쉬운 경우

또한 풀링이 Update 호출 수, 물리 충돌 수, 드로우 콜 수를 자동으로 줄여 주지는 않습니다. 비활성 객체는 일반적으로 동작 비용이 줄어들지만 활성 객체가 너무 많다면 별도의 LOD, 컬링, 배치 처리, 이펙트 품질 제어가 필요합니다.

적용 전후를 검증하는 방법

풀을 도입했다면 체감만으로 판단하지 말고 같은 상황을 재현해 측정합니다. Unity Profiler에서는 CPU Usage와 Memory 모듈을 함께 보며 다음 항목을 비교할 수 있습니다.

  • 전투 구간의 프레임 시간 최고치와 변동 폭
  • 프레임당 GC Alloc 발생량
  • Instantiate와 Destroy 호출 빈도
  • 가비지 컬렉션이 발생하는 시점
  • 풀 확장이 발생한 횟수와 최대 활성 객체 수

Object Pooling의 목표는 객체 생성 자체를 금지하는 것이 아닙니다. 반복적으로 생성되는 객체의 수명 주기를 통제하고 비용이 몰리는 구간을 예측 가능한 형태로 바꾸는 데 있습니다. 먼저 프로파일러로 병목을 확인하고 재사용 빈도가 높은 객체부터 작은 단위로 적용하면 안정적으로 프레임 드랍을 줄일 수 있습니다.