공용 스크립트 제작기🎈 - Object Pool 편

Unity 프로젝트의 공용으로 사용할 수 있는 스크립트를 제작하면서 어떻게 만들었는지, 어떤 점에 신경썼는지 설명하는 글입니다.
게임을 만들다보면 반복적으로 생성/파괴되는 오브젝트들이 많다. 총알, 이펙트, 적 캐릭터 등등. 이런 것들을 매번 Instantiate/Destroy 하면 GC가 미쳐 날뛴다. 그래서 범용적으로 사용할 수 있는 오브젝트 풀링 시스템을 만들기로 했다.
내가 원하는건 Attribute만 달아주면 PoolManager가 알아서 풀링을 관리하고, Addressable로 비동기 로딩까지 지원하는 시스템이었다.
💡예시
예를들어, 게임에 총알 프리팹이 필요하다고 해보자. 그럴 때 이렇게 먼저 Component 클래스를 만든다.
[PoolAddress("Prefabs/Bullet")] // Addressable 주소만 지정
public class Bullet : MonoBehaviour, IPoolable
{
public void OnGetFromPool()
{
// 풀에서 꺼낼 때 초기화
gameObject.SetActive(true);
}
public void OnReturnToPool()
{
// 풀로 반환할 때 정리
gameObject.SetActive(false);
}
}
이후 사용할 때는 다음과 같이 간단하게 사용한다.
// 풀에서 가져오기 (없으면 자동으로 로드)
Bullet bullet = await PoolManager.GetFromPool<Bullet>(ct);
// 사용...
// 풀로 반환
PoolManager.ReturnToPool(bullet);
필요하다면 프리로드도 가능하다.
// 게임 시작 시 10개 미리 생성
await PoolManager.PreloadPool<Bullet>(10, ct);
다음으로는 구현은 어떻게 했는지 상세하게 설명해 보겠다.
📊 클래스 다이어그램
IPoolable (인터페이스)
↑ implements
Bullet, Enemy, Effect, ... (사용자 정의 클래스들)
↓ uses
PoolManager (정적 클래스)
├─ ObjectPool<T>
│ ├─ PoolStorage<T> (저장소)
│ ├─ InstanceLifecycle<T> (생명주기)
│ ├─ InstanceTracker<T> (추적)
│ └─ ReferenceCounter (참조 카운팅)
└─ AttributeCache<T> (Attribute 캐싱)
ObjectPoolBase (추상 클래스)
↑ extends
ObjectPool<T>
📁 시스템 아키텍처
핵심 컴포넌트
PoolManager
- 역할: 전체 풀링 시스템의 진입점
- 주요 기능:
- Attribute 기반 자동 풀 생성
- 타입별 ObjectPool 캐싱 및 관리
- Parent Transform 자동 생성 및 캐싱
- 씬 전환 시 자동 정리
- 핵심 API:
- GetFromPool: 풀에서 인스턴스 가져오기
- ReturnToPool: 풀로 인스턴스 반환
- PreloadPool: 미리 로드
- ClearAllPools: 모든 풀 비우기
ObjectPool
- 역할: Component 타입별 실제 풀링 로직 구현
- 주요 기능:
- Addressable/Resources 비동기 로딩
- 인스턴스 생명주기 관리
- 참조 카운팅 및 메모리 안전성
- 풀 크기 제한 및 자동 정리
- 생성 방식:
- CreateForAddressable
- CreateForResources
- CreateCustom
PoolStorage
- 역할: 풀 내 인스턴스 저장 및 관리
- 주요 기능:
- 타입별 Queue 관리 (Dictionary<Type, Queue>)
- 풀 크기 제한 (타입별 설정 가능)
- FIFO 방식 인스턴스 관리
- 빠른 TryTake/Store 연산
Attribute 시스템
PoolAddressAttribute
[PoolAddress("Prefabs/Bullet")] // Pool Container 사용
public class Bullet : MonoBehaviour { }
[PoolAddress("Prefabs/Enemy", "EnemyContainer")] // 사용자 지정 부모
public class Enemy : MonoBehaviour { }
[PoolAddress("Prefabs/UI", dontDestroyOnLoad: true)] // 씬 전환에도 유지
public class UIPanel : MonoBehaviour { }
- Address: Addressable 주소 또는 Resources 경로
- ParentName: 부모 GameObject 이름 (선택적)
- UsePoolContainer: Pool Container 사용 여부
- DontDestroyOnLoad: 씬 전환 시 유지 여부
AttributeCache (정적 캐싱)
- PoolManager 내부 클래스
- 타입별 Attribute 정보를 정적 생성자에서 한 번만 추출
- 리플렉션 오버헤드 제거 (최초 1회만 실행)
IPoolable (인터페이스)
- 모든 풀링 가능한 객체가 구현하는 인터페이스
- OnGetFromPool(): 풀에서 꺼낼 때 호출
- OnReturnToPool(): 풀로 반환할 때 호출
참조 관리 시스템
ReferenceCounter
- 역할: 프리팹별 인스턴스 개수 추적
- 동작 방식:
- 프리팹 최초 로드 시 참조 카운트 1로 시작
- 인스턴스 생성마다 Increase()
- 인스턴스 파괴마다 Decrease()
- 참조 카운트가 0이 되면 자동으로 프리팹 해제
- 메모리 안전성 보장
InstanceTracker
- 역할: 활성 인스턴스의 메타데이터 추적
- 추적 정보:
- Address: 원본 리소스 주소
- Type: Component 타입
- TargetParent: 원래 부모 Transform
- 용도: Return 시 올바른 정리 수행
InstanceLifecycle
- 역할: 인스턴스 생성/활성화/비활성화/파괴 관리
- 주요 메서드:
- Create: GameObject.Instantiate + Component 추출
- Activate: SetActive(true) + Parent 설정
- Deactivate: SetActive(false) + Parent 복원
- Destroy: IPoolable.OnReturnToPool + GameObject.Destroy
🔄 데이터 흐름
최초 사용 (풀 생성)
- PoolManager.GetFromPool() 호출
↓ - [AttributeCache] Attribute 추출
- [PoolManager] ObjectPool 생성 및 캐싱
- [ObjectPool] 새 인스턴스 로드
↓ - [AddressableLoader] 프리팹 비동기 로드
- [InstanceLifecycle] GameObject.Instantiate
- [InstanceTracker] 메타데이터 추적 시작
- [ReferenceCounter] 참조 카운트 증가
↓ - [완료] Bullet 인스턴스 반환
재사용 (풀에서 가져오기)
- PoolManager.GetFromPool() 호출
↓ - [PoolStorage] TryTake() → 풀에 있음!
- [InstanceLifecycle] Activate(SetActive(true))
- [IPoolable] OnGetFromPool() 호출
↓ - [완료] 재사용된 Bullet 인스턴스 반환
반환 (풀로 되돌리기)
- PoolManager.ReturnToPool(bullet) 호출
↓ - [InstanceTracker] 메타데이터 조회 (Address, Type, Parent)
- [PoolStorage] 풀 크기 확인
↓ - 풀에 공간 있음
- [IPoolable] OnReturnToPool() 호출
- [InstanceLifecycle] Deactivate(SetActive(false))
- [PoolStorage] Queue에 저장 ↓
- 풀 크기 초과
- [InstanceTracker] Untrack()
- [InstanceLifecycle] Destroy()
- [ReferenceCounter] Decrease() → 0이면 프리팹 해제
🎯 신경 쓴 부분
Parent Transform 캐싱
- 씬에서 GameObject.Find() 호출을 최소화
- Dictionary로 캐싱하여 재사용
- 씬 언로드 시 자동으로 캐시 정리
private static Dictionary<string, Transform> parentCache = new();
private static Transform GetOrCreateParent(string parentName)
{
// 캐시 확인
if (parentCache.TryGetValue(parentName, out Transform cached))
{
if (cached != null) return cached;
parentCache.Remove(parentName); // 파괴된 경우
}
// Find 또는 생성
// ...
parentCache[parentName] = parent;
return parent;
}
참조 카운팅
- 프리팹별로 현재 생성된 인스턴스 개수 추적
- 모든 인스턴스가 제거되면 자동으로 프리팹 해제 (Addressable Release)
- 메모리 누수 방지
public class ReferenceCounter<TKey, TValue>
{
private Dictionary<TKey, int> refCounts = new();
private Dictionary<TKey, TValue> values = new();
private Action<TKey, TValue> onReleaseCallback;
public void Increase(TKey key)
{
refCounts[key]++;
}
public void Decrease(TKey key)
{
if (--refCounts[key] <= 0)
{
// 참조 카운트 0 → 프리팹 해제!
onReleaseCallback?.Invoke(key, values[key]);
refCounts.Remove(key);
values.Remove(key);
}
}
}
씬 전환 자동 정리
- SceneManager.sceneUnloaded 이벤트 구독
- 씬 언로드 시 Parent 캐시 자동 정리
- DontDestroyOnLoad 옵션으로 유지 가능
private static void Initialize()
{
SceneManager.sceneUnloaded += OnSceneUnloaded;
}
private static void OnSceneUnloaded(Scene scene)
{
parentCache.Clear(); // 파괴된 Parent 참조 제거
}
비동기 프리로드
- 게임 시작 시 필요한 오브젝트 미리 생성
- 프리팹은 한 번만 로드하고 인스턴스만 count개 생성
- 런타임 로딩 지연 최소화
await PoolManager.PreloadPool<Bullet>(10, ct); // 총알 10개 미리 생성
await PoolManager.PreloadPool<Enemy>(5, ct); // 적 5개 미리 생성
결론
외부에서 사용하기는 쉽게 만들고, 내부에서 하는 게 많은 시스템. 특히 Addressable과 잘 합쳐 써보려고 노력했다.