반응형

Programming 

오브젝트 풀링 기법

 

 

 

작성 기준 버전 :: 2019.2

 

프로그래밍에서 오브젝트를 생성하거나 파괴하는 작업은 꽤나 무거운 작업으로 분류된다. 오브젝트 생성은 메모리를 새로 할당하고 리소스를 로드하는 등의 초기화하는 과정으로, 오브젝트 파괴는 파괴 이후에 발생하는 가비지 컬렉팅으로 인한 프레임 드랍이 발생할 수 있다.

 

이러한 문제를 해결하기 위해서 사용되는 기법이 바로 오브젝트 풀링(Object pooling)이다.

 

오브젝트 풀링의 개념

 

오브젝트 풀링의 개념은 아주 간단하다. 

 

 

먼저 풀링을 할 오브젝트를 담을 오브젝트 풀을 구성한다.

 

 

그 다음 풀링할 오브젝트를 생성해서 오브젝트 풀에서 그 오브젝트들을 관리하게 만든다.

 

 

외부에서 해당 오브젝트가 필요하면 오브젝트 풀에서 꺼내간다.

 

 

오브젝트 풀에서 꺼낸 오브젝트의 사용이 끝나면 오브젝트를 풀에 돌려준다.

 

 

오브젝트 풀에서 오브젝트를 가져오려고 할 때, 모든 오브젝트가 이미 사용중이라면 새로운 오브젝트를 생성해서 꺼내준다.

 

이것이 바로 오브젝트 풀의 개념이다. 게임에 필요한 오브젝트를 미리 생성해서 필요할 때마다 꺼내쓰고 사용이 끝나면 오브젝트 풀에 돌려주는 것이다. 이렇게 함으로써, 오브젝트가 필요할 때마다 생성하고 다 쓰면 파괴하는 것이 아니라, 게임이 시작될 때, 필요한 만큼의 오브젝트만 생성하고, 모자라면 추가로 생성하고, 게임이 끝나면 파괴하는 방식으로 오브젝트의 생성/파괴 횟수를 줄일 수 있게 된다.

 

오브젝트 풀링 기법은 특히 메모리가 부족하고 CPU의 성능이 낮은 디바이스의 사양이 낮았던 예전에 주로 사용되었던 기법이지만, 현재에 와서도 게임의 최적화를 위해서 많이 사용되며, 모바일 게임이 많이 제작되면서 PC에 비해서 모자란 디바이스 성능으로 인해서 재조명 받는 기법이다.

 

보통 자주 생성되었다가 파괴되어야 하는 총알이나, 캐릭터가 뛸 때 발생하는 먼지 이펙트 같은 곳에 많이 사용되는 기법이다.

 

 

유니티 엔진에서의 구현

 

기본 세팅

 

기본적인 세팅은 다음과 같다.

 

 

씬 한 가운데 캐릭터의 역할을 할 초록색 박스를 배치했다.

 

public class Shooter : MonoBehaviour
{
    [SerializeField]
    private GameObject bulletPrefab;

    private Camera mainCam;

    void Start()
    {
        mainCam = Camera.main;
    }

    void Update()
    {
        if(Input.GetMouseButton(0))
        {
            RaycastHit hitResult;
            if(Physics.Raycast(mainCam.ScreenPointToRay(Input.mousePosition), out hitResult))
            {
                var bullet = Instantiate(bulletPrefab).GetComponent<Bullet>();
                var direction = new Vector3(hitResult.point.x, transform.position.y, hitResult.point.z) - transform.position;
                bullet.transform.position = direction.normalized;
                bullet.Shoot(direction.normalized);
            }
        }
    }
}

 

그리고 박스 게임 오브젝트에는 마우스 클릭 방향으로 총알 게임 오브젝트를 생성해서 발사하는 Shooter 컴포넌트가 부착되어 있다.

 

 

