C++ 객체 풀 구현: RAII 반납 래퍼, 동적 확장, reset 누락과 스레드 안전

이 글의 핵심

new와 delete를 매 프레임 반복하면 할당 비용과 메모리 단편화가 쌓이는데, 객체 풀은 이를 재사용으로 바꾸는 대신 새로운 책임을 만듭니다. 반납된 객체의 상태를 초기화하지 않으면 이전 값이 남고, 풀보다 오래 사는 포인터는 댕글링이 되며, 여러 스레드가 동시에 꺼내면 동기화가 필요합니다. 풀 크기를 정하는 기준과 함께 이런 문제를 피하는 구현을 단계별로 보여줍니다.

Object Pool이란?

객체 재사용 패턴

메모리 풀이 “바이트 블록을 재사용하는 것”에 초점을 둔다면, Object Pool은 한 걸음 더 나아가 “이미 생성된 객체 자체를 재사용하는 것”에 초점을 둡니다 — std::make_unique<T>()로 미리 T 인스턴스들을 만들어 pool에 소유권을 두고, available에는 그 원시 포인터만 담아 “지금 대여 가능한 것”을 추적합니다. 이 구조가 중요한 이유는 unique_ptr가 실제 메모리 소유권을 갖고 소멸자에서 자동으로 정리해 주므로, ObjectPool 자신은 수동 delete를 전혀 신경 쓸 필요가 없다는 데 있습니다 — acquire/release는 오직 “누가 지금 그 객체를 쓰고 있는가”라는 대여 상태만 관리할 뿐, 객체의 생성·소멸이라는 더 근본적인 수명 관리는 unique_ptr에게 위임되어 있습니다.

이 기본 구현이 검사하지 않는 것들도 알아 두어야 합니다. release는 넘겨받은 포인터가 정말 이 풀에서 나온 것인지, 이미 반납된 것은 아닌지 확인하지 않습니다. 같은 객체를 두 번 release하면 available에 같은 포인터가 두 개 들어가고, 이후 두 번의 acquire가 같은 객체를 서로 다른 사용자에게 빌려줍니다. 다른 풀의 객체나 스택 변수의 주소를 넘겨도 그대로 받아들입니다. 이런 버그는 크래시가 아니라 “두 총알이 같은 좌표로 움직인다” 같은 논리 오류로 나타나 찾기 어렵기 때문에, 디버그 빌드에서는 객체마다 “대여 중” 플래그를 두고 release에서 assert로 확인하는 장치를 넣어 두면 좋습니다. 또 T가 기본 생성 가능해야 한다는 제약도 있어서, 생성자 인자가 필요한 타입이라면 인자를 전달하는 팩터리 함수나 가변 인자 템플릿 생성자를 추가해야 합니다.

template<typename T>
class ObjectPool {
    std::vector<std::unique_ptr<T>> pool;
    std::vector<T*> available;
    
public:
    ObjectPool(size_t size) {
        for (size_t i = 0; i < size; ++i) {
            auto obj = std::make_unique<T>();
            available.push_back(obj.get());
            pool.push_back(std::move(obj));
        }
    }
    
    T* acquire() {
        if (available.empty()) return nullptr;
        T* obj = available.back();
        available.pop_back();
        return obj;
    }
    
    void release(T* obj) {
        available.push_back(obj);
    }
};

기본 사용

reset() 메서드가 GameObject에 존재하는 것이 이 패턴의 핵심 규칙을 보여줍니다 — 풀에서 빌려온 객체는 이전 사용자가 남긴 상태를 그대로 간직하고 있으므로, release하기 전에 그 상태를 알려진 초기값으로 되돌려야 다음 대여자가 깨끗한 객체를 받을 수 있습니다. 이 초기화 책임이 풀 자신이 아니라 객체 타입(reset())과 호출자(obj->reset()을 잊지 않고 호출하는 것) 양쪽에 걸쳐 있다는 점은 이 패턴의 가장 흔한 실수 원천이기도 합니다 — 뒤의 “자주 발생하는 문제” 절에서 이 실수를 구체적으로 재현합니다.

struct GameObject {
    int id;
    float x, y;
    
    void reset() {
        id = 0;
        x = y = 0.0f;
    }
};

int main() {
    ObjectPool<GameObject> pool(100);
    
    // 획득
    GameObject* obj = pool.acquire();
    obj->id = 1;
    obj->x = 10.0f;
    
    // 반환
    obj->reset();
    pool.release(obj);
}

