서비스
교육 철학

객체 풀 (Object Pool)

객체 풀 (Object Pool)

19.1. 의도

객체를 매번 할당하고 해제하는 대신, 고정 크기 풀에 들어 있는 객체를 재사용하여 메모리 사용 성능을 개선합니다.

19.2. 동기

게임에는 시각적 효과가 필요합니다. 영웅이 마법을 쓰면 광채가 화면을 가로질러야 합니다. 이를 위해 작고 반짝이는 것들을 만들고, 스르륵 사라질 때까지 움직이게 해주는 파티클 시스템이 필요합니다.
마법봉을 한 번 휘두르는 것만으로도 수백 개의 파티클이 생길 수 있으므로, 파티클 시스템은 파티클을 아주 빠르게 생성해야 합니다. 더욱 중요한 것은 이렇게 파티클을 만들고 제거하는 동안 메모리 단편화가 생기지 않아야 한다는 점입니다.

메모리 단편화의 저주

콘솔이나 모바일 게임 개발은 메모리가 부족하고, 사용자는 게임이 조금만 버벅거려도 불평하며, 효율 좋은 메모리 관리자를 쓰기 어려운 환경이라는 점에서 전통적인 PC 프로그래밍보다 임베디드 개발에 가깝습니다. 이런 환경에서는 메모리 단편화가 치명적입니다.
단편화란 힙의 사용 가능한 공간이 큰 덩어리로 모여 있지 않고 작은 조각으로 나뉜 상태를 말합니다. 남아 있는 메모리의 총량은 많아도, 연속해서 사용할 수 있는 영역은 아주 작을 수 있습니다. 예를 들어 14바이트가 남아 있더라도 7바이트씩 나뉘어 있고 그 사이에 사용 중인 메모리가 끼어 있다면, 12바이트를 새로 할당할 수 없습니다. 그러면 화면에 파티클을 더 추가하지 못합니다.
원주 1 차가 많이 주차된 복잡한 도로에서 평행주차하는 것과 비슷합니다. 차들을 빽빽하게 주차했다면 모를까, 띄엄띄엄 세웠다면 공간이 단편화되어 실제 남은 공간에 비해 주차할 수 있는 자리가 부족해집니다.
다음 그림은 힙이 단편화되는 과정과, 이론상 충분한 메모리가 남아 있어도 할당에 실패하는 이유를 보여줍니다.
그림 19-1. 메모리는 충분히 남아 있지만, 연속해서 비어 있는 메모리는 부족합니다.
단편화가 자주 발생하지 않더라도 그대로 방치하면 힙에 구멍과 틈이 계속 생깁니다. 결국 힙을 제대로 사용할 수 없게 되고 게임이 망가집니다.
원주 2 많은 콘솔 제조사는 게임을 출시하기 전에 데모 모드로 며칠 동안 켜두는 ‘침투 테스트(soak test)’를 통과하도록 요구합니다. 테스트 도중 게임이 뻗으면 출시할 수 없습니다. 드물게 발생하는 버그 때문에 실패하기도 하지만, 대부분은 조금씩 늘어나는 메모리 단편화나 메모리 누수가 원인입니다.
💡
그림에서 볼 점 — 총 여유 공간과 연속된 여유 공간은 다릅니다. 7바이트짜리 빈칸 두 개는 합계 14바이트이지만, 12바이트짜리 객체 하나를 넣을 자리는 아닙니다. 객체 풀은 같은 크기의 자리를 미리 확보하고 그 자리만 반복해서 사용합니다.

둘 다 만족시키기

단편화와 할당 속도 때문에 게임에서는 메모리를 언제, 어떻게 관리할지 매우 신경 씁니다. 대개는 단순한 방법이 가장 좋습니다. 게임을 시작할 때 메모리를 큰 덩어리로 미리 잡아두고, 게임이 끝날 때까지 계속 보유하는 것입니다. 하지만 실행 중에 객체를 생성하고 제거해야 하는 시스템에는 이것도 골칫거리입니다.
객체 풀은 양쪽의 요구를 모두 만족시킵니다. 메모리 관리자 관점에서는 처음에 큰 메모리를 한 번 할당한 뒤 게임이 실행되는 동안 계속 보유합니다. 객체 풀 사용자 관점에서는 객체를 자유롭게 할당하고 해제할 수 있습니다.

19.3. 패턴

재사용 가능한 객체를 모아두는 객체 풀 클래스를 정의합니다. 풀에 들어가는 객체는 자신이 현재 ‘사용 중’인지 알아낼 수 있는 방법을 제공해야 합니다. 풀을 초기화할 때 사용할 객체를 미리 생성하고, 보통 같은 종류의 객체를 연속된 배열에 넣습니다. 처음에는 모든 객체를 ‘사용 안 함’ 상태로 초기화합니다.
새 객체가 필요하면 풀에 요청합니다. 풀은 사용 가능한 객체를 찾아 ‘사용 중’으로 초기화한 뒤 반환합니다. 객체를 더 이상 사용하지 않으면 ‘사용 안 함’ 상태로 돌려놓습니다. 이렇게 하면 메모리나 다른 자원의 할당을 신경 쓰지 않고 객체를 자유롭게 생성하고 삭제할 수 있습니다.

19.4. 언제 쓸 것인가?

객체 풀은 게임 개체나 시각 효과처럼 눈에 보이는 것에 많이 사용합니다. 물론 현재 출력 중인 사운드처럼 눈에 보이지 않는 객체에도 사용합니다. 다음 조건에 해당할 때 적합합니다.
•
객체를 자주 생성하고 삭제해야 합니다.
•
객체들의 크기가 비슷합니다.
•
객체를 힙에 생성하는 작업이 느리거나 메모리 단편화가 걱정됩니다.
•
데이터베이스 연결이나 네트워크 연결처럼 접근 비용이 비싸고 재사용할 수 있는 자원을 객체가 캡슐화합니다.

19.5. 주의사항

많은 프로그램은 가비지 컬렉터나 new, delete로 메모리를 관리합니다. 객체 풀을 사용하겠다는 것은 ‘메모리 처리 방법을 내가 더 잘 안다’고 선언하는 셈입니다. 따라서 객체 풀의 한계도 직접 해결해야 합니다.
원주 3 윈도우에서 16kB 이하의 메모리를 많이 할당한다면 직접 만든 메모리 풀보다 LFH(low-fragmentation heap)를 사용할 때 더 효과적인 경우가 많다고 하니 참고하세요. 원문 참고 링크: https://goo.gl/TV6zXe — 옮긴이

사용하지 않는 객체는 메모리 낭비입니다

객체 풀의 크기는 필요에 맞게 조절해야 합니다. 너무 작으면 크래시가 발생하므로 바로 알아차릴 수 있습니다. 동시에 풀이 지나치게 커지지 않도록 주의해야 합니다. 풀을 작게 유지할수록 아낀 메모리를 다른 곳에 사용할 수 있습니다.

동시에 사용할 수 있는 객체 수가 정해져 있습니다

이것은 장점이기도 합니다. 메모리를 객체 종류별 풀로 나누면 이펙트가 한꺼번에 터질 때 파티클 시스템이 메모리를 모두 소비하거나, 그 때문에 새로운 적을 생성하지 못하는 문제를 막을 수 있습니다.
그래도 풀의 모든 객체가 사용 중이어서 재사용할 객체를 얻지 못하는 상황에는 대비해야 합니다. 다음과 같은 방법이 있습니다.
그런 상황이 아예 생기지 않게 합니다. 가장 흔한 ‘해결책’입니다. 사용자가 어떻게 행동하든 절대로 부족하지 않도록 풀의 크기를 정합니다. 적이나 아이템처럼 중요한 객체에서는 이것이 정답일 때가 많습니다. 마지막 스테이지에서 보스를 만들어야 하는데 풀이 부족하다면 다른 해결책이 마땅치 않습니다. 처음부터 그런 상황을 막는 편이 좋습니다.
다만 드물게 일어나는 최악의 상황에 맞춰 풀 크기를 유지해야 한다는 단점이 있습니다. 그래서 크기를 항상 고정하는 것이 최선은 아닐 수 있습니다. 어떤 레벨에서는 이펙트가 많이 나오고, 다른 레벨에서는 사운드가 풍부할 수도 있습니다. 상황에 따라 풀 크기를 다르게 조절할 수 있는지 고려하세요.
객체를 생성하지 않습니다. 과격하게 들리지만 파티클 시스템에서는 합리적일 수 있습니다. 모든 파티클을 사용 중이라면 이미 화면이 번쩍이는 그래픽으로 가득하다는 뜻입니다. 새 폭발 이펙트가 기존 것보다 훨씬 화려하지 않다면 별로 눈에 띄지 않을 것입니다.
기존 객체를 강제로 제거합니다. 재생 중인 사운드를 풀로 관리하는데 풀이 비어 있는 상태에서 새 소리를 틀어야 한다고 해봅시다. 마법봉을 멋지게 휘둘렀는데 가끔 소리가 나지 않으면 사용자가 거슬려할 것입니다. 이때는 새 사운드 요청을 무시하기보다 현재 소리가 가장 작은 사운드를 새 사운드로 교체하는 편이 낫습니다. 새 객체가 기존 객체를 덮어씁니다.
원주 4 여기서 풀이 ‘비어 있다’는 것은 사용할 수 있는 객체가 남아 있지 않다는 뜻입니다. — 옮긴이
기존 객체가 사라지는 쪽이, 새로 등장해야 할 객체가 등장하지 않는 것보다 덜 눈에 띌 때 이 방법을 사용합니다.
풀 크기를 늘립니다. 메모리를 더 유연하게 사용할 수 있다면 실행 중에 풀을 확장하거나 보조 풀을 만들 수도 있습니다. 어느 방법으로 메모리를 추가하든, 더 이상 추가 공간이 필요하지 않을 때 원래 크기로 줄일지 그대로 유지할지 정해야 합니다.

객체에 할당되는 메모리 크기가 고정됩니다

대부분의 객체 풀은 객체를 배열로 관리합니다. 모든 객체의 자료형이 같다면 문제가 없습니다. 다른 자료형이나 필드가 추가된 하위 클래스의 인스턴스를 같은 풀에 넣으려면, 배열 한 칸의 크기를 가장 큰 자료형에 맞춰야 합니다. 예상보다 큰 객체가 들어오면 다음 칸에 있는 객체의 메모리를 덮어쓰게 됩니다.
객체 크기가 다양하면 메모리가 낭비됩니다. 한 칸에는 가장 큰 객체도 들어가야 합니다. 대부분의 객체가 그보다 작다면 객체를 넣을 때마다 남는 공간을 낭비합니다. 공항 보안 검색대에서 열쇠와 지갑을 커다란 여행 가방용 바구니에 담는 것과 같습니다.
여행 가방은 큰 바구니에, 주머니 속 물건은 작은 바구니에 담듯이 객체 크기별로 풀을 나누는 편이 좋습니다.
원주 5 속도 효율을 중시하는 메모리 관리자에서 흔히 사용하는 방법입니다. 블록 크기에 따라 풀을 여러 개 마련하고, 메모리 요청을 받으면 요청 크기에 맞는 풀에서 빈 메모리를 찾아 반환합니다.

재사용하는 객체는 저절로 초기화되지 않습니다

거의 모든 메모리 관리자는 디버깅을 돕기 위해 새로 할당하거나 해제한 메모리에 0xdeadbeef 같은 특수한 값을 덮어씁니다. 초기화하지 않은 변수나 이미 해제한 메모리를 사용해서 생기는 골치 아픈 버그를 찾는 데 도움이 됩니다.
원주 6 ‘죽은 쇠고기’를 뜻하는 ‘dead beef’와 철자가 같습니다. 이런 프로그래머들의 말장난을 hexspeak라고 합니다. — 옮긴이
객체 풀은 메모리 관리자를 거치지 않고 객체를 재사용하므로 이 안전망이 없습니다. 게다가 ‘새 객체’로 받은 메모리에는 이전 객체의 상태가 거의 그대로 남아 있습니다. 그래서 초기화했는지 구별하기가 거의 불가능합니다.
풀에서 객체를 새로 초기화할 때에는 모든 상태를 빠짐없이 초기화해야 합니다. 객체를 회수할 때 해당 배열 칸의 메모리를 싹 덮어쓰는 디버깅 기능을 추가하는 것도 고려할 만합니다.
원주 7 여러분이 메모리를 0x1deadb0b로 초기화한다면 영광으로 생각하겠습니다. ‘dead beef’처럼 ‘one dead Bob’을 16진수로 표현한 것입니다. Bob은 저자 Robert의 애칭입니다. — 괄호 설명은 옮긴이
원주 8 메모리 관련 버그로 서버가 2, 3일에 한 번씩 죽는 상황을 몇 주간 겪어보면 이런 디버깅 기능이 얼마나 소중한지 알게 됩니다. — 옮긴이

