게임 개발에서 성능 최적화는 매우 중요한 요소입니다. 특히 총알, 적, 이펙트 등 많은 수의 오브젝트를 빈번하게 생성하고 파괴해야 하는 경우, 오브젝트 풀링(Object Pooling) 시스템은 필수적인 기술입니다. 이번 글에서는 Unity에서 효율적인 오브젝트 풀링 시스템을 구현하는 방법을 자세히 알아보겠습니다.
오브젝트 풀링은 미리 정해진 수의 게임 오브젝트를 생성해두고 재사용하는 시스템입니다. 매번 Instantiate()와 Destroy()를 호출하는 대신, 이미 생성된 오브젝트를 활성화/비활성화하여 사용함으로써 성능을 크게 향상시킬 수 있습니다.
오브젝트 풀링 시스템은 두 개의 핵심 클래스로 구성됩니다:
풀링 시스템을 이해하기 전에, C#의 Dictionary 자료구조를 알아야 합니다.
Key-Value Pair(키-값 쌍)은 고유한 식별자(키)와 그에 연결된 특정 값으로 구성된 데이터 구조입니다.
실생활 예시:
Dictionary<TKey, TValue>
오브젝트 풀링에서의 적용:
GameObject 프리팹 (총알, 적, 이펙트 등)Queue<GameObject> (해당 프리팹의 오브젝트들을 담는 큐)// 예시: 각 프리팹별로 오브젝트 큐를 관리
Dictionary<GameObject, Queue<GameObject>> poolDictionary;
public class ObjectPool : MonoBehaviour
{
// 싱글톤 패턴으로 전역 접근 가능
public static ObjectPool instance;
// 각 타입별 기본 풀 크기
[SerializeField] private int poolSize = 10;
// 핵심: 프리팹별 오브젝트 큐를 관리하는 딕셔너리
private Dictionary<GameObject, Queue<GameObject>> poolDictionary;
}
private void Awake()
{
// 싱글톤 패턴 구현
if (instance == null)
{
instance = this;
DontDestroyOnLoad(gameObject);
poolDictionary = new Dictionary<GameObject, Queue<GameObject>>();
}
else
{
Destroy(gameObject);
}
}
public GameObject GetObject(GameObject prefab)
{
// 해당 프리팹의 풀이 존재하지 않으면 새로 생성
if (!poolDictionary.ContainsKey(prefab))
{
InitializeNewPool(prefab);
}
// 풀이 비어있으면 새 오브젝트 생성
if (poolDictionary[prefab].Count == 0)
{
CreateNewObject(prefab);
}
// 풀에서 오브젝트 가져와서 활성화 후 반환
GameObject objectToSpawn = poolDictionary[prefab].Dequeue();
objectToSpawn.SetActive(true);
return objectToSpawn;
}
public void ReturnObject(GameObject objectToReturn, float delay = 0.001f)
{
StartCoroutine(DelayReturn(delay, objectToReturn));
}
private IEnumerator DelayReturn(float delay, GameObject objectToReturn)
{
yield return new WaitForSeconds(delay);
ReturnToPool(objectToReturn);
}
private void ReturnToPool(GameObject objectToReturn)
{
// PooledObject 컴포넌트에서 원본 프리팹 정보 가져오기
PooledObject pooledComponent = objectToReturn.GetComponent<PooledObject>();
GameObject originalPrefab = pooledComponent.originalPrefab;
// 오브젝트 비활성화 및 부모 설정
objectToReturn.SetActive(false);
objectToReturn.transform.parent = transform;
// 해당 프리팹의 풀에 다시 추가
poolDictionary[originalPrefab].Enqueue(objectToReturn);
}
private void InitializeNewPool(GameObject prefab)
{
// 새 프리팹을 위한 큐 생성
poolDictionary[prefab] = new Queue<GameObject>();
// 기본 크기만큼 오브젝트 미리 생성
for (int i = 0; i < poolSize; i++)
{
CreateNewObject(prefab);
}
}
private void CreateNewObject(GameObject prefab)
{
// 새 오브젝트 생성
GameObject newObject = Instantiate(prefab);
// PooledObject 컴포넌트 추가 및 원본 프리팹 참조 설정
PooledObject pooledComponent = newObject.AddComponent<PooledObject>();
pooledComponent.originalPrefab = prefab;
// 비활성화 후 풀에 추가
newObject.SetActive(false);
poolDictionary[prefab].Enqueue(newObject);
}
public class PooledObject : MonoBehaviour
{
// 원본 프리팹 참조를 저장하는 프로퍼티
public GameObject originalPrefab { get; set; }
}
이 간단한 헬퍼 클래스는 각 풀링된 오브젝트가 어떤 프리팹에서 생성되었는지 기억하여, 올바른 풀로 반환될 수 있도록 돕습니다.
public class Gun : MonoBehaviour
{
[SerializeField] private GameObject bulletPrefab;
[SerializeField] private Transform firePoint;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
Fire();
}
}
void Fire()
{
// 풀에서 총알 오브젝트 가져오기
GameObject bullet = ObjectPool.instance.GetObject(bulletPrefab);
bullet.transform.position = firePoint.position;
bullet.transform.rotation = firePoint.rotation;
// 3초 후 풀에 반환
ObjectPool.instance.ReturnObject(bullet, 3f);
}
}
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
[SerializeField] private Transform[] spawnPoints;
[SerializeField] private float spawnInterval = 2f;
void Start()
{
InvokeRepeating(nameof(SpawnEnemy), 0f, spawnInterval);
}
void SpawnEnemy()
{
Transform spawnPoint = spawnPoints[Random.Range(0, spawnPoints.Length)];
GameObject enemy = ObjectPool.instance.GetObject(enemyPrefab);
enemy.transform.position = spawnPoint.position;
enemy.transform.rotation = spawnPoint.rotation;
}
}
GetObject(prefab) 호출
↓
프리팹의 풀이 존재하는가?
↓ (No)
InitializeNewPool() 실행
↓
풀에 사용 가능한 오브젝트가 있는가?
↓ (No)
CreateNewObject() 실행
↓
Dequeue()로 오브젝트 가져오기
↓
SetActive(true) 후 반환
ReturnObject() 호출
↓
DelayReturn() 코루틴 시작
↓
지정된 시간 후 ReturnToPool() 실행
↓
PooledObject에서 originalPrefab 확인
↓
SetActive(false) 및 부모 오브젝트로 이동
↓
Enqueue()로 해당 풀에 다시 추가
// 게임의 특성에 따라 풀 크기 조정
[SerializeField] private int bulletPoolSize = 50; // 빈번한 생성
[SerializeField] private int enemyPoolSize = 20; // 중간 빈도
[SerializeField] private int effectPoolSize = 30; // 이펙트용
void Start()
{
// 게임 시작 시 자주 사용되는 오브젝트들을 미리 생성
PrewarmPool(bulletPrefab, 50);
PrewarmPool(enemyPrefab, 20);
}
void PrewarmPool(GameObject prefab, int count)
{
for (int i = 0; i < count; i++)
{
GameObject obj = ObjectPool.instance.GetObject(prefab);
ObjectPool.instance.ReturnObject(obj, 0f);
}
}
public void LogPoolStatus()
{
foreach (var kvp in poolDictionary)
{
Debug.Log($"Pool {kvp.Key.name}: {kvp.Value.Count} objects available");
}
}
풀에서 가져온 오브젝트는 이전 상태를 유지할 수 있으므로, 사용 전 반드시 초기화해야 합니다.
public class Bullet : MonoBehaviour
{
void OnEnable()
{
// 풀에서 활성화될 때마다 초기화
GetComponent<Rigidbody>().velocity = Vector3.zero;
transform.localScale = Vector3.one;
// 기타 초기화 로직...
}
}
public void ClearPool(GameObject prefab)
{
if (poolDictionary.ContainsKey(prefab))
{
while (poolDictionary[prefab].Count > 0)
{
DestroyImmediate(poolDictionary[prefab].Dequeue());
}
poolDictionary.Remove(prefab);
}
}
public void AdjustPoolSize(GameObject prefab, int newSize)
{
if (!poolDictionary.ContainsKey(prefab)) return;
Queue<GameObject> pool = poolDictionary[prefab];
while (pool.Count > newSize)
{
DestroyImmediate(pool.Dequeue());
}
while (pool.Count < newSize)
{
CreateNewObject(prefab);
}
}
[System.Serializable]
public class PoolInfo
{
public GameObject prefab;
public int poolSize;
public bool expandable = true;
}
public class AdvancedObjectPool : MonoBehaviour
{
[SerializeField] private PoolInfo[] poolsToCreate;
void Start()
{
foreach (var poolInfo in poolsToCreate)
{
CreatePool(poolInfo);
}
}
}
public class ObjectPool : MonoBehaviour
{
public System.Action<GameObject> OnObjectSpawned;
public System.Action<GameObject> OnObjectReturned;
public GameObject GetObject(GameObject prefab)
{
// ... 기존 로직 ...
OnObjectSpawned?.Invoke(objectToSpawn);
return objectToSpawn;
}
}
오브젝트 풀링 시스템은 Unity 게임의 성능을 크게 향상시킬 수 있는 필수 기술입니다. Dictionary를 활용한 효율적인 자료구조 설계와 Queue를 통한 FIFO(First In, First Out) 관리 방식은 메모리 효율성과 성능 최적화를 동시에 달성할 수 있게 해줍니다.
핵심은 적절한 풀 크기 설정, 올바른 오브젝트 초기화, 그리고 메모리 관리입니다. 이 시스템을 기반으로 게임의 특성에 맞는 커스터마이징을 통해 더욱 효율적인 게임을 개발할 수 있을 것입니다.
오브젝트 풀링은 단순한 최적화 기법을 넘어서, 안정적이고 예측 가능한 게임 퍼포먼스를 보장하는 핵심 아키텍처 패턴입니다. 이를 마스터하여 보다 전문적인 게임 개발자로 성장하시기 바랍니다.