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보다 예측 가능하고 캐시 친화적일 수 있습니다.
목차
- 객체 풀 (Object Pool)
- 슬랩 할당자 (Slab Allocator)
- 메모리 아레나 (Memory Arena)
- std::pmr 활용
- 완전한 메모리 풀 예제
- 자주 하는 실수와 해결법
- 모범 사례 (Best Practices)
- 프로덕션 패턴
- 정리
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;
}
주의: deallocate에 size를 넘겨야 올바른 풀에 반환됩니다. 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 이상 | 커스터마이징 제한 |
핵심 원칙:
- 풀 수명 > 객체 수명
- 객체 풀은
release()반드시 호출 - 슬랩은
deallocate(p, size)에 정확한 size 전달 - 아레나는 스코프 단위로만 사용
- 프로파일링으로 병목 확인 후 적용
- 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. cppreference의 std::memory_resource, jemalloc 슬랩 설계를 참고하세요.
한 줄 요약: 객체 풀·슬랩·아레나·std::pmr로 메모리 할당 비용과 단편화를 줄일 수 있습니다.
이전 글: [C++ 코테 압축 #32-1] C++ 입출력 최적화 한 번에 정리
다음 글: [C++ 코테 압축 #32-3] C++ STL 치트시트
관련 글
- C++ 시리즈 전체 보기
- C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
- C++ ADL |
- C++ Aggregate Initialization |