C++ 메모리 풀 고급 기법 | 객체 풀·슬랩 할당자·메모리 아레나 완벽 가이드 [#51-4]
이 글의 핵심
C++ 고급 메모리 관리: 객체 풀, 슬랩 할당자, 메모리 아레나 설계·구현. 프로파일러 병목 해결, 문제 시나리오, 완전한 예제, 흔한 에러, 성능 벤치마크, 프로덕션 패턴까지 실전 코드로 다룹니다.
들어가며: 기본 풀을 넘어선 고급 메모리 관리
”고정 블록 풀만으로는 부족해요”
#48-3 메모리 풀과 #39-2 std::pmr에서 기본 풀과 표준 할당자를 다뤘습니다. 이 글은 객체 풀(Object Pool), 슬랩 할당자(Slab Allocator), 메모리 아레나(Memory Arena)처럼 더 세밀한 제어가 필요한 고급 기법을 다룹니다.
비유: 기본 풀은 “창고에서 상자 하나씩 꺼내 쓰는 것”이라면, 객체 풀은 “특정 타입 전용 창고”, 슬랩은 “크기별로 나눠진 창고”, 아레나는 “한 번에 큰 공간을 잡고 순차적으로 잘라 쓰는 것”입니다.
이 글을 읽으면:
- 객체 풀으로 생성자/소멸자 비용까지 줄이는 방법을 알 수 있습니다.
- 슬랩 할당자로 크기별 최적화를 적용할 수 있습니다.
- 메모리 아레나로 단편화 없이 순차 할당하는 패턴을 이해할 수 있습니다.
- 프로덕션에서 어떤 패턴을 선택할지 결정할 수 있습니다.
요구 환경: C++17 이상
문제 시나리오
시나리오 1: 객체 생성/소멸 비용이 누적될 때
"Bullet 객체를 매 프레임 수천 개 생성·파괴하는데, 생성자 호출만 해도 부담이에요."
"placement new는 알겠는데, 타입별로 풀을 어떻게 관리하죠?"
상황: 게임에서 Bullet, Particle 같은 작은 객체를 매 프레임 대량 생성/해제합니다. new/delete 대신 풀을 써도, 매번 생성자·소멸자를 호출하면 오버헤드가 남습니다.
해결 포인트: 객체 풀은 메모리만 재사용하는 게 아니라, 객체의 생성/소멸을 풀과 연동해 “풀에서 꺼낼 때만 생성자, 반환할 때만 소멸자”를 호출합니다.
시나리오 2: 할당 크기가 다양할 때
"32바이트, 64바이트, 128바이트, 256바이트가 섞여서 할당돼요."
"고정 블록 풀 하나로 하면 256바이트 풀에 32바이트 요청 시 낭비가 심해요."
상황: HTTP 파서에서 헤더(작음), 바디 청크(중간), 큰 JSON(큼) 등 크기가 다양합니다. 256바이트 고정 풀에 32바이트만 쓰면 내부 단편화가 큽니다.
해결 포인트: 슬랩 할당자는 32, 64, 128, 256 등 크기 클래스별로 풀을 두어, 요청 크기에 맞는 최소 풀에서 할당합니다.
시나리오 3: 순차 할당만 하고 개별 해제가 없을 때
"요청 처리 중에만 할당하고, 요청 끝에 한 번에 다 해제해요."
"개별 해제가 필요 없어서, 풀의 free list 관리 오버헤드가 아깝습니다."
상황: HTTP 요청 처리, 프레임 렌더링처럼 “할당만 하고 끝에 일괄 해제”하는 패턴입니다. free list를 유지할 필요가 없습니다.
해결 포인트: 메모리 아레나는 커서만 앞으로 이동시키며 순차 할당하고, reset() 한 번으로 전체를 되돌립니다. monotonic_buffer_resource와 같은 개념입니다.
시나리오 4: 스레드별 피크 사용량이 다를 때
"워커 스레드마다 할당 패턴이 달라요. 어떤 스레드는 10MB, 어떤 스레드는 100KB만 써요."
"전역 풀 하나로 하면 락 경합이 있고, 스레드별 풀은 메모리 낭비가 걱정돼요."
상황: 스레드 풀 워커마다 작업 특성이 달라, 스레드별 풀을 두면 일부 스레드 풀은 비어 있고 일부는 부족합니다.
해결 포인트: 계층적 아레나 + 스레드 로컬 풀 조합. 부족 시 상위 풀에서 청크를 가져오는 구조로 메모리 효율과 확장성을 동시에 확보합니다.
시나리오별 권장 패턴
| 시나리오 | 특징 | 권장 패턴 |
|---|---|---|
| 타입별 대량 생성/해제 | Bullet, Particle 등 | 객체 풀 |
| 크기 다양, 내부 단편화 우려 | 32~256바이트 혼합 | 슬랩 할당자 |
| 순차 할당, 일괄 해제 | HTTP 요청, 프레임 | 메모리 아레나 |
| 스레드별 사용량 편차 | 워커 풀 | 계층 아레나 + TLS 풀 |
목차
- 객체 풀 (Object Pool)
- 슬랩 할당자 (Slab Allocator)
- 메모리 아레나 (Memory Arena)
- 완전한 고급 메모리 풀 예제
- 자주 발생하는 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 정리
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>
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;
};
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 = sizeof(Node) * 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 * sizeof(Node));
node->next = head_;
head_ = node;
}
}
static_assert(sizeof(T) >= sizeof(Node), "T must be at least sizeof(Node*)");
static_assert(alignof(T) >= alignof(Node), "T alignment must be sufficient");
Node* head_ = nullptr;
};
주의: 위 구현은 sizeof(T)가 sizeof(Node*) 이상일 때만 동작합니다. 더 작은 타입은 블록 크기를 max(sizeof(T), sizeof(Node*))로 조정해야 합니다.
사용 예: Bullet 객체 풀
// bullet_pool_example.cpp
#include "object_pool_basic.hpp"
#include <iostream>
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();
b2->x = 50.0f;
b2->y = 100.0f;
pool.release(b1);
pool.release(b2);
// 재사용
Bullet* b3 = pool.acquire(0, 0, 1, 1);
std::cout << "Reused: " << b3->x << "," << b3->y << "\n";
pool.release(b3);
return 0;
}
블록 크기 조정 (작은 타입 지원)
template <typename T>
class ObjectPool {
// ...
static constexpr std::size_t block_size_ =
(sizeof(T) > sizeof(Node)) ? sizeof(T) : sizeof(Node);
static constexpr std::size_t block_align_ =
(alignof(T) > alignof(Node)) ? alignof(T) : alignof(Node);
void expand() {
constexpr std::size_t blocks_per_chunk = 64;
std::size_t chunk_size = block_size_ * blocks_per_chunk;
chunk_size = (chunk_size + block_align_ - 1) & ~(block_align_ - 1);
// ...
}
};
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
크기 클래스 선택
// 32, 64, 128, 256, 512, 1024, 2048 ...
constexpr std::size_t size_classes[] = {32, 64, 128, 256, 512, 1024, 2048};
std::size_t get_size_class(std::size_t size) {
for (std::size_t sc : size_classes) {
if (size <= sc) return sc;
}
return 0; // fallback to heap
}
슬랩 할당자 구현
// slab_allocator.hpp
#include <cstddef>
#include <new>
#include <array>
#include <vector>
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},
};
};
주의: deallocate에 size를 넘겨야 올바른 풀에 반환됩니다. malloc/free처럼 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
#include <cstddef>
#include <new>
#include <vector>
#include <cstring>
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_;
};
확장 가능 아레나 (청크 체인)
// arena_expandable.hpp
#include <cstddef>
#include <new>
#include <vector>
class ExpandableArena {
public:
explicit ExpandableArena(std::size_t chunk_size = 65536)
: chunk_size_(chunk_size)
, current_offset_(0) {
chunks_.push_back(static_cast<char*>(::operator new(chunk_size_)));
}
~ExpandableArena() {
for (char* c : chunks_) ::operator delete(c);
}
void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
std::size_t aligned = (current_offset_ + alignment - 1) & ~(alignment - 1);
if (aligned + size > chunk_size_) {
chunks_.push_back(static_cast<char*>(::operator new(chunk_size_)));
current_offset_ = 0;
aligned = 0;
}
char* chunk = chunks_.back();
void* p = chunk + aligned;
current_offset_ = aligned + size;
return p;
}
void reset() {
current_offset_ = 0;
// chunks_는 유지, 다음 allocate에서 재사용
}
private:
std::size_t chunk_size_;
std::size_t current_offset_;
std::vector<char*> chunks_;
};
4. 완전한 고급 메모리 풀 예제
예제 1: 게임 엔티티 객체 풀 (프레임 리셋)
// game_entity_pool.cpp
#include <cstddef>
#include <new>
#include <vector>
#include <iostream>
struct Entity {
int id;
float x, y;
Entity() : id(0), x(0), y(0) {}
Entity(int i, float x_, float y_) : id(i), x(x_), y(y_) {}
};
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_;
std::size_t capacity_;
std::size_t count_;
char* storage_;
std::vector<void*> free_list_;
};
int main() {
FrameObjectPool<Entity> pool(sizeof(Entity), 128);
std::vector<Entity*> entities;
for (int i = 0; i < 10; ++i) {
entities.push_back(pool.acquire(i, 100.0f + i, 200.0f + i));
}
for (auto* e : entities) {
pool.release(e);
}
pool.reset();
std::cout << "Frame pool test OK\n";
return 0;
}
예제 2: HTTP 요청 스코프 아레나
// http_request_arena.cpp
#include <memory_resource>
#include <vector>
#include <string>
#include <map>
void handleRequest(const std::string& raw_request) {
// 요청 스코프: 64KB 아레나
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\"}";
// 처리...
// 함수 종료 시 arena 소멸 → 모든 할당 일괄 해제
}
예제 3: 슬랩 + std::allocator 트레이트
// slab_allocator_traits.cpp
template <typename T>
class SlabAllocatorAdapter {
public:
using value_type = T;
SlabAllocatorAdapter(SlabAllocator* alloc) : alloc_(alloc) {}
T* allocate(std::size_t n) {
return static_cast<T*>(alloc_->allocate(n * sizeof(T)));
}
void deallocate(T* p, std::size_t n) {
alloc_->deallocate(p, n * sizeof(T));
}
SlabAllocator* alloc_;
};
// 사용: std::vector<int, SlabAllocatorAdapter<int>> v{&slab};
5. 자주 발생하는 에러와 해결법
에러 1: 풀 수명보다 객체가 오래 살 때 (Use-After-Free)
증상: 크래시, 정의되지 않은 동작, 힙 손상.
원인: 풀이 먼저 소멸되었는데, 그 풀에서 할당받은 객체가 아직 참조될 때.
// ❌ 잘못된 코드
ObjectPool<Bullet>* createPool() {
ObjectPool<Bullet> pool;
return &pool; // 반환 시 pool 소멸!
}
void use() {
auto* pool = createPool();
Bullet* b = pool->acquire(); // UB: 이미 소멸된 풀
}
해결법:
// ✅ 올바른 코드: 풀 수명을 객체보다 길게
ObjectPool<Bullet> pool; // 전역 또는 장수명
void use() {
Bullet* b = pool.acquire();
// ...
pool.release(b);
}
에러 2: 객체 풀에서 release 없이 풀 소멸
증상: 소멸자가 호출되지 않은 객체가 남아 있음. 리소스 누수(파일 핸들, 락 등).
원인: release()를 호출하지 않고 풀이 소멸되면, 풀에 남아 있는 객체의 소멸자가 호출되지 않습니다.
// ❌ 잘못된 코드
{
ObjectPool<FileHandle> pool;
FileHandle* f = pool.acquire("test.txt");
// f 사용...
// pool.release(f) 누락!
} // pool 소멸 시 f->~FileHandle() 호출 안 됨 → 파일 미닫힘
해결법:
// ✅ RAII 래퍼 사용
struct PooledHandle {
ObjectPool<FileHandle>* pool;
FileHandle* ptr;
~PooledHandle() { if (pool && ptr) pool->release(ptr); }
};
에러 3: 슬랩에서 잘못된 크기로 반환
증상: 메모리 손상, 크래시. A 풀에서 할당하고 B 풀에 반환.
원인: deallocate(p, wrong_size)로 다른 크기 클래스 풀에 반환.
// ❌ 잘못된 코드
void* p = slab.allocate(100); // 128바이트 풀에서 할당
slab.deallocate(p, 64); // 64바이트 풀에 반환 → UB
해결법:
// ✅ 할당 시 크기 저장 (또는 호출자가 정확히 전달)
struct PooledPtr {
void* ptr;
std::size_t size;
};
// deallocate 시 저장된 size 사용
에러 4: 아레나에서 개별 해제 기대
증상: deallocate가 없거나 no-op인데, 개별 해제를 기대함.
원인: 아레나는 reset()으로만 해제합니다.
// ❌ 잘못된 기대
ExpandableArena arena;
void* p1 = arena.allocate(100);
void* p2 = arena.allocate(200);
// p1만 해제하고 싶음 — 불가능
해결법: 개별 해제가 필요하면 풀 또는 슬랩을 사용. 아레나는 “스코프 단위 일괄 해제”용입니다.
에러 5: 정렬 무시
증상: SIMD 타입(__m256) 사용 시 SIGBUS, 크래시.
원인: allocate에서 요청된 alignment를 지키지 않음.
// ❌ 잘못된 구현
void* allocate(std::size_t size) {
void* p = buffer_ + offset_;
offset_ += size;
return p; // alignment 보장 안 됨
}
해결법:
// ✅ alignment 준수
void* allocate(std::size_t size, std::size_t alignment = alignof(std::max_align_t)) {
std::size_t aligned = (offset_ + alignment - 1) & ~(alignment - 1);
if (aligned + size > capacity_) return nullptr;
void* p = buffer_ + aligned;
offset_ = aligned + size;
return p;
}
에러 6: 스레드 안전성 오해
증상: 멀티스레드에서 간헐적 크래시.
원인: 객체 풀/슬랩/아레나가 스레드 안전하지 않은데 여러 스레드가 공유.
// ❌ 위험
ObjectPool<Bullet> global_pool;
void worker() {
Bullet* b = global_pool.acquire(); // data race
// ...
global_pool.release(b);
}
해결법:
// ✅ 스레드 로컬 풀
void worker() {
thread_local ObjectPool<Bullet> pool;
Bullet* b = pool.acquire();
// ...
pool.release(b);
}
6. 성능 벤치마크
벤치마크 1: new/delete vs 객체 풀 vs 슬랩
// benchmark_memory_pools.cpp
// g++ -std=c++17 -O2 -o bench benchmark_memory_pools.cpp
#include <chrono>
#include <iostream>
#include <vector>
struct SmallObject {
int id;
float x, y, z;
SmallObject() : id(0), x(0), y(0), z(0) {}
};
constexpr std::size_t N = 500000;
void bench_new_delete() {
std::vector<SmallObject*> ptrs;
ptrs.reserve(N);
auto start = std::chrono::high_resolution_clock::now();
for (std::size_t i = 0; i < N; ++i) {
ptrs.push_back(new SmallObject());
}
for (auto* p : ptrs) delete p;
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "new/delete: " << ms << " ms, "
<< (N * 1000 / (ms + 1)) << " K ops/sec\n";
}
void bench_object_pool() {
ObjectPool<SmallObject> pool;
std::vector<SmallObject*> 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.acquire());
}
for (auto* p : ptrs) pool.release(p);
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "ObjectPool: " << ms << " ms, "
<< (N * 1000 / (ms + 1)) << " K ops/sec\n";
}
void bench_slab() {
SlabAllocator slab;
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(slab.allocate(sizeof(SmallObject)));
}
for (std::size_t i = 0; i < N; ++i) {
slab.deallocate(ptrs[i], sizeof(SmallObject));
}
auto end = std::chrono::high_resolution_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "SlabAllocator: " << ms << " ms, "
<< (N * 1000 / (ms + 1)) << " K ops/sec\n";
}
int main() {
std::cout << "N = " << N << " alloc+free pairs\n";
bench_new_delete();
bench_object_pool();
bench_slab();
return 0;
}
예상 결과 (참고용)
| 할당자 | 50만 회 (ms) | K ops/sec | 상대 |
|---|---|---|---|
| new/delete | ~80 | ~6250 | 1x |
| ObjectPool | ~12 | ~41666 | ~6.7x |
| SlabAllocator | ~15 | ~33333 | ~5.3x |
벤치마크 2: 아레나 순차 할당
void bench_arena_sequential() {
MemoryArena arena(1024 * 1024);
for (int round = 0; round < 10; ++round) {
arena.reset();
for (std::size_t i = 0; i < 100000; ++i) arena.allocate(32);
}
}
성능 비교 다이어그램
flowchart TB
subgraph Slow["느린 경로 (new/delete)"]
S1[malloc 락]
S2[힙 탐색]
S3[메타데이터]
S4[생성자]
S5[소멸자]
end
subgraph Fast["빠른 경로 (객체 풀)"]
F1[포인터 pop]
F2[placement new]
F3[소멸자]
F4[포인터 push]
end
S1 --> S2 --> S3 --> S4
F1 --> F2
F3 --> F4
7. 프로덕션 패턴
패턴 1: 게임 프레임 풀 (객체 풀 + reset)
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 호출
// 또는 reset 시 남은 객체 정리
}
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);
} // arena 소멸 → 일괄 해제
패턴 3: 크기별 슬랩 + 모니터링
class MonitoredSlabAllocator : public SlabAllocator {
public:
void* allocate(std::size_t size) override {
void* p = SlabAllocator::allocate(size);
if (p) {
++alloc_count_;
current_used_ += get_size_class(size);
peak_used_ = std::max(peak_used_, current_used_);
}
return p;
}
void deallocate(void* p, std::size_t size) override {
SlabAllocator::deallocate(p, size);
current_used_ -= get_size_class(size);
}
std::size_t peak_usage() const { return peak_used_; }
private:
std::size_t alloc_count_ = 0;
std::size_t current_used_ = 0;
std::size_t peak_used_ = 0;
};
패턴 4: 스레드 로컬 풀 + 상위 풀 폴백
class TieredPool {
public:
void* allocate(std::size_t size) {
auto& local = get_local_pool();
void* p = local.allocate(size);
if (!p) {
std::lock_guard lock(mutex_);
p = global_pool_.allocate(size);
}
return p;
}
private:
SlabAllocator& get_local_pool() {
thread_local SlabAllocator local;
return local;
}
std::mutex mutex_;
SlabAllocator global_pool_;
};
패턴 5: 구현 체크리스트
- [ ] 풀/아레나 수명이 할당 객체 수명보다 길다
- [ ] 객체 풀 사용 시 release() 누락 방지 (RAII 래퍼)
- [ ] 슬랩 deallocate 시 올바른 size 전달
- [ ] 아레나는 개별 해제 불가 — 스코프 단위 사용
- [ ] 멀티스레드 시 스레드 로컬 풀 또는 락
- [ ] 프로파일링으로 실제 이득 확인 후 적용
8. 정리
| 패턴 | 용도 | 장점 | 한계 |
|---|---|---|---|
| 객체 풀 | 타입별 대량 생성/해제 | 생성/소멸자 비용까지 절감 | 타입별 풀 필요 |
| 슬랩 할당자 | 크기 다양할 때 | 내부 단편화 감소 | deallocate 시 size 필요 |
| 메모리 아레나 | 순차 할당, 일괄 해제 | 구현 단순, 오버헤드 최소 | 개별 해제 불가 |
핵심 원칙:
- 풀 수명 > 객체 수명
- 객체 풀은
release()반드시 호출 - 슬랩은
deallocate(p, size)에 정확한 size 전달 - 아레나는 스코프 단위로만 사용
- 프로파일링으로 병목 확인 후 적용
자주 묻는 질문 (FAQ)
Q. 객체 풀 vs std::pmr::pool_resource 차이는?
A. 객체 풀은 타입 전용이고 acquire/release로 생성/소멸을 풀과 연동합니다. pool_resource는 범용 메모리 풀로, 컨테이너에 주입해 씁니다. 타입별 생성 비용까지 줄이려면 객체 풀이 유리합니다.
Q. 슬랩 크기 클래스를 어떻게 정하나요?
A. 실제 할당 크기 분포를 프로파일링해, 90% 이상이 커버되는 클래스를 선택합니다. 32, 64, 128, 256, 512, 1024 등 2의 거듭제곱이 일반적입니다.
Q. 아레나와 monotonic_buffer_resource 차이는?
A. 개념적으로 동일합니다. std::pmr::monotonic_buffer_resource가 표준 구현이고, 커스텀 아레나는 프로젝트별 확장(로깅, 통계 등)이 필요할 때 직접 구현합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 메모리 풀 #48-3, std::pmr #39-2를 먼저 읽으면 좋습니다.
Q. 더 깊이 공부하려면?
A. cppreference의 std::memory_resource, jemalloc 슬랩 설계, Linux 커널 슬랩 할당자 문서를 참고하세요.
한 줄 요약: 객체 풀·슬랩·아레나로 고급 메모리 관리를 적용할 수 있습니다.
이전 글: C++ SIMD 최적화 실전 #51-2
다음 글: C++ 컴파일 타임 최적화 #51-5
관련 글
- C++ 고급 프로파일링 완벽 가이드 | perf·gprof
- C++ 스택 vs 힙 완벽 가이드 | 재귀 크래시, 메모리 레이아웃, RAII·스마트 포인터 실전 패턴
- C++ Custom Allocator |
- C++ Allocator |