게임 개발자를 위한 메모리 구조 5 – Object Pool을 사용하는 이유

게임이나 프로그램을 개발하다보면 동적으로 메모리가 필요할 때가 있습니다.

이때 보통은 객체가 필요하면 생성하고, 필요 없어지면 제거하면 된다고 생각하게 됩니다.

이렇게 필요할 때 new로 필요한 메모리를 할당하고, 모두 사용한 객체는 delete로 해제해 사용하는 방식은 대부분의 경우에 문제가 없습니다.

Bullet* bullet = new Bullet();

delete bullet;

너무나 자연스럽고, 크게 고민하지 않고 이렇게 사용합니다.

필요하면 생성하고, 사용이 끝나면 제거하면 되기 때문입니다.

그런데 게임이라는 프로그램은 메모리 사용성 측면에서 조금 다른 특징을 같습니다.

게임은 객체를 생성하고 제거하는 상황이 굉장히 자주 발생합니다.

FPS 게임에서 총알을 생성하거나, 아이템을 수집했을 때 파티클 효과를 보여주거나,
특정 구역에 들어갔을 때 몬스터 여럿이 단체로 생성됩니다. 이렇게 생성된 객체들은 생성된 시점에서 머지않아 제거됩니다.

그리고 이런 작업들이 한 번만 발생하는 것이 아니라, 게임이 실행되는 동안 계속 반복됩니다.

이렇게 단적인 예만 살펴봐도 게임은 객체를 굉장히 많이 생성하고, 굉장히 많이 제거하는 프로그램이라는 것을 알 수 있습니다.

이런 특징을 이해하게 되면 “객체를 생성하고 제거하는 작업” 자체가 성능 문제를 야기할 수 있다는 고민을 하게됩니다.


//
//

객체 생성은 생각보다 무겁다

new를 호출하는 작업이 단순하게 느껴질 수도 있습니다.

그런데 실제로는 생각보다 다양한 작업들이 내부적으로 수행됩니다.

  1. 힙 할당이 발생합니다.
  2. 메모리 관리자가 빈 공간을 탐색합니다.
  3. Alignment를 맞춥니다.
  4. 필요에 따라 메모리를 분할하거나 병합하기도 합니다.
  5. 그리고 마지막으로 생성자가 호출됩니다.

우리는 단순히 객체 하나를 생성했다고 생각하지만, 내부에서 꽤 많은 작업이 수행되고 있는 것입니다.

그리고 이런 작업이 게임에서는 굉장히 자주 발생합니다.

예를 들어 총알이 초당 수백 개씩 생성되는 상황을 생각해보겠습니다.

그때마다 힙 할당과 생성자 호출이 반복적으로 이루어집니다.

그리고 객체를 제거할 때는 다시 메모리 해제 작업까지 처리됩니다.

따라서 게임에서는 객체 생성과 제거 자체가 너무 빈번하게 발생하기 때문에 생각보다 꽤 큰 비용이 될 수 있습니다.


게임 엔진은 객체를 재사용하기 시작했다

게임 엔진에서 Object Pool을 사용하는 이유와 메모리 재사용 구조를 설명하는 이미지
게임 엔진에서 Object Pool 구조를 사용하는 이유와 객체 재사용 방식, 메모리 최적화 흐름을 설명하는 이미지

이런 문제를 해결하기 위해 등장한 대표적인 구조 중 하나가 Object Pool입니다.

개념 자체는 생각보다 단순합니다.

객체를 필요할 때마다 새로 생성하고 제거하는 것이 아니라,
처음부터 일정 개수만큼 미리 만들어두고 계속 재사용하는 방식입니다.

예를 들어, 총알 객체가 최대 100개까지 필요할 수 있다고 가정해보겠습니다.

일반적인 방식이라면 총알이 발사될 때마다 아래 코드와 같이 객체가 필요하기 때문에 생성합니다.

Bullet* bullet = new Bullet();

이렇게 생성한 총알은 화면에서 벗어나거나 다른 물체와 충돌했을 때 제거해야 합니다.

이렇게 총알을 제거할 때마다 delete를 호출합니다.

delete bullet;

그런데 Object Pool 구조에서는 처음부터 Bullet 객체 100개를 미리 생성해둡니다.

