개요
현재 개발중인 Keeping Night에서 사용하는 오브젝트 풀링 기법을 어떻게 구현하였는지 소개하고자 한다.
내용
배경
인게임에서 플레이어블 캐릭터와, 대다수의 적 유닛들이 총을 사용한다.
이 때문에, 많은 총알 객체가 반복 생성 및 제거되어 성능 저하와 메모리 단편화 우려가 있었고 이를 해결하기 위해 풀링 시스템을 도입하였다.
문제
유니티에서 객체가 반복적으로 생성 및 제거되면 다음과 같은 문제가 있다.
성능 저하
Unity에서 객체를 반복적으로 생성하고 제거하면 성능 저하가 발생할 수 있다.
객체를 생성할 때는 메모리 할당과 초기화 등의 연산이 필요하다. 따라서 짧은 시간에 많은 객체가 생성될 경우 프레임 드랍과 같은 성능 문제가 발생할 수 있다.
객체를 제거할 때는 가비지 컬렉션이 일어난다. GC가 메모리를 과정에서 추가적인 CPU 자원을 소모하여 성능 저하를 초래할 수 있다.
메모리 단편화
메모리 단편화(memory fragmentation)는 메모리 할당과 해제가 반복되면서 사용 가능한 메모리가 여러 작은 조각으로 나뉘어, 연속된 큰 메모리 블록을 할당하지 못하게 되는 현상이다. 이 문제는 연속적인 메모리 공간이 필요한 경우에 특히 두드러지며, 메모리 부족 에러나 성능 저하를 유발할 수 있다.
Unity GC(Boehm GC)는 .NET C#의 GC(CoreCLR GC)와는 다르게 메모리 압축을 실행하지 않는다. 따라서 메모리 단편화 문제가 발생할 수 있으며, 잦은 객체의 생성 및 제거는 이 문제를 야기할 수 있다.
이를 해결하기 위하여, Unity의 오브젝트 풀을 이용하여 풀링 시스템을 구축하였다.
해결
ComponentPoolBase 클래스 구현
public abstract class ComponentPoolBase<T> : MonoBehaviour where T : Component
{
[SerializeField] private T prefab;
protected ObjectPool<T> objectPool;
[SerializeField] private int defaultCapacity = 10;
[SerializeField] private int maxSize = 20;
protected virtual void Awake()
{
objectPool = new ObjectPool<T>(
CreatePooledItem,
OnTakeFromPool,
OnReturnedToPool,
OnDestroyPoolObject,
true,
defaultCapacity,
maxSize);
}
protected virtual T CreatePooledItem()
{
return Instantiate(prefab);
}
protected virtual void OnTakeFromPool(T item)
{
item.gameObject.SetActive(true);
}
protected virtual void OnReturnedToPool(T item)
{
item.gameObject.SetActive(false);
}
protected virtual void OnDestroyPoolObject(T item)
{
Destroy(item.gameObject);
}
public T GetObject()
{
return objectPool.Get();
}
public void ReturnObject(T item)
{
objectPool.Release(item);
}
}
public class BulletPool : ComponentPoolBase<Bullet>
{
protected override Bullet CreatePooledItem()
{
Bullet bullet = base.CreatePooledItem();
bullet.SetPool(this); // Bullet에게 자신의 풀을 설정
return bullet;
}
}
먼저 위와 같이 UnityEngine.Pool.ObjectPool을 사용하여 풀을 구현하였다. 그리고 이를 상속받는 BulletPool을 구현하였다.
구현 후에는 다음과 같이 사용하였다.
// Gun 클래스 내부
protected virtual void ProcessBulletFire(Vector2 direction)
{
Bullet bullet = bulletPool.GetObject();
// ...
}
// Bullet 클래스 내부
private async UniTask ReturnBulletToPool()
{
// ...
_bulletPool.ReturnObject(this);
}
의도한 대로 동작함을 볼 수 있었다.
응용
디버프 시스템을 설계하면서, 특정 총알에 피격된 상대에게 디버프를 적용해야 하는 요구 사항이 생겼다. 이 과정에서 디버프 객체 역시 반복적으로 생성 및 제거될 경우 성능 저하와 메모리 단편화 문제가 발생할 수 있어 재사용이 필요했다.
디버프 객체는 Monobehaviour의 제어를 받을 필요가 없어 일반 클래스로 구현하였으며, 따라서 위에서 구현한 제약조건이 달린 ComponentPoolBase는 사용할 수 없었다.
따라서 다음과 같이 InstancePoolBase와 DebuffPool을 구현하였다.
public abstract class InstancePoolBase<T> where T : class
{
private readonly ObjectPool<T> _objectPool;
protected InstancePoolBase(int defaultCapacity = 10, int maxSize = 100)
{
_objectPool = new ObjectPool<T>(
CreatePooledItem,
OnTakeFromPool,
OnReturnedToPool,
OnDestroyPoolObject,
true,
defaultCapacity,
maxSize);
}
protected abstract T CreatePooledItem();
protected virtual void OnTakeFromPool(T deBuff) { }
protected virtual void OnReturnedToPool(T deBuff) { }
protected virtual void OnDestroyPoolObject(T deBuff) { }
public T GetObject()
{
return _objectPool.Get();
}
public void ReturnObject(T item)
{
_objectPool.Release(item);
}
}
public class DeBuffPool : InstancePoolBase<DeBuffData>
{
private readonly EWeaponDeBuffType _weaponDeBuffType;
public DeBuffPool(EWeaponDeBuffType weaponDeBuffType, int defaultCapacity = 100, int maxSize = 100)
: base(defaultCapacity, maxSize)
{
_weaponDeBuffType = weaponDeBuffType;
}
protected override DeBuffData CreatePooledItem()
{
var data = WeaponDeBuffCreateModule.CreateDeBuffData(_weaponDeBuffType);
return data;
}
}
디버프 시스템의 요구사항에 맞춰 객체 풀링을 확장하고, 일반 객체를 관리할 수 있는 구조로 성공적으로 응용하였다.
마무리
반복적인 객체 생성과 제거로 인한 성능 저하와 메모리 단편화 문제를 효과적으로 해결하였고, 추상화된 풀을 통해 총알과 디버프와 같은 다양한 객체를 관리할 수 있는 확장 가능한 구조로 구현되었다.
앞으로도 비슷한 문제를 해결해야할 때, 더 빠르게 문제를 해결할 수 있을 것이다.
'프로젝트 > Keeping Night' 카테고리의 다른 글
KEEPING NIGHT 프로젝트 소개 (0) | 2025.04.08 |
---|---|
조준 방향 기반 탄착군 구현 (0) | 2025.04.06 |
로딩 시스템 구현 (2) | 2025.01.03 |
씬 구조 설계 (0) | 2025.01.03 |