오브젝트 풀링(Object Pooling)

JP Kim·2024년 9월 19일

유니팅

목록 보기
1/5

오브젝트 풀링은 게임 개발에서 성능 최적화를 위해 사용하는 중요한 기법 중 하나다.
유니티에서 오브젝트를 생성하고 삭제할 때 흔히 InstantiateDestroy 메서드를 사용한다.
그러나, 오브젝트가 많아질수록 이는 비효율적이다. 이때 오브젝트 풀링을 사용하면 더 효율적인 메모리 관리를 할 수 있다. 예를 들어, 총알이나 적 오브젝트와 같은 자주 생성되는 오브젝트에 유용하다.

오브젝트 풀링의 동작 방식

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)의 길이만큼 선언해 준다.
예를 들어 prefabsBulletPrefab, 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을 매개변수로 받으면서 호출된다. 여기서 poolObjectTypeenum으로 인덱스 역할을 하며 다음과 같이 선언되어 있다.

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);로 사용이 다 끝난 오브젝트를 비활성화 시켜준다.

intindex 대신 enum을 사용했다.
그리고 오브젝트를 반환하기 위한 목적으로ReturnObject 메서드를 정의했는데 내가 만든 게임에선 obj.SetActive(false);로 사용해도 별 문제가 없었다.

하지만 아래처럼 추가적인 로직이 필요할 경우, ReturnObject 메서드를 사용하는 것이 더 나을 것이다.

    public void ReturnObject(GameObject obj)
    {
        // 반환할 오브젝트를 비활성화하여 풀에 다시 넣음
        obj.SetActive(false);
        // 오브젝트의 계층 관리
		bullet.transform.SetParent(Instance.transform);

		// 이 외의 다른 로직들...
    }

위의 PoolManager 클래스는 싱글톤 패턴도 적용되어 있는데 이는 다른 포스트에서 다뤄보도록 하겠다.

참고자료:
골드메탈 유튜브 강의
티스토리 포스트
베르의 프로그래밍 노트

profile
당신을 한 줄로 소개해보세요

0개의 댓글