개요
게임 서버/클라처럼 실시간으로 돌아가는 코드에서 new/delete 횟수가 늘기 시작하면, 어느 순간부터 “로직이 느린 게 아니라 할당이 발목을 잡는” 순간이 옵니다.
할당기는 내부적으로 메타데이터를 관리하고, 멀티스레드 환경에서는 동기화 비용도 들고, 상황에 따라 OS로부터 새 메모리 페이지를 받아오는 과정까지 포함될 수 있습니다.
이런 비용은 한 번은 작아도, 초당 수만~수십만 번 누적되면 지연의 원인이 됩니다.
그래서 자주 생성/삭제되는 고정 크기 객체는 힙 대신 “풀(pool)”을 만들어 재사용하는 방식이 많이 쓰입니다.
이번 글은 “메모리 풀 + 프리리스트”를 섞은 형태로, 아래 목표를 가진 간단한 구현을 정리합니다.
- 미리 노드를 확보해두고(Prewarm)
- 필요할 때 하나 꺼내서(Alloc) 쓰고
- 다 쓰면 되돌려(Free) 다시 쓰는 구조
- 디버깅을 돕는 최소한의 방어(가드 값)까지 포함
메모리 풀과 프리리스트 개념 정리
1) 메모리 풀(오브젝트 풀 관점)
메모리 풀은 “필요할 때마다 힙에서 새로 받는 것” 대신,
- 초기에 일정량을 미리 확보(prewarm) 해두고
- 이후에는 그 안에서 꺼내 쓰고(Alloc), 다 쓰면 돌려받아(Free) 재사용하는 방식입니다.
중요한 포인트는 “힙을 안 쓴다”가 아니라,
힙/OS 호출을 ‘매번’ 하지 않고, ‘처음/가끔’만 하게 만들어서 hot path를 가볍게 만든다
는 데 있습니다.
2) 프리리스트(Free List)
프리리스트는 “현재 비어있는 노드들의 목록”입니다. 보통 단일 연결 리스트(스택처럼)로 관리합니다.
- Alloc: free list에서 하나 pop
- Free: free list에 다시 push
이러면 핵심 경로가 거의 포인터 조작(O(1))만 남습니다.
흔한 오해: “풀은 큰 연속 메모리를 잡아놓고 resize하면 복사해야 한다”
그건 단일 연속 버퍼를 끝까지 유지하려는 arena류 설계에서 그럴 수 있는 이야기고,
오브젝트 풀은 보통 “chunk 단위로 추가 확보”해서 기존 데이터를 복사하지 않고 확장하는 경우가 많습니다.
다만, 아래에 소개하는 제 코드는 “chunk 리스트”까지는 가지 않고, 필요 시 노드를 malloc로 추가하는 단순 버전입니다. (prewarm를 충분히 해두면 런타임에서 malloc이 거의 안 나오게 만드는 목적)
구현 전에 고민했던 포인트들 (문제점 → 선택 → 코드 반영)
아래 네 가지를 먼저 정리하고, 성능을 크게 해치지 않는 선에서 방어 장치를 넣었습니다.
1) 언제 초기화(생성/소멸)해야 할까? — 객체 생명주기 정책
메모리 풀은 처음에 Node를 여러 개 만들어 두고 재사용하는 구조인데, 여기서 항상 고민이 생깁니다.
- T의 생성자/소멸자를
(A) Alloc/Free 때마다 호출할지
(B) 풀 생성/삭제 때만 1회 호출할지
정답은 “컨텐츠 로직/타입 성격에 따라 다르다”입니다.
- 일반적인 C++ 객체(자원 소유, 불변식 유지 등)는 (A) Alloc/Free마다 생성/소멸이 자연스럽고 안전합니다.
- 반대로, 재사용이 전제이고 Reset() 같은 리셋 로직이 명확한 타입은 (B) 1회 생성 후 계속 재사용이 더 빠를 수 있습니다.
✅ 코드에 적용한 방식
이 선택지를 런타임에 고를 수 있게 placementNew 옵션을 두었습니다.
- mIsPlacementNew == true
- Alloc()에서 placement new로 생성자 호출
- Free()에서 ~T()로 소멸자 호출
- mIsPlacementNew == false
- 노드를 만들 때(초기 prewarm 또는 확장 시) 한 번만 T를 생성
- 풀 파괴 시(현재 구현 기준 free list에 남아있는 노드에 한해) 소멸자 호출
- Alloc/Free 경로는 최대한 가볍게 유지
주의: mIsPlacementNew == false 모드에서는 “객체 상태가 그대로 재사용”될 수 있으니, 타입에 맞게 초기화/리셋 정책을 함께 가져가야 합니다.
2) front/back overwrite (앞뒤 메모리 침범) — 빠른 디버깅 포인트
포인터를 다루다 보면 할당된 범위 밖을 건드리는 버그(언더런/오버런) 가 생각보다 자주 나옵니다.
이건 “나중에 엉뚱한 곳에서 크래시”로 이어져서 원인 추적이 정말 어렵습니다.
그래서 최소한의 비용으로 빨리 잡기 위해, 노드 앞/뒤에 가드(카나리) 값을 둡니다.
✅ 코드에 적용한 방식
Node 구조체에 아래를 넣었습니다.
- bufferGuardFront
- bufferGuardEnd
그리고 Free()에서 두 값을 검사해서, 값이 깨졌으면 바로 실패 처리합니다.
if (ptrNode->bufferGuardFront != mbufferGuardValue ||
ptrNode->bufferGuardEnd != mbufferGuardValue)
{
return false;
}
이 방식은 “완벽한 오버런 검출기”는 아니지만(패딩/정렬 이슈로 guard를 안 건드릴 수도 있음),
실전에서 큰 침범/잘못된 포인터를 빠르게 걸러내는 데 효과가 큽니다.
3) 메모리 풀을 구분해야 할까? — wrong pool free 방지
예를 들어 Monster 풀을 2개 만들었는데,
- A 풀에서 Alloc한 포인터를
- B 풀에 Free 해버리면
이건 아주 위험합니다.
- 서로 다른 풀의 free list가 섞이면서 리스트가 깨질 수도 있고,
- 더 흔하게는 “생성자/소멸자 정책이 다르면” 기대한 시점에 소멸이 안 되는 문제가 생깁니다.
(A는 Alloc/Free마다 소멸자를 부르는데, B는 1회 생성 후 재사용 모드라면? 같은 포인터라도 의미가 달라짐)
✅ 코드에 적용한 방식
각 풀은 자기 자신을 식별할 수 있도록, 풀 생성 시 mbufferGuardValue를 풀 고유 값으로 잡고(현재는 this 기반),
- 노드를 만들 때 bufferGuardFront/End에 그 값을 써두고
- Free()에서 현재 풀의 값과 같은지 비교합니다.
즉, 다른 풀에서 나온 포인터는 guard 검사에서 걸러져 Free()가 false로 끝납니다.
참고: 지금 코드는 uintptr_t(this)를 uint32_t로 줄여서 저장합니다.
“디버그/학습용 방어”로는 충분하지만, 더 엄격하게 가려면 uintptr_t 전체를 저장하거나 별도 pool id를 두는 방식이 더 안전합니다.
4) double free (같은 포인터를 두 번 반환) — 별도의 검출이 필요
헷갈리기 쉬운데,
- “A 풀에서 만든 걸 B 풀에 반환”은 double free가 아니라 wrong pool free
- double free는 “같은 포인터를 같은 풀에 두 번 Free”하는 상황입니다.
double free가 터지면 free list에 같은 노드가 두 번 들어가서, 이후 Alloc에서 같은 객체가 중복으로 나가거나 리스트가 망가져서 랜덤 크래시가 납니다.
✅ 현재 코드에서의 상태
지금 구현은 wrong pool free / overwrite는 guard로 막지만,
double free 자체를 확실히 잡는 로직(state 플래그 등)은 넣지 않았습니다.
(검증을 넣으면 노드당 오버헤드가 늘어나서, “간단/핵심 경로 최소화”를 우선했기 때문)
구현 아이디어: Node + Guard + FreeList
내 풀은 “노드(Node)”를 최소 단위로 관리합니다.
- Node 내부에 T data를 가지고 있고
- 앞/뒤에 guard 값을 두어
- 다른 풀에서 온 포인터 반환(wrong pool)
- 메모리 침범(overwrite)
을 빠르게 잡을 수 있게 합니다.
- free list는 Node* next로 연결합니다.
또 하나 중요한 옵션은 “생성자/소멸자 정책”입니다.
- placementNew == true
→ Alloc에서 생성자 호출, Free에서 소멸자 호출 (일반적인 C++ 객체 생명주기) - placementNew == false
→ 노드를 만들 때 한 번만 생성(초기/확장 시), 풀 파괴 시 한 번만 소멸
→ Alloc/Free 경로를 더 가볍게 만들 수 있지만 타입 성격을 가립니다
코드 (MemoryPool)
아래 코드는 그대로 사용해도 되고, 글 아래 “주의점/개선 포인트”까지 같이 보면 더 안전합니다.
#pragma once
#include <cstddef>
#include <cstdlib>
#include <new>
template <class T>
class MemoryPool
{
public:
static constexpr unsigned int kDefaultInitSize = 100;
static constexpr unsigned int kDefaultMaxSize = static_cast<unsigned int>(-1);
MemoryPool(
bool placementNew = true,
unsigned int sizeInitialize = kDefaultInitSize,
unsigned int sizeMax = kDefaultMaxSize) noexcept;
virtual ~MemoryPool() noexcept;
T* Alloc(void) noexcept;
bool Free(T* data) noexcept;
inline unsigned int GetCapacityCount(void) const noexcept { return mCapacity; }
inline unsigned int GetUseCount(void) const noexcept { return mUseCount; }
MemoryPool(const MemoryPool&) = delete;
MemoryPool& operator=(const MemoryPool&) = delete;
MemoryPool(MemoryPool&&) = delete;
MemoryPool& operator=(MemoryPool&&) = delete;
private:
struct Node
{
unsigned int bufferGuardFront = 0;
T data;
unsigned int bufferGuardEnd = 0;
Node* next = nullptr;
};
bool mIsPlacementNew;
unsigned int mBufferGuardValue;
unsigned int mSizeInitialize;
unsigned int mSizeMax;
Node* mFreeNode;
unsigned int mCapacity;
unsigned int mUseCount;
};
template <class T>
inline MemoryPool<T>::MemoryPool(
bool placementNew,
unsigned int sizeInitialize,
unsigned int sizeMax) noexcept
: mIsPlacementNew(placementNew),
mBufferGuardValue(static_cast<unsigned int>(reinterpret_cast<size_t>(this))),
mSizeInitialize(sizeInitialize),
mSizeMax(sizeMax),
mFreeNode(nullptr),
mCapacity(0),
mUseCount(0)
{
if (mSizeInitialize > mSizeMax)
{
mSizeInitialize = mSizeMax;
}
for (unsigned int i = 0; i < mSizeInitialize; ++i)
{
Node* newNode = static_cast<Node*>(std::malloc(sizeof(Node)));
newNode->bufferGuardFront = mBufferGuardValue;
newNode->bufferGuardEnd = mBufferGuardValue;
if (mIsPlacementNew == false)
{
new (&(newNode->data)) T;
}
newNode->next = mFreeNode;
mFreeNode = newNode;
++mCapacity;
}
}
template <class T>
inline MemoryPool<T>::~MemoryPool() noexcept
{
Node* deleteNode = mFreeNode;
while (deleteNode != nullptr)
{
Node* nextNode = deleteNode->next;
if (mIsPlacementNew == false)
{
deleteNode->data.~T();
}
std::free(deleteNode);
deleteNode = nextNode;
}
}
template <class T>
inline T* MemoryPool<T>::Alloc(void) noexcept
{
Node* returnNode = nullptr;
if (mFreeNode != nullptr)
{
returnNode = mFreeNode;
mFreeNode = mFreeNode->next;
if (mIsPlacementNew)
{
new (&(returnNode->data)) T;
}
}
else
{
if (mCapacity >= mSizeMax)
{
return nullptr;
}
returnNode = static_cast<Node*>(std::malloc(sizeof(Node)));
returnNode->bufferGuardFront = mBufferGuardValue;
returnNode->bufferGuardEnd = mBufferGuardValue;
returnNode->next = nullptr;
new (&(returnNode->data)) T;
++mCapacity;
}
++mUseCount;
return &(returnNode->data);
}
template <class T>
inline bool MemoryPool<T>::Free(T* data) noexcept
{
if (data == nullptr)
{
return false;
}
Node* ptrNode = reinterpret_cast<Node*>(
reinterpret_cast<char*>(data) - offsetof(Node, data));
if (ptrNode->bufferGuardFront != mBufferGuardValue ||
ptrNode->bufferGuardEnd != mBufferGuardValue)
{
return false;
}
if (mIsPlacementNew)
{
ptrNode->data.~T();
}
ptrNode->next = mFreeNode;
mFreeNode = ptrNode;
--mUseCount;
return true;
}
코드 흐름 설명 (읽는 사람 기준으로)
1) 생성자: prewarm로 노드 미리 확보
mSizeInitialize 만큼 malloc(sizeof(Node))로 노드를 만들어 free list에 쌓습니다.
- bufferGuardFront/End에 풀 고유값(현재는 this 기반)을 넣어둡니다.
- placementNew == false면 이 시점에 T를 한 번 생성해둡니다.
(Alloc/Free 경로를 더 가볍게 만들기 위한 정책)
핵심은 “초기 물량을 충분히 잡아두면 런타임 hot path에서 malloc이 거의 안 나오게 한다”입니다.
2) Alloc: free list에서 pop, 없으면 확장
- free list가 있으면 pop 해서 바로 반환
- 없으면 mSizeMax를 확인하고, 여유가 있으면 malloc로 노드를 하나 더 확보
placementNew == true일 때만, Alloc에서 placement new로 생성자 호출이 들어갑니다.
3) Free: 포인터 검증 + 소멸 + free list로 push
Free는 받은 T*를 다시 Node*로 되돌린 뒤(offsetof 기반),
- guard 값을 검사해서 wrong pool free / 메모리 침범을 걸러내고
- placementNew == true면 ~T()로 소멸자 호출
- 다시 free list에 push
이 구현을 그대로 쓸 때 “주의해야 하는 점”
이 코드는 “핵심 아이디어를 보여주는 간단한 풀”로는 좋지만, 실전에 넣을 때는 아래 전제를 이해하고 쓰는 게 안전합니다.
1) 반환 안 한 노드는 “소멸자만”이 아니라 “메모리도” 회수되지 않습니다
현재 구현은 풀 내부에 free list만 들고 있습니다.
즉, Alloc해서 빠져나간 노드를 따로 추적하지 않기 때문에, 반납되지 않은 노드는 풀 파괴 시 free할 방법이 없습니다.
- 글 주석에는 “반환되지 않은 노드의 객체 소멸자는 호출하지 않음”이라고 되어 있지만,
- 실제로는 노드 메모리 자체도 해제되지 않습니다.
✅ 운영 코드라면 보통 두 가지 중 하나로 갑니다.
- “반드시 전부 반납한 뒤 풀을 파괴한다”를 규칙으로 강제
- 현재 코드는 이 규칙을 따릅니다.
- chunk/전체 노드 목록을 따로 저장해서 풀 파괴 시 일괄 해제합니다
- 하지만 이 방식은 메모리를 더 소모하고 반환되지 않은 메모리를 파괴 시 문제(!)가 있을 경우가 있습니다.
2) double free는 막지 못합니다
같은 포인터를 두 번 Free하면 free list가 깨질 수 있습니다.
하지만 이를 위해서 Node의 변수를 추가해서 크기를 더 잡는것 보다는 개발자가 double free를 하지 않게 합니다.
3) noexcept인데 생성자/소멸자가 throw 하면 terminate 됩니다
Alloc/Free가 noexcept인데 T()가 예외를 던지면 std::terminate로 이어질 수 있습니다.
- “서버/게임에서 예외를 안 쓰겠다”면 괜찮지만,
- 일반적인 라이브러리 형태로 만들 거면 noexcept를 빼거나 정책을 정하는 게 좋아요.
4) guard가 “완전한 오버런 검출”은 아닙니다
정렬/패딩 때문에 작은 언더런/오버런이 guard에 닿지 않고 패딩만 건드릴 수 있습니다.
그래도 “다른 풀 포인터/큰 침범”을 빨리 잡는 용도로는 충분히 쓸만합니다.
마무리
정리하면, 이 풀은 아래 상황에 특히 잘 맞습니다.
- 고정 크기 객체를 매우 자주 Alloc/Free 하는 경우
- 초기 prewarm로 충분히 확보해 hot path에서 힙 호출을 거의 없애고 싶은 경우
- wrong pool free / overwrite 같은 실수를 guard로 빨리 잡고 싶은 경우
그리고 더 “정석 풀”로 발전시키려면 보통 다음 순서로 개선합니다.
- 노드를 개별 malloc 대신 chunk 단위 확보 + chunk 리스트 보관
- T data 대신 raw storage로 타입/레이아웃 안전성 개선
- Debug에서만 double free 감지(state), Release에서는 제거
- Alloc(args...)로 가변 인자 생성자 지원(placement new 템플릿)
'TIL(Today I Learn) > TIL' 카테고리의 다른 글
| 바이브 코딩 중인 내가 쓰는 Agent Skills - 260611 (0) | 2026.06.02 |
|---|---|
| 구조체 패킷의 한계와 직렬화 버퍼(Serialization Buffer)가 필요한 이유 (0) | 2026.02.19 |
| 링버퍼(Ring Buffer) - 원활한 통신을 위한 자료구조 (0) | 2026.02.19 |
| 컴파일 시간을 크게 줄여주는 사전 컴파일된 헤더, PCH ( Visual C++ ) (0) | 2026.02.18 |
| NHN 코딩테스트 후기 (0) | 2023.08.12 |