C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]

C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]

이 글의 핵심

C++ 메모리 풀 완벽 가이드에 대한 실전 가이드입니다. 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2] 등을 예제와 함께 상세히 설명합니다.

들어가며: “malloc이 병목이에요”

왜 메모리 풀인가

대량의 같은 크기(또는 비슷한 크기) 객체를 반복해서 할당·해제하면 힙 단편화할당/해제 비용이 누적됩니다. 고성능 네트워크 가이드 #5#39-2 커스텀 알로케이터에서 풀·커스텀 할당자를 다뤘습니다. 이 글은 객체 풀, 슬랩 할당자, 메모리 아레나, std::pmr를 한데 모아 문제 시나리오부터 프로덕션 패턴까지 실전 코드로 다룹니다.

이 글에서 다루는 것:

  • 객체 풀: 타입 전용 풀, 생성자/소멸자와 연동
  • 슬랩 할당자: 크기 클래스별 풀, 내부 단편화 최소화
  • 메모리 아레나: 순차 할당, 일괄 해제
  • std::pmr: C++17 표준 메모리 리소스, STL 통합
  • 흔한 에러프로덕션 패턴

선수 지식: #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: 할당 크기가 다양할 때

"32바이트, 64바이트, 128바이트, 256바이트가 섞여서 할당돼요."
"고정 블록 풀 하나로 하면 256바이트 풀에 32바이트 요청 시 낭비가 심해요."

상황: HTTP 파서에서 헤더(작음), 바디 청크(중간), 큰 JSON(큼) 등 크기가 다양합니다.

해결 포인트: 슬랩 할당자는 32, 64, 128, 256 등 크기 클래스별로 풀을 두어, 요청 크기에 맞는 최소 풀에서 할당합니다.

시나리오 5: 순차 할당만 하고 개별 해제가 없을 때

"요청 처리 중에만 할당하고, 요청 끝에 한 번에 다 해제해요."
"개별 해제가 필요 없어서, 풀의 free list 관리 오버헤드가 아깝습니다."

상황: HTTP 요청 처리, 프레임 렌더링처럼 “할당만 하고 끝에 일괄 해제”하는 패턴입니다.

해결 포인트: 메모리 아레나는 커서만 앞으로 이동시키며 순차 할당하고, reset() 한 번으로 전체를 되돌립니다.

시나리오별 권장 패턴

시나리오특징권장 패턴
malloc 병목할당 횟수 과다객체 풀, 고정 블록 풀
힙 단편화장시간 실행 후 OOM풀 + 단일 큰 블록
스레드 경합멀티스레드 확장성스레드 로컬 풀
크기 다양내부 단편화 우려슬랩 할당자
순차 할당, 일괄 해제HTTP 요청, 프레임메모리 아레나, std::pmr::monotonic_buffer_resource

개념을 잡는 비유

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


목차

  1. 객체 풀 (Object Pool)
  2. 슬랩 할당자 (Slab Allocator)
  3. 메모리 아레나 (Memory Arena)
  4. std::pmr 활용
  5. 완전한 메모리 풀 예제
  6. 자주 하는 실수와 해결법
  7. 모범 사례 (Best Practices)
  8. 프로덕션 패턴
  9. 정리

1. 객체 풀 (Object Pool)

핵심 아이디어

객체 풀은 특정 타입 T 전용으로, 메모리 할당뿐 아니라 객체 생성/소멸을 풀과 연동합니다. allocate 대신 acquire()로 “풀에서 꺼낸 메모리에 placement new로 객체 생성”, deallocate 대신 release(obj)로 “소멸자 호출 후 풀에 반환”합니다.

flowchart TB
    subgraph Pool["객체 풀 (Bullet)"]
        F1[빈 슬롯 1]
        F2[빈 슬롯 2]
        F3[빈 슬롯 3]
    end
    subgraph Acquire["acquire()"]
        A1[빈 슬롯 선택]
        A2["placement new Bullet()"]
        A3[객체 반환]
    end
    subgraph Release["release(obj)"]
        R1["obj-~Bullet()"]
        R2[슬롯을 free list에 반환]
    end
    F1 --> A1 --> A2 --> A3
    A3 --> R1 --> R2 --> F1

기본 객체 풀 구현

// object_pool_basic.hpp
// g++ -std=c++17 -O2 -o object_pool object_pool_basic.cpp
#include <cstddef>
#include <new>
#include <utility>
#include <iostream>

template <typename T>
class ObjectPool {
public:
    ObjectPool() = default;
    ~ObjectPool() {
        while (head_) {
            Node* next = head_->next;
            ::operator delete(head_);
            head_ = next;
        }
    }

    ObjectPool(const ObjectPool&) = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;

    template <typename... Args>
    T* acquire(Args&&... args) {
        void* mem = allocate_raw();
        return new (mem) T(std::forward<Args>(args)...);
    }

    void release(T* obj) {
        if (!obj) return;
        obj->~T();
        deallocate_raw(obj);
    }

private:
    union Node {
        Node* next;
    };

    static constexpr std::size_t block_size_ =
        (sizeof(T) > sizeof(Node)) ? sizeof(T) : sizeof(Node);

    void* allocate_raw() {
        if (!head_) expand();
        Node* p = head_;
        head_ = head_->next;
        return p;
    }

    void deallocate_raw(void* p) {
        Node* node = static_cast<Node*>(p);
        node->next = head_;
        head_ = node;
    }

    void expand() {
        constexpr std::size_t blocks_per_chunk = 64;
        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;
        }
    }

    Node* head_ = nullptr;
};

struct Bullet {
    float x, y, vx, vy;
    Bullet() : x(0), y(0), vx(0), vy(0) {}
    Bullet(float x_, float y_, float vx_, float vy_)
        : x(x_), y(y_), vx(vx_), vy(vy_) {}
};

int main() {
    ObjectPool<Bullet> pool;
    Bullet* b1 = pool.acquire(100.0f, 200.0f, 5.0f, 0.0f);
    Bullet* b2 = pool.acquire();
    pool.release(b1);
    pool.release(b2);
    Bullet* b3 = pool.acquire(0, 0, 1, 1);
    std::cout << "객체 풀 테스트 완료: " << b3->x << "," << b3->y << "\n";
    pool.release(b3);
    return 0;
}

설명: Node union으로 free list의 next 포인터를 블록 내부에 저장합니다. block_size_sizeof(T)sizeof(Node*) 중 큰 값으로, 작은 타입도 지원합니다.


2. 슬랩 할당자 (Slab Allocator)

핵심 아이디어

슬랩 할당자는 여러 크기 클래스(예: 32, 64, 128, 256, 512 바이트)별로 풀을 두고, 요청 크기에 맞는 최소 풀에서 할당합니다. Linux 커널의 슬랩 할당자에서 이름을 따왔습니다.

flowchart LR
    subgraph Request["할당 요청"]
        R1["48바이트"]
        R2["200바이트"]
    end
    subgraph Slabs["슬랩"]
        S32["32B 풀"]
        S64["64B 풀"]
        S128["128B 풀"]
        S256["256B 풀"]
    end
    R1 --> S64
    R2 --> S256

슬랩 할당자 구현

// slab_allocator.hpp
// g++ -std=c++17 -O2 -o slab slab_allocator.cpp
#include <cstddef>
#include <new>
#include <array>
#include <vector>
#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(void*)))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}

    void* allocate() {
        if (!head_) expand();
        if (!head_) return nullptr;
        void* p = head_;
        head_ = *static_cast<void**>(head_);
        return p;
    }

    void deallocate(void* p) {
        if (!p) return;
        *static_cast<void**>(p) = head_;
        head_ = p;
    }

private:
    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) {
            void* block = chunk + i * block_size_;
            *static_cast<void**>(block) = head_;
            head_ = block;
        }
    }

    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    void* head_;
};

class SlabAllocator {
public:
    static constexpr std::size_t NUM_CLASSES = 7;
    static constexpr std::size_t SIZE_CLASSES[NUM_CLASSES] =
        {32, 64, 128, 256, 512, 1024, 2048};

    void* allocate(std::size_t size) {
        std::size_t sc = get_size_class(size);
        if (sc == 0) return ::operator new(size);
        return pools_[get_class_index(sc)].allocate();
    }

    void deallocate(void* p, std::size_t size) {
        if (!p) return;
        std::size_t sc = get_size_class(size);
        if (sc == 0) {
            ::operator delete(p);
            return;
        }
        pools_[get_class_index(sc)].deallocate(p);
    }

private:
    static std::size_t get_size_class(std::size_t size) {
        for (std::size_t i = 0; i < NUM_CLASSES; ++i) {
            if (size <= SIZE_CLASSES[i]) return SIZE_CLASSES[i];
        }
        return 0;
    }