사용하지 않는 객체도 메모리에 남습니다

가비지 컬렉션을 지원하는 시스템에서는 GC가 메모리 단편화를 처리하므로 객체 풀을 덜 쓰는 편입니다. 그래도 CPU가 느리고 단순한 GC를 사용하는 모바일 같은 환경에서는 풀로 할당과 해제 비용을 줄이는 것이 의미가 있습니다.
GC와 객체 풀을 함께 사용한다면 서로 충돌하지 않도록 주의해야 합니다. 풀에서는 객체를 사용하지 않아도 메모리를 해제하지 않습니다. 그 객체가 다른 객체를 참조하고 있다면 GC는 참조 대상도 회수할 수 없습니다. 이를 피하려면 풀의 객체를 더 이상 사용하지 않을 때 다른 객체에 대한 참조를 모두 정리해야 합니다.
💡
반납은 상태 정리까지 포함합니다 — 예를 들어 탄환을 풀로 돌려보낼 때 ‘비활성’만 표시하고 이전 목표물 참조를 남겨두면, 다음 사용에 잘못된 목표물이 섞이거나 목표물의 메모리가 계속 붙잡힐 수 있습니다. 비활성화, 외부 참조 해제, 다음 대여 시 완전 초기화를 한 흐름으로 생각하면 이해하기 쉽습니다.

19.6. 예제 코드

