C++ 메모리 풀 직접 만들기: free list 고정 블록 풀, 정렬, fallback, 게임용 할당자

들어가며: new/delete가 병목일 때

같은 크기(또는 비슷한 크기)의 객체를 대량으로 반복해서 할당하고 해제하면 할당·해제 비용과 힙 단편화가 쌓입니다. 고성능 네트워크 가이드 #5와 #39-2 커스텀 알로케이터에서 풀·커스텀 할당자를 다뤘습니다. 이 글은 고정 크기 블록 풀 하나를 free list로 직접 설계·구현하고, 같은 조건에서 new/delete와 성능을 측정하는 방법까지 따라갑니다. 크기가 제각각인 할당을 위한 슬랩 할당자, 일괄 해제용 아레나, 표준 std::pmr 리소스를 서로 비교하는 내용은 #32-2 메모리 풀 구현 비교에 따로 정리했습니다.

게임에서 자주 쓰는 오브젝트 풀, 프레임 할당자, 스택 할당자도 함께 구현합니다. #39-2 std::pmr·커스텀 할당자를 먼저 읽어 두면 이해가 쉽습니다.


malloc 병목·단편화·락 경합: 풀이 필요해지는 상황

프로파일러에서 malloc이 상위를 차지할 때

"프로파일러에서 malloc/free가 전체 실행 시간의 30%를 차지해요."
"게임 프레임마다 엔티티를 생성/삭제하는데 프레임 드랍이 심해요."

상황: 게임 엔진에서 매 프레임 수많은 Bullet, Particle 객체를 new/delete로 생성·해제합니다. 프로파일러 결과 operator new가 30% 이상을 차지하며, 60fps 목표를 달성하지 못합니다. 원인: 작은 객체의 반복 할당/해제 → malloc 오버헤드 누적, 전역 힙 락 경합. 해결 포인트: 고정 크기 풀로 블록을 미리 확보해 두고 프레임마다 재사용하면, 실제 힙 할당 횟수가 크게 줄어듭니다.

힙 단편화로 OOM 발생

"작은 객체를 수만 개 할당하다 보니 힙 단편화로 OOM이 나요."
"장시간 실행 시 메모리가 점점 늘어나다가 결국 할당 실패해요."

상황: HTTP 서버에서 요청마다 파싱 결과를 할당하는데, 24시간 이상 운영 후 malloc이 실패합니다. 전체 메모리 사용량은 여유가 있는데도 할당이 실패합니다. 원인: 작은 빈 공간이 흩어져 큰 연속 메모리 할당이 불가능해지는 힙 단편화. 해결 포인트: 풀은 큰 블록을 한 번 할당하고 그 안에서만 잘라 쓰므로, 작은 할당이 전역 힙 곳곳에 흩어지지 않아 단편화가 덜 생깁니다.

멀티스레드에서 전역 힙 락 경합

"동시 요청이 많으면 지연이 커요."
"스레드 수를 늘려도 처리량이 선형으로 늘지 않아요."

상황: 16코어 서버에서 16스레드로 요청을 처리하는데, throughput이 4스레드의 2배 정도에 그칩니다. 원인: 할당기 구현에 따라 스레드들이 같은 힙 영역(arena)과 그 락을 공유하게 되면 경합이 생깁니다. glibc malloc은 스레드별 캐시(tcache)와 여러 arena로 이를 완화하지만, 한 스레드에서 할당하고 다른 스레드에서 해제하는 패턴이 많으면 여전히 비용이 큽니다. 해결 포인트: 스레드 로컬 풀을 두면 각 스레드가 자기 풀에서만 할당·해제하므로 락이 필요 없습니다.

캐시 미스로 인한 성능 저하

"할당은 빠른데, 전체 루프가 느려요."
"객체들이 메모리 상에 흩어져 있어서 순회가 비효율적입니다."

상황: 링크드 리스트 노드를 new로 할당해 사용하는데, 순회 시 캐시 미스가 많습니다. 원인: malloc은 요청마다 다른 위치에 메모리를 주므로, 객체들이 물리적으로 흩어져 캐시 효율이 떨어집니다. 해결 포인트: 풀은 큰 연속 블록에서 나눠 주므로 객체들이 인접해 배치될 가능성이 높아 캐시 효율이 좋아집니다.

시나리오별 해결 방향 요약

시나리오특징권장 접근
malloc 병목할당 횟수 과다고정 블록 풀
힙 단편화장시간 실행 후 OOM풀 + 단일 큰 블록
스레드 경합멀티스레드 확장성스레드 로컬 풀
캐시 미스순회 성능 저하연속된 풀 블록

풀 설계: 고정 블록 크기

고정 블록과 free list의 기본 아이디어

  • 큰 메모리(예: 64KB)를 malloc이나 ::operator new로 한 번에 할당합니다. 이 덩어리를 청크(chunk)라고 부르겠습니다.
  • 청크를 고정 크기 블록(예: 256바이트)으로 나눕니다. 비어 있는 블록의 첫 몇 바이트를 “다음 빈 블록을 가리키는 포인터”로 써서 연결 리스트, 즉 free list를 만듭니다. 빈 블록 자체를 리스트 노드로 쓰므로 별도 메모리가 들지 않습니다.
  • allocate 요청이 오면 free list의 맨 앞 블록을 꺼내 반환하고, deallocate 요청이 오면 그 블록을 다시 맨 앞에 넣습니다.
  • free list가 비면 새 청크를 할당해 블록들을 free list에 추가합니다.

메모리 풀 동작 흐름

flowchart TB
    subgraph Heap[전역 힙]
        H[한 번 큰 블록 할당]
    end
    subgraph Pool[메모리 풀]
        B[청크]
        B --> S1[블록 1]
        B --> S2[블록 2]
        B --> S3[블록 N]
    end
    subgraph FreeList[Free List]
        FL[head → 블록1 → 블록2 → ...]
    end
    H --> B
    S1 -.->|사용 가능| FL
    S2 -.->|사용 가능| FL
    S3 -.->|할당됨| A1[객체]

