투사체/파티클 오브젝트 풀링: 수백 개의 투사체를 메모리 릭 없이 처리하는 고도화 기법

투사체/파티클 오브젝트 풀링: 수백 개의 투사체를 메모리 릭 없이 처리하는 고도화 기법

대규모 탄막 및 파티클 생성 시 발생하는 GC 스파이크와 메모리 누수를 완벽히 방지하는 고도화된 오브젝트 풀 아키텍처와 C# 구현 패턴을 다룹니다.

1. 대량 오브젝트 생성과 파괴가 일으키는 성능 병목

탄막 시뮬레이션이나 대규모 파티클 효과가 포함된 게임에서는 초당 수백 개 이상의 오브젝트가 생성되고 소멸합니다. 이때 Instantiate와 Destroy를 무분별하게 호출하면 힙 메모리 파편화와 함께 가비지 컬렉터(Garbage Collector)의 부하가 급격히 증가합니다.

게임의 프레임 타임 TframeT_{\text{frame}}은 다음과 같은 요소의 합으로 결정됩니다.

Tframe=Tlogic+Trender+TGCT_{\text{frame}} = T_{\text{logic}} + T_{\text{render}} + T_{\text{GC}}

여기서 TGCT_{\text{GC}}가 지속적으로 불규칙한 스파이크를 발생시키면 유저가 체감하는 프레임 드롭(Micro-stuttering)으로 직결됩니다. 주된 원인은 동적 메모리 할당과 해제 과정에서 생성되는 쓰레기 객체입니다.

Instantiate와 Destroy 반복으로 인한 동적 메모리 할당과 프레임 드롭 현상

2. 단순 오브젝트 풀링의 한계와 메모리 누수 요인

많은 개발자가 큐(Queue)나 스택(Stack)을 이용해 비활성화된 오브젝트를 재사용하는 기본 풀링을 구현합니다. 하지만 오브젝트 수명이 짧고 복잡한 상태를 가질수록 단순 풀링만으로는 메모리 누수(Memory Leak)나 댕글링 참조(Dangling Reference) 문제를 완전히 막기 어렵습니다.

대표적인 메모리 누수 및 오작동 원인은 다음과 같습니다.

  1. 이벤트 리스너 미해제: C# event나 Action에 오브젝트 메서드를 구독해 둔 뒤 풀에 반환할 때 이를 해제하지 않으면 가비지 컬렉터가 해당 오브젝트를 수거 대상에서 제외합니다.
  2. 비동기 작업 및 코루틴 지속 실행: 반환된 오브젝트에서 멈추지 않은 Coroutine이나 Task가 작동하면 나중에 예기치 않은 타이밍에 내부 변수나 트랜스폼을 변형합니다.
  3. 파티클 시스템 잔여 입자: ParticleSystem을 가진 오브젝트를 풀에 넣을 때 Clear()를 호출하지 않으면 재활용하여 활성화되는 순간 이전의 잔여 파티클이 1프레임 동안 노출되는 비주얼 버그가 발생합니다.

3. 고도화된 오브젝트 풀 아키텍처 설계

이러한 문제를 해결하기 위해서는 오브젝트의 생명주기와 상태 초기화를 엄격하게 통제하는 구조가 필요합니다. IPoolable 인터페이스를 정의하고 유니티 2021 이상에서 제공되는 UnityEngine.Pool.ObjectPool을 활용하거나 래핑하여 안전성을 확보합니다.

flowchart TD
    Client[오브젝트 요청] --> Pool{가용 오브젝트 존재 여부}
    Pool -- 예 --> Pop[Stack/Queue에서 꺼냄]
    Pool -- 아니오 --> Create[Instantiate 신규 생성]
    Pop --> Reset[IPoolable.OnSpawn 호출 및 상태 초기화]
    Create --> Reset
    Reset --> Active[월드 활성화 및 로직 수행]
    Active --> DestroyCheck{반환 조건 충족}
    DestroyCheck -- 충돌 또는 시간 만료 --> Release[Pool.Release 호출]
    Release --> ClearState[IPoolable.OnDespawn 호출 및 이벤트/코루틴 정리]
    ClearState --> Push[Stack/Queue로 반환 및 비활성화]

이 흐름을 거치면 재사용되는 모든 투사체가 항상 동일한 깨끗한 상태(Clean State)에서 재활용되도록 보장할 수 있습니다.

4. C# 메모리 릭 방지 풀링 구현 예제

아래는 IPoolable 인터페이스를 구현하여 풀에 반환될 때 이벤트 구독 해제와 멈춤 처리를 보장하는 실전 예제 코드입니다.

using System;
using UnityEngine;
using UnityEngine.Pool;

public interface IPoolable
{
    void OnSpawn();
    void OnDespawn();
}

public class Projectile : MonoBehaviour, IPoolable
{
    [SerializeField] private float speed = 20f;
    [SerializeField] private float lifeTime = 3f;
    [SerializeField] private ParticleSystem trailParticle;

    private IObjectPool<Projectile> originPool;
    private float currentLifeTime;
    private bool isReleased;

    public void Initialize(IObjectPool<Projectile> pool)
    {
        originPool = pool;
    }

    public void OnSpawn()
    {
        isReleased = false;
        currentLifeTime = 0f;

        if (trailParticle != null)
        {
            trailParticle.Clear();
            trailParticle.Play();
        }
    }

    public void OnDespawn()
    {
        isReleased = true;

        if (trailParticle != null)
        {
            trailParticle.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
        }

        // 진행 중인 이동 계산 및 상태를 명시적으로 정리
        transform.localPosition = Vector3.zero;
        transform.localRotation = Quaternion.identity;
    }

    private void Update()
    {
        if (isReleased) return;

        transform.Translate(Vector3.forward * (speed * Time.deltaTime));
        currentLifeTime += Time.deltaTime;

        if (currentLifeTime >= lifeTime)
        {
            ReturnToPool();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if (isReleased) return;
        
        // 피격 로직 수행 후 풀로 반환
        ReturnToPool();
    }

    private void ReturnToPool()
    {
        if (isReleased) return;
        originPool.Release(this);
    }
}

이 구현 방식의 핵심은 isReleased 플래그를 두어 이미 풀로 반환된 오브젝트가 중복 반환되거나 Update 로직을 계속 수행하는 일을 차단하는 것입니다.

5. GC Alloc 0Byte 달성과 성능 검증

고도화된 오브젝트 풀을 적용한 후에는 Unity Profiler를 통해 메모리 할당량을 검증해야 합니다.

풀 생성 시 초기 용량(Collection Checks)과 최대 크기(Max Size)를 조절할 수 있습니다. 예를 들어 평균 동시 존재 수량이 200개라면 초기 용량을 200으로 설정하여 런타임 중 배열 재할당(Array Resize)에 따른 Garbage가 생성되는 것을 사전에 예방할 수 있습니다.

Unity Profiler를 통해 확인한 0Byte GC Alloc 상태의 오브젝트 풀링 모니터링

결과적으로 피크 타임 시에도 추가적인 GC Alloc이 발생하지 않으며 프레임 스파이크 없는 안정적인 퍼포먼스를 유지할 수 있게 됩니다.

#Unity#C##ObjectPooling#MemoryManagement#Performance Optimization

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs