C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]

C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]

이 글의 핵심

C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]에 대한 실전 가이드입니다.

들어가며: “new/delete가 병목일 때 풀을 쓴다”

왜 메모리 풀인가

대량의 같은 크기(또는 비슷한 크기) 객체를 반복해서 할당·해제하면 힙 단편화할당/해제 비용이 누적됩니다. 고성능 네트워크 가이드 #5#39-2 커스텀 알로케이터에서 풀·커스텀 할당자를 다뤘습니다. 이 글은 직접 메모리 풀을 설계·구현하고, new/delete와 벤치마크(동일 조건으로 성능을 측정·비교하는 것)로 비교해 보는 딥다이브입니다. “이렇게 만들면 얼마나 빨라지는지”를 숫자로 확인할 수 있습니다.

이 글에서 다루는 것:

  • 고정 크기 블록 풀, 스레드 로컬 풀, 벤치마크 (new/delete vs 풀)
  • 게임용: 오브젝트 풀, 프레임 할당자, 스택 할당자
  • 자주 하는 실수, 모범 사례, 프로덕션 패턴

선수 지식: #39-2 std::pmr·커스텀 할당자를 알면 좋습니다.


문제 시나리오: “이런 상황에서 막혔다”

시나리오 1: 프로파일러에서 malloc이 상위를 차지할 때

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

상황: 게임 엔진에서 매 프레임 수많은 Bullet, Particle 객체를 new/delete로 생성·해제합니다. 프로파일러 결과 operator new가 30% 이상을 차지하고, 60fps 목표를 달성하지 못합니다.

원인: 작은 객체의 반복 할당/해제 → malloc 오버헤드 누적, 전역 힙 락 경합.

해결 포인트: 고정 크기 풀로 미리 블록을 할당해 두고, 프레임 단위로 재사용·리셋하면 할당 횟수가 크게 줄어듭니다.

시나리오 2: 힙 단편화로 OOM 발생

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

상황: HTTP 서버에서 요청마다 파싱 결과를 할당하는데, 24시간 이상 운영 후 malloc이 실패합니다. 전체 메모리 사용량은 여유가 있는데도 할당이 실패합니다.

원인: 작은 빈 공간이 흩어져 큰 연속 메모리 할당이 불가능해지는 힙 단편화.

해결 포인트: 은 큰 블록을 한 번 할당하고 그 안에서만 잘라 쓰므로, 전역 힙의 단편화를 유발하지 않습니다.

시나리오 3: 멀티스레드에서 전역 힙 락 경합

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

상황: 16코어 서버에서 16스레드로 요청을 처리하는데, throughput이 4스레드의 2배 정도에 그칩니다.

원인: 전역 malloc/free가 내부 락을 사용해, 스레드가 많아질수록 경합이 심해집니다.

해결 포인트: 스레드 로컬 풀을 두면 스레드당 독립적으로 할당/해제가 일어나 락이 필요 없습니다.

시나리오 4: 캐시 미스로 인한 성능 저하

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

상황: 링크드 리스트 노드를 new로 할당해 사용하는데, 순회 시 캐시 미스가 많습니다.

원인: malloc은 요청마다 다른 위치에 메모리를 주므로, 객체들이 물리적으로 흩어져 캐시 효율이 떨어집니다.

해결 포인트: 은 큰 연속 블록에서 나눠 주므로, 객체들이 인접해 배치될 가능성이 높아 캐시 효율이 좋아집니다.

시나리오별 해결 방향 요약

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

개념을 잡는 비유

메모리 풀·PMR은 창고 칸을 미리 나눠 두고 필요할 때만 꺼내 쓰는 방식에 가깝습니다. 할당·해제 패턴이 고정돼 있으면 전역 new보다 예측 가능하고 캐시 친화적일 수 있습니다.


목차

  1. 풀 설계: 고정 블록 크기
  2. 구현 요지: 할당·반환·재사용
  3. 완전한 메모리 풀 예제
  4. 게임용 메모리 풀 완전 예제
  5. 스레드 로컬 풀
  6. 벤치마크 방법·결과 해석
  7. 자주 하는 실수와 해결법
  8. 모범 사례·베스트 프랙티스
  9. 프로덕션 패턴
  10. 정리·한계

1. 풀 설계: 고정 블록 크기