블록 크기 선택

  • 할당 요청이 항상 블록 크기 이하라고 가정하면 구현이 단순합니다. 요청 크기가 블록보다 크면 fallback으로 ::operator new를 호출할 수 있습니다.
  • 블록 크기는 64, 128, 256처럼 두고, 실제 서비스의 할당 크기 분포에 맞추면 블록 안에서 낭비되는 공간(내부 단편화)을 줄일 수 있습니다.

Free List 구조

flowchart LR
    subgraph Allocate[할당 시]
        A1[head] --> A2[블록A]
        A2 --> A3[블록B]
        A3 --> A4[null]
        N1["head = 블록B.next 반환 블록A"]
    end
    subgraph Deallocate[해제 시]
        D1[head] --> D2[블록X]
        D2 --> D3[블록Y]
        N2["반환 블록을 새 head로, 기존 head를 next로"]
    end

구현 요지: 할당·반환·재사용

할당 (allocate)

  • free list의 head가 있으면 head를 반환하고, head를 head->next로 옮깁니다.
  • free list가 비어 있으면 새 청크를 할당해 블록 단위로 쪼개 free list에 연결한 뒤, head에서 하나를 반환합니다.

반환 (deallocate)

  • 반환된 포인터를 free list의 새 head로 넣고, 기존 head를 next로 연결합니다. 가장 최근에 반환된 블록이 먼저 재사용되므로(LIFO) 캐시에 남아 있을 가능성이 높습니다.
  • use-after-free를 빨리 발견하려면 디버그 빌드에서 반환된 블록을 0xDD 같은 특정 패턴으로 덮어쓰는 옵션을 둘 수 있습니다.

정렬

  • ::operator new가 돌려주는 청크 시작 주소는 alignof(std::max_align_t)로 정렬되어 있습니다. 블록 크기도 이 값의 배수로 올려 두면 모든 블록 주소가 같은 정렬을 만족합니다.

Free List 풀부터 스레드 로컬 래퍼까지 구현

최소 동작 풀 (Free List 기반)

// memory_pool_basic.cpp
// g++ -std=c++17 -O2 -o pool_basic memory_pool_basic.cpp
#include <cstddef>
#include <cstdlib>
#include <new>
#include <algorithm>
#include <iostream>
#include <vector>
class FixedBlockPool {
public:
    explicit FixedBlockPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : block_size_(round_up(std::max(block_size, sizeof(Node))))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}
    ~FixedBlockPool() {
        // 블록이 아니라 청크 단위로 할당했으므로 청크를 해제한다
        for (char* chunk : chunks_) ::operator delete(chunk);
    }
    FixedBlockPool(const FixedBlockPool&) = delete;
    FixedBlockPool& operator=(const FixedBlockPool&) = delete;
    void* allocate() {
        if (!head_) {
            expand();
        }
        Node* p = head_;
        head_ = head_->next;
        return p;
    }
    void deallocate(void* p) {
        if (!p) return;
        Node* node = static_cast<Node*>(p);
        node->next = head_;
        head_ = node;
    }
private:
    union Node {
        Node* next;
    };
    static std::size_t round_up(std::size_t n) {
        constexpr std::size_t align = alignof(std::max_align_t);
        return (n + align - 1) & ~(align - 1);
    }
    void expand() {
        char* chunk = static_cast<char*>(::operator new(block_size_ * blocks_per_chunk_));
        chunks_.push_back(chunk);
        for (std::size_t i = 0; i < blocks_per_chunk_; ++i) {
            Node* node = reinterpret_cast<Node*>(chunk + i * block_size_);
            node->next = head_;
            head_ = node;
        }
    }
    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    Node* head_;
    std::vector<char*> chunks_;  // 소멸자에서 해제할 청크 목록
};
int main() {
    FixedBlockPool pool(256);
    void* p1 = pool.allocate();
    void* p2 = pool.allocate();
    pool.deallocate(p1);
    pool.deallocate(p2);
    std::cout << "풀 기본 동작 테스트 완료\n";
    return 0;
}

설명: 비어 있는 블록 안에 Node의 next 포인터를 저장해 free list를 만듭니다. expand()는 새 청크를 할당하고 블록들을 free list에 연결하며, 청크 포인터는 따로 모아 두었다가 소멸자에서 한꺼번에 해제합니다. 흔한 실수는 소멸자에서 free list를 따라가며 블록마다 ::operator delete를 호출하는 것입니다. 블록은 청크 중간을 가리키는 포인터라 개별 해제하면 정의되지 않은 동작이고, 사용 중이던 블록은 free list에 없으니 청크를 해제할 방법도 사라집니다. 블록 크기는 포인터를 담을 수 있으면서 alignof(std::max_align_t)의 배수가 되도록 올려 잡습니다.

Fallback 지원 (크기 초과 시)

위 클래스에 크기를 받는 오버로드를 추가하면, 블록보다 큰 요청은 전역 힙으로 넘길 수 있습니다. 해제할 때도 같은 크기를 넘겨야 어느 쪽에서 온 메모리인지 구분할 수 있습니다.

// 블록 크기 초과 요청 시 전역 힙으로 위임 (클래스 안에 선언 추가 필요)
void* FixedBlockPool::allocate(std::size_t size) {
    if (size > block_size_) {
        return ::operator new(size);  // fallback
    }
    return allocate();  // 풀에서 할당
}
void FixedBlockPool::deallocate(void* p, std::size_t size) {
    if (!p) return;
    if (size > block_size_) {
        ::operator delete(p);
        return;
    }
    deallocate(p);
}

