본문 바로가기
개발💻/Unity

[Unity] Object Pooling

by Sports Entrepreneur 2024. 10. 27.

 오브젝트 풀링이란?

오브젝트 풀링은 필요한 오브젝트를 매번 새로 생성하고 삭제하는 대신, 미리 생성해둔 오브젝트들을 재사용하는 기법입니다. 특히 총알, 적, 파티클 등 다량의 오브젝트가 빈번하게 생성되고 소멸되는 경우 큰 효과를 볼 수 있습니다. 이 방법은 객체 생성과 삭제 시 발생하는 메모리 할당과 해제 비용을 줄이고, 성능을 개선하는 데 도움이 됩니다.

하지만 언제나 오브젝트 풀링이 필요한 것은 아닙니다. 오브젝트 풀링을 무조건 적용하면 오히려 코드가 복잡해지고 관리가 어려워질 수 있습니다. 따라서 오브젝트 풀링을 적용할 때는 해당 상황에 대한 비판적 사고가 필요합니다. 과연 지금 이 상황에서 오브젝트 풀링이 성능 최적화에 기여할 만큼 중요한가? 라는 질문을 던져봐야 합니다.

 

 Unity에서 오브젝트 풀링 구현 방법

Step 1: 기본적인 Pool 클래스 생성

오브젝트 풀링을 구현하려면 먼저 Pool 클래스를 만들어야 합니다. 이 클래스는 미리 생성된 오브젝트들을 저장하는 리스트와 오브젝트를 가져오거나 반환하는 메서드를 포함합니다.

public class ObjectPool : MonoBehaviour
{
    [SerializeField] private GameObject prefab;
    private List<GameObject> pool = new List<GameObject>();

    public GameObject GetObject()
    {
        foreach (GameObject obj in pool)
        {
            if (!obj.activeInHierarchy)
            {
                obj.SetActive(true);
                return obj;
            }
        }

        GameObject newObj = Instantiate(prefab);
        pool.Add(newObj);
        return newObj;
    }

    public void ReturnObject(GameObject obj)
    {
        obj.SetActive(false);
    }
}

 

Step 2: 미리 오브젝트 생성하기

게임의 시작 부분에 미리 오브젝트를 생성하여 풀에 추가해두면 첫 프레임에서 발생할 수 있는 성능 저하를 방지할 수 있습니다.

void Start()
{
    for (int i = 0; i < 10; i++)
    {
        GameObject obj = Instantiate(prefab);
        obj.SetActive(false);
        pool.Add(obj);
    }
}

 

Step 3: 게임 플레이 중 사용 및 반환

 

게임 플레이 중 필요할 때 GetObject를 호출해 오브젝트를 활성화하고, 사용 후에는 ReturnObject를 통해 비활성화하여 풀로 반환합니다.

 

 

오브젝트 풀링 vs Instantiate/Destroy 성능 비교  

성능 테스트를 위해 두 가지 방식의 코드를 작성했습니다.

  1. 오브젝트 풀링 방식: 미리 생성해 둔 오브젝트를 Queue에 저장했다가 필요할 때 꺼내 사용하고, 필요하지 않을 때 다시 Queue에 반환하여 관리합니다.
  2. Instantiate/Destroy 방식: 매번 Instantiate로 오브젝트를 생성하고 Destroy로 파괴하는 방식입니다.