public class Bullet : MonoBehaviour
{
    private Vector3 direction;
    public void Shoot(Vector3 direction)
    {
        this.direction = direction;
        Destroy(gameObject, 5f);
    }

    void Update()
    {
        transform.Translate(direction);   
    }
}

 

그리고 지정된 방향으로 5초 간 날아가다 자동으로 소멸하는 총알 프리팹 역시 만들어두었다.

 

 

이것을 테스트 해보면 마우스를 클릭하면 엄청난 양의 총알 오브젝트가 새로 생성되고 시간이 지나면 모두 파괴되는 것을 볼 수 있다.

 

 

이 상황을 프로파일러를 통해서 확인해보면 총알을 생성하는 Instantiate와 파괴하는 Destroy가 어느 정도 성능을 잡아먹고 있는 것을 확인할 수 있다.

 

오브젝트 풀 구현

 

public class ObjectPool : MonoBehaviour
{
    public static ObjectPool Instance;

    [SerializeField]
    private GameObject poolingObjectPrefab;

    Queue<Bullet> poolingObjectQueue = new Queue<Bullet>();

    private void Awake()
    {
        Instance = this;

        Initialize(10);
    }

    private void Initialize(int initCount)
    {
        for(int i = 0; i < initCount; i++)
        {
            poolingObjectQueue.Enqueue(CreateNewObject());
        }
    }

    private Bullet CreateNewObject()
    {
        var newObj = Instantiate(poolingObjectPrefab).GetComponent<Bullet>();
        newObj.gameObject.SetActive(false);
        newObj.transform.SetParent(transform);
        return newObj;
    }

    public static Bullet GetObject()
    {
        if(Instance.poolingObjectQueue.Count > 0)
        {
            var obj = Instance.poolingObjectQueue.Dequeue();
            obj.transform.SetParent(null);
            obj.gameObject.SetActive(true);
            return obj;
        }
        else
        {
            var newObj = Instance.CreateNewObject();
            newObj.gameObject.SetActive(true);
            newObj.transform.SetParent(null);
            return newObj;
        }
    }

    public static void ReturnObject(Bullet obj)
    {
        obj.gameObject.SetActive(false);
        obj.transform.SetParent(Instance.transform);
        Instance.poolingObjectQueue.Enqueue(obj);
    }
}

 

오브젝트 풀링 기법을 구현하는 기초적인 코드의 전체는 위의 코드와 같다. 

 

public static ObjectPool Instance;

private void Awake()
{
    Instance = this;

    Initialize(10);
}

 

이 코드를 하나씩 살펴보자. 우선 오브젝트 풀의 경우 오브젝트를 생성하는 어떤 코드에서든 접근이 가능해야하는 경우가 많기 때문에 싱글톤 패턴으로 구현되는 경우가 많다. 

 

[SerializeField]
private GameObject poolingObjectPrefab;

 

그리고 생성해야할 오브젝트의 프리팹을 가지고 있다. 프리팹을 가지고 있어야 하는 이유는 처음 생성해둔 오브젝트를 모두 꺼내주고 나서, 꺼내준 오브젝트를 미처 돌려받기 전에, 오브젝트를 요청받았을 때, 새로운 오브젝트를 생성해서 꺼내주기 위한 것이다.

 

Queue<Bullet> poolingObjectQueue = new Queue<Bullet>();

 

큐(Queue)를 이용해서 생성된 총알 오브젝트를 순서대로 관리한다. 만약 오브젝트 풀에서 오브젝트를 꺼내주는 순서와 돌려받는 순서를 제대로 관리하지 못하면, 하나의 오브젝트를 여러 곳에 빌려주는 등의 문제가 발생할 수 있다.

 

private void Awake()
{
    Instance = this;

    Initialize(10);
}

 

게임 오브젝트가 생성되면 제일 먼저 실행되는 Awake() 함수에서는 싱글톤 패턴에서 사용되는 오브젝트 풀 인스턴스에 자신을 할당하는 일과 오브젝트 풀을 초기화하는 Initialize() 함수를 호출한다. 

 