스레드 로컬 풀 래퍼

// thread_local_pool.cpp
#include <cstddef>
#include <cstdlib>
#include <new>
#include <algorithm>
#include <thread>
#include <vector>
#include <iostream>
// 위의 FixedBlockPool 정의 생략 (동일)
class ThreadLocalPool {
public:
    static FixedBlockPool& instance() {
        thread_local FixedBlockPool pool(256, 64);
        return pool;
    }
};
struct Entity {
    int id;
    float x, y;
    // ... 기타 필드
};
int main() {
    std::vector<std::thread> threads;
    for (int t = 0; t < 4; ++t) {
        threads.emplace_back([t]() {
            auto& pool = ThreadLocalPool::instance();
            for (int i = 0; i < 1000; ++i) {
                Entity* e = static_cast<Entity*>(pool.allocate());
                e->id = t * 1000 + i;
                e->x = e->y = 0.0f;
                // ... 사용 ...
                pool.deallocate(e);
            }
        });
    }
    for (auto& th : threads) th.join();
    std::cout << "스레드 로컬 풀 테스트 완료\n";
    return 0;
}

설명: thread_local로 스레드마다 풀 인스턴스를 두어 락 없이 할당·해제합니다. 스레드가 끝나면 풀 소멸자가 불려 메모리가 해제됩니다. 이 구조에서는 한 스레드에서 할당한 블록을 반드시 같은 스레드에서 반환해야 합니다. 다른 스레드의 풀에 반환하면 블록이 엉뚱한 풀로 옮겨 가고, 원래 스레드가 먼저 끝나면 해제된 청크를 가리키게 됩니다.

placement new와 함께 사용

// 풀에서 메모리를 받아 객체 생성
template<typename T>
T* pool_new(FixedBlockPool& pool) {
    void* p = pool.allocate();
    return new (p) T();
}
template<typename T>
void pool_delete(FixedBlockPool& pool, T* obj) {
    if (obj) {
        obj->~T();
        pool.deallocate(obj);
    }
}
// 사용 예
struct Bullet {
    float x, y, vx, vy;
    Bullet() : x(0), y(0), vx(0), vy(0) {}
};
int main() {
    FixedBlockPool pool(sizeof(Bullet), 128);
    Bullet* b = pool_new<Bullet>(pool);
    b->x = 100.0f;
    b->y = 200.0f;
    pool_delete(pool, b);
    return 0;
}

게임용 오브젝트 풀·프레임 할당자·스택 할당자

게임 엔진에서는 오브젝트 풀, 프레임 할당자, 스택 할당자 세 가지 패턴이 자주 쓰입니다.

게임용 할당자 비교

할당자수명해제 방식용도
오브젝트 풀개별 객체명시적 반환Bullet, Particle, Projectile
프레임 할당자프레임 단위프레임 끝 일괄 리셋임시 데이터, 이벤트
스택 할당자스코프 단위LIFO 역순 해제함수 내 임시 버퍼

오브젝트 풀 (Object Pool)

총알이나 파티클처럼 생성과 삭제가 잦은 같은 타입의 객체를 풀에서 재사용합니다. 생성자와 소멸자는 그대로 호출하되, 힙 할당 없이 미리 확보한 슬롯을 재활용해 할당 비용을 줄입니다.

// object_pool_game.cpp - 게임용 오브젝트 풀
// g++ -std=c++17 -O2 -o object_pool object_pool_game.cpp
#include <cstddef>
#include <new>
#include <utility>
#include <vector>
#include <memory>
#include <iostream>
template<typename T>
class ObjectPool {
public:
    explicit ObjectPool(std::size_t initial_capacity = 64)
        : block_size_(sizeof(Storage))
        , capacity_(initial_capacity) {
        Storage* chunk = static_cast<Storage*>(::operator new(capacity_ * block_size_));
        chunks_.push_back(chunk);
        for (std::size_t i = 0; i < capacity_; ++i) {
            chunk[i].next = (i + 1 < capacity_) ? &chunk[i + 1] : nullptr;
        }
        free_list_ = &chunk[0];
    }
    ~ObjectPool() {
        for (auto* c : chunks_) ::operator delete(c);
    }
    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;
    template<typename... Args>
    T* acquire(Args&&... args) {
        if (!free_list_) expand();
        Storage* slot = free_list_;
        free_list_ = free_list_->next;
        return new (slot->storage) T(std::forward<Args>(args)...);
    }
    void release(T* obj) {
        if (!obj) return;
        obj->~T();
        Storage* slot = reinterpret_cast<Storage*>(obj);
        slot->next = free_list_;
        free_list_ = slot;
    }
private:
    // 빈 슬롯은 next 포인터로, 사용 중인 슬롯은 T 객체로 쓴다
    union Storage {
        Storage* next;
        alignas(T) unsigned char storage[sizeof(T)];
    };
    void expand() {
        std::size_t new_cap = capacity_ * 2;
        Storage* new_chunk = static_cast<Storage*>(::operator new(new_cap * block_size_));
        for (std::size_t i = 0; i < new_cap; ++i) {
            new_chunk[i].next = (i + 1 < new_cap) ? &new_chunk[i + 1] : free_list_;
        }
        free_list_ = &new_chunk[0];
        chunks_.push_back(new_chunk);
        capacity_ = new_cap;
    }
    std::size_t block_size_;
    std::size_t capacity_;
    std::vector<Storage*> chunks_;
    Storage* free_list_;
};
// 게임 엔티티 예시
struct Bullet {
    float x, y, vx, vy;
    int damage;
    Bullet(float x_, float y_, float vx_, float vy_, int dmg = 10)
        : x(x_), y(y_), vx(vx_), vy(vy_), damage(dmg) {}
    void update(float dt) { x += vx * dt; y += vy * dt; }
};
int main() {
    ObjectPool<Bullet> bullet_pool(128);
    std::vector<Bullet*> bullets;
    for (int i = 0; i < 100; ++i)
        bullets.push_back(bullet_pool.acquire(0.0f, 0.0f, 10.0f, 0.0f, 10));
    for (auto* b : bullets) bullet_pool.release(b);
    return 0;
}