각 테스트는 5,000개의 오브젝트를 10번 반복해서 생성 및 비활성화하거나 파괴하며, 각 방식의 실행 시간을 측정했습니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ObjectPoolingMemoryTest : MonoBehaviour
{
    // 생성할 오브젝트 프리팹
    public GameObject objectPrefab;
    // 풀에 미리 생성할 오브젝트 수
    public int objectCount = 5000;
    // 반복 테스트 횟수
    public int repeatCount = 10;

    // 오브젝트 풀을 저장할 큐
    private Queue<GameObject> objectPool = new Queue<GameObject>();

    void Start()
    {
        // 초기 오브젝트 풀 생성: 지정된 개수만큼 미리 오브젝트를 생성하여 비활성화 상태로 풀에 저장
        for (int i = 0; i < objectCount; i++)
        {
            GameObject obj = Instantiate(objectPrefab); // 프리팹을 기반으로 오브젝트 생성
            obj.SetActive(false); // 생성된 오브젝트를 비활성화
            objectPool.Enqueue(obj); // 풀에 추가
        }
    }

    void Update()
    {
        // 숫자 '1' 키를 눌렀을 때 풀링 방식으로 오브젝트 생성 및 재사용 테스트
        if (Input.GetKeyDown(KeyCode.Alpha1))
        {
            StartCoroutine(SpawnWithPooling());
        }

        // 숫자 '2' 키를 눌렀을 때 Instantiate/Destroy 방식으로 오브젝트 생성 및 삭제 테스트
        if (Input.GetKeyDown(KeyCode.Alpha2))
        {
            StartCoroutine(SpawnWithInstantiateDestroy());
        }
    }

    // 오브젝트 풀링을 사용하여 오브젝트를 스폰하고 성능을 측정하는 코루틴
    IEnumerator SpawnWithPooling()
    {
        float startTime = Time.realtimeSinceStartup; // 시작 시간 기록

        // 지정된 횟수만큼 반복 실행
        for (int i = 0; i < repeatCount; i++)
        {
            List<GameObject> activeObjects = new List<GameObject>(); // 활성화된 오브젝트 리스트 생성

            // 지정된 수만큼 오브젝트를 활성화
            for (int j = 0; j < objectCount; j++)
            {
                if (objectPool.Count > 0) // 풀에 오브젝트가 있을 때만 실행
                {
                    GameObject obj = objectPool.Dequeue(); // 풀에서 오브젝트 가져오기
                    obj.SetActive(true); // 오브젝트 활성화
                    obj.transform.position = Random.insideUnitSphere * 5f; // 임의의 위치에 배치
                    activeObjects.Add(obj); // 활성화된 오브젝트 리스트에 추가
                }
            }

            yield return new WaitForSeconds(0.1f); // 0.1초 대기 (오브젝트가 활성화된 상태 유지)

            // 활성화된 오브젝트를 비활성화하고 풀에 다시 추가
            foreach (var obj in activeObjects)
            {
                obj.SetActive(false); // 오브젝트 비활성화
                objectPool.Enqueue(obj); // 풀에 반환
            }

            yield return new WaitForSeconds(0.1f); // 0.1초 대기
        }

        float endTime = Time.realtimeSinceStartup; // 종료 시간 기록
        Debug.Log("Object Pooling Time: " + (endTime - startTime) + " seconds"); // 실행 시간 출력
    }

    // Instantiate와 Destroy를 사용하여 오브젝트를 생성 및 삭제하고 성능을 측정하는 코루틴
    IEnumerator SpawnWithInstantiateDestroy()
    {
        float startTime = Time.realtimeSinceStartup; // 시작 시간 기록

        // 지정된 횟수만큼 반복 실행
        for (int i = 0; i < repeatCount; i++)
        {
            List<GameObject> activeObjects = new List<GameObject>(); // 활성화된 오브젝트 리스트 생성

            // 지정된 수만큼 오브젝트를 새로 생성
            for (int j = 0; j < objectCount; j++)
            {
                GameObject obj = Instantiate(objectPrefab); // 새로운 오브젝트 생성
                obj.transform.position = Random.insideUnitSphere * 5f; // 임의의 위치에 배치
                activeObjects.Add(obj); // 활성화된 오브젝트 리스트에 추가
            }

            yield return new WaitForSeconds(0.1f); // 0.1초 대기 (오브젝트가 활성화된 상태 유지)

            // 생성된 오브젝트를 삭제
            foreach (var obj in activeObjects)
            {
                Destroy(obj); // 오브젝트 삭제
            }

            yield return new WaitForSeconds(0.1f); // 0.1초 대기
        }

        float endTime = Time.realtimeSinceStartup; // 종료 시간 기록
        Debug.Log("Instantiate/Destroy Time: " + (endTime - startTime) + " seconds"); // 실행 시간 출력
    }
}


