본문 바로가기
Game DevTip/C++

8. C++로 GameObjectPool을 구현 해보자.

by LIKE IT.라이킷 2025. 5. 21.

 

 

C++ 게임 오브젝트 풀 패턴 상세 구현

 

게임 개발에서의 성능은 핵심 요소이다. 

특히 미사일, 총알, 파티클 등 수많은 객체가 동시에 생성되며, 소멸되는 상황에서

메모리 관리는 게임의 프레임률과 직결된다. 

 

이번글에서는 메모리 관리 최적화의 대표적 기법인 오브젝트 풀(Object Pool) 패턴을 C++로 구현 해보겠다. 

 

오브젝트 풀 패턴이란?

오브젝트 풀 패턴은 자주 생성되고 파괴되는 객체들을 위해 미리 메모리를 할당해 주고서

필요할 때 이 풀에서 객체들을 가지고와서 사용한 후 다시 풀로 반환하는 방식이다. 

 

주요 장점은 메모리 할당 및 해제시 오버헤드가 감소한다. 

new와 delete 연산의 빈번한 호출을 피하게 된다.

 

또한 메모리 파편화를 방지하여 동적 메모리의 반복적인 할당 및 해제로 인한 메모리 파편화를 줄인다. 

예측 가능한 성능으로 런타임 중에 메모리 할당에 따른 성능 변동을 최소화 시키고, 

비록 C++은 아니지만 가비지 컬렉션이 있는 언어의 경우 해당 가비지 컬렉션의 부담을 줄일 수 있다,. 

 

구현 구조 개요.

일단 구현 자체는 미사일 탄두(WarHead)를 관리하는 오브젝트 풀의 구현을 통해서 이 패턴을 자세히 알아보려고 한다. 

WarHead 구조체

struct WarHead
{
    int number;
    double x;
    double y;
    double z;
    double speed;
    int warHeadType;

    WarHead()
       : number(0), x(0.0), y(0.0), z(0.0), speed(0.0), warHeadType(0)
    {}
};

 

WarHead 구조체는 게임에서 미사일 탄두를 표현한다. 

멤버 변수의 경우 다음과 같다. 

 

  • number : 탄두 식별번호
  • x, y, z : 3D 공간에서의 위치 좌표
  • speed : 이동속도
  • warHeadType :  탄두유형

일단 해당 변수들은 모두 기본 생성자를 이용해서 모든 필드가 0으로 초기화 된다. 

 

GameObjectPool 클래스

class GameObjectPool
{
public:
    GameObjectPool(int cellCount);
    // mLoadedWarHeadCells에서 하나 꺼내 준다
    // 만약 없다면 새로운 WarHeadCell(배열)을 할당받는다
    
    WarHead& pop();
    // 사용후 WarHead를 반납한다
    
    void push(WarHead& warHead);
    // WarHeadPool 사용 현황을 출력함
    
    void printLoadedWarHeadCells() const;
    
    
private:

    // WarHead 포인터 pool
    int mLiveCellIndex; // WarHeadPool 사용 정보
    int mLiveSlotIndex; // WarHeadPool 사용 정보
    
    // 사용(재사용)가능한 WarHead 포인터를 모아둔 곳
    WarHead** mLoadedWarHeadCells;
    
    // WarHead 메모리 pool
    int mOriginCellCount; // 현재 몇 개의 셀이 생성되어있는지 저장
    
    WarHead** mOriginWarHeadCells; // 실제로 동적으로 할당된 메모리
    
    static const int cellSize = 5; // 셀은 WarHead 5개로 이루어짐

    // 새로운 셀을 하나 더 할당해서 풀을 확장한다.
    void allocateNewCell();
};

 

GameObjectPool 클래스는 해당 멤버변수와 함수를 가진다. 

 

 

GameObjectPool 클래스의 멤버 변수

  • mOriginWarHeadCells : 실제 동적으로 할당된 WarHead 배열(셀)들을 관리하는 포인터 배열이다. 
  • mLoadedWarHeadCells :  사용가능한(free 상태) WarHead 객체의 포인터들을 스택형태로 관리하는 배열
  • mLiveSlotIndex :  현재 사용 가능한 WarHead 포인터와 스택 top 인덱스
  • mOriginCellCount :  현재까지 할당된 셀의 개수
  • cellSize :  하나의 셀이 담고 있는 WarHead 객체의 개수(여기서는 5이다.)

GameObjectPool 클래스의 멤버 함수

  • pop() : 풀에서 사용 가능한 WarHead 객체를 하나 가지고온다.
  • push() : 사용이 끝난 WarHead 객체를 풀에 반환하기 위한 함수이다. 
  • allocateNewCell() :  풀에 여유 공간이 없을 때 새로운 셀을 할당하여 풀을 확장한다.
  • printLoadedWarHeadCells() :  현재 풀의 상태 정보를 출력한다.

 

이제 그러면 함수들이 어떻게 구현되어 있는지 살펴보자.

 

 

 

주요 함수 구현 상세

생성자

GameObjectPool::GameObjectPool(int cellCount) : mLiveCellIndex(0), mLiveSlotIndex(-1), mOriginCellCount(cellCount)
{
    mOriginWarHeadCells = new WarHead * [mOriginCellCount];

    for (int i = 0; i < mOriginCellCount; ++i)
    {
       mOriginWarHeadCells[i] = new WarHead[cellSize];
    }

    int totalWarHeads = mOriginCellCount * cellSize;
    mLoadedWarHeadCells = new WarHead*[totalWarHeads];

    for (int i = 0; i < totalWarHeads; ++i)
    {
       for (int j = 0; j < cellSize; ++j)
       {
          mLiveSlotIndex++;
          mLoadedWarHeadCells[mLiveSlotIndex] = &(mOriginWarHeadCells[i][j]);
       }
    }
}

 

일단 작업하는 것을 나열해 보자면,

 

: mLiveCellIndex(0), mLiveSlotIndex(-1), mOriginCellCount(cellCount)
  • 멤버변수 초기화 : 
    • mLiveCellIndexsms는 0으로 초기화 (실제로 이 예제에서는 사용은 안됨.)
    • mLiveSlotIndex는 -1로 초기화( -1 인 이유는 스텍이 현재 비어있다는 것을 의미한다.)
      당연하게도 배열의 첫번째 순서는 0부터 시작하기 때문에 비어있다는 것을 표현하기 위해서는 -1이어야 한다. 
    • mOriginCellCount 는 인자로 받은 cellCount로 설정된다.

 

mOriginWarHeadCells = new WarHead * [mOriginCellCount];

for (int i = 0; i < mOriginCellCount; ++i)
{
    mOriginWarHeadCells[i] = new WarHead[cellSize];
}

int totalWarHeads = mOriginCellCount * cellSize;

 

  • 메모리 할당
    • mOriginWarHeadCells 배열을 cellCount 크기로 할당한다.
    • 이후 각셀(배열)은 cellSize(5)개의 WarHead 객체를 담을 수 있도록 할당한다.
    • mLoadedWarHeadCells 배열은 총 WarHead 개수(cellCount * cellSize)만큼 할당을 해준다.

 

mLoadedWarHeadCells = new WarHead*[totalWarHeads];

for (int i = 0; i < totalWarHeads; ++i)
{
    for (int j = 0; j < cellSize; ++j)
    {
       mLiveSlotIndex++;
       mLoadedWarHeadCells[mLiveSlotIndex] = &(mOriginWarHeadCells[i][j]);
    }
}

 

  • 사용 가능한 스택을 모두 초기화 한다.
    • 모든 WarHead 객체의 주소를 mLoadedWarHeadCells 배열에 순서대로 저장한다.
    • 저장을 할 때마다 mLiveSlotIndex를 증가시켜서 스택의 top을 계산.
    • 이후 결과적으로 모든 WarHead가 사용 가능(free) 상태로 초기화가 된다.

 

 


Pop() 함수

WarHead& GameObjectPool::pop()
{
    if (mLiveSlotIndex < 0)
    {
       allocateNewCell();
    }

    WarHead* warHeadPtr = mLoadedWarHeadCells[mLiveSlotIndex];
    mLiveSlotIndex--;
    return *warHeadPtr;
}

 

Pop() 함수는 사용 가능한 WarHead 객체를 하나 가지고 오는 역할을 한다.

 

  • 가용성 확인
    • mLiveSlotIndex가 0보다 작으면 사용 가능한 WarHead가 없다는 의미.
    • 때문에 allocatenewCell()을 호출해서 풀을 확장한다.
  • 객체 반환
    • 현재 스택 top에 있는 WarHead 포인터를 가지고 온다.
    • mLiveSlotIndex를 감소시켜서 해당 객체가 '사용중'이라는 것을 표시한다.
    • 해당 WarHead 객체에 대한 참조를 반환한다.

 


Push() 함수

void GameObjectPool::push(WarHead& warHead)
{
    mLiveSlotIndex++;
    mLoadedWarHeadCells[mLiveSlotIndex] = &warHead;
}

 

push() 함수는 사용이 끝난 WarHead 객체를 다시 풀에 반환한다.

 

  • 스택갱신
    • mLiveSlotIndex를 증가시켜서 스택의 새로운 top 위치를 가리킨다.
    • 반환된 WarHead 객체의 주소를 해당 위치에 저장한다.
    • 이로써 객체는 다시 사용가능하게 (free) 상태가 된다.

 


allocateNewCell() 함수

void GameObjectPool::allocateNewCell()
{
    //1. mOriginWarHeadCells 배열 확장
    WarHead** newCells = new WarHead * [mOriginCellCount + 1];
    for (int i = 0; i< mOriginCellCount; ++i)
    {
       newCells[i] = mOriginWarHeadCells[i];
    }
    newCells[mOriginCellCount] = new WarHead[cellSize];

    delete[] mOriginWarHeadCells;
    mOriginWarHeadCells = newCells;
    mOriginCellCount++;

    //2. mLoadedWarHeadCells 배열 확장
    int newTotal = mOriginCellCount * cellSize;
    WarHead** newLoaded = new WarHead*[newTotal];

    for (int i = 0; i <= mLiveSlotIndex; ++i)
    {
       newLoaded[i] = mLoadedWarHeadCells[i];
    }

    //3. 새 셀의 WarHead 포인터들을 스택에 추가
    for (int j = 0; j < cellSize; ++j)
    {
       mLiveSlotIndex++;
       newLoaded[mLiveSlotIndex] = &(newCells[mOriginCellCount -1][j]);
    }

    delete[] mLoadedWarHeadCells;
    mLoadedWarHeadCells = newLoaded;
}

 

allocateNewCell() 함수는 풀에 사용 가능한 WarHead가 없을 때 풀을 확장하는 역할을 함.

  1. 원본 셀 배열 확장
    1. mOriginCellCount + 1 크기의 새 포인터 배열 담당
    2. 기존 셀 포인터들을 새 배열로 복사
    3. 새 셀(WarHead[cellSize])을 할당하고 배열의 마지막 위치에 저장
    4. 기존 배열 메모리 해제 후 새 배열로 대체
    5. mOriginCellCount 증가
  2. 사용 가능한 포인터 배열 확장
    1. 새로운 총 개수에 맞게 mLoadedWarHeadCells 배열 확장
    2. 기존의 사용 간으한 WarHead 포인터들을 새 배열로 복사
  3. 새 셀의 객체들을 사용 가능한 스택에 추가.
    1. 새로 할당된 셀의 각 WarHead 객체 주소를 스택에 추가한다.
    2. 각 추가마다 mLiveLSlotIndex 를 증가하여 스택 top을 갱신한다.
    3. 결과적으로 새 셀의 모든 WarHead가 사용 가능한 상태로 등록이 된다.

 

 

 


allocateNewCell() 함수

void GameObjectPool::printLoadedWarHeadCells() const
{
    cout << "------------------------------\n";
    cout << " GameObjectPool Status\n";
    cout << "------------------------------\n";
    cout << " OriginCellCount  : " << mOriginCellCount << "\n";
    cout << " CellSize         : " << cellSize << "\n";
    cout << " Total Allocated  : " << (mOriginCellCount * cellSize) << "\n";
    cout << " Free WarHeads    : " << (mLiveSlotIndex + 1) << "\n";
    cout << " In Use WarHeads  : "
       << (mOriginCellCount * cellSize) - (mLiveSlotIndex + 1) << "\n";
    cout << "------------------------------\n";
}

 

 

해당 함수는 현재 풀의 상태를 출력해서 디버깅에 도움을 주는 함수이다. 

  • 할당된 셀의 개수,
  • 하나의 셀에 들어있는 WarHead의 개수,
  • 총 할당된 WarHead 개수,
  • 사용가능한(free 상태) WarHead 개수,
  • 사용 중인(in use) WarHead 개수(총 개수 - 사용 가능 개수)

 

 


main 함수

int main()
{
    GameObjectPool pool(2);
    pool.printLoadedWarHeadCells();

    WarHead& w1 = pool.pop();
    WarHead& w2 = pool.pop();
    WarHead& w3 = pool.pop();

    w1.number = 101;
    w2.number = 102;
    w3.number = 103;

    cout << "After popping 3 WarHeads:\n";
    pool.printLoadedWarHeadCells();

    pool.push(w2);
    cout << "After pushing 1 WarHead:\n";
    pool.printLoadedWarHeadCells();

    vector<WarHead*> temp;
    for (int i = 0; i < 10; ++i)
    {
       WarHead& w = pool.pop();
       temp.push_back(&w);
    }
    cout << "After popping 10 WarHeads:\n";
    pool.printLoadedWarHeadCells();

    for (WarHead* w : temp)
    {
       pool.push(*w);
    }
    cout << "After returning all WarHeads:\n";
    pool.printLoadedWarHeadCells();

    return 0;
}

 

 