acquire()는 빈 슬롯에 placement new로 객체를 만들고, release()는 소멸자를 명시적으로 호출한 뒤 슬롯을 free list에 돌려놓습니다. 슬롯을 T 멤버가 아니라 정렬된 바이트 배열로 둔 이유는, T에 사용자 정의 생성자나 소멸자가 있으면 그대로 union 멤버로 넣을 때 union의 생성자·소멸자가 삭제되어 다루기 까다롭기 때문입니다. 풀을 타입별로 두므로 크기 계산 실수가 생기지 않습니다.

프레임 할당자 (Frame Allocator)

프레임 동안 쓸 임시 데이터를 포인터만 앞으로 옮기며 할당하고, 프레임이 끝나면 한 번에 리셋합니다. 개별 해제가 없어 사용이 단순하며, std::pmr::monotonic_buffer_resource와 비슷한 동작입니다. 소멸자가 호출되지 않으므로 트리비얼하게 파괴 가능한 데이터에만 쓰는 것이 원칙입니다.

// frame_allocator_game.cpp - 게임 프레임 할당자
// g++ -std=c++17 -O2 -o frame_alloc frame_allocator_game.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <memory>
#include <cstring>
#include <iostream>
class FrameAllocator {
public:
    explicit FrameAllocator(std::size_t chunk_size = 64 * 1024)  // 64KB
        : chunk_size_(chunk_size)
        , current_chunk_(nullptr)
        , current_offset_(0)
        , align_(alignof(std::max_align_t)) {
        allocate_chunk();
    }
    ~FrameAllocator() {
        for (auto& chunk : chunks_) {
            ::operator delete(chunk);
        }
    }
    FrameAllocator(const FrameAllocator&) = delete;
    FrameAllocator& operator=(const FrameAllocator&) = delete;
    void* allocate(std::size_t size) {
        size = (size + align_ - 1) & ~(align_ - 1);
        if (size > chunk_size_) throw std::bad_alloc();  // 청크보다 큰 요청은 지원하지 않음
        if (current_offset_ + size > chunk_size_) {
            allocate_chunk();
        }
        void* p = static_cast<char*>(current_chunk_) + current_offset_;
        current_offset_ += size;
        return p;
    }
    // 프레임 끝에 호출: 모든 할당을 무효화하고 다음 프레임 준비
    // 추가로 늘어난 청크는 해제하고 첫 청크만 남긴다 (남겨 두고 재사용하도록 바꿀 수도 있음)
    void reset() {
        for (std::size_t i = 1; i < chunks_.size(); ++i) {
            ::operator delete(chunks_[i]);
        }
        chunks_.resize(1);
        current_chunk_ = chunks_[0];
        current_offset_ = 0;
    }
    // 모든 청크 해제 (선택적, 메모리 절약 시)
    void release() {
        for (auto& chunk : chunks_) {
            ::operator delete(chunk);
        }
        chunks_.clear();
        current_chunk_ = nullptr;
        current_offset_ = 0;
        allocate_chunk();
    }
private:
    void allocate_chunk() {
        char* chunk = static_cast<char*>(::operator new(chunk_size_));
        chunks_.push_back(chunk);
        current_chunk_ = chunk;
        current_offset_ = 0;
    }
    std::size_t chunk_size_;
    char* current_chunk_;
    std::size_t current_offset_;
    std::size_t align_;
    std::vector<char*> chunks_;
};
// 게임 루프에서 사용 예
void game_loop() {
    FrameAllocator frame_alloc(64 * 1024);
    for (int frame = 0; frame < 60; ++frame) {
        for (int i = 0; i < 100; ++i)
            frame_alloc.allocate(16);  // 프레임 내 임시 데이터
        frame_alloc.reset();  // 프레임 끝: 모든 할당 무효화
    }
}

평소 프레임에서는 청크 하나로 충분하도록 크기를 잡으면 reset()은 오프셋을 0으로 돌리는 것으로 끝납니다. 어떤 프레임에서 청크를 넘치면 추가 청크가 생기는데, 위 구현은 리셋할 때 그것들을 해제합니다. 추가 청크를 해제하지 않고 current_chunk_만 첫 청크로 되돌리면, 다음에 넘칠 때마다 새 청크를 또 할당해서 메모리가 계속 늘어나는 버그가 됩니다.

스택 할당자 (Stack Allocator)

할당한 역순(LIFO)으로만 해제하는 할당자입니다. 레벨 로딩이나 파싱처럼 단계별로 임시 버퍼를 쌓았다가 단계가 끝나면 한꺼번에 되돌리는 작업에 씁니다.