실제 파티클은 중력, 바람, 마찰 등의 영향을 받습니다. 여기서는 정해진 프레임 동안 한 방향으로 움직이다가 사라지는 파티클을 만듭니다. 영화에 사용할 수준은 아니지만 객체 풀을 설명하기에는 충분합니다.
먼저 가장 단순한 파티클 클래스입니다.
class Particle { public: Particle() : framesLeft_(0) {} void init(double x, double y, double xVel, double yVel, int lifetime); void animate(); bool inUse() const { return framesLeft_ > 0; } private: int framesLeft_; double x_, y_; double xVel_, yVel_; };
C++
복사
생성자는 파티클을 ‘사용 안 함’ 상태로 만듭니다. 이후 init()을 호출하면 파티클을 사용하기 시작합니다.
void Particle::init(double x, double y, double xVel, double yVel, int lifetime) { x_ = x; y_ = y; xVel_ = xVel; yVel_ = yVel; framesLeft_ = lifetime; }
C++
복사
animate()는 프레임마다 한 번 호출합니다.
원주 9 업데이트 메서드 패턴(10장)을 참고하세요. 원문 설명의 animation()은 예제 코드의 animate()를 가리킵니다.
void Particle::animate() { if (!inUse()) return; framesLeft_--; x_ += xVel_; y_ += yVel_; }
C++
복사
풀은 inUse()로 재사용 가능 여부를 확인합니다. 파티클에는 정해진 수명이 있으므로 framesLeft_만으로 사용 여부를 알 수 있습니다. 별도의 플래그는 필요하지 않습니다.
풀 클래스도 간단합니다.
class ParticlePool { public: void create(double x, double y, double xVel, double yVel, int lifetime); void animate(); private: static const int POOL_SIZE = 100; Particle particles_[POOL_SIZE]; };
C++
복사
외부에서는 create()로 새 파티클을 생성합니다. 게임은 매 프레임 풀의 animate()를 호출하고, 풀은 모든 파티클을 갱신합니다.
void ParticlePool::animate() { for (int i = 0; i < POOL_SIZE; i++) { particles_[i].animate(); } }
C++
복사
예제를 단순하게 만들기 위해 particles_를 클래스 안의 고정 크기 배열로 두었습니다. 동적으로 할당한 외부 배열을 사용하거나, 풀 크기를 템플릿 인수로 받을 수도 있습니다.
새 파티클을 생성하는 코드도 쉽습니다.
void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) { // 사용 가능한 파티클을 찾습니다. for (int i = 0; i < POOL_SIZE; i++) { if (!particles_[i].inUse()) { particles_[i].init(x, y, xVel, yVel, lifetime); return; } } }
C++
복사
사용 가능한 파티클을 찾을 때까지 풀을 순회합니다. 찾으면 해당 객체를 초기화하기만 하면 됩니다. 이 구현에서는 빈 파티클이 없으면 새 파티클을 만들지 않습니다.
렌더링 같은 부분을 제외하면 이것으로 파티클 시스템을 모두 갖췄습니다. 객체 풀을 만들었고, 풀을 통해 파티클을 생성할 수 있습니다. 파티클은 수명이 끝나면 스스로 비활성화됩니다.
게임을 출시하기에 충분한 구현이지만, 새 파티클을 만들 때마다 전체 컬렉션을 순회해야 할 수도 있다는 점이 눈에 띕니다. 원문은 풀이 매우 크고 대부분 비어 있을 때 이 작업이 느릴 수 있다고 설명합니다. 이제 이 부분을 개선해봅시다.
원주 10 알고리즘 수업을 들은 독자를 위해 표현하면, 파티클 생성의 시간 복잡도는 O(n)입니다.
💡
순회 비용을 정확히 읽기 — 위 코드는 처음 발견한 빈칸에서 곧바로 반환합니다. 따라서 특히 오래 걸리는 경우는 앞부분이 사용 중이어서 빈칸이 뒤에 있거나 아예 없는 경우입니다. 원문의 ‘대부분 비어 있다’는 표현과 구별해서, 최악에는 전체 배열을 검사한다는 점을 기억하세요.

빈칸 리스트 (Free List)

빈 파티클을 찾느라 시간을 낭비하지 않으려면 사용 가능한 객체를 계속 추적해야 합니다. 사용 가능한 파티클의 포인터를 별도 리스트에 넣는 방법이 있습니다. 새 파티클이 필요하면 리스트의 첫 포인터를 꺼내 그 객체를 사용하면 됩니다.
하지만 이 방법은 풀의 객체 수만큼 포인터가 들어갈 수 있는 별도 리스트를 관리해야 합니다. 풀을 처음 만들면 모든 파티클이 사용 안 함 상태이므로, 리스트도 풀의 모든 객체를 가리킬 수 있어야 합니다.
추가 메모리를 쓰지 않고 성능을 개선할 수 있다면 더 좋습니다. 마침 사용할 수 있는 메모리가 이미 있습니다. 사용하지 않는 파티클 객체의 데이터 영역을 활용하면 됩니다.
비활성 파티클에서는 위치나 속도 같은 데이터가 의미 없습니다. 살아 있는지 알려주는 상태 하나만 필요합니다. 이 예제에서는 framesLeft_만 남기면 나머지 공간은 다른 용도로 쓸 수 있습니다. Particle을 다음과 같이 수정합니다.
class Particle { public: // 원래 있던 코드들... Particle* getNext() const { return state_.next; } void setNext(Particle* next) { state_.next = next; } private: int framesLeft_; union { // 사용 중일 때의 상태 struct { double x, y; double xVel, yVel; } live; // 사용 중이 아닐 때의 상태 Particle* next; } state_; };
C++
복사
framesLeft_를 제외한 멤버를 state_ 공용체의 live 구조체로 옮겼습니다. 파티클이 살아 있는 동안에는 live를 사용합니다. 사용 중이 아닐 때에는 공용체의 next를 사용해 다음 빈 파티클을 가리킵니다.
원주 11 요즘에는 공용체를 예전만큼 많이 사용하지 않아 문법이 낯설 수 있습니다. 게임 개발팀에는 메모리가 부족할 때 해결책을 제시하는 까다로운 일을 맡은 ‘메모리 구루(guru)’가 있기 마련입니다. 공용체를 물어보면 공용체뿐 아니라 비트 단위로 데이터를 압축하는 비결도 들을 수 있을 것입니다.
next 포인터로 사용 가능한 파티클들을 연결 리스트로 묶을 수 있습니다. 추가 메모리 없이 죽은 객체의 메모리를 재활용하여, 그 객체 스스로 빈 객체 리스트의 일부가 됩니다.
이것이 빈칸 리스트(free list) 기법입니다. 처음에 포인터를 초기화하고, 이후 파티클을 생성하거나 삭제할 때에도 연결을 관리해야 합니다. 리스트의 머리(head)도 보관합니다.
class ParticlePool { // 이전 코드들... private: Particle* firstAvailable_; };
C++
복사
풀을 처음 만들면 모든 파티클을 사용할 수 있으므로 빈칸 리스트가 풀 전체를 연결합니다. 생성자에서 다음과 같이 초기화합니다.
ParticlePool::ParticlePool() { // 처음 파티클부터 사용 가능합니다. firstAvailable_ = &particles_[0]; // 모든 파티클은 다음 파티클을 가리킵니다. for (int i = 0; i < POOL_SIZE - 1; i++) { particles_[i].setNext(&particles_[i + 1]); } // 마지막 파티클에서 리스트를 끝냅니다. particles_[POOL_SIZE - 1].setNext(NULL); }
C++
복사
이제 새 파티클이 필요하면 첫 번째 빈 파티클을 바로 꺼낼 수 있습니다.
원주 12 만세! 복잡도가 O(1)으로 줄었습니다. 훨씬 쓸 만해졌습니다!
void ParticlePool::create(double x, double y, double xVel, double yVel, int lifetime) { // 사용 가능한 파티클이 남아 있는지 확인합니다. assert(firstAvailable_ != NULL); // 얻은 파티클을 빈칸 리스트에서 제거합니다. Particle* newParticle = firstAvailable_; firstAvailable_ = newParticle->getNext(); newParticle->init(x, y, xVel, yVel, lifetime); }
C++
복사
파티클이 죽으면 다시 빈칸 리스트에 넣어야 합니다. 이번 프레임에 죽었는지 알 수 있도록 animate()의 반환형을 bool로 바꾸고, 방금 죽은 경우에 true를 반환합니다.
bool Particle::animate() { if (!inUse()) return false; framesLeft_--; x_ += xVel_; y_ += yVel_; return framesLeft_ == 0; }
C++
복사
파티클이 죽으면 풀에서 빈칸 리스트의 맨 앞에 다시 넣습니다.
void ParticlePool::animate() { for (int i = 0; i < POOL_SIZE; i++) { if (particles_[i].animate()) { // 방금 죽은 파티클을 빈칸 리스트 앞에 추가합니다. particles_[i].setNext(firstAvailable_); firstAvailable_ = &particles_[i]; } } }
C++
복사
상수 시간에 생성하고 삭제할 수 있는 작고 아름다운 객체 풀이 완성되었습니다.
💡
단계별 코드 연결하기 — 원문은 공용체 도입 뒤에도 animate()에서 이전 멤버 이름인 x_, y_, xVel_, yVel_를 사용합니다. 공용체 버전으로 실제 코드를 합칠 때는 init()과 animate()의 접근도 state_.live.x, state_.live.y, state_.live.xVel, state_.live.yVel로 함께 바꿔야 합니다. next와 live가 같은 메모리를 쓰므로, getNext()는 init()으로 덮어쓰기 전에 읽어야 합니다. 생성·반납은 O(1)이지만 위 ParticlePool::animate()의 전체 순회는 여전히 O(n)입니다.

19.7. 디자인 결정

간단한 객체 풀은 배열을 마련하고 필요할 때 객체를 다시 초기화하는 것만으로 구현할 수 있습니다. 하지만 실제 제품에서는 보통 이것만으로 부족합니다. 풀을 더 일반적으로 만들거나, 더 안전하게 사용하고 쉽게 유지보수하도록 확장할 수 있습니다. 게임에 적용할 때 다음 사항을 결정하세요.

풀이 객체와 결합되는가?

먼저 객체가 자신이 풀에 들어 있는지 알게 할 것인지 결정합니다. 보통은 알고 있지만, 아무 객체나 담는 범용 풀을 만들어야 한다면 이런 편의를 누리지 못할 수도 있습니다.
객체가 풀과 결합되는 경우
•
구현이 간단합니다. 객체에 ‘사용 중’ 플래그나 같은 역할의 함수를 추가하면 됩니다.
•
풀을 통해서만 객체를 생성하도록 강제할 수 있습니다. C++에서는 풀 클래스를 객체 클래스의 friend로 지정하고 생성자를 private에 둡니다.
class Particle { friend class ParticlePool; private: Particle() : inUse_(false) {} bool inUse_; }; class ParticlePool { Particle pool_[100]; };
C++
복사
이 관계는 객체 클래스를 어떻게 사용해야 하는지 문서화하는 효과도 있으며, 풀을 통해서만 객체를 만들도록 강제합니다.
•
별도의 ‘사용 중’ 플래그가 필요하지 않을 수 있습니다. 객체에는 이미 사용 여부를 판단할 수 있는 상태가 있는 경우가 많습니다. 예를 들어 화면 밖으로 나간 파티클은 재사용할 수 있을 것입니다. 객체가 풀에서 사용된다는 것을 안다면 inUse()를 제공할 수 있으므로, 풀에서 플래그 목록을 별도로 저장하지 않아도 됩니다.
객체가 풀과 결합되지 않는 경우
•
어떤 객체든 풀에 넣을 수 있습니다. 이것이 가장 큰 장점입니다. 객체와 풀을 분리하면 범용적이고 재사용 가능한 풀 클래스를 만들 수 있습니다.
•
사용 여부는 객체 밖에서 관리해야 합니다. 가장 간단한 방법은 별도의 비트 필드를 두는 것입니다. 예제에서는 불리언 배열로 표현합니다.
template <class TObject> class GenericPool { private: static const int POOL_SIZE = 100; TObject pool_[POOL_SIZE]; bool inUse_[POOL_SIZE]; };
C++
복사

재사용하는 객체를 어디서 초기화하는가?

기존 객체를 다시 사용하려면 먼저 상태를 새로 초기화해야 합니다. 풀 안에서 초기화할지, 밖에서 초기화할지 정해야 합니다.
풀 안에서 초기화하는 경우
•
풀은 객체를 완전히 캡슐화할 수 있습니다. 객체를 풀 안에 숨기면 외부에서는 아예 참조할 수 없으므로, 예상치 못하게 재사용되는 문제를 막을 수 있습니다.
원주 13 캡슐화를 유지하면서 외부에서 객체를 참조하게 하고 싶다면 객체 포인터 대신 핸들 값을 반환하는 방법도 있습니다. — 옮긴이
•
풀 클래스가 객체의 초기화 방법과 결합됩니다. 객체가 여러 초기화 메서드를 제공할 수 있습니다.
class Particle { // 다양한 초기화 방법 void init(double x, double y); void init(double x, double y, double angle); void init(double x, double y, double xVel, double yVel); };
C++
복사
풀에서 초기화를 관리한다면 모든 초기화 메서드를 풀에서도 제공하고, 각각을 객체에 전달해야 합니다.
class ParticlePool { public: void create(double x, double y) { // Particle 클래스로 포워딩합니다... } void create(double x, double y, double angle) { // Particle 클래스로 포워딩합니다... } void create(double x, double y, double xVel, double yVel) { // Particle 클래스로 포워딩합니다... } };
C++
복사
풀 밖에서 초기화하는 경우
•
풀 인터페이스가 단순해집니다. 모든 초기화 함수를 제공하지 않고, 새 객체에 대한 참조만 반환하면 됩니다.
class Particle { public: // 다양한 초기화 방법 void init(double x, double y); void init(double x, double y, double angle); void init(double x, double y, double xVel, double yVel); }; class ParticlePool { public: Particle* create() { // 사용 가능한 파티클에 대한 참조를 반환합니다... } private: Particle pool_[100]; };
C++
복사
외부에서는 반환받은 객체의 초기화 메서드를 곧바로 호출할 수 있습니다.
ParticlePool pool; pool.create()->init(1, 2); pool.create()->init(1, 2, 0.3); pool.create()->init(1, 2, 3.3, 4.4);
C++
복사
•
외부 코드가 생성 실패 처리까지 담당해야 할 수 있습니다. 위 코드는 create()가 항상 성공하여 객체 포인터를 반환한다고 가정합니다. 하지만 빈 객체가 없으면 NULL을 반환할 수도 있습니다. 안전하게 사용하려면 초기화 전에 확인하는 편이 좋습니다.
Particle* particle = pool.create(); if (particle != NULL) particle->init(1, 2);
C++
복사

19.8. 관련 자료

객체 풀은 경량 패턴(3장)과 공통점이 많습니다. 둘 다 재사용할 수 있는 객체 집합을 관리합니다. 차이는 ‘재사용’의 의미에 있습니다. 경량 패턴은 여러 객체가 동일한 인스턴스를 공유합니다. 같은 객체를 동시에 여러 문맥에서 사용하여 메모리 중복 사용을 피하는 것이 목적입니다.
객체 풀도 객체를 재사용하지만 여러 곳에서 동시에 사용하지는 않습니다. 여기서 재사용은 이전 사용자가 다 쓴 뒤에 객체의 메모리를 회수하여 다시 쓰는 것입니다. 객체 하나는 한 번에 한 곳에서만 사용합니다.
같은 종류의 객체를 메모리에 모아두면 이들을 순회할 때 CPU 캐시를 효율적으로 채울 수 있습니다. 데이터 지역성 패턴(17장)에서 이 주제를 다룹니다.