실전 예시

예시 1: 게임 오브젝트

BulletPool은 범용 ObjectPool<Bullet>을 감싸 게임 로직에 맞는 이름 있는 인터페이스(spawn/despawn)를 제공하는 흔한 래핑 패턴입니다. 총알처럼 초당 수백 발이 생성·소멸될 수 있는 객체에 매번 new Bullet()/delete bullet을 쓰면, 짧은 프레임 시간(60 FPS라면 약 16ms) 안에서 힙 할당자 호출이 프레임 드랍을 유발할 정도로 누적될 수 있습니다. spawn이 bullet->active = true로 설정하고 despawn이 bullet->reset()으로 되돌리는 흐름은, 실제 메모리 할당·해제 없이 “이 슬롯은 지금 화면에 살아있는 총알인가, 아니면 재사용 대기 중인가”라는 논리적 상태만 오가는 것 — 이것이 객체 풀이 게임 개발에서 널리 쓰이는 이유입니다.

다만 게임에서는 이런 “포인터를 빌려주는” 풀보다 더 단순한 형태도 많이 씁니다. std::vector<Bullet>을 최대 크기로 미리 reserve해 두고, 총알이 사라지면 마지막 원소와 자리를 바꾼 뒤 pop_back하는 방식(swap-and-pop)입니다. 총알들이 메모리에 실제로 연속해 놓이므로 매 프레임 update를 도는 루프가 캐시를 잘 타고, 비활성 슬롯을 건너뛰는 분기도 없습니다. 대신 원소의 위치가 바뀌므로 외부에서 총알을 포인터로 오래 들고 있을 수 없고, 인덱스나 ID로 참조해야 합니다. 외부 참조가 필요한 객체(적 캐릭터, UI 요소)에는 이 글의 포인터형 풀이, 대량으로 생성·소멸하며 일괄 갱신되는 객체(총알, 파티클)에는 연속 배열 방식이 잘 맞습니다.

class Bullet {
public:
    float x, y;
    float vx, vy;
    bool active = false;
    
    void reset() {
        x = y = 0.0f;
        vx = vy = 0.0f;
        active = false;
    }
    
    void update(float dt) {
        if (active) {
            x += vx * dt;
            y += vy * dt;
        }
    }
};

class BulletPool {
    ObjectPool<Bullet> pool;
    
public:
    BulletPool(size_t size) : pool(size) {}
    
    Bullet* spawn(float x, float y, float vx, float vy) {
        Bullet* bullet = pool.acquire();
        if (bullet) {
            bullet->x = x;
            bullet->y = y;
            bullet->vx = vx;
            bullet->vy = vy;
            bullet->active = true;
        }
        return bullet;
    }
    
    void despawn(Bullet* bullet) {
        bullet->reset();
        pool.release(bullet);
    }
};

예시 2: RAII 래퍼

BulletPool::despawn처럼 반환을 명시적으로 호출하는 코드는 그 호출을 빠뜨리는 실수(특히 여러 반환 경로나 예외가 있는 함수에서)에 취약합니다. PooledObject는 이 문제를 스마트 포인터와 동일한 원리로 해결합니다 — 생성자에서 acquire()하고 소멸자에서 release()하므로, { PooledObject<GameObject> obj{&pool}; ... }처럼 중괄호 스코프 하나만 만들면 그 블록을 어떻게 빠져나가든(정상 종료, return, 예외로 인한 스택 풀림) 반납이 자동으로 보장됩니다. 복사 금지·이동 허용이라는 소유권 규칙도 이 시리즈 전반에서 반복해 온 것과 동일하게, “이 대여 슬롯의 소유자는 항상 하나여야 한다”는 요구를 그대로 반영합니다.

이 래퍼에는 보완할 점이 두 가지 있습니다. 풀이 비어 있으면 acquire()가 nullptr를 돌려주는데 생성자가 이를 확인하지 않으므로, 사용하는 쪽에서 if (obj.get())을 검사하지 않으면 obj->id = 1에서 널 포인터 역참조가 됩니다. 그리고 소멸자에서 reset()을 호출하지 않으므로 앞에서 말한 “이전 상태가 남는” 문제가 그대로입니다. 반납 시점에 obj->reset()을 함께 호출하면 RAII가 반납과 초기화를 모두 보장하게 됩니다. 별도 클래스를 만드는 대신 std::unique_ptr<T, Deleter>에 “풀로 돌려보내는” 커스텀 삭제자를 주는 방법도 흔한데, 이동·nullptr 처리·get()을 표준 스마트 포인터가 이미 제공하므로 코드가 더 짧아집니다.