    static std::size_t get_class_index(std::size_t sc) {
        for (std::size_t i = 0; i < NUM_CLASSES; ++i) {
            if (SIZE_CLASSES[i] == sc) return i;
        }
        return 0;
    }

    std::array<FixedBlockPool, NUM_CLASSES> pools_{
        FixedBlockPool{32, 64},
        FixedBlockPool{64, 64},
        FixedBlockPool{128, 64},
        FixedBlockPool{256, 64},
        FixedBlockPool{512, 32},
        FixedBlockPool{1024, 16},
        FixedBlockPool{2048, 8},
    };
};

int main() {
    SlabAllocator slab;
    void* p1 = slab.allocate(48);
    void* p2 = slab.allocate(200);
    slab.deallocate(p1, 48);
    slab.deallocate(p2, 200);
    std::cout << "슬랩 할당자 테스트 완료\n";
    return 0;
}

주의: deallocatesize를 넘겨야 올바른 풀에 반환됩니다. size 없이 쓰려면 블록에 크기 메타데이터를 저장하는 방식이 필요합니다.


3. 메모리 아레나 (Memory Arena)

핵심 아이디어

메모리 아레나는 큰 버퍼를 한 번 할당하고, 커서를 앞으로만 이동시키며 순차 할당합니다. 개별 deallocate는 지원하지 않고, reset()으로 커서를 처음으로 되돌려 “전체 해제”합니다.

flowchart LR
    subgraph Arena["메모리 아레나"]
        direction LR
        A1[""(할당1"][할당2][할당3]     ..."]
        C["커서 →"]
    end
    subgraph Reset["reset()"]
        R["커서 = 0"]
    end
    A1 --> C
    C --> R

아레나 구현

// memory_arena.hpp
// g++ -std=c++17 -O2 -o arena memory_arena.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <iostream>

class MemoryArena {
public:
    explicit MemoryArena(std::size_t initial_size = 65536)
        : capacity_(initial_size)
        , offset_(0) {
        buffer_ = static_cast<char*>(::operator new(capacity_));
    }

    ~MemoryArena() {
        ::operator delete(buffer_);
    }

    MemoryArena(const MemoryArena&) = delete;
    MemoryArena& operator=(const MemoryArena&) = delete;

    void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
        std::size_t aligned_offset = (offset_ + alignment - 1) & ~(alignment - 1);
        if (aligned_offset + size > capacity_) {
            return nullptr;  // 또는 expand
        }
        void* p = buffer_ + aligned_offset;
        offset_ = aligned_offset + size;
        return p;
    }

    void reset() {
        offset_ = 0;
    }

    std::size_t used() const { return offset_; }
    std::size_t capacity() const { return capacity_; }

private:
    char* buffer_;
    std::size_t capacity_;
    std::size_t offset_;
};

int main() {
    MemoryArena arena(4096);
    void* p1 = arena.allocate(32);
    void* p2 = arena.allocate(64);
    std::cout << "사용량: " << arena.used() << " / " << arena.capacity() << "\n";
    arena.reset();
    std::cout << "리셋 후: " << arena.used() << "\n";
    return 0;
}

확장 가능 아레나 (청크 체인)

버퍼가 부족하면 새 청크를 할당해 체인으로 연결합니다. reset() 시 현재 청크 커서만 0으로 되돌리고, 청크는 유지해 다음 alloc에서 재사용합니다. 구현은 cpp-series-51-4에서 확인할 수 있습니다.


4. std::pmr 활용

C++17 표준 메모리 리소스

C++17 std::pmr는 다형 메모리 리소스를 제공합니다. memory_resource를 상속해 do_allocate, do_deallocate를 구현하면 STL 컨테이너에 주입할 수 있습니다.

flowchart TB
    subgraph Containers["컨테이너"]
        V["std pmr vector"]
        M["std pmr map"]
        S["std pmr string"]
    end
    subgraph Allocator["polymorphic_allocator"]
        PA[memory_resource*]
    end
    subgraph Resources["memory_resource 구현체"]
        MONO[monotonic_buffer_resource]
        POOL[pool_resource]
        CUSTOM[커스텀]
    end
    V --> PA
    M --> PA
    S --> PA
    PA --> MONO
    PA --> POOL
    PA --> CUSTOM