// stack_allocator_game.cpp - 스택 할당자
// g++ -std=c++17 -O2 -o stack_alloc stack_allocator_game.cpp
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <new>
#include <vector>
#include <stdexcept>
#include <iostream>
class StackAllocator {
public:
    explicit StackAllocator(std::size_t capacity = 64 * 1024)
        : capacity_(capacity)
        , offset_(0)
        , align_(alignof(std::max_align_t)) {
        buffer_ = static_cast<char*>(::operator new(capacity_));
    }
    ~StackAllocator() {
        ::operator delete(buffer_);
    }
    StackAllocator(const StackAllocator&) = delete;
    StackAllocator& operator=(const StackAllocator&) = delete;
    void* allocate(std::size_t size) {
        std::size_t aligned = (size + align_ - 1) & ~(align_ - 1);
        if (offset_ + aligned > capacity_) {
            throw std::bad_alloc();
        }
        void* p = buffer_ + offset_;
        offset_ += aligned;
        return p;
    }
    // 마지막 allocate로 받은 포인터만 해제 가능 (LIFO)
    void deallocate(void* p) {
        if (!p || p < buffer_ || p >= buffer_ + offset_) return;
        offset_ = static_cast<char*>(p) - buffer_;
    }
    // 특정 오프셋으로 롤백 (마커 패턴)
    struct Marker {
        std::size_t offset;
    };
    Marker get_marker() const { return Marker{offset_}; }
    void rollback(Marker m) { offset_ = m.offset; }
    void reset() { offset_ = 0; }
private:
    std::size_t capacity_;
    std::size_t offset_;
    std::size_t align_;
    char* buffer_;
};
// 사용 예: 파싱 중 임시 버퍼
void parse_level_data(const char* data, std::size_t len) {
    StackAllocator stack(32 * 1024);
    auto marker = stack.get_marker();
    char* temp_buf = static_cast<char*>(stack.allocate(4096));
    std::memcpy(temp_buf, data, std::min(len, std::size_t(4096)));
    if (/* 파싱 실패 */ false) { stack.rollback(marker); return; }
}

get_marker()로 현재 위치를 기억해 두었다가 rollback()으로 그 시점까지 한꺼번에 되돌릴 수 있습니다. 이 구현의 deallocate(p)는 오프셋을 p 위치로 되돌리므로, 가장 나중에 할당한 것이 아닌 포인터를 넘기면 그 뒤에 할당한 메모리까지 함께 해제된 상태가 됩니다. 그 메모리를 계속 쓰면 다음 할당과 겹쳐 데이터가 깨집니다.

게임 엔진에서의 조합 사용

// 게임 엔진 메모리 아키텍처 예시
class GameMemory {
public:
    ObjectPool<Bullet> bullet_pool{256};
    ObjectPool<Particle> particle_pool{1024};
    FrameAllocator frame_alloc{64 * 1024};  // 프레임당 임시 데이터
    void begin_frame() {
        frame_alloc.reset();
    }
    void end_frame() {
        // bullet_pool, particle_pool은 개별 release
        // frame_alloc은 reset으로 일괄 해제
    }
};

스레드 로컬 풀

  • thread_local 풀 인스턴스를 두면 해당 스레드 안에서만 할당과 해제가 일어나 뮤텍스가 필요 없습니다. 여러 스레드가 동시에 할당해도 서로 기다리지 않습니다.
  • 스레드가 종료될 때 풀에 남은 블록을 어떻게 할지(다른 스레드 풀로 넘기기 vs 그냥 해제)는 정책 문제입니다. 최소 버전에서는 스레드 종료 시 풀 메모리를 해제해도 됩니다.

스레드 로컬 vs 전역 풀 비교

flowchart TB
    subgraph TLS[스레드 로컬 풀]
        T1[스레드1 풀] --> A1[할당/해제]
        T2[스레드2 풀] --> A2[할당/해제]
        T3[스레드3 풀] --> A3[할당/해제]
        N1["락 없음, 확장성 좋음"]
    end
    subgraph Global[전역 풀 + 뮤텍스]
        G[풀] --> M[뮤텍스]
        M --> B1[스레드1]
        M --> B2[스레드2]
        M --> B3[스레드3]
        N2["락 경합, 스레드 많을수록 병목"]
    end

벤치마크 방법·결과 해석

측정 방법

  • 같은 횟수(예: 100만 번)의 할당과 해제를 반복하며, new/delete와 풀의 allocate/deallocate를 한 블록씩 비교합니다.
  • 할당만 N번 한 뒤 해제만 N번 하는 패턴과, 할당과 해제를 섞는 패턴을 모두 측정합니다. 실제 워크로드는 대개 후자에 가깝습니다.
  • 스레드 수를 1, 4, 16 등으로 늘려 가며 스레드당 처리량과 총 처리량을 비교합니다.
  • 최적화 빌드(-O2)로 측정하고, 컴파일러가 사용하지 않는 할당을 제거하지 않도록 결과 포인터를 실제로 사용합니다.

벤치마크 코드

// benchmark_pool.cpp - g++ -std=c++17 -O2 -o bench benchmark_pool.cpp
// FixedBlockPool는 위 예제와 동일하게 정의
#include <chrono>
#include <iostream>
#include <vector>
constexpr std::size_t N = 1'000'000;
constexpr std::size_t BLOCK_SIZE = 256;
void bench_new_delete() {
    std::vector<void*> ptrs; ptrs.reserve(N);
    auto start = std::chrono::high_resolution_clock::now();
    for (std::size_t i = 0; i < N; ++i) ptrs.push_back(::operator new(BLOCK_SIZE));
    for (auto p : ptrs) ::operator delete(p);
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::high_resolution_clock::now() - start).count();
    std::cout << "new/delete: " << ms << " ms, " << (N*1000/(ms+1)) << " K ops/sec\n";
}
void bench_pool() {
    FixedBlockPool pool(BLOCK_SIZE, 64);
    std::vector<void*> ptrs; ptrs.reserve(N);
    auto start = std::chrono::high_resolution_clock::now();
    for (std::size_t i = 0; i < N; ++i) ptrs.push_back(pool.allocate());
    for (auto p : ptrs) pool.deallocate(p);
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
        std::chrono::high_resolution_clock::now() - start).count();
    std::cout << "Pool: " << ms << " ms, " << (N*1000/(ms+1)) << " K ops/sec\n";
}

멀티스레드 벤치마크는 thread_local FixedBlockPool로 스레드당 풀을 두어 락 없이 측정합니다.

결과 해석