template<typename T>
class PooledObject {
    ObjectPool<T>* pool;
    T* obj;
    
public:
    PooledObject(ObjectPool<T>* p) : pool(p), obj(pool->acquire()) {}
    
    ~PooledObject() {
        if (obj) {
            pool->release(obj);
        }
    }
    
    PooledObject(const PooledObject&) = delete;
    PooledObject& operator=(const PooledObject&) = delete;
    
    PooledObject(PooledObject&& other) noexcept
        : pool(other.pool), obj(other.obj) {
        other.obj = nullptr;
    }
    
    T* get() const { return obj; }
    T& operator*() const { return *obj; }
    T* operator->() const { return obj; }
};

int main() {
    ObjectPool<GameObject> pool(100);
    
    {
        PooledObject<GameObject> obj{&pool};
        obj->id = 1;
    }  // 자동 반환
}

예시 3: 동적 확장

기본 ObjectPool은 크기가 고정되어 있어 소진되면 nullptr을 반환하는 반면, DynamicPool은 available이 비면 expand(batchSize)를 호출해 그 자리에서 새 객체 묶음을 추가로 생성한 뒤 대여를 계속 진행합니다. 이는 “예측 가능한 성능”과 “항상 성공하는 할당” 사이의 실용적인 절충안입니다 — 처음 정해둔 크기를 넘어서는 순간은 여전히 힙 할당(그리고 그로 인한 지연)이 발생하지만, 그 이후 확장된 슬롯들은 다시 재사용 가능한 풀의 일부가 되므로 소진이 반복되지 않는 한 대부분의 호출은 여전히 빠릅니다. batchSize를 필요량 하나가 아니라 묶음 단위로 잡는 것도 의도적인 설계입니다 — 확장이 필요할 때마다 한 개씩 늘리면 확장 자체가 너무 자주 일어날 수 있으므로, 한 번에 여러 개를 미리 마련해 다음 확장까지의 간격을 벌리는 것입니다.

동적 풀은 한 번 늘어난 뒤 줄어들지 않는다는 점을 알고 써야 합니다. 보스전에서 순간적으로 총알이 5,000개까지 늘었다면, 이후 평소에는 200개만 써도 풀은 5,000개를 계속 들고 있습니다. 대부분의 게임에서는 이것이 오히려 바람직하지만(다음 보스전에서 다시 할당하지 않음), 메모리가 빠듯한 모바일 환경이나 장시간 실행되는 서버라면 “일정 시간 이상 쓰이지 않은 여분을 해제”하는 정책이 필요합니다. 또 확장 순간에는 batchSize개의 생성자 호출과 힙 할당이 한 프레임에 몰리므로, 프레임 스파이크가 문제라면 로딩 화면에서 예상 최대치까지 미리 채워 두는 것이 일반적입니다.

template<typename T>
class DynamicPool {
    std::vector<std::unique_ptr<T>> pool;
    std::stack<T*> available;
    size_t batchSize;
    
public:
    DynamicPool(size_t initial, size_t batch) : batchSize(batch) {
        expand(initial);
    }
    
    void expand(size_t count) {
        for (size_t i = 0; i < count; ++i) {
            auto obj = std::make_unique<T>();
            available.push(obj.get());
            pool.push_back(std::move(obj));
        }
    }
    
    T* acquire() {
        if (available.empty()) {
            expand(batchSize);
        }
        T* obj = available.top();
        available.pop();
        return obj;
    }
    
    void release(T* obj) {
        available.push(obj);
    }
};

예시 4: 타입별 풀

지금까지의 예시는 모두 “타입 하나당 풀 하나”를 전제로 했지만, 실제 애플리케이션은 Bullet, Particle, Enemy처럼 서로 다른 여러 타입 각각에 풀이 필요할 수 있습니다. PoolManager는 typeid(T)를 키로 삼아 임의의 타입에 대한 풀을 하나의 자료구조(unordered_map<type_index, ...>)에 모아 관리합니다 — std::unique_ptr<void, void(*)(void*)>라는 타입 소거 트릭이 핵심인데, 서로 다른 ObjectPool<T> 인스턴스화는 서로 다른 타입이라 하나의 맵에 그대로 담을 수 없으므로 void*로 지워 저장하고, 대신 각 항목마다 그 정확한 타입을 아는 커스텀 삭제자(delete static_cast<ObjectPool<T>*>(p))를 함께 저장해 소멸 시 올바른 타입으로 정확히 해제되도록 보장합니다. acquire<T>()/release<T>()가 내부적으로 다시 static_cast<ObjectPool<T>*>로 원래 타입을 복원하는 것도 같은 원리이며, 이런 타입 소거 기법은 std::function이나 std::any 같은 표준 라이브러리 타입이 내부적으로 쓰는 것과 본질적으로 같습니다.

