메모리 풀과 프리리스트 – 고정 크기 객체의 Alloc/Free를 빠르게 만드는 방법

2026. 2. 19. 02:19·TIL(Today I Learn)/TIL

개요

게임 서버/클라처럼 실시간으로 돌아가는 코드에서 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할 방법이 없습니다.

  • 글 주석에는 “반환되지 않은 노드의 객체 소멸자는 호출하지 않음”이라고 되어 있지만,
  • 실제로는 노드 메모리 자체도 해제되지 않습니다.

✅ 운영 코드라면 보통 두 가지 중 하나로 갑니다.

  1. “반드시 전부 반납한 뒤 풀을 파괴한다”를 규칙으로 강제
    1. 현재 코드는 이 규칙을 따릅니다.
  2. 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
'TIL(Today I Learn)/TIL' 카테고리의 다른 글
  • 바이브 코딩 중인 내가 쓰는 Agent Skills - 260611
  • 구조체 패킷의 한계와 직렬화 버퍼(Serialization Buffer)가 필요한 이유
  • 링버퍼(Ring Buffer) - 원활한 통신을 위한 자료구조
  • 컴파일 시간을 크게 줄여주는 사전 컴파일된 헤더, PCH ( Visual C++ )
메카인
메카인
  • 메카인
    메카인의 지식창고
    메카인
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 게임개발
        • 언데드서바이벌_골드메탈_클론코딩
        • 3D_골드메탈_클론코딩
        • 유니티_문제해결
        • 게임 수학
      • C#
      • C++
        • C++
        • C++ STL
        • C,C++ mCoding yotube
      • 코딩테스트(C++)
      • TIL(Today I Learn) N
        • TIL N
      • 강의
        • UE5 GameDev
        • Unreal Engine 4 C++ The Ult..
      • 교재 문제 풀이
        • 운영체제
      • 정보처리기사
        • 정처기 요약
        • 정처기 오답노트
      • CS
        • 데이터베이스
        • 프로그래밍 언어론
        • 리눅스 시스템
        • 네트워크
      • 기타
        • Python
        • 알고리즘
        • 프로젝트 회고
      • 일상
        • 주식
        • 독서
        • 일기
      • (비공개 전용)
        • memory
        • Build
        • OOP
        • Smart Pointer
        • lamda
        • 게임 수학
        • 모던 C++
        • 모던 C++ STL
        • 모던 C++ Concurrency, Paralle..
        • 책
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
    • 블로그 관리
  • 링크

  • 공지사항

    • 공지사항 - 인생과 블로그의 목표
  • 인기 글

  • 태그

    ~
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
메카인
메모리 풀과 프리리스트 – 고정 크기 객체의 Alloc/Free를 빠르게 만드는 방법
상단으로

티스토리툴바