const int bulletPoolCount = 100;
Bullet* bulletPool[bulletPoolCount] = {};
int currentEmptyIndex = 0;
for (int ix = 0; ix < bulletPoolCount; ++ix)
{
    bulletPool[ix] = new Bullet();
}

...
// 필요할 때 마다 미리 생성해둔 총알을 재사용.
Bullet* newBullet = bulletPool[currentEmptyIndex++];

그리고 총알이 필요해지면 새로 생성하는 것이 아니라, 미리 만들어둔 객체 하나를 꺼내서 사용하는 방식입니다.

필요할 때마다 객체를 “생성”하는 것이 아니라, 미리 필요한 만큼 생성해둔 객체를 “재사용”하기 시작하는 것입니다.


//

Object Pool은 결국 메모리 재사용 구조다

Object Pool을 단순히 객체 관리 패턴 정도로 생각할 수도 있습니다.

그런데 조금 더 깊게 생각해 보면 결국 메모리 재사용 구조와 고민의 방향이 같다는 것을 알 수 있습니다.

이는 이전 글에서 이야기했던 allocator, memory pool, placement new 과도 자연스럽게 이어집니다.

핵심 개념 자체가 거의 비슷하기 때문입니다.

처음에 메모리를 미리 확보해두고, 필요할 때 재사용합니다.

객체를 제거하더라도 실제 메모리를 반환하지 않습니다.

다시 사용 가능한 상태로만 표시해둡니다.

언젠가부터 게임은 점점 “필요할 때마다 생성하고 제거하는 구조”보다,
“미리 준비해두고 계속 재사용하는 구조”를 선호하기 시작했습니다.

그리고 이런 구조는 성능 측면에서도 굉장히 큰 장점을 가지게 됩니다.


왜 게임에서는 Object Pool이 특히 중요할까?

게임은 실시간으로 동작하며, 즉각적인 반응이 매우 중요한 프로그램입니다.

다시 말하면, 특정 순간에 프레임이 끊기는 현상에 게이머들은 매우 민감하게 반응합니다.

그런데 힙 할당은 상황에 따라 순간적으로 꽤 큰 비용이 발생할 수 있습니다.

특히, 객체 생성과 제거가 몰리는 상황에서는 간헐적인 프레임 드랍이나 끊김 현상이 발생하기도 합니다.

Object Pool은 이런 문제를 줄이는 데 굉장히 효과적입니다.

왜냐하면 대부분의 메모리 작업을 초기에 미리 끝내두기 때문입니다.

즉, 게임 플레이 중에는 가능한 한 힙 할당 자체를 줄이려고 노력합니다.

그리고 Object Pool을 사용하는 이런 특징은 최근의 게임 및 게임 엔진 구조로 갈수록 더욱 더 강해집니다.


최근 엔진 구조는 점점 더 재사용 중심으로 바뀌고 있다

최근 게임 엔진 구조를 보다 보면 생각보다 많은 시스템들이 재사용 중심으로 설계되고 있다는 것도 알게됩니다.

ECS, Job System, GPU Driven Rendering, Object Pool, Custom Allocator 같은 구조들도 결국 데이터를 어떻게 재사용할 것인가를 고민한 결과라고 볼 수 있습니다.

예전에는 객체를 얼마나 유연하게 설계할 것인가를 중요하게 보는 경우가 많았다면,
최근에는 데이터를 얼마나 연속적으로 배치할 수 있는지, 얼마나 재사용할 수 있는지를 더 중요하게 생각합니다.

그리고 이 과정에서 Object Pool 같은 구조도 점점 더 중요해지기 시작했습니다.


마무리

Object Pool이 단순히 객체를 재활용하는 패턴 정도로 느껴질 수도 있습니다.

하지만, 게임 엔진 구조를 조금 더 깊게 살펴보면 생각보다 많은 문제들이 메모리를 재사용함으로써 해결된다는 것을 이해하게 됩니다.

allocator를 구현하는 이유, memory pool을 사용하는 이유, placement new가 등장한 이유, 캐시 지역성이 중요한 이유는 결국 메모리를 어떻게 잘 사용할 것인가를 고민한 결과입니다.


👉 게임 엔진 구조를 더 깊이 이해하고 싶다면

아래 강의를 통해 직접 구현해보는 것을 추천드립니다.

C++로 만드는 게임 엔진 프레임워크 강의

👉 C++로 만드는 게임 엔진 프레임워크 강의 바로가기

//
   

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다