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가 없을 때 풀을 확장하는 역할을 함.
- 원본 셀 배열 확장
- mOriginCellCount + 1 크기의 새 포인터 배열 담당
- 기존 셀 포인터들을 새 배열로 복사
- 새 셀(WarHead[cellSize])을 할당하고 배열의 마지막 위치에 저장
- 기존 배열 메모리 해제 후 새 배열로 대체
- mOriginCellCount 증가
- 사용 가능한 포인터 배열 확장
- 새로운 총 개수에 맞게 mLoadedWarHeadCells 배열 확장
- 기존의 사용 간으한 WarHead 포인터들을 새 배열로 복사
- 새 셀의 객체들을 사용 가능한 스택에 추가.
- 새로 할당된 셀의 각 WarHead 객체 주소를 스택에 추가한다.
- 각 추가마다 mLiveLSlotIndex 를 증가하여 스택 top을 갱신한다.
- 결과적으로 새 셀의 모든 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이다.
- 풀 초기화
- GameObjectPool pool(2)개로 총 2개의 셀(총 10개의 WarHead)를 가진 풀을 생성한다.
- 초기 상태를 출력한다(10개 모두 사용가능)
- 객체 사용
- pop()으로 3개의 WarHead를 가지고와서 사용한다.
- 각 객체의 고유 번호(101, 102, 103)을 할당한다.
- 이후 상태를 출력하여 7개는 사용가능, 3개는 사용중이라는 것을 알린다.
- 객체 반환
- push()로 w2(번호 102)를 풀에 반환시킨다.
- 상태 출력으로 8개는 사용가능하며 2개는 사용중이라는 것을 알린다.
- 풀 확장 테스트
- 추가로 10개 WarHead를 Pop() 시키고
- 사용가능한 WarHead가 부족하면 자동으로 allocateNewCell()이 호출되어 풀을 확장하게 한다,.
- 이후 동일하게 상태를 출력하므로써 풀의 크기가 증가하였고 더 많은 객체가 사용중이라는 것을 알린다.
- 모든 객체를 반환
- vector에 저장된 모든 WarHead를 풀에 반환시킨다.
- 최종 상태를 출력하여 모든 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)등을 사용해서
더 효율적으로 개발을 할 수도 있을 것이다.
다음 글은 스마트 포인터에 관련된 것을 진행할 예정이므로,
자세한 건 다음 글에서 살펴보겠다.
다만, 무엇보다 중요한 것은 이러한 코드들은 패턴의 원리를 이해해야하며, 각 프로젝트 특성에 맞게
적절하게 응용하는 것이 가장 중요하다.
'Game DevTip > C++' 카테고리의 다른 글
7. C++ friend, 동적메모리 관리, 클래스 상속에 대해서 알아보자. (0) | 2025.05.21 |
---|---|
6. C++ string 타입에 대해서 (0) | 2024.12.05 |
5. C++ auto & decltype에 대해서. (0) | 2024.12.05 |
4. C++ Typedef(타입 별칭)에 대해서 (0) | 2024.12.05 |
3. C++ constexpr에 대해서 (1) | 2024.12.05 |
댓글