메모리 릭을 막는 오브젝트 풀링 고도화
오브젝트 풀링은 할당 비용을 줄이는 기법이지만 잘못 설계하면 오히려 객체와 리소스를 오래 붙잡아 메모리 릭을 키울 수 있다. 생명주기, 반환 규약, 리소스 해제를 중심으로 안전한 풀을 설계하는 방법을 정리한다.

오브젝트 풀링은 메모리 릭 해결책이 아니다
오브젝트 풀링은 자주 생성하고 파괴하는 객체를 재사용해 할당과 가비지 컬렉션 부담을 줄이는 기법이다. 탄환, 피격 이펙트, 데미지 텍스트처럼 짧은 시간에 많이 생기는 객체에 특히 효과적이다.
다만 풀링 자체가 메모리 릭을 자동으로 막아 주지는 않는다. 반환되지 않은 객체를 풀이 계속 참조하거나 반환된 객체가 이벤트 구독과 비동기 작업을 유지하거나 풀 전체가 씬 종료 뒤에도 남아 있으면 메모리는 회수되지 않는다. 목표는 객체를 재사용하는 데서 끝나지 않고 객체가 필요한 기간에만 참조와 리소스를 소유하게 만드는 것이다.

먼저 구분할 문제: 누수와 메모리 점유
풀에 객체를 보관하면 비활성 객체도 메모리를 사용한다. 이는 의도적인 캐시이며 곧바로 메모리 릭이라고 할 수는 없다. 반면 더 이상 사용하지 않을 객체에 도달할 수 있는 참조가 남아 있다면 누수다.
다음 기준으로 판단하면 혼동이 줄어든다.
- 풀의 최대 크기가 정해져 있고 씬 또는 기능 종료 시 정리된다면 예측 가능한 캐시다.
- 활성 객체 수가 줄었는데 풀의 크기나 메모리 사용량이 계속 증가한다면 반환 경로를 의심해야 한다.
- 비활성 객체가 전역 이벤트, 타이머, 코루틴,
Task콜백을 통해 다시 참조된다면 수명 관리 오류다. - 텍스처, 메시, 네이티브 핸들처럼 GC가 직접 관리하지 않는 리소스는 별도 해제 규약이 필요하다.
풀의 생명주기와 소유권을 명확히 한다
안전한 풀은 대여, 사용, 반환, 폐기의 경계를 코드로 드러낸다. 특히 호출자가 객체를 직접 Destroy하거나 new로 우회 생성하지 못하게 해야 한다.
flowchart LR
A[풀에서 대여] --> B[초기화 및 활성화]
B --> C[게임플레이에서 사용]
C --> D{반환 조건 충족?}
D -- 아니오 --> C
D -- 예 --> E[이벤트·작업 해제]
E --> F[상태 초기화]
F --> G{풀 용량 여유?}
G -- 예 --> H[비활성화 후 보관]
G -- 아니오 --> I[객체와 소유 리소스 폐기]
이 흐름에서 중요한 지점은 반환 직전이다. SetActive(false)만 호출하면 화면에서는 사라지지만 객체 안의 구독과 실행 중인 작업은 남을 수 있다. 반환을 하나의 공식 진입점으로 만들고 정리 작업을 그 안에 모아야 한다.
Unity: 반환 전 초기화와 구독 해제
Unity에서는 UnityEngine.Pool.ObjectPool<T>를 이용하면 기본적인 대여와 반환 규칙을 구현하기 편하다. 아래 예제는 투사체가 대여될 때만 이벤트를 구독하고 풀로 돌아갈 때 반드시 구독을 해제하도록 만든 구조다.
using System;
using UnityEngine;
using UnityEngine.Pool;
public sealed class Projectile : MonoBehaviour
{
private IObjectPool<Projectile> pool;
private Action<int> onDamage;
private Rigidbody body;
private bool isInPool;
private void Awake()
{
body = GetComponent<Rigidbody>();
}
public void Initialize(
IObjectPool<Projectile> owner,
Vector3 position,
Vector3 velocity,
Action<int> damageCallback)
{
pool = owner;
isInPool = false;
transform.SetPositionAndRotation(position, Quaternion.identity);
body.linearVelocity = velocity;
onDamage = damageCallback;
DamageSystem.DamageApplied += onDamage;
}
private void OnCollisionEnter(Collision collision)
{
onDamage?.Invoke(10);
ReturnToPool();
}
public void ReturnToPool()
{
if (isInPool)
{
return;
}
isInPool = true;
pool.Release(this);
}
public void ResetForPool()
{
DamageSystem.DamageApplied -= onDamage;
onDamage = null;
body.linearVelocity = Vector3.zero;
body.angularVelocity = Vector3.zero;
transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);
}
}
public sealed class ProjectilePool : MonoBehaviour
{
[SerializeField] private Projectile prefab;
[SerializeField] private int defaultCapacity = 32;
[SerializeField] private int maxSize = 128;
private IObjectPool<Projectile> pool;
private void Awake()
{
pool = new ObjectPool<Projectile>(
Create,
OnGet,
OnRelease,
OnDestroyPoolObject,
collectionCheck: true,
defaultCapacity,
maxSize);
}
public Projectile Get(Vector3 position, Vector3 velocity, Action<int> callback)
{
Projectile projectile = pool.Get();
projectile.Initialize(pool, position, velocity, callback);
return projectile;
}
private Projectile Create()
{
return Instantiate(prefab);
}
private static void OnGet(Projectile projectile)
{
projectile.gameObject.SetActive(true);
}
private static void OnRelease(Projectile projectile)
{
projectile.ResetForPool();
projectile.gameObject.SetActive(false);
}
private static void OnDestroyPoolObject(Projectile projectile)
{
Destroy(projectile.gameObject);
}
private void OnDestroy()
{
pool?.Clear();
}
}
collectionCheck: true는 같은 객체를 두 번 반환하는 실수를 개발 중에 찾는 데 유용하다. 릴리스 빌드에서 비용이 신경 쓰인다면 프로파일링 결과를 근거로 끌 수 있지만 반환 규약이 충분히 검증되기 전에는 유지하는 편이 안전하다.
또한 OnDisable에 무조건 반환 코드를 넣는 방식은 주의해야 한다. 풀에서 반환하려고 비활성화한 것이 다시 반환을 부르는 재진입 문제를 만들 수 있다. 수명 종료 판단은 충돌, 타임아웃, 소유자 해제처럼 명시적인 경로에서 수행하고 비활성화는 그 결과로만 실행하는 편이 낫다.
비동기 작업과 코루틴은 세대 번호로 보호한다
풀 객체는 재사용되므로 이전 대여 시점에 시작한 비동기 작업의 완료 콜백이 다음 대여 상태를 바꾸는 문제가 생길 수 있다. 단순히 메모리 문제를 넘어 잘못된 대상에 이펙트가 적용되는 게임플레이 버그로 이어진다.
객체를 대여할 때 세대 번호를 증가시키고 콜백에서 번호가 일치하는지 확인하면 오래된 작업을 무시할 수 있다.
private int leaseVersion;
public async void PlayDelayedEffectAsync()
{
int versionAtStart = leaseVersion;
await System.Threading.Tasks.Task.Delay(500);
if (isInPool || versionAtStart != leaseVersion)
{
return;
}
PlayEffect();
}
public void InitializeForLease()
{
leaseVersion++;
isInPool = false;
}
가능하다면 CancellationToken도 함께 사용해 작업을 취소한다. 세대 번호는 이미 끝난 콜백의 부작용을 막고 취소 토큰은 불필요하게 계속 실행되는 작업을 줄인다. 두 방법은 경쟁 관계가 아니라 서로 보완하는 장치다.

