Unity 오브젝트 풀링(Object Pooling) 정리
Unity로 게임을 만들다 보면 오브젝트를 계속 생성하고 삭제해야 하는 경우가 많다.
대표적인 예시는 다음과 같다.
총알
몬스터
이펙트
데미지 텍스트
아이템
예를 들어 플레이어가 총알을 쏠 때마다 Instantiate()로 총알을 만들고, 총알이 벽에 닿으면 Destroy()로 삭제한다고 해보자.
Instantiate(bulletPrefab);
Destroy(gameObject);
처음에는 문제가 없어 보이지만, 총알이 많아지고 생성과 삭제가 자주 반복되면 성능에 부담이 생길 수 있다.
이 문제를 해결하기 위해 사용하는 방식이 바로 오브젝트 풀링(Object Pooling)이다.
오브젝트 풀링이란?
오브젝트 풀링은 오브젝트를 필요할 때마다 새로 만들고 삭제하는 것이 아니라,
미리 여러 개 만들어두고 필요할 때 꺼내서 사용한 뒤, 다 쓰면 다시 보관하는 방식이다.
쉽게 말하면 기존 방식은 다음과 같다.
총알 생성 → 사용 → 삭제
오브젝트 풀링 방식은 다음과 같다.
총알 미리 생성 → 필요할 때 활성화 → 사용 → 비활성화 → 다시 재사용
즉, 오브젝트를 완전히 삭제하지 않고 SetActive(false)로 꺼두었다가,
필요할 때 다시 SetActive(true)로 켜서 사용하는 구조이다.
왜 오브젝트 풀링을 사용할까?
게임에서 오브젝트를 계속 생성하고 삭제하면 성능에 좋지 않을 수 있다.
특히 총알이나 이펙트처럼 짧은 시간 동안 많이 생성되는 오브젝트는 문제가 더 커질 수 있다.
예를 들어 총알을 1초에 여러 발씩 쏘는 게임이라면, 총알이 계속 생성되고 삭제된다.
Instantiate(bulletPrefab);
Destroy(bullet);
이 과정이 반복되면 Unity는 계속 새로운 오브젝트를 만들고, 필요 없어진 오브젝트를 정리해야 한다.
이때 메모리 관리 비용이 생기고, 경우에 따라 게임이 순간적으로 끊기는 현상이 발생할 수 있다.
오브젝트 풀링을 사용하면 생성과 삭제를 줄이고, 이미 만들어진 오브젝트를 재사용하기 때문에 성능을 더 안정적으로 유지할 수 있다.
오브젝트 풀링의 기본 흐름
총알을 예시로 들면 오브젝트 풀링의 흐름은 다음과 같다.
1. 게임 시작 시 총알을 여러 개 미리 만든다.
2. 만든 총알들을 전부 비활성화한다.
3. 플레이어가 총알을 쏘면 비활성화된 총알 하나를 가져온다.
4. 총알의 위치와 방향을 설정한다.
5. 총알을 활성화한다.
6. 총알이 벽에 닿거나 일정 시간이 지나면 비활성화한다.
7. 비활성화된 총알은 나중에 다시 사용한다.
이 방식의 핵심은 오브젝트를 삭제하지 않는 것이다.
Destroy(gameObject);
대신 다음처럼 비활성화한다.
gameObject.SetActive(false);
그리고 다시 필요할 때 활성화한다.
gameObject.SetActive(true);
Stack이란?
오브젝트 풀링을 구현할 때 자주 사용되는 자료구조가 Stack이다.
Stack은 나중에 넣은 데이터를 먼저 꺼내는 구조이다.
이를 LIFO라고 한다.
Last In First Out
나중에 들어온 것이 먼저 나간다
쉽게 비유하면 책이나 접시를 위로 쌓아두는 것과 비슷하다.
책을 순서대로 쌓는다고 해보자.
책 A
책 B
책 C
가장 나중에 올린 책은 C이다.
책을 다시 꺼낼 때는 맨 위에 있는 C부터 꺼내게 된다.
C → B → A
이것이 Stack의 기본 구조이다.
오브젝트 풀링에서 Stack을 쓰는 이유
오브젝트 풀링에서는 보통 두 가지 작업이 자주 일어난다.
1. 사용 가능한 오브젝트를 하나 꺼낸다.
2. 사용이 끝난 오브젝트를 다시 넣는다.
Stack은 이 작업을 간단하게 처리할 수 있다.
오브젝트를 넣을 때는 Push()를 사용한다.
pool.Push(obj);
오브젝트를 꺼낼 때는 Pop()을 사용한다.
GameObject obj = pool.Pop();
그래서 오브젝트 풀링 예제에서 Stack이 자주 등장한다.
Stack을 꼭 써야 할까?
결론부터 말하면 꼭 Stack을 써야 하는 것은 아니다.
오브젝트 풀링의 핵심은 Stack이 아니라 오브젝트를 재사용하는 구조이다.
즉, Stack은 오브젝트 풀링을 구현하는 여러 방법 중 하나일 뿐이다.
오브젝트 풀링에는 다음과 같은 자료구조를 사용할 수 있다.
| Stack | 나중에 반환된 오브젝트를 먼저 재사용 |
| Queue | 먼저 반환된 오브젝트를 먼저 재사용 |
| List | 직접 순회하면서 비활성화된 오브젝트를 찾음 |
| 배열 | 크기가 고정된 간단한 풀에 사용 가능 |
따라서 “오브젝트 풀링은 무조건 Stack을 써야 한다”는 말은 정확하지 않다.
더 정확히는 다음과 같다.
오브젝트 풀링을 구현할 때 Stack을 쓰면 편하다.
Stack을 사용한 오브젝트 풀링 예시
아래는 총알 오브젝트를 Stack으로 관리하는 간단한 예시이다.
using System.Collections.Generic;
using UnityEngine;
public class BulletPool : MonoBehaviour
{
[SerializeField] private GameObject bulletPrefab;
[SerializeField] private int poolSize = 20;
private Stack<GameObject> pool = new Stack<GameObject>();
private void Awake()
{
for (int i = 0; i < poolSize; i++)
{
GameObject bullet = Instantiate(bulletPrefab);
bullet.SetActive(false);
pool.Push(bullet);
}
}
public GameObject GetBullet()
{
if (pool.Count > 0)
{
GameObject bullet = pool.Pop();
bullet.SetActive(true);
return bullet;
}
GameObject newBullet = Instantiate(bulletPrefab);
newBullet.SetActive(true);
return newBullet;
}
public void ReturnBullet(GameObject bullet)
{
bullet.SetActive(false);
pool.Push(bullet);
}
}
코드 설명
먼저 Stack을 만든다.
private Stack<GameObject> pool = new Stack<GameObject>();
이 Stack은 사용하지 않는 총알들을 보관하는 공간이다.
총알 미리 생성하기
private void Awake()
{
for (int i = 0; i < poolSize; i++)
{
GameObject bullet = Instantiate(bulletPrefab);
bullet.SetActive(false);
pool.Push(bullet);
}
}
게임이 시작될 때 총알을 poolSize만큼 미리 만든다.
그리고 바로 사용하지 않을 것이기 때문에 비활성화한다.
bullet.SetActive(false);
그다음 Stack에 넣는다.
pool.Push(bullet);
이렇게 하면 사용 가능한 총알들이 풀 안에 보관된다.
총알 꺼내기
public GameObject GetBullet()
{
if (pool.Count > 0)
{
GameObject bullet = pool.Pop();
bullet.SetActive(true);
return bullet;
}
GameObject newBullet = Instantiate(bulletPrefab);
newBullet.SetActive(true);
return newBullet;
}
GetBullet() 함수는 풀에서 총알 하나를 꺼내는 함수이다.
GameObject bullet = pool.Pop();
Pop()을 사용하면 Stack 안에 있던 오브젝트 하나를 꺼낼 수 있다.
그리고 총알을 사용해야 하므로 활성화한다.
bullet.SetActive(true);
만약 풀 안에 남아 있는 총알이 없다면 새 총알을 생성한다.
GameObject newBullet = Instantiate(bulletPrefab);
총알 다시 반환하기
public void ReturnBullet(GameObject bullet)
{
bullet.SetActive(false);
pool.Push(bullet);
}
총알 사용이 끝났다면 삭제하지 않고 비활성화한다.
bullet.SetActive(false);
그리고 다시 Stack에 넣는다.
pool.Push(bullet);
이렇게 하면 나중에 다시 사용할 수 있다.
Queue를 사용해도 된다
Stack 대신 Queue를 사용할 수도 있다.
Queue는 먼저 들어온 데이터가 먼저 나가는 구조이다.
이를 FIFO라고 한다.
First In First Out
먼저 들어온 것이 먼저 나간다
Queue는 줄 서기와 비슷하다.
먼저 줄 선 사람이 먼저 나가는 것처럼, 먼저 넣은 오브젝트가 먼저 나온다.
Queue<GameObject> pool = new Queue<GameObject>();
Queue에 넣을 때는 Enqueue()를 사용한다.
pool.Enqueue(obj);
Queue에서 꺼낼 때는 Dequeue()를 사용한다.
GameObject obj = pool.Dequeue();
Stack과 Queue 둘 다 오브젝트 풀링에 사용할 수 있다.
차이는 어떤 오브젝트를 먼저 재사용하느냐이다.
List를 사용하는 방식
초보자 입장에서는 List 방식이 더 이해하기 쉬울 수 있다.
List 방식은 오브젝트들을 리스트에 저장해두고, 필요할 때 비활성화된 오브젝트를 찾아서 사용하는 방식이다.
using System.Collections.Generic;
using UnityEngine;
public class BulletPool : MonoBehaviour
{
[SerializeField] private GameObject bulletPrefab;
[SerializeField] private int poolSize = 20;
private List<GameObject> bullets = new List<GameObject>();
private void Awake()
{
for (int i = 0; i < poolSize; i++)
{
GameObject bullet = Instantiate(bulletPrefab);
bullet.SetActive(false);
bullets.Add(bullet);
}
}
public GameObject GetBullet()
{
foreach (GameObject bullet in bullets)
{
if (!bullet.activeInHierarchy)
{
bullet.SetActive(true);
return bullet;
}
}
GameObject newBullet = Instantiate(bulletPrefab);
newBullet.SetActive(true);
bullets.Add(newBullet);
return newBullet;
}
}
이 방식은 Stack이나 Queue보다 직관적이다.
비활성화된 총알을 직접 찾아서 다시 사용하는 방식이기 때문이다.
하지만 오브젝트 수가 많아지면 매번 리스트를 순회해야 하므로 비효율적일 수 있다.
Stack, Queue, List 중 무엇을 써야 할까?
초보자 기준으로는 이렇게 생각하면 된다.
| 이해하기 쉽게 만들고 싶다 | List |
| 간단하고 빠르게 꺼내 쓰고 싶다 | Stack |
| 먼저 반환된 오브젝트부터 쓰고 싶다 | Queue |
| 오브젝트 수가 고정되어 있다 | 배열 |
총알, 이펙트, 데미지 텍스트처럼 순서가 크게 중요하지 않은 오브젝트는 Stack을 많이 사용한다.
왜냐하면 그냥 사용 가능한 오브젝트 하나만 꺼내면 되기 때문이다.
오브젝트 풀링을 쓰기 좋은 경우
오브젝트 풀링은 모든 오브젝트에 무조건 사용할 필요는 없다.
다음처럼 자주 생성되고 사라지는 오브젝트에 사용하면 좋다.
총알
폭발 이펙트
피격 이펙트
데미지 텍스트
몬스터
아이템 드롭
반대로 한 번만 생성되고 오래 유지되는 오브젝트라면 굳이 풀링을 사용할 필요가 없다.
예를 들어 플레이어, 메인 카메라, 게임 매니저 같은 오브젝트는 보통 풀링 대상이 아니다.
오브젝트 풀링의 장점
오브젝트 풀링의 장점은 다음과 같다.
생성/삭제 비용을 줄일 수 있다.
게임 중 끊김 현상을 줄일 수 있다.
메모리를 더 안정적으로 사용할 수 있다.
자주 반복되는 오브젝트 관리에 좋다.
특히 슈팅 게임처럼 총알이 많이 나가는 게임에서는 오브젝트 풀링이 매우 유용하다.
오브젝트 풀링의 단점
오브젝트 풀링이 항상 좋은 것만은 아니다.
미리 오브젝트를 만들어두기 때문에 처음에 메모리를 어느 정도 사용한다.
또한 사용이 끝난 오브젝트를 제대로 초기화하지 않으면 문제가 생길 수 있다.
예를 들어 총알을 다시 사용할 때 이전 속도, 위치, 데미지 값이 남아 있으면 이상하게 동작할 수 있다.
그래서 풀에서 꺼낼 때는 필요한 값을 다시 설정해주는 것이 좋다.
bullet.transform.position = firePoint.position;
bullet.transform.rotation = firePoint.rotation;
최종 정리
오브젝트 풀링은 오브젝트를 계속 생성하고 삭제하지 않고,
미리 만들어둔 오브젝트를 재사용하는 최적화 기법이다.
기존 방식은 다음과 같다.
Instantiate → 사용 → Destroy
오브젝트 풀링 방식은 다음과 같다.
미리 생성 → 활성화 → 사용 → 비활성화 → 재사용
오브젝트 풀링을 사용하면 생성과 삭제를 줄일 수 있어 성능에 도움이 된다.
Stack은 오브젝트 풀링에서 자주 사용되는 자료구조이지만, 반드시 Stack을 써야 하는 것은 아니다.
Stack, Queue, List, 배열 모두 사용할 수 있다.
중요한 것은 어떤 자료구조를 쓰느냐가 아니라,
오브젝트를 삭제하지 않고 다시 재사용하는 구조를 만드는 것이다.