개념

  • 한 번에 큰 메모리(예: 64KB)를 malloc 또는 operator new로 할당합니다.
  • 이 영역을 고정 크기 블록(예: 256바이트)으로 나눕니다. 각 블록의 처음 몇 바이트를 “다음 빈 블록을 가리키는 포인터”로 쓰는 free list를 만듭니다.
  • allocate 요청이 오면 free list에서 블록 하나를 꺼내 반환합니다. deallocate 요청이 오면 그 블록을 free list에 다시 넣습니다.
  • 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

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

할당 (allocate)

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

반환 (deallocate)

  • 반환된 포인터를 free list의 새 head로 넣고, 기존 head를 next로 연결합니다. (LIFO로 재사용)
  • use-after-free를 막기 위해 반환 후 해당 블록을 특정 패턴으로 덮어쓰는 옵션을 둘 수 있습니다(디버그 빌드에서만).

정렬

  • 반환하는 주소가 alignof(std::max_align_t) 또는 요청된 정렬을 만족하도록, 블록을 정렬된 경계에 두고 free list를 구성합니다.

3. 완전한 메모리 풀 예제

예제 1: 최소 동작 풀 (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>

class FixedBlockPool {
public:
    explicit FixedBlockPool(std::size_t block_size, std::size_t blocks_per_chunk = 64)
        : block_size_(std::max(block_size, sizeof(Node*)))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}

    ~FixedBlockPool() {
        while (head_) {
            Node* next = head_->next;
            ::operator delete(head_);
            head_ = next;
        }
    }

    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;
    };

    void expand() {
        std::size_t chunk_size = block_size_ * blocks_per_chunk_;
        std::size_t align = alignof(std::max_align_t);
        chunk_size = (chunk_size + align - 1) & ~(align - 1);

        char* chunk = static_cast<char*>(::operator new(chunk_size));
        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_;
};

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 union으로 free list의 next 포인터를 블록 내부에 저장합니다. expand()에서 새 청크를 할당하고 블록들을 free list에 연결합니다. block_size_sizeof(Node*) 이상이어야 합니다.

예제 2: 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);
}

예제 3: 스레드 로컬 풀 래퍼

// 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로 스레드당 풀 인스턴스를 두어, 락 없이 할당/해제가 가능합니다. 스레드 종료 시 풀 메모리는 자동으로 해제됩니다.

예제 4: 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;
}

4. 게임용 메모리 풀 완전 예제

게임 엔진에서는 오브젝트 풀, 프레임 할당자, 스택 할당자 세 가지 패턴이 자주 쓰입니다. 각각의 특성과 완전한 구현 예제를 다룹니다.

게임용 할당자 비교

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

예제 5: 오브젝트 풀 (Object Pool)

총알·파티클처럼 생성/삭제가 빈번한 동일 타입 객체를 풀에서 재사용합니다. 생성자/소멸자 호출 없이 메모리만 재활용해 할당 비용을 최소화합니다.

// object_pool_game.cpp - 게임용 오브젝트 풀
// g++ -std=c++17 -O2 -o object_pool object_pool_game.cpp
#include <cstddef>
#include <new>
#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:
    union Storage {
        T storage;
        Storage* next;
    };

    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에 반환. 같은 타입만 풀에 넣어 타입 안전성을 유지합니다.

예제 6: 프레임 할당자 (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 (current_offset_ + size > chunk_size_) {
            allocate_chunk();
        }
        void* p = static_cast<char*>(current_chunk_) + current_offset_;
        current_offset_ += size;
        return p;
    }

    // 프레임 끝에 호출: 모든 할당을 무효화하고 다음 프레임 준비
    void reset() {
        current_offset_ = 0;
        if (!chunks_.empty()) {
            current_chunk_ = chunks_[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으로 돌려 O(1). 프레임 내에서만 유효한 임시 데이터에 적합합니다.

예제 7: 스택 할당자 (Stack Allocator)

LIFO(후입선출) 순서로 해제하는 할당자입니다. 함수 스코프 내 임시 버퍼, 재귀 호출 시 스택 오버플로우 방지에 쓰입니다.

// stack_allocator_game.cpp - 스택 할당자
// g++ -std=c++17 -O2 -o stack_alloc stack_allocator_game.cpp
#include <cstddef>
#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()으로 중간 시점으로 되돌리기 가능. LIFO가 아니면 UB이므로 주의합니다.

게임 엔진에서의 조합 사용

// 게임 엔진 메모리 아키텍처 예시
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으로 일괄 해제
    }
};

5. 스레드 로컬 풀

  • thread_local 풀 인스턴스를 두면, 해당 스레드에서만 allocate/deallocate가 일어나 뮤텍스가 필요 없습니다. 동시에 여러 스레드가 할당해도 경합이 없어 처리량이 올라갑니다.
  • 스레드가 종료될 때 풀에 남은 블록을 어떻게 할지(다른 스레드 풀로 넘기기 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

6. 벤치마크 방법·결과 해석

측정 방법

  • 동일한 횟수(예: 100만 번)의 할당 + 해제를 반복합니다. new/delete 한 번에 한 블록 vs 풀 allocate/deallocate 한 번에 한 블록으로 측정합니다.
  • 순서: 할당만 N번 → 해제만 N번, 또는 할당-해제를 한 쌍으로 N번. 실제 워크로드에 가깝게 “할당과 해제가 섞인” 패턴도 측정하면 좋습니다.
  • 스레드 수: 1, 4, 16 등으로 늘려가며 스레드당 처리량 또는 총 처리량을 비교합니다.

벤치마크 코드

// benchmark_pool.cpp - g++ -std=c++17 -O2 -o bench benchmark_pool.cpp
// FixedBlockPool는 예제 1과 동일하게 정의
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/deletePool배율
1M 할당+해제 (batch)~50ms~5ms~10x
1M 할당+해제 (interleaved)~80ms~8ms~10x
4스레드 (TLS 풀)~120ms~15ms~8x

주의: 벤치마크는 특정 환경·컴파일 옵션에서만 유효합니다. 자신의 타겟 빌드·플랫폼에서 반드시 다시 측정하는 것이 좋습니다.

성능 비교 다이어그램

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

7. 자주 하는 실수와 해결법

에러 1: 풀 수명보다 객체가 오래 살 때 (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);
}

에러 2: 잘못된 풀에 반환

증상: 메모리 손상, 크래시.

원인: 풀 A에서 할당받고 풀 B에 반환함.

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

해결법:

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

에러 3: 블록 크기보다 큰 객체 할당

증상: 인접 블록 덮어쓰기, 메모리 손상.

원인: 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();
}

에러 4: 이중 해제 (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 사용

에러 5: 정렬 문제

증상: 특정 플랫폼에서만 크래시, 느린 접근.

원인: 블록이 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);

에러 6: 스레드 안전성 오해

증상: 멀티스레드에서 간헐적 크래시.

원인: 전역 풀을 여러 스레드가 락 없이 사용함.

// ❌ 잘못된 코드
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);
}

에러 7: 스택 할당자 LIFO 위반

증상: 메모리 손상, UB. 원인: 스택 할당자는 마지막 할당부터 역순 해제해야 함.

// ❌ p1을 먼저 해제 → LIFO 위반
void* p1 = stack.allocate(100);
void* p2 = stack.allocate(200);
stack.deallocate(p1);  // UB

// ✅ 역순 해제 또는 get_marker()/rollback() 사용

에러 8: 프레임 할당자 reset 후 참조

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

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

에러 9: 풀 반환 후 포인터 재사용

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


8. 모범 사례·베스트 프랙티스

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

9. 프로덕션 패턴

패턴 1: 게임 프레임 풀

매 프레임 풀을 리셋하고 프레임 끝에서 일괄 해제. begin_frame()에서 free list 재구성, allocate()에서 블록 반환. 게임용 프레임 할당자 예제 6 참조.

참고: std::pmr::monotonic_buffer_resource를 쓰면 release() 한 번으로 전체 리셋이 가능합니다.

패턴 2: 요청 스코프 풀 (HTTP 서버)

HTTP 요청 처리 시마다 풀을 만들고, 요청 종료 시 풀과 함께 모든 할당을 해제합니다.

// 요청 처리: 풀 스코프 = 요청 수명
void handle_request(const Request& req) {
    FixedBlockPool request_pool(256, 128);
    std::vector<Header*, PoolAllocator<Header>> headers(&request_pool);
    std::vector<Param*, PoolAllocator<Param>> params(&request_pool);

    parse_headers(req, headers);
    parse_params(req, params);
    process(headers, params);
    // request_pool 소멸 → 모든 할당 일괄 해제
}

패턴 3: 크기별 풀 (Size-Class Pool)

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

// 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};
};

패턴 4: 풀 통계 및 모니터링

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

// 풀 래퍼로 할당 횟수·피크 사용량 추적
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_;
};

패턴 5: 풀 기반 스마트 포인터 (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>>;

패턴 6: 네트워크 핸들러 풀 (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);
}

핵심: 핸들러 객체 크기가 풀 블록 크기 이하일 때만 풀 사용. 초과 시 fallback으로 operator new 사용.

프로덕션 체크리스트

- [ ] 블록 크기를 실제 할당 크기 분포에 맞게 설정
- [ ] 멀티스레드 시 스레드 로컬 풀 또는 락 사용
- [ ] 풀 수명이 할당 객체 수명보다 길도록 보장
- [ ] fallback: 블록 크기 초과 시 전역 힙 처리
- [ ] 디버그 빌드에서 use-after-free 검사 (선택)
- [ ] 프로덕션에서 풀 사용량 모니터링 (선택)

10. 정리·한계

  • 고정 블록 풀은 같은(또는 비슷한) 크기의 대량 할당/해제에서 비용·단편화를 줄입니다.
  • 스레드 로컬로 두면 락 없이 처리량을 높일 수 있습니다.
  • 한계: 블록 크기가 고정이라 크기가 다양한 할당에는 별도 크기 클래스가 필요하고, 풀 자체가 메모리를 미리 잡아두므로 메모리 사용량이 늘어날 수 있습니다. “실제 서비스의 할당 패턴”을 측정한 뒤, 그에 맞게 블록 크기·개수를 정하는 것이 중요합니다.

이렇게 직접 만든 풀new/delete를 벤치마크해 보면, “언제 풀이 이득인지”를 숫자로 이해할 수 있습니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]
  • C++ 현대적 메모리 관리: 커스텀 알로케이터 제작과 std::pmr 가이드
  • C++ std::pmr 완벽 가이드 | Polymorphic Memory Resources로 메모리 풀

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

메모리 풀, 할당자, C++, 힙 단편화, free list, 스레드 로컬 풀 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 프로파일러에서 malloc이 상위를 차지할 때, 힙 단편화로 OOM이 날 때, 멀티스레드에서 전역 힙 락 경합이 심할 때 메모리 풀을 고려합니다. 게임 엔티티, 네트워크 패킷, 파싱 결과 등 같은 크기 객체의 반복 할당/해제가 많을 때 효과적입니다.

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

A. std::pmr는 표준 인터페이스로 컨테이너에 주입 가능한 할당자를 제공합니다. 직접 구현은 커스터마이징이 자유롭고, 의존성이 없습니다. 학습 목적이나 특수한 요구사항이 있을 때 직접 구현을 선택합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

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

A. 실제 할당하는 객체 크기의 최대값에 맞춥니다. sizeof(Entity)가 128이면 블록 128 또는 256으로 설정. 프로파일링으로 할당 크기 분포를 확인한 뒤, 90% 이상이 커버되는 크기를 선택합니다.

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

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

한 줄 요약: 커스텀 메모리 풀로 할당 비용·단편화를 줄일 수 있습니다. 다음으로 Segfault 디버깅(#49-1)를 읽어보면 좋습니다.

이전 글: [실전 딥다이브 #48-2] 초경량 HTTP 웹 프레임워크 바닥부터 만들기

다음 글: [에러 해결·트러블슈팅 #49-1] “Segmentation fault (core dumped)” 완벽 추적 및 디버깅 프로세스


관련 글

  • C++ 게임 엔진 기초 | 게임 루프·ECS·씬 그래프·입력 처리 완전 가이드
  • C++ Redis 클론 | Modern C++ 인메모리 KV 스토어 [#48-1]
  • C++ ECS 패턴 완벽 가이드 | Entity·Component·System·쿼리·컴포넌트 스토리지 실전
  • C++ 초경량 HTTP 웹 프레임워크 바닥부터 만들기 [#48-2]
  • C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]