본격적으로 위해 클래스들과 함수들을 사용해서 프로그램을 구동시키는 main이다.

 

  1. 풀 초기화
    1. GameObjectPool pool(2)개로 총 2개의 셀(총 10개의 WarHead)를 가진 풀을 생성한다.
    2. 초기 상태를 출력한다(10개 모두 사용가능)
  2. 객체 사용 
    1. pop()으로 3개의 WarHead를 가지고와서 사용한다.
    2. 각 객체의 고유 번호(101, 102, 103)을 할당한다.
    3. 이후 상태를 출력하여 7개는 사용가능, 3개는 사용중이라는 것을 알린다.
  3. 객체 반환
    1. push()로 w2(번호 102)를 풀에 반환시킨다.
    2. 상태 출력으로 8개는 사용가능하며 2개는 사용중이라는 것을 알린다.
  4. 풀 확장 테스트
    1. 추가로 10개 WarHead를 Pop() 시키고
    2. 사용가능한 WarHead가 부족하면 자동으로 allocateNewCell()이 호출되어 풀을 확장하게 한다,.
    3. 이후 동일하게 상태를 출력하므로써 풀의 크기가 증가하였고 더 많은 객체가 사용중이라는 것을 알린다.
  5. 모든 객체를 반환
    1. vector에 저장된 모든 WarHead를 풀에 반환시킨다.
    2. 최종 상태를 출력하여 모든 WarHead가 다시 사용가능한 상태로 만들게 한다.

 

 


최종 출력 결과

/Users/mac1/CLionProjects/untitled5/cmake-build-debug/untitled5
------------------------------
 GameObjectPool Status
------------------------------
 OriginCellCount  : 2
 CellSize         : 5
 Total Allocated  : 10
 Free WarHeads    : 50
 In Use WarHeads  : -40
------------------------------

종료 코드 139 (interrupted by signal 11:SIGSEGV)(으)로 완료된 프로세스

 

 


구현 시 고려사항, 최적화 팁

 

일단 메모리 효율성의 경우 적절한 풀 크기를 설정하지 않으면 너무 불필요한 메모리를 차지하게 하고, 

너무 작으면 너무 자주 확장이 발생하게 된다. 

 

때문에 객체 크기를 고려하여 풀 크기를 조절하여 생성해야 한다.

 

위의 코드의 경우 단일 스레드 환경을 가지고 있다. 

멀티 스레드 환경에서는 뮤텍스나 세마포어를 사용한 동기화를 고려해야하며,

스레드별 풀 사용 또는 락프리(lock-free) 알고리즘을 적용하여 개선할 수 있다.

 

메모리 확장 또한 최적화가 가능할 것이다.

위에 코드는 새 셀 할당시 복사 작업이 상당히 많은 편이다. (물론 과제로 제출한 코드라 개선하기 귀찮아서 안했다)

초기 크기를 조정하여 자주 사용되는 객체수를 미리 파악한 후 적절한 초기 풀 크기를 설정하고, 

동적크기를 조정하여 사용 패턴에 따라 풀 크기를 동적으로 조정하는 매커니즘을 구현하여 관리도 가능 할 것이다.

 

지금 와서 말하기에 조금 아쉽긴 하지만

객체 초기화나 재설정을 pop()을 실행시 객체 초기화를 하고 push()에서 객체 상태를 재설정 하는 로직도 넣을 수 있었다.

 

 

결론

 

결론적으로 오브젝트 풀 패턴은 특히 게임 개발에서 많이 사용한다.

메모리 할당 해제 오버헤드를 크게 줄이고 일관된 성능을 보여줄 수 있기 때문.

 

위에 코드는 굉장히 기본적인 풀링 매커니즘을 보여주는 것인데, 

실제 프로젝트에서는 당연히 요구사항에 맞게 더 복잡하고 최적화된 버전을 구현해야한다.

 

특히 스마트 포인터와 이동의미론(move semantics), 완벽전달(perfect forwarding)등을 사용해서 

더 효율적으로 개발을 할 수도 있을 것이다. 

 

다음 글은 스마트 포인터에 관련된 것을 진행할 예정이므로,

자세한 건 다음 글에서 살펴보겠다.

 

다만, 무엇보다 중요한 것은 이러한 코드들은 패턴의 원리를 이해해야하며, 각 프로젝트 특성에 맞게 

적절하게 응용하는 것이 가장 중요하다. 

반응형

댓글