풀의 할당은 포인터 두어 번 읽고 쓰는 것으로 끝나므로 단일 스레드 마이크로벤치마크에서는 대개 new/delete보다 빠르게 나옵니다. 다만 최신 할당기(glibc의 tcache, jemalloc, mimalloc 등)도 작은 크기의 할당은 스레드 로컬 캐시로 매우 빠르게 처리하므로, 차이가 기대보다 작을 수 있습니다. 결과는 할당기 종류, 플랫폼, 컴파일 옵션에 따라 크게 달라지니 반드시 타깃 환경에서 직접 측정해야 합니다. 마이크로벤치마크보다 실제 애플리케이션에 풀을 적용했을 때 프로파일러에서 할당 비중이 얼마나 줄었는지가 더 믿을 만한 지표입니다.

성능 비교 다이어그램

flowchart LR
    subgraph NewDelete[new/delete]
        ND1["malloc 락"]
        ND2["힙 탐색"]
        ND3[메타데이터]
    end
    subgraph Pool[메모리 풀]
        P1["포인터 연산"]
        P2["O(1)"]
    end
    ND1 --> ND2 --> ND3
    P1 --> P2

Use-After-Free, 잘못된 풀 반환, 정렬 문제: 풀 사용 실수

풀 수명보다 객체가 오래 살 때 (Use-After-Free)

증상: 크래시, 정의되지 않은 동작, 힙 손상. 원인: 풀이 먼저 소멸되었는데, 그 풀에서 할당받은 객체가 아직 살아 있을 때.

// ❌ 잘못된 코드
void* create() {
    FixedBlockPool pool(256);
    return pool.allocate();
}
void use() {
    void* p = create();  // pool은 함수 종료 시 소멸!
    // p는 이미 해제된 풀의 메모리를 가리킴 → UB
    *(int*)p = 42;  // use-after-free
}

해결법:

// ✅ 올바른 코드: 풀 수명을 객체보다 길게
FixedBlockPool pool(256);  // 전역 또는 장수명 스코프
void* create() {
    return pool.allocate();
}
void use() {
    void* p = create();
    *(int*)p = 42;
    pool.deallocate(p);
}

잘못된 풀에 반환

증상: 메모리 손상, 크래시. 원인: 풀 A에서 할당받고 풀 B에 반환함.

// ❌ 잘못된 코드
FixedBlockPool pool1(256), pool2(256);
void* p = pool1.allocate();
pool2.deallocate(p);  // 다른 풀에 반환 → UB

해결법:

// ✅ 올바른 코드: 할당한 풀에 반환
void* p = pool1.allocate();
pool1.deallocate(p);

블록 크기보다 큰 객체 할당

증상: 인접 블록 덮어쓰기, 메모리 손상. 원인: sizeof(Entity)가 블록 크기(256)보다 큰데 풀에서 할당함.

// ❌ 잘못된 코드
FixedBlockPool pool(256);  // 블록 256바이트
struct LargeEntity { char data[512]; };  // 512바이트
LargeEntity* e = static_cast<LargeEntity*>(pool.allocate());  // 오버플로!

해결법:

// ✅ 올바른 코드: 블록 크기 확인
void* allocate(std::size_t size) {
    if (size > block_size_) {
        return ::operator new(size);  // fallback
    }
    return allocate();
}

이중 해제 (Double Free)

증상: 크래시, free list 손상. 원인: 같은 포인터를 두 번 deallocate함.

// ❌ 잘못된 코드
void* p = pool.allocate();
pool.deallocate(p);
pool.deallocate(p);  // double free → free list 손상

해결법:

// ✅ 올바른 코드: 해제 후 nullptr로
void* p = pool.allocate();
pool.deallocate(p);
p = nullptr;  // 재사용 방지
// 또는 스마트 포인터로 풀 기반 deleter 사용

정렬 문제

증상: 특정 플랫폼에서만 크래시, 느린 접근. 원인: 블록이 alignof(std::max_align_t)로 정렬되지 않음.

// ❌ 잘못된 코드
char* chunk = static_cast<char*>(::operator new(chunk_size));
Node* node = reinterpret_cast<Node*>(chunk + i * block_size_);
// block_size가 8의 배수가 아니면 정렬 위반

해결법:

// ✅ 올바른 코드: 정렬 보장
block_size_ = (block_size + alignof(std::max_align_t) - 1)
              & ~(alignof(std::max_align_t) - 1);
std::size_t chunk_size = block_size_ * blocks_per_chunk_;
chunk_size = (chunk_size + alignof(std::max_align_t) - 1)
             & ~(alignof(std::max_align_t) - 1);

스레드 안전성 오해

증상: 멀티스레드에서 간헐적 크래시. 원인: 전역 풀을 여러 스레드가 락 없이 사용함.

// ❌ 잘못된 코드
FixedBlockPool global_pool(256);  // 전역
void worker() {
    void* p = global_pool.allocate();  // data race!
    // ...
    global_pool.deallocate(p);
}

해결법:

// ✅ 올바른 코드: 스레드 로컬 풀
void worker() {
    thread_local FixedBlockPool pool(256);
    void* p = pool.allocate();
    // ...
    pool.deallocate(p);
}

스택 할당자 LIFO 위반

증상: 데이터가 조용히 덮어써짐. 원인: 스택 할당자는 마지막 할당부터 역순으로 해제해야 합니다.

// ❌ p1을 먼저 해제 → LIFO 위반
void* p1 = stack.allocate(100);
void* p2 = stack.allocate(200);
stack.deallocate(p1);  // 오프셋이 p1로 돌아가 p2 영역까지 해제된 상태
void* p3 = stack.allocate(300);  // p2와 겹침
// ✅ 역순 해제 또는 get_marker()/rollback() 사용

프레임 할당자 reset 후 참조

증상: use-after-free. 원인: reset() 후 이전 프레임에서 할당한 포인터 사용.