monotonic_buffer_resource (아레나)

// std::pmr::monotonic_buffer_resource 예제
// g++ -std=c++17 -O2 -o pmr_mono pmr_mono.cpp
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
#include <iostream>

void handleRequest() {
    std::array<std::byte, 65536> stack_buffer;
    std::pmr::monotonic_buffer_resource arena{
        stack_buffer.data(), stack_buffer.size(),
        std::pmr::new_delete_resource()
    };

    std::pmr::vector<std::pmr::string> path_segments(&arena);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
        headers(&arena);
    std::pmr::string body(&arena);

    path_segments.push_back("api");
    path_segments.push_back("v1");
    headers["Content-Type"] = "application/json";
    body = "{\"key\":\"value\"}";

    std::cout << "경로: " << path_segments[0] << "/" << path_segments[1] << "\n";
    std::cout << "Content-Type: " << headers["Content-Type"] << "\n";
}

pool_resource (고정 블록 풀)

std::pmr::pool_options로 블록 크기·청크당 블록 수를 설정하고, synchronized_pool_resource(스레드 안전) 또는 unsynchronized_pool_resource(단일 스레드)를 사용합니다. 멀티스레드 환경에서는 synchronized_pool_resource를 선택합니다.

커스텀 memory_resource 예제

// custom_memory_resource.hpp
#include <memory_resource>
#include <cstdio>

class LoggingMemoryResource : public std::pmr::memory_resource {
public:
    explicit LoggingMemoryResource(std::pmr::memory_resource* upstream
        = std::pmr::get_default_resource())
        : upstream_(upstream) {}

    void* do_allocate(std::size_t bytes, std::size_t alignment) override {
        void* p = upstream_->allocate(bytes, alignment);
        std::printf("allocate(%zu, %zu) -> %p\n", bytes, alignment, p);
        return p;
    }

    void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
        std::printf("deallocate(%p, %zu, %zu)\n", p, bytes, alignment);
        upstream_->deallocate(p, bytes, alignment);
    }

    bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
        return this == &other;
    }

private:
    std::pmr::memory_resource* upstream_;
};

5. 완전한 메모리 풀 예제

예제 1: 게임 프레임 풀 (객체 풀 + reset)

// game_frame_pool.cpp - g++ -std=c++17 -O2 -o game_pool game_frame_pool.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <iostream>

struct Entity { int id; float x, y; Entity() : id(0), x(0), y(0) {} };

template <typename T>
class FrameObjectPool {
public:
    explicit FrameObjectPool(std::size_t block_size, std::size_t capacity = 256)
        : block_size_(block_size), capacity_(capacity), count_(0) {
        storage_ = static_cast<char*>(::operator new(block_size_ * capacity_));
        for (std::size_t i = 0; i < capacity_; ++i)
            free_list_.push_back(storage_ + i * block_size_);
    }
    ~FrameObjectPool() { ::operator delete(storage_); }

    template <typename... Args>
    T* acquire(Args&&... args) {
        if (free_list_.empty()) return nullptr;
        void* mem = free_list_.back();
        free_list_.pop_back();
        ++count_;
        return new (mem) T(std::forward<Args>(args)...);
    }
    void release(T* obj) {
        if (!obj) return;
        obj->~T();
        free_list_.push_back(static_cast<void*>(obj));
        --count_;
    }
    void reset() {
        free_list_.clear();
        for (std::size_t i = 0; i < capacity_; ++i)
            free_list_.push_back(storage_ + i * block_size_);
        count_ = 0;
    }
private:
    std::size_t block_size_, capacity_, count_;
    char* storage_;
    std::vector<void*> free_list_;
};

int main() {
    FrameObjectPool<Entity> pool(sizeof(Entity), 128);
    auto* e = pool.acquire(1, 100.0f, 200.0f);
    pool.release(e);
    pool.reset();
    std::cout << "프레임 풀 테스트 완료\n";
    return 0;
}

예제 2: HTTP 요청 스코프 (std::pmr 아레나)

// http_request_arena.cpp - g++ -std=c++17 -O2 -o http_arena http_request_arena.cpp
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
#include <iostream>