private void Initialize(int initCount)
{
    for(int i = 0; i < initCount; i++)
    {
        poolingObjectQueue.Enqueue(CreateNewObject());
    }
}

 

Initialze() 함수에서는 매개변수로 받은 초기화 갯수 값에 따라서 CreateNewObject() 함수에서 만들어진 새로운 총알 오브젝트를 pooling Object Queue에 넣어준다. 이렇게 게임이 시작하기 전에 사용될 게임 오브젝트를 미리 적절한 갯수를 만들어 줌으로써 게임 플레이 도중에 발생할 과부하를 게임 준비 과정인 로딩으로 돌릴 수 있다. 모두 잘 알겠지만, 플레이어는 아주 조금 더 긴 로딩은 당장 용납하지만, 플레이 도중에 발생하는 렉은 용납하지 못한다.

 

private Bullet CreateNewObject()
{
    var newObj = Instantiate(poolingObjectPrefab).GetComponent<Bullet>();
    newObj.gameObject.SetActive(false);
    newObj.transform.SetParent(transform);
    return newObj;
}

 

CreateNewObject() 함수는 poolingObjectPrefab으로부터 새 게임 오브젝트를 만든 뒤 비활성화해서 반환하는 역할을 한다. 이렇게 곧바로 비활성화하는 이유는 아직 오브젝트 풀이 꺼내주지 않은 오브젝트는 비활성화된 것으로써 플레이어의 눈에 띄지 않아야하기 때문이다.

 

public static Bullet GetObject()
{
    if(Instance.poolingObjectQueue.Count > 0)
    {
        var obj = Instance.poolingObjectQueue.Dequeue();
        obj.transform.SetParent(null);
        obj.gameObject.SetActive(true);
        return obj;
    }
    else
    {
        var newObj = Instance.CreateNewObject();
        newObj.gameObject.SetActive(true);
        newObj.transform.SetParent(null);
        return newObj;
    }
}

 

GetObject() 함수는 오브젝트 풀이 가지고 있는 게임 오브젝트를 요청한 자에게 꺼내주는 역할을 한다. 단, 모든 오브젝트를 꺼내서 빌려줘서 poolingObjectQueue에 빌려줄 오브젝트가 없는 상태라면 CreateNewObject() 함수를 호출해서 새로운 오브젝트를 생성해서 빌려준다.

 

public static void ReturnObject(Bullet obj)
{
    obj.gameObject.SetActive(false);
    obj.transform.SetParent(Instance.transform);
    Instance.poolingObjectQueue.Enqueue(obj);
}

 

ReturnObject() 함수는 빌려준 오브젝트를 돌려받는 함수이다. 돌려받은 오브젝트를 비활성화한 뒤 정리하는 일을 처리한다.

 

무엇이든지 빌려줬으면 돌려받아야 한다. 이 원칙은 프로그래밍에서도 매우 중요한 원칙으로 사용한 오브젝트를 제대로 돌려받지 않으면 GetObject() 함수를 호출할 때, 계속해서 새로운 오브젝트를 생성하고 사라지지 않은 오브젝트들이 씬에 쌓이게 된다. 이것이 바로 메모리 누수이다.

 

 

코드 작성이 끝나면 씬에 새 게임 오브젝트를 배치하고 오브젝트 풀 컴포넌트를 부착하고 pooling Object Prefab 프로퍼티에 풀링해야할 게임 오브젝트의 프리팹을 할당해주면 된다.

 

 

그 다음에는 총알 오브젝트를 생성하는 부분과 파괴하는 부분을 수정해야 한다.

 

public class Shooter : MonoBehaviour
{
    void Update()
    {
        if(Input.GetMouseButton(0))
        {
            RaycastHit hitResult;
            if(Physics.Raycast(mainCam.ScreenPointToRay(Input.mousePosition), out hitResult))
            {
                var bullet = ObjectPool.GetObject(); // 수정
                var direction = new Vector3(hitResult.point.x, transform.position.y, hitResult.point.z) - transform.position;
                bullet.transform.position = direction.normalized;
                bullet.Shoot(direction.normalized);
            }
        }
    }
}

 

우선 생성하는 부분은 Shooter 클래스의 Update() 함수에 Instanitate()로 총알을 생성하던 부분을 ObjectPool.GetObject()로 대체하면 된다.

 

public class Bullet : MonoBehaviour
{
    private Vector3 direction;
    public void Shoot(Vector3 direction)
    {
        this.direction = direction;
        Invoke("DestroyBullet", 5f);
    }

    public void DestroyBullet()
    {
        ObjectPool.ReturnObject(this);
    }

    void Update()
    {
        transform.Translate(direction);   
    }
}

 

그 다음 파괴하는 부분은 Bullet 클래스에서 DestoryBullet() 함수에서 ObjectPool.ReturnObject()를 호출해서 오브젝트 풀에 돌려주도록 구현하고 Shoot() 함수에서 Destory() 함수를 호출하던 부분을 Invoke()로 5초 뒤에 DestoryBullet() 함수를 호출하도록 수정한다.

 

 

 

플레이를 해서 테스트 해보면 총알이 일정 갯수만큼 생성되고 나면 더 이상 생성되지 않으며, 시간이 지나면 소멸되어야 하는 총알 오브젝트들이 비활성화되서 오브젝트 풀로 반환되는 것을 확인할 수 있다.

 

 

프로파일러를 통해서 확인해보면 최대 생성량에 도달한 이후에는 Instatiate와 Destroy로 인한 작업이 발생하지 않는 것을 볼 수 있다.

반응형
  1. 료용 2020.05.04 01:14 신고

    베르님 안녕하세요 유투브 잘보고있습니다.

    이 글 정말 좋은글인데 제가 이해를 못하는부분이있어서 질문드립니다.

    Initialze() 이 함수 awake에서 10으로 주시지않았습니까? 그러면 queue에 10개의 bullet의 오브젝트가 들어있는거지않습니까??

    그런데 그러면 10개가 넘는다면 새로운 bullet를 생성해주시는데

    var newObj = Instance.CreateNewObject();

    newObj.gameObject.SetActive(true);

    newObj.transform.SetParent(null);

    return newObj;

    여기서 이렇게 새로 생성한다음에

    ReturnObject()

    으로 받는다면 queue에는 지금 영상에서 보듯이 수십개의 bullet가 저장되는 개념아닌가요?

    그렇다면 처음에 Initialze() 에 10으로 줄이유가없지않나요? 이부분이 너무헷갈려서 .. 유투브에서 물어보려다가 너무길게 될 질문이라 여기다가 올립니다 ㅠㅠ

    • wergia 2020.05.04 12:47 신고

      Awake 함수에서 Initialize로 10개를 미리 만들어두는 것은 게임에서 사용될 오브젝트의 갯수를 예측해서 미리 만들어두는 것입니다.

      예시에서는 미리 만들어둔 갯수를 모두 사용하고나면 이후에 생성되는 것을 보여드리기 위해서 10개만 만들었지만 실제로는 게임을 여러 번 실행해서 테스트를 진행해보고 평균적으로 게임에 몇 개의 오브젝트가 필요한지 산출해낸 뒤 그 갯수를 미리 생성해두게 만듭니다.

      그러면 게임이 시작될 때 그 오브젝트 갯수를 미리 생성해두는 과정이 처리됩니다.

      이렇게 미리 필요한 갯수를 적당히 생성해두면 대부분은 미리 생성해둔 오브젝트를 사용하고 오브젝트가 부족해지면 새로 생성해서 사용하게 됩니다.

    • 료용 2020.05.04 15:20 신고

      그렇다면 혹시 10개정도만 쓰겠다! 라는느낌으로 awake에 해놓은건가요? 어차피 10개이상 bullet가 생성된다면 queue 의 사이즈는 bullet의 반납되는 수만큼 늘어났다가 다시 queue에 있던 bullet을 빌려주는 이런개념인거죠? 10개를 훨씬넘어서는

    • wergia 2020.05.04 22:21 신고

      10개 정도 사용할 것 같다고 예측하고 10개만 생성해둔 겁니다. 하지만 예시에서는 예측을 넘어서서 더 많은 양을 사용하게 되었으니 추가로 생성한 거구요.

      그리고 추가로 생성한 Bullet은 사용이 모두 완료된 후에 다시 Queue로 반납되어서 보관됩니다.

    • 료용 2020.05.08 19:12 신고

      감사합니다 베르님 확이해갔어요

    • wergia 2020.05.08 20:21 신고

      다행입니다 ㅜㅜ
      설명하기가 어려워서 이해 잘하셨을까 했는데

    • 료용 2020.05.08 20:57 신고

      죄송합니다 ...

      베르님 딱하나만 더...

      그런데 bullet을 많이생성하게되면 queue가 이때까지 최대로 많이생성했던 bullet의 갯수만큼의 사이즈를 가지게되지않나요?
      그러면 그거대로 느려지게되는 이유가되지않는건가요?

    • wergia 2020.05.09 11:08 신고

      만약 풀링하는 오브젝트가 사용하는 리소스의 용량이 매우 크다면 메모리를 많이 소모할 수도 있습니다.

      하지만 대부분 풀링하는 오브젝트는 총알 화살 같은 물체이기 때문에 사용하는 리소스의 용량이 크지 않은 물체가 대부분입니다.

      그리고 Bullet 같은 오브젝트가 쌓여서 메모리가 완전히 소모되어 게임이 느려지는 순간은 굉장히 늦게 오는 편이고 그렇게 될 오브젝트가 쌓이기도 어려운 구조입니다.

      하지만 가비지 컬렉터로 인한 프레임 드랍은 언제든지 찾아올 수 있습니다.

      이러한 프레임 드랍을 없애는게 주요한 목표이기 때문에 오브젝트 풀링을 사용합니다.

      오브젝트를 미리 생성해서 게임이 끝날때까지 사용하다가 게임이 끝나고 난 뒤 로딩 타임에 소멸시켜서 가비지 컬렉팅으로 인한 프레임 드랍을 로딩시간으로 편입시키는 등의 방법으로 게임 중의 프레임 드랍을 최소화하는 겁니다.

    • 료용 2020.05.09 16:37 신고

      감사합니다 베르님 마지막설명이 딱 이해하기쉬워졌어요

    • wergia 2020.05.10 22:08 신고

      언제든 궁금하신 점 있으시면 질문해주세요!
      아는 부분이라면 최대한 답변해드릴게요!

  2. moody 2020.06.17 13:09

    너무 좋은 자료 잘보고갑니다 ! 이런글 올려주셔서 너무 감사해요

    • wergia 2020.06.22 23:44 신고

      다음에는 심화과정으로 한 번 만들어 보겠습니다 ㅎㅎ

  3. Groza 2020.12.03 20:58

    정말 감사합니다 ㅎㅎ

  4. 베르사랑 2020.12.16 17:57

    베르님 현재 디펜스 게임 제작중에 있습니다.
    베르님의 강의대로 오브젝풀링 구현 후
    여러개의 타워에서 업데이트로 빌려오는데
    가끔 어느 타워가 공격을 하지 않습니다.ㅜ
    이게 동시에 꺼내오면서 몇몇 타워들이 가져오질
    못하는건가요?ㅜ
    여러 타워 업데이트 문에서 호출하여 사용하고 싶은데
    방법이 없을까요?ㅜ

  5. Cargold 2021.08.11 18:16

    풀링은 선택이 아니라 필수!

+ Recent posts