class PoolManager {
    std::unordered_map<std::type_index, std::unique_ptr<void, void(*)(void*)>> pools;
    
public:
    template<typename T>
    void createPool(size_t size) {
        auto pool = std::make_unique<ObjectPool<T>>(size);
        pools[typeid(T)] = {pool.release(), [](void* p) {
            delete static_cast<ObjectPool<T>*>(p);
        }};
    }
    
    template<typename T>
    T* acquire() {
        auto it = pools.find(typeid(T));
        if (it == pools.end()) return nullptr;
        
        auto* pool = static_cast<ObjectPool<T>*>(it->second.get());
        return pool->acquire();
    }
    
    template<typename T>
    void release(T* obj) {
        auto it = pools.find(typeid(T));
        if (it != pools.end()) {
            auto* pool = static_cast<ObjectPool<T>*>(it->second.get());
            pool->release(obj);
        }
    }
};

장점

이 네 가지는 앞서 다룬 Memory Pool의 장점과 근본 원리가 비슷하지만, 이 글의 구현에서는 조건이 붙습니다. 할당을 미리 준비된 재사용으로 대체하므로 실행 중 new/delete(할당자 내부의 잠금·탐색 비용, 드물게 운영체제 호출)가 사라지고(빠른 할당), 실행 중 할당·해제가 반복되지 않으니 힙 단편화가 늘지 않으며(단편화 방지), 풀이 소진되지 않는 한 대여 시간이 일정합니다(예측 가능). 반면 캐시 친화성은 이 구현에서는 성립하지 않습니다. pool 벡터에 연속으로 놓인 것은 unique_ptr(포인터)뿐이고, 객체 자체는 make_unique로 하나씩 따로 할당되어 힙 여기저기에 흩어져 있을 수 있습니다. 객체를 실제로 연속 배치하려면 std::vector<T>나 큰 배열 하나에 객체를 담고 인덱스로 관리해야 합니다. Object Pool이 Memory Pool과 다른 점은 “이미 생성자가 호출된 완전한 객체”를 재사용한다는 것 — 생성자가 무거운 객체(내부에 큰 버퍼를 할당하는 객체 등)라면 반복되는 생성·소멸 비용까지 함께 절감됩니다.

// 1. 빠른 할당
// - 미리 생성
// - new/delete 회피

// 2. 단편화 방지
// - 고정 크기

// 3. 캐시 친화적 (객체를 연속 배열에 담을 때만)
// - 이 글의 make_unique 방식은 객체가 흩어질 수 있음

// 4. 예측 가능
// - 대여 시간 일정 (단, 소진되면 nullptr)

자주 발생하는 문제

문제 1: 초기화

이 예시는 이 글 서두에서 언급한 “reset 책임” 규칙을 어겼을 때 실제로 벌어지는 일입니다 — obj->reset()을 호출하지 않고 release하면, 그 슬롯의 health = 100이라는 상태가 그대로 메모리에 남아있고, 다음 acquire()가 우연히 같은 슬롯을 돌려주면 obj2는 아무것도 설정하지 않았는데도 health가 이미 100으로 “초기화되어 있는” 것처럼 보입니다. 이 버그가 특히 위험한 이유는 새 객체를 만드는 일반적인 코드(GameObject obj2;라면 항상 기본값으로 시작)와 다르게, 재사용된 객체는 “이전 사용자가 어떤 상태로 두고 갔는가”에 따라 매번 다른 시작 상태를 가질 수 있어 재현이 어렵고 테스트에서 우연히 발견되지 않을 수 있다는 점입니다. 이런 종류의 실수를 원천 차단하려면, release 자체가 내부적으로 reset()을 호출하도록 강제해 호출자가 그 책임을 잊을 수 없게 캡슐화하는 편이 안전합니다. reset()을 손으로 관리하는 방식은 멤버를 추가할 때마다 reset()을 함께 고쳐야 한다는 약점도 있습니다. 새 멤버를 추가하고 reset()에 넣는 것을 잊으면 같은 버그가 다시 생기므로, *obj = T{};처럼 기본값을 가진 새 객체로 통째로 대입하는 방식이 더 안전한 경우가 많습니다. 멤버 기본값(int health = 100;)을 한 곳에만 선언해 두면 생성과 재사용 양쪽에서 같은 초기 상태가 보장됩니다.