풀 크기를 무한정 늘리지 않는다
최대 크기가 없는 풀은 피크 상황에서 객체를 대량 보관한 뒤 한가한 구간에도 메모리를 계속 점유한다. 풀은 사용량을 흡수하는 완충 장치이지 모든 객체를 영구 보존하는 저장소가 아니다.
풀 크기는 다음 순서로 정한다.
- 실제 플레이에서 동시 활성 수의 최고치를 측정한다.
- 짧은 순간의 변동을 고려해 작은 여유를 둔다.
maxSize를 설정하고 초과 객체는 즉시 폐기한다.- 씬 전환이나 라운드 종료처럼 수요가 크게 바뀌는 시점에 풀을 비우거나 축소한다.
프리워밍도 같은 원칙을 따른다. 시작 시 객체를 많이 만들어 두면 첫 사용 시 끊김은 줄어들 수 있지만 모든 풀에 큰 기본 용량을 적용하면 시작 메모리와 로딩 시간이 늘어난다. 프로파일러에서 실제 생성 스파이크가 확인된 대상만 선별해 프리워밍한다.
C++와 Unreal에서 더 엄격해지는 규칙
C++ 객체 풀은 메모리만 재사용할 뿐 생성자와 소멸자의 의미를 없애지 않는다. placement new를 사용해 객체를 다시 구성했다면 기존 객체의 소멸 시점과 자원 정리 책임을 명확히 해야 한다. 파일 핸들, 소켓, GPU 리소스, 등록된 콜백처럼 RAII로 관리하는 자원은 반환 시 정리하거나 다음 대여 전 완전히 재초기화해야 한다.
Unreal Engine에서는 UObject를 일반 C++ 풀처럼 직접 생성하고 파괴하지 않는 것이 원칙이다. UObject는 가비지 컬렉션과 엔진의 참조 추적에 따라 관리된다. 대신 액터나 컴포넌트를 재사용할 때는 다음 항목을 점검한다.
- 델리게이트를
AddDynamic또는AddUObject로 등록했다면 반환 시 대응하는 해제 함수를 호출한다. - 타이머를 사용했다면
FTimerManager::ClearTimer로 해제한다. - Tick, 충돌, 나이아가라 컴포넌트, 오디오 컴포넌트의 활성 상태를 초기화한다.
TWeakObjectPtr가 필요한 관계에 강한 참조를 남기지 않는다.- 레벨 종료와 월드 종료 시 풀 관리자가 더 이상 월드를 붙잡지 않는지 확인한다.
엔진마다 API는 달라도 핵심은 같다. 풀에 반환된 객체는 다음 사용자에게 넘기기 전까지 외부 세계와 연결되어 있으면 안 된다.
점검표와 프로파일링 방법
문제는 보통 코드 한 줄보다 수명 경계가 여러 곳에 흩어져 있을 때 생긴다. 다음 점검표를 코드 리뷰와 테스트에 활용할 수 있다.
- 모든 대여에 대응하는 반환 경로가 있는가?
- 중복 반환을 감지하거나 방지하는가?
- 이벤트, 델리게이트, 입력 콜백, 타이머를 반환 시 해제하는가?
- 코루틴과 비동기 작업이 반환 뒤에도 객체를 조작하지 않는가?
- 물리 상태, 애니메이션 상태, 파티클 상태, 부모 Transform을 초기화하는가?
- 풀의 최대 크기와 씬 종료 정리 정책이 있는가?
- 전역 관리자나 정적 컬렉션이 씬 객체를 계속 참조하지 않는가?
Unity에서는 Memory Profiler와 Profiler의 GC Alloc, Managed Heap, 객체 참조 정보를 함께 본다. 반복적으로 대여와 반환을 수행한 뒤 활성 객체 수가 0인 상태에서 관리 힙, 네이티브 객체 수, 이벤트 구독 수가 안정되는지 확인한다. 한 번의 스냅샷보다 동일한 시나리오 전후의 스냅샷 비교가 유용하다.
마무리
좋은 오브젝트 풀은 단순한 Queue가 아니다. 대여와 반환의 단일 진입점, 반환 시 정리, 제한된 용량, 씬과 기능의 종료 처리까지 갖춘 수명 관리 장치다. 성능 최적화를 시작점으로 삼되 각 객체가 무엇을 참조하고 언제 그 참조를 끊는지 추적하면 풀링은 메모리 릭의 원인이 아니라 예측 가능한 시스템이 된다.