void handleHttpRequest(const std::string&) {
    std::pmr::monotonic_buffer_resource arena(std::pmr::new_delete_resource());
    std::pmr::vector<std::pmr::string> path(&arena);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>> headers(&arena);
    path.push_back("api");
    path.push_back("v1");
    headers["Content-Type"] = "application/json";
    std::cout << "요청 처리 완료\n";
}
int main() { handleHttpRequest(""); return 0; }

예제 3: 스레드 로컬 풀

// thread_local_pool.cpp
#include <cstddef>
#include <new>
#include <thread>
#include <vector>
#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(void*)))
        , blocks_per_chunk_(blocks_per_chunk)
        , head_(nullptr) {}

    void* allocate() {
        if (!head_) expand();
        void* p = head_;
        head_ = *static_cast<void**>(head_);
        return p;
    }

    void deallocate(void* p) {
        if (!p) return;
        *static_cast<void**>(p) = head_;
        head_ = p;
    }

private:
    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) {
            void* block = chunk + i * block_size_;
            *static_cast<void**>(block) = head_;
            head_ = block;
        }
    }

    std::size_t block_size_;
    std::size_t blocks_per_chunk_;
    void* head_;
};

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]() {
            thread_local FixedBlockPool pool(256, 64);
            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;
}

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

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

증상: 크래시, 정의되지 않은 동작, 힙 손상.

원인: 풀이 먼저 소멸되었는데, 그 풀에서 할당받은 객체가 아직 참조될 때.

// ❌ 잘못된 코드
void* create() {
    FixedBlockPool pool(256);
    return pool.allocate();
}
void use() {
    void* p = create();  // pool은 함수 종료 시 소멸!
    *(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)가 블록 크기보다 큰데 풀에서 할당함.

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

해결법:

// ✅ 올바른 코드: fallback 추가
void* allocate(std::size_t size) {
    if (size > block_size_) {
        return ::operator new(size);
    }
    return allocate();
}

에러 4: 이중 해제 (Double Free)

증상: 크래시, free list 손상.

원인: 같은 포인터를 두 번 deallocate함.

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

해결법:

// ✅ 올바른 코드: 해제 후 nullptr로
void* p = pool.allocate();
pool.deallocate(p);
p = nullptr;

에러 5: 슬랩에서 잘못된 크기로 반환

원인: deallocate(p, wrong_size)로 다른 크기 클래스 풀에 반환. 해결: 할당 시 크기 저장 또는 호출자가 정확히 전달.

에러 6: 아레나에서 개별 해제 기대

원인: 아레나는 reset()으로만 해제합니다. 해결: 개별 해제가 필요하면 풀 또는 슬랩 사용.

에러 7: std::pmr 리소스 수명 문제

증상: 컨테이너가 리소스보다 오래 살 때 use-after-free.

// ❌ 잘못된 코드
std::pmr::vector<int>* createVector() {
    std::pmr::monotonic_buffer_resource pool;
    return new std::pmr::vector<int>(&pool);  // pool은 함수 종료 시 소멸!
}

해결법:

// ✅ 리소스 수명을 컨테이너보다 길게
std::pmr::monotonic_buffer_resource* pool =
    new std::pmr::monotonic_buffer_resource;
std::pmr::vector<int>* vec = new std::pmr::vector<int>(pool);

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

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

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

// ❌ 잘못된 코드
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. 모범 사례 (Best Practices)

1. 풀 수명 > 객체 수명

풀에서 할당한 객체는 풀이 소멸되기 전에 반드시 해제하거나 사용을 마쳐야 합니다.

2. 블록 크기 선택

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

3. 정렬 보장

// ✅ 정렬 보장
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);

4. Fallback 처리

블록 크기 초과 요청 시 전역 힙으로 위임합니다.

void* allocate(std::size_t size) {
    if (size > block_size_) {
        return ::operator new(size);
    }
    return allocate();
}

5. 객체 풀에서 release() 반드시 호출

release()를 호출하지 않고 풀이 소멸되면, 풀에 남아 있는 객체의 소멸자가 호출되지 않습니다. 리소스 누수(파일 핸들, 락 등)가 발생할 수 있습니다.

6. RAII 래퍼 사용