오브젝트 풀링 결과 영상 

Instantiate/Destroy 결과 영상

 

 

오브젝트 풀링의 장단점  

오브젝트 풀링은 분명한 장점이 있지만, 단점도 존재합니다. 이를 명확히 이해하고 필요에 따라 유연하게 활용하는 것이 중요합니다.

장점

  • 성능 향상: 오브젝트의 빈번한 생성 및 삭제를 피할 수 있어 메모리 할당/해제와 관련된 비용을 절감합니다.
  • GC(Garbage Collection) 부담 감소: 오브젝트를 재사용하므로 메모리 압박이 줄어들고, GC로 인한 성능 저하를 방지할 수 있습니다.

단점 및 주의사항

  • 초기 메모리 비용: 오브젝트 풀을 미리 구성하려면 초기 메모리 자원을 더 많이 요구할 수 있으며, 제한된 메모리 환경에서 부담이 될 수 있습니다.
  • 복잡한 관리 필요성: 풀에서 오브젝트 상태를 계속 추적하고 관리해야 하며, 코드가 복잡해질 수 있습니다.
  • 과도한 풀 크기: 필요한 양 이상으로 오브젝트를 미리 생성하면 오히려 메모리 낭비로 이어질 수 있습니다.

 

오브젝트 풀링의 한계와 대안 기술 

오브젝트 풀링이 모든 상황에서 최고의 성능을 보장하는 것은 아닙니다. 아래와 같은 경우에는 오브젝트 풀링이 효과적이지 않거나, 오히려 성능을 저하할 수 있습니다.

  • 일회성 생성 오브젝트: 일회성 오브젝트는 굳이 풀링할 필요가 없으며, 불필요한 메모리 사용을 초래할 수 있습니다.
  • 복잡한 오브젝트 풀링 구조: 지나치게 복잡한 구조는 코드 유지보수에 부담을 줄 수 있습니다.

대안: Unity의 Addressables 시스템

최근 Unity에서는 Addressables를 통해 리소스 관리를 보다 유연하게 할 수 있는 시스템을 제공하고 있습니다. Addressables는 런타임에서 리소스를 관리하며, 메모리와 퍼포먼스를 효율적으로 관리할 수 있도록 도와줍니다. Addressables을 활용하면, 초기 메모리 로드가 부담스러운 경우에 대안으로 고려할 수 있습니다.

 

오브젝트 풀링 성능 최적화 팁 

Unity에서 오브젝트 풀링을 최적화하려면 아래의 팁들을 고려해 보세요.

  • 메모리 Pool 크기 최적화: 풀의 크기는 필요한 만큼만 유지하는 것이 좋습니다. 필요 시 동적으로 확장할 수 있도록 코드를 작성합니다.
  • Multi-threading 활용: Unity의 Job System이나 Task와 같은 비동기적 프로그래밍을 통해 풀에 오브젝트를 생성하거나 삭제하는 부분을 백그라운드에서 처리할 수 있습니다.
  • 메모리 Alignment 고려: Android나 iOS처럼 제한된 메모리 환경에서는 메모리 효율을 높이기 위해 오브젝트 할당 위치와 크기를 신경 쓰는 것이 중요합니다.

 

결론: 오브젝트 풀링의 적절한 활용이 핵심  

오브젝트 풀링은 잘 활용하면 성능을 향상할 수 있지만, 무분별하게 적용하면 오히려 프로젝트의 복잡도와 메모리 사용량을 증가시킬 수 있습니다. 필요에 따라 Addressables와 같은 대안 기술을 검토하고, 풀의 크기나 관리 방식을 최적화하는 것이 중요합니다. 또한 성능 향상이 반드시 필요하지 않은 경우에는 오브젝트 풀링을 피하는 것도 좋은 선택이 될 수 있습니다.