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

공용 스크립트 제작기🎈 - 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. 프리팹 최초 로드 시 참조 카운트 1로 시작
    2. 인스턴스 생성마다 Increase()
    3. 인스턴스 파괴마다 Decrease()
    4. 참조 카운트가 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

🔄 데이터 흐름

최초 사용 (풀 생성)
  1. PoolManager.GetFromPool() 호출
  2. [AttributeCache] Attribute 추출
  3. [PoolManager] ObjectPool 생성 및 캐싱
  4. [ObjectPool] 새 인스턴스 로드
  5. [AddressableLoader] 프리팹 비동기 로드
  6. [InstanceLifecycle] GameObject.Instantiate
  7. [InstanceTracker] 메타데이터 추적 시작
  8. [ReferenceCounter] 참조 카운트 증가
  9. [완료] Bullet 인스턴스 반환
재사용 (풀에서 가져오기)
  1. PoolManager.GetFromPool() 호출
  2. [PoolStorage] TryTake() → 풀에 있음!
  3. [InstanceLifecycle] Activate(SetActive(true))
  4. [IPoolable] OnGetFromPool() 호출
  5. [완료] 재사용된 Bullet 인스턴스 반환
반환 (풀로 되돌리기)
  1. PoolManager.ReturnToPool(bullet) 호출
  2. [InstanceTracker] 메타데이터 조회 (Address, Type, Parent)
  3. [PoolStorage] 풀 크기 확인
  4. 풀에 공간 있음
    • [IPoolable] OnReturnToPool() 호출
    • [InstanceLifecycle] Deactivate(SetActive(false))
    • [PoolStorage] Queue에 저장 ↓
  5. 풀 크기 초과
    • [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과 잘 합쳐 써보려고 노력했다.


© 2022. All rights reserved.