template <typename T>
struct PooledPtr {
    ObjectPool<T>* pool;
    T* ptr;
    ~PooledPtr() {
        if (pool && ptr) pool->release(ptr);
    }
};

7. 프로파일링 후 적용

메모리 풀은 프로파일러로 병목이 확인된 경우에 적용합니다.盲目 적용은 복잡도만 늘립니다.

8. std::pmr 우선 고려

C++17 이상이라면 std::pmr를 먼저 고려합니다. 표준 구현이 대부분의 요구를 충족합니다.


8. 프로덕션 패턴

패턴 1: 게임 프레임 풀

매 프레임마다 엔티티를 생성/해제하는 게임에서, 프레임 시작 시 풀을 리셋하고 프레임 끝에서 일괄 해제합니다.

class GameFramePool {
public:
    void beginFrame() {
        bullet_pool_.reset();
        particle_pool_.reset();
    }

    Bullet* createBullet(float x, float y, float vx, float vy) {
        return bullet_pool_.acquire(x, y, vx, vy);
    }

    void destroyBullet(Bullet* b) {
        bullet_pool_.release(b);
    }

    void endFrame() {
        // 모든 프레임 내 생성 객체는 반드시 destroy 호출
    }

private:
    FrameObjectPool<Bullet> bullet_pool_{sizeof(Bullet), 1024};
    FrameObjectPool<Particle> particle_pool_{sizeof(Particle), 4096};
};

패턴 2: HTTP 요청 스코프 (아레나)

void handleHttpRequest(const Request& req) {
    std::pmr::monotonic_buffer_resource arena(
        std::pmr::new_delete_resource());
    std::pmr::vector<std::pmr::string> path(&arena);
    std::pmr::map<std::pmr::string, std::pmr::string, std::less<>>
        headers(&arena);

    parseRequest(req, path, headers);
    Response resp = process(path, headers);
    sendResponse(resp);
}

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

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: 풀 통계 및 모니터링

allocate/deallocate를 래핑해 alloc_count_, current_used_, peak_used_를 추적합니다. 프로덕션에서 메모리 사용량 추이를 모니터링할 때 유용합니다.

패턴 5: 스레드 로컬 풀 + 상위 풀 폴백

로컬 풀에서 할당 실패 시 std::lock_guard로 락을 걸고 전역 풀에서 할당합니다. 스레드별 피크 사용량이 다를 때 메모리 효율과 확장성을 동시에 확보합니다.

프로덕션 체크리스트

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

9. 정리

패턴용도장점한계
객체 풀타입별 대량 생성/해제생성/소멸자 비용까지 절감타입별 풀 필요
슬랩 할당자크기 다양할 때내부 단편화 감소deallocate 시 size 필요
메모리 아레나순차 할당, 일괄 해제구현 단순, 오버헤드 최소개별 해제 불가
std::pmr표준 통합STL 호환, C++17 이상커스터마이징 제한

핵심 원칙:

  1. 풀 수명 > 객체 수명
  2. 객체 풀release() 반드시 호출
  3. 슬랩deallocate(p, size)에 정확한 size 전달
  4. 아레나는 스코프 단위로만 사용
  5. 프로파일링으로 병목 확인 후 적용
  6. std::pmr 우선, 특수 요구 시 커스텀

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

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

  • C++ 현대적 메모리 관리: 커스텀 알로케이터 제작과 std::pmr 가이드
  • C++ 커스텀 메모리 할당자(Memory Pool) 제작기 [#48-3]
  • C++ RAII | “파일을 열 수 없습니다” 장애의 원인과 자동 리소스 관리

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

메모리 풀, 객체 풀, 슬랩 할당자, 메모리 아레나, std::pmr, 할당자, C++, 힙 단편화, free list 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

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

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

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

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

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

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

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

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

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

A. 메모리 기초 #6-1, RAII #6-4, std::pmr #39-2를 먼저 읽으면 좋습니다.

Q. 더 깊이 공부하려면?

A. cppreferencestd::memory_resource, jemalloc 슬랩 설계를 참고하세요.

한 줄 요약: 객체 풀·슬랩·아레나·std::pmr로 메모리 할당 비용과 단편화를 줄일 수 있습니다.

이전 글: [C++ 코테 압축 #32-1] C++ 입출력 최적화 한 번에 정리

다음 글: [C++ 코테 압축 #32-3] C++ STL 치트시트


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |