오브젝트 풀링은 게임 개발에서 성능 최적화를 위해 사용하는 중요한 기법 중 하나다.
유니티에서 오브젝트를 생성하고 삭제할 때 흔히 Instantiate와 Destroy 메서드를 사용한다.
그러나, 오브젝트가 많아질수록 이는 비효율적이다. 이때 오브젝트 풀링을 사용하면 더 효율적인 메모리 관리를 할 수 있다. 예를 들어, 총알이나 적 오브젝트와 같은 자주 생성되는 오브젝트에 유용하다.
1. 오브젝트 풀 생성(Create)
게임을 시작하면서 사용될 오브젝트들의 인스턴스를 필요한 만큼 미리 생성하고 풀에 저장한다.
2. 오브젝트 요청(Call)
게임 중 오브젝트가 필요할 때마다 Instantiate를 사용하는 것이 아닌, 풀에서 오브젝트를 가져온다.
3. 오브젝트 반환(Return)
다 사용한 오브젝트는 Destroy를 호출해 삭제시키는 것이 아니라, 풀로 반환하여 다음 요청 때 재사용할 준비를 한다.
그러면 이런 오브젝트 풀링은 왜 사용하는 걸까?
1. 성능 최적화
오브젝트를 매번 새로 생성하고 삭제할 때마다 메모리 할당과 해제 비용이 발생한다. 오브젝트 수가 많아질수록 이 비용이 커져서 게임 성능에 영향을 미칠 수 있다. 오브젝트 풀링을 사용하면 오브젝트를 미리 생성해 두고 재사용하기 때문에 이러한 성능 저하를 막을 수 있다.
2. 가비지 컬렉터(GC) 호출 감소
Destroy 메서드를 호출하면 사용하지 않는 메모리를 해제하는 과정에서 가비지 컬렉터가 작동하게 된다. 이 가비지 컬렉션이 빈번하게 발생하면 게임이 일시적으로 멈추거나 렉이 걸릴 수 있다. 오브젝트 풀링을 사용하면 Destroy 호출을 줄여 가비지 컬렉션의 빈도를 낮출 수 있다.
아래는 내가 사용했던 오브젝트 풀링 코드이다.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PoolManager : MonoBehaviour
{
public static PoolManager Inst { get; private set; }
// 프리펩을 보관할 변수
public GameObject[] prefabs;
// 풀 담당을 하는 리스트들
List<GameObject>[] pools;
private void Awake()
{
if (Inst == null)
{
Inst = this;
DontDestroyOnLoad(gameObject); // 씬 전환 시에도 유지
}
else if (Inst != this)
{
Destroy(gameObject); // 기존 인스턴스가 있다면 새로운 것을 파괴
}
// pools의 길이를 프리펩의 길이 만큼 선언
pools = new List<GameObject>[prefabs.Length];
// 각 인덱스마다 프리펩을 담을 리스트 선언
for (int index = 0; index < pools.Length; index++)
pools[index] = new List<GameObject>();
}
public GameObject GetObject(PoolObjectType poolObjectType)
{
GameObject select = null;
int index = (int)poolObjectType;
// 해당 인덱스의 게임 오브젝트 리스트 중 비활성화된 게임 오브젝트 선택 후 select에 할당
foreach (GameObject prefab in pools[index])
{
if(!prefab.activeSelf)
{
select = prefab;
select.SetActive(true);
break;
}
}
// 만약 다 사용 중이면 새롭게 생성 후 select에 할당
if(!select)
{
select = Instantiate(prefabs[index], transform);
pools[index].Add(select);
}
// 선택된 게임 오브젝트 반환
return select;
}
public void ReturnObject(GameObject obj)
{
// 반환할 오브젝트를 비활성화하여 풀에 다시 넣음
obj.SetActive(false);
}
}
코드를 하나씩 살펴보면,
private void Awake()
{
//... 싱글톤 패턴 ...
// pools의 길이를 프리펩의 길이 만큼 선언
pools = new List<GameObject>[prefabs.Length];
// 각 인덱스마다 프리펩을 담을 리스트 선언
for (int index = 0; index < pools.Length; index++)
pools[index] = new List<GameObject>();
}
Awake 메서드에서는 pools(GameObject List)를 prefabs(GameObject Array)의 길이만큼 선언해 준다.
예를 들어 prefabs에 BulletPrefab, EnemyPrefab, ItemPrefab이 있다면, pools는 3개의 크기로 선언된다. 그리고 각 index마다 프리팹을 담을 리스트를 선언해 준다.
방금 예시로 든 BulletPrefab, EnemyPrefab, ItemPrefab에 대한 리스트가 각각 생성되며, 이는 일종의 2차원 리스트 구조라고 볼 수 있다.
public GameObject GetObject(PoolObjectType poolObjectType)
{
GameObject select = null;
int index = (int)poolObjectType;
// 해당 인덱스의 게임 오브젝트 리스트 중 비활성화된 게임 오브젝트 선택 후 select에 할당
foreach (GameObject prefab in pools[index])
{
if(!prefab.activeSelf)
{
select = prefab;
select.SetActive(true);
break;
}
}
// 만약 다 사용 중이면 새롭게 생성 후 select에 할당
if(!select)
{
select = Instantiate(prefabs[index], transform);
pools[index].Add(select);
}
// 선택된 게임 오브젝트 반환
return select;
}
그 다음에 GetObject 메서드가 poolObjectType을 매개변수로 받으면서 호출된다. 여기서 poolObjectType은 enum으로 인덱스 역할을 하며 다음과 같이 선언되어 있다.
public enum PoolObjectType
{
Bullet,
Enemy,
Item
}
그리고 GameObject 타입 변수, select를 선언해주고 매개변수로 받은 poolObjectType으로 pools에서 원하는 리스트가 있는 인덱스를 참조한다.
그리고 그 리스트에서 활성화되지 않은 오브젝트가 있다면, select는 그 오브젝트를 참조하고, 오브젝트를 활성화한다.
만약 리스트를 다 돌았는데도 활성화되지 않은 오브젝트가 없다면 (즉 모든 오브젝트가 사용 중이거나 아예 없다면)
Instantiate를 호출하고 생성된 오브젝트를 pools의 리스트에 추가한다.
예를 들어, 만약 매개변수로 Bullet이 들어오면 pools[0]에 위치한 리스트를 순회하고 만약 사용 중이지 않은 오브젝트가 있다면 걔를 반환하고, 없다면 생성 후 pools[0]에 위치한 리스트에 추가한다.
그리고 select(선택한 오브젝트)를 반환한다.
public void ReturnObject(GameObject obj)
{
// 반환할 오브젝트를 비활성화하여 풀에 다시 넣음
obj.SetActive(false);
}
ReturnObject 메서드에선 반환할 오브젝트를 매개변수로 받고
obj.SetActive(false);로 사용이 다 끝난 오브젝트를 비활성화 시켜준다.
int형 index 대신 enum을 사용했다.
그리고 오브젝트를 반환하기 위한 목적으로ReturnObject 메서드를 정의했는데 내가 만든 게임에선 obj.SetActive(false);로 사용해도 별 문제가 없었다.
하지만 아래처럼 추가적인 로직이 필요할 경우, ReturnObject 메서드를 사용하는 것이 더 나을 것이다.
public void ReturnObject(GameObject obj)
{
// 반환할 오브젝트를 비활성화하여 풀에 다시 넣음
obj.SetActive(false);
// 오브젝트의 계층 관리
bullet.transform.SetParent(Instance.transform);
// 이 외의 다른 로직들...
}
위의 PoolManager 클래스는 싱글톤 패턴도 적용되어 있는데 이는 다른 포스트에서 다뤄보도록 하겠다.
참고자료:
골드메탈 유튜브 강의
티스토리 포스트
베르의 프로그래밍 노트