// ❌ 상태 남음
GameObject* obj = pool.acquire();
obj->health = 100;
pool.release(obj);

GameObject* obj2 = pool.acquire();  // 같은 객체
// obj2->health == 100 (이전 상태)

// ✅ reset
obj->reset();
pool.release(obj);

문제 2: 풀 크기

이는 앞서 Memory Pool 글에서도 다룬 것과 동일한 근본 문제이지만, Object Pool에서는 “동시에 살아있어야 하는 게임 오브젝트의 최대 개수” 같은 도메인 지식으로 답해야 한다는 점이 다릅니다 — 예를 들어 총알 풀의 크기는 “화면에 동시에 존재 가능한 최대 총알 수”를 게임 디자인 관점에서 추정해야 정할 수 있습니다. 이 추정이 부정확해 풀이 소진되면 acquire()가 nullptr을 반환하고, 그 반환값을 확인하지 않는 호출부는 크래시로 이어집니다. 예시 3: 동적 확장의 DynamicPool이 이 문제에 대한 하나의 답이지만, 게임처럼 예측 가능한 프레임 타임이 중요한 경우에는 오히려 “총알 개수 제한에 도달하면 가장 오래된 총알을 강제로 회수한다”처럼 크기를 고정한 채 정책으로 대응하는 편을 선호하기도 합니다.

// ❌ 풀 부족
ObjectPool<T> pool(10);

for (int i = 0; i < 100; ++i) {
    T* obj = pool.acquire();  // nullptr
}

// ✅ 충분한 크기 또는 동적 확장

문제 3: 수명

풀에서 빌려온 T*는 풀 자신이 실제 메모리를 소유하고 있으므로(unique_ptr<T>가 pool 벡터 안에 있음을 기억하세요), 그 포인터의 유효성은 정확히 풀 객체의 수명과 묶여 있습니다 — pool이 중괄호 스코프를 벗어나 소멸되면, 그 안의 모든 unique_ptr<T>도 함께 소멸되며 실제 GameObject 메모리를 해제하는데, 그 전에 acquire()로 빼내 바깥 스코프에 저장해 둔 obj 포인터는 이제 이미 해제된 메모리를 가리키는 댕글링 포인터가 됩니다. 이는 컨테이너에서 얻은 반복자나 참조가 그 컨테이너보다 오래 살아남으면 안 된다는 일반적인 C++ 규칙의 한 형태이며, 풀을 함수의 지역 변수로 선언하고 그 풀에서 얻은 포인터를 함수 밖으로 반환하거나 전역/더 오래 사는 구조에 저장하지 않도록 설계 단계에서부터 주의해야 합니다.

// ❌ 풀 소멸 후 사용
GameObject* obj;
{
    ObjectPool<GameObject> pool(100);
    obj = pool.acquire();
}  // pool 소멸

// obj 댕글링

// ✅ 풀 수명 보장

문제 4: 스레드 안전

available 벡터에 대한 동시 push_back/pop_back은 정확히 데이터 레이스가 성립하는 조건입니다 — 두 스레드가 동시에 acquire()를 호출하면 최악의 경우 같은 객체 포인터를 둘 다 받아가거나(같은 GameObject를 두 스레드가 동시에 소유), 벡터의 내부 상태(크기, 포인터) 자체가 손상될 수 있습니다. 이런 레이스는 항상 재현되지 않고 스레드 스케줄링 타이밍에 따라 간헐적으로만 드러나므로, 멀티스레드 환경에서 풀을 공유하기로 결정했다면 처음부터 뮤텍스로 보호하거나(경합 비용을 감수) 스레드마다 독립된 풀을 두는(메모리 사용량 증가를 감수) 두 선택지 중 하나를 명시적으로 채택해야 하며, “일단 단일 스레드용으로 짜고 나중에 안전하게 만들자”는 접근은 이런 종류의 버그를 나중에야 발견하게 만드는 흔한 함정입니다.

뮤텍스를 선택할 때는 풀의 원래 목적을 기억해야 합니다. 풀을 쓰는 이유가 할당 비용을 줄이기 위해서인데, 모든 acquire/release가 하나의 전역 뮤텍스를 거치면 스레드가 많을 때 그 뮤텍스 경합이 new보다 비싸질 수 있습니다. 실제로 현대의 범용 할당자(glibc malloc, jemalloc, mimalloc 등)는 스레드별 캐시를 두어 작은 할당을 이미 꽤 빠르게 처리하므로, 멀티스레드 환경에서 “풀을 만들었더니 더 느려졌다”는 결과가 드물지 않습니다. 그래서 스레드마다 thread_local 풀을 두고, 한 스레드에서 빌린 객체는 같은 스레드에서 반납한다는 규칙을 정하는 설계가 많이 쓰입니다. 풀 도입 전후를 반드시 실제 부하로 측정해 보는 것이 좋습니다.

// ❌ 스레드 안전 아님
ObjectPool<T> pool(100);

std::thread t1([&]() { pool.acquire(); });
std::thread t2([&]() { pool.acquire(); });  // 레이스

// ✅ 뮤텍스 또는 스레드 로컬

활용 패턴

이 네 줄은 이 글에서 다룬 클래스들을 실제 코드베이스에 도입할 때의 자연스러운 진행 순서를 요약합니다 — 게임 오브젝트처럼 빈번하게 오가는 대상에 기본 ObjectPool을 붙이고(1, 2), 반환 누락 위험이 있다면 PooledObject로 RAII를 적용하며(3), 정확한 최대 동시 사용량을 미리 알기 어렵다면 고정 크기 대신 DynamicPool로 전환하는(4) 흐름입니다. 프로젝트 초기에는 가장 단순한 형태(기본 ObjectPool)로 시작해, 실제 사용 중 반환 누락 버그나 크기 부족 문제가 관찰될 때 그에 맞는 다음 단계(RAII, 동적 확장)로 넘어가는 점진적 접근이 처음부터 모든 기능을 갖춘 풀을 설계하려는 것보다 실무에서 더 안전한 경우가 많습니다.

// 1. 게임 오브젝트
ObjectPool<Bullet> bulletPool(1000);

// 2. 임시 객체
auto obj = pool.acquire();
// 사용
pool.release(obj);

// 3. RAII
PooledObject<T> obj{&pool};

// 4. 동적 확장
DynamicPool<T> pool(100, 50);

FAQ

Q1: 객체 풀이 실제로 빠른지 어떻게 확인하나요?

A: 같은 시나리오(예: 초당 N개 생성·소멸)를 new/delete 버전과 풀 버전으로 각각 돌려 프레임 시간이나 처리량, 그리고 최악의 경우 지연(p99)을 비교합니다. 평균은 비슷한데 풀 쪽의 최악 지연이 확연히 낮다면 게임처럼 프레임 안정성이 중요한 곳에서 도입할 가치가 있습니다. 차이가 없거나 풀이 더 느리다면 범용 할당자가 이미 충분히 빠른 것이므로 복잡도만 늘리는 셈입니다.

Q2: reset()을 자동으로 호출하게 하려면 어떻게 하나요?

A: release 안에서 obj->reset()(또는 *obj = T{})을 호출하도록 풀을 바꾸고, 사용자가 직접 release를 부르지 않도록 RAII 래퍼나 커스텀 삭제자를 가진 std::unique_ptr로만 객체를 내주는 것이 가장 확실합니다. 이렇게 하면 반납과 초기화가 한 지점에서만 일어나므로 누락될 경로가 사라집니다.

Q3: 풀 크기는 어떻게 정하나요?

A: “동시에 살아 있을 수 있는 최대 개수”를 기준으로 합니다. 개발 빌드에서 풀의 최대 사용량(high-water mark)을 기록해 두고, 실제 플레이나 부하 테스트에서 나온 최댓값에 여유를 더해 정하는 방식이 실용적입니다. 소진 시의 정책(nullptr 반환, 동적 확장, 가장 오래된 객체 회수)도 함께 정해 두어야 합니다.

Q4: std::pmr와 객체 풀은 어떻게 다른가요?

A: std::pmr::unsynchronized_pool_resource 같은 메모리 리소스는 메모리 블록을 재사용할 뿐 객체의 생성자·소멸자는 매번 호출됩니다. 컨테이너 전체의 할당 전략을 바꾸고 싶을 때 적합합니다. 객체 풀은 생성된 객체 자체를 재사용하므로 생성 비용이 큰 객체에 효과적이지만, 초기화 책임을 직접 져야 합니다.


같이 보면 좋은 글