// ❌ reset 후 p 사용 → UB
void* p = frame_alloc.allocate(64);
frame_alloc.reset();
*(int*)p = 42;  // UB

풀 반환 후 포인터 재사용

증상: 다른 객체 데이터 읽기, 간헐적 크래시. 해결: release(b) 후 b = nullptr 또는 스코프로 수명 제한.

풀 소진 시 nullptr 역참조

증상: 부하가 몰리는 순간에만 크래시. 원인: 이 글의 FixedBlockPool은 비면 스스로 확장하지만, 실시간 시스템에서는 미리 정한 개수만큼만 블록을 두고 확장하지 않는 풀을 쓰는 경우가 많습니다. 이런 풀이 비었을 때 allocate()가 nullptr을 돌려주는데, 호출부가 확인하지 않고 바로 사용하는 경우입니다.

고정 크기 풀의 “예측 가능한 할당”은 풀이 소진되지 않는다는 전제 위에 있습니다. 최대 동시 사용량을 미리 알 수 있다면 그보다 넉넉하게 블록 수를 잡아 소진 자체가 일어나지 않게 하는 것이 가장 단순합니다. 예측이 어렵다면 두 가지 중 하나를 고릅니다. 소진 시 새 청크를 힙에서 추가로 붙이는 동적 확장은 “항상 성공”을 얻는 대신 확장 순간의 지연을 감수해야 하고, 소진을 명시적 실패(예외 또는 에러 코드)로 올려 호출부가 처리하게 하는 방식은 지연 상한을 지키는 대신 실패 경로를 설계해야 합니다. 실시간성이 중요한 게임 루프나 임베디드 코드라면 후자를 택하고, 아래 “풀 통계 및 모니터링”처럼 최대 사용량을 기록해 풀 크기를 조정하는 편이 맞고, 서버 요청 처리처럼 가끔의 지연보다 실패가 더 비싼 곳이라면 전자가 낫습니다.

void* p = pool.allocate();
if (!p) {
    // ✅ 명시적으로 처리: 확장, fallback(::operator new), 또는 요청 거절
    p = ::operator new(block_size);
}

블록 크기·청크 크기·풀 수명 권장값

항목권장 사항
블록 크기sizeof(객체)에 정렬 맞춤. 64, 128, 256 등 2의 거듭제곱
청크 크기수십~수백 KB, 페이지(보통 4KB) 배수
풀 수명할당 객체보다 반드시 길게 (전역/싱글톤)
타입 분리ObjectPool<Bullet>, ObjectPool<Particle>처럼 타입별 풀
디버그deallocate 후 0xDEADBEEF 패턴 덮어쓰기 (NDEBUG에서 비활성화)
스마트 포인터unique_ptr<T, PoolDeleter>로 RAII. shared_ptr는 제어 블록 오버헤드
프로파일링풀 도입 전 malloc 병목 여부 반드시 확인

프레임 풀·요청 스코프 풀·크기별 풀 운영 패턴

게임 프레임 풀

프레임 안에서만 쓰는 데이터는 프레임 할당자로 받고 프레임이 끝날 때 한 번에 리셋합니다. 구현은 게임용 할당자 절의 프레임 할당자 예제를 참고하세요. 표준 라이브러리의 std::pmr::monotonic_buffer_resource를 쓰면 release() 한 번으로 같은 효과를 얻을 수 있습니다.

요청 스코프 풀 (HTTP 서버)

HTTP 요청마다 메모리 자원을 만들고, 요청이 끝나면 그 자원과 함께 모든 할당을 한 번에 해제합니다. 컨테이너처럼 크기가 제각각인 할당이 섞이므로, 고정 블록 풀보다 표준 std::pmr::monotonic_buffer_resource가 잘 맞습니다.

#include <array>
#include <cstddef>
#include <memory_resource>
#include <vector>
// 요청 처리: 메모리 자원의 수명 = 요청 수명
void handle_request(const Request& req) {
    std::array<std::byte, 16 * 1024> buffer;  // 먼저 스택 버퍼를 쓰고, 넘치면 힙에서 추가
    std::pmr::monotonic_buffer_resource arena(buffer.data(), buffer.size());
    std::pmr::vector<Header> headers(&arena);
    std::pmr::vector<Param> params(&arena);
    parse_headers(req, headers);
    parse_params(req, params);
    process(headers, params);
    // arena 소멸 → 모든 할당 일괄 해제
}

크기별 풀 (Size-Class Pool)

여러 블록 크기를 지원하려면 크기별로 풀을 둡니다.

크기가 제각각인 요청을 처리할 때 먼저 떠오르는 방법은 반환된 블록을 크기와 함께 목록에 쌓아 두고, 새 요청이 오면 “요청 크기 이상인 첫 블록”을 꺼내 주는 최초 적합(first-fit) 풀입니다. 구현은 짧지만 실제로 돌려 보면 두 가지 문제가 곧 드러납니다. 블록을 쪼개지 않으면 16바이트 요청에 1KB 블록이 나가 그 차이만큼 메모리가 묶이고, 맞는 블록이 없을 때마다 ::operator new로 새로 할당하니 시간이 지날수록 일반 힙 할당과 다를 바가 없어집니다. 목록을 선형 탐색하는 비용도 블록이 쌓일수록 커집니다. 그래서 가변 크기 요구가 있으면 아래처럼 크기 구간마다 고정 크기 풀을 두는 편이 할당 시간과 메모리 사용량 모두 예측하기 쉽습니다. jemalloc, tcmalloc 같은 범용 할당기도 내부적으로 이런 크기 클래스 구조를 씁니다.

// 64, 128, 256, 512 바이트 풀
class SizeClassPool {
public:
    void* allocate(std::size_t size) {
        if (size <= 64) return pool64_.allocate();
        if (size <= 128) return pool128_.allocate();
        if (size <= 256) return pool256_.allocate();
        if (size <= 512) return pool512_.allocate();
        return ::operator new(size);
    }
    void deallocate(void* p, std::size_t size) {
        if (size <= 64) return pool64_.deallocate(p);
        if (size <= 128) return pool128_.deallocate(p);
        if (size <= 256) return pool256_.deallocate(p);
        if (size <= 512) return pool512_.deallocate(p);
        ::operator delete(p);
    }
private:
    FixedBlockPool pool64_{64, 64};
    FixedBlockPool pool128_{128, 64};
    FixedBlockPool pool256_{256, 64};
    FixedBlockPool pool512_{512, 32};
};

풀 통계 및 모니터링

프로덕션에서 풀 사용량을 모니터링합니다.

// 풀 래퍼로 할당 횟수·피크 사용량 추적
class MonitoredPool {
public:
    explicit MonitoredPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : pool_(block_size, blocks_per_chunk)
        , alloc_count_(0)
        , current_used_(0)
        , peak_used_(0) {}
    void* allocate() {
        void* p = pool_.allocate();
        ++alloc_count_;
        ++current_used_;
        if (current_used_ > peak_used_) peak_used_ = current_used_;
        return p;
    }
    void deallocate(void* p) {
        pool_.deallocate(p);
        --current_used_;
    }
    std::size_t peak_usage() const { return peak_used_; }
    std::size_t total_allocations() const { return alloc_count_; }
private:
    FixedBlockPool pool_;
    std::size_t alloc_count_;
    std::size_t current_used_;
    std::size_t peak_used_;
};

풀 기반 스마트 포인터 (RAII 통합)

template<typename T, typename Pool>
struct PoolDeleter {
    Pool* pool;
    void operator()(T* p) const { if (p && pool) { p->~T(); pool->deallocate(p); } }
};
template<typename T, typename Pool>
using PoolUniquePtr = std::unique_ptr<T, PoolDeleter<T, Pool>>;

네트워크 핸들러 풀 (Boost.Asio 스타일)

비동기 완료 핸들러를 풀에서 할당해 할당 오버헤드를 줄입니다. 고성능 네트워크 가이드 #5와 연계됩니다.

// 비동기 핸들러: 풀에서 할당 (개념 예시)
template<typename Handler>
void async_read_with_pool(Socket& sock, FixedBlockPool& pool, Handler&& h) {
    using handler_type = std::decay_t<Handler>;
    void* mem = pool.allocate();
    auto* ptr = new (mem) handler_type(std::forward<Handler>(h));
    // 완료 시: ptr->~handler_type(); pool.deallocate(mem);
}

핸들러 객체 크기가 풀 블록 크기 이하일 때만 풀을 쓰고, 초과하면 operator new로 넘깁니다.


메모리 풀의 한계

고정 블록 풀은 블록 크기가 하나라서 크기가 다양한 할당에는 크기 클래스를 따로 두어야 합니다. 또 풀이 메모리를 미리 확보하고, 이 글의 구현처럼 한 번 늘어난 청크를 돌려주지 않으면 피크 사용량만큼의 메모리를 계속 붙잡고 있게 됩니다. AddressSanitizer나 Valgrind 같은 도구도 풀 내부의 블록 단위 오류는 잡지 못하므로(도구 입장에서는 청크 하나를 할당한 것뿐), 디버그 빌드에서는 풀을 끄고 일반 할당으로 돌리는 스위치를 두면 버그를 찾기 쉽습니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 고정 크기 블록 풀에 블록보다 큰 객체를 할당하면 어떻게 되나요?

A. 고정 크기 풀은 모든 블록이 같은 크기라고 가정하므로, 더 큰 객체를 넣으면 인접한 블록이나 free list에 저장된 다음 블록 포인터를 덮어써 메모리가 손상됩니다. 문제는 할당 시점이 아니라 한참 뒤 다른 객체를 할당하거나 해제할 때 드러나서 원인을 찾기 어렵습니다. allocate에서 요청 크기를 검사해 블록보다 크면 일반 operator new로 넘기는 fallback을 두고 deallocate도 같은 기준으로 분기하며, 타입이 정해진 풀이라면 static_assert로 sizeof를 컴파일 타임에 검사하는 것이 좋습니다.

Q. std::pmr와 직접 구현의 차이는?

A. std::pmr는 표준 인터페이스로 컨테이너에 주입할 수 있는 메모리 자원(unsynchronized_pool_resource, monotonic_buffer_resource 등)을 제공합니다. 대부분은 이것으로 충분하고, 특수한 레이아웃이나 모니터링, 게임 엔진처럼 할당 정책을 세밀하게 통제해야 할 때 직접 구현을 선택합니다. 직접 구현한 풀도 std::pmr::memory_resource를 상속하면 pmr 컨테이너에 그대로 꽂아 쓸 수 있습니다.

Q. 블록 크기를 어떻게 정하나요?

A. 타입별 풀이라면 sizeof(T)를 정렬 단위로 올린 값이면 됩니다. 여러 크기를 받는 풀이라면 프로파일링으로 할당 크기 분포를 확인한 뒤, 대부분의 요청을 담을 수 있는 크기 몇 개를 크기 클래스로 정하고 나머지는 fallback으로 넘깁니다.

Q. 풀 메모리가 너무 많이 쌓이면?

A. 스레드 로컬 풀은 스레드 종료 시 해제됩니다. 장기 실행 시 피크 사용량을 모니터링하며, 필요하면 release() 또는 풀 리셋 정책을 추가합니다. monotonic 스타일은 release()로 한 번에 비울 수 있습니다.

이전 글: [실전 딥다이브 #48-2] 초경량 HTTP 웹 프레임워크 바닥부터 만들기 다음 글: C++ Segmentation fault 디버깅: core dump·GDB·LLDB·ASan·Valgrind로 원인 찾기