C++ 커스텀 Allocator: 메모리 풀·스택·추적 할당자와 PMR
이 글의 핵심
STL 컨테이너가 메모리를 받아 오는 Allocator 인터페이스를 이해하고, 메모리 풀·스택·추적 할당자를 직접 구현해 컨테이너에 끼워 넣는 방법을 정리합니다. C++17 PMR(polymorphic_allocator)로 타입을 바꾸지 않고 할당 전략만 교체하는 법과 할당자 전파처럼 자주 걸리는 문제도 다룹니다.
기본 Allocator
allocate/construct/destroy/deallocate 네 단계가 분리되어 있다는 것이 std::allocator를 이해하는 핵심입니다 — 일반적인 new T(42)는 “메모리를 확보하고 그 자리에 객체를 생성한다”는 두 단계를 하나의 표현식으로 묶어서 처리하지만, 할당자 인터페이스는 이 둘을 의도적으로 나눠 둡니다. allocate(10)은 T 10개가 들어갈 수 있는 원시 메모리만 확보할 뿐 그 위에 어떤 객체도 아직 존재하지 않고, construct(ptr, 42)가 되어서야 그 메모리 위에 실제로 생성자를 호출해 T 객체를 만듭니다. 이 분리 덕분에 std::vector는 reserve(100)으로 미래에 쓸 메모리를 미리 확보해 두면서도, 실제로 push_back이 호출되기 전까지는 그 공간에 객체를 생성하지 않을 수 있습니다 — 메모리 확보와 객체 생성이 하나로 묶여 있었다면 이런 “예약은 해 두되 아직 초기화는 하지 않는” 동작 자체가 불가능했을 것입니다. (참고로 construct/destroy 멤버는 C++17부터 std::allocator_traits가 기본 구현을 대신 제공하므로 사용자 정의 할당자에서 직접 작성할 필요가 거의 없어졌습니다.)
#include <memory>
// 기본 할당자
allocator<int> alloc;
// 메모리 할당
int* ptr = alloc.allocate(10); // 10개 int 공간
// 객체 생성 / 파괴 — allocator_traits를 거친다
using Traits = std::allocator_traits<std::allocator<int>>;
Traits::construct(alloc, ptr, 42);
Traits::destroy(alloc, ptr);
// 메모리 해제
alloc.deallocate(ptr, 10);
예전 예제에서 흔히 보이는 alloc.construct(ptr, 42) / alloc.destroy(ptr)는 C++17에서 deprecated되고 C++20에서 std::allocator의 멤버에서 삭제되었습니다. GCC 10에서 -std=c++17로는 컴파일되지만 -std=c++20에서는 'class std::allocator<int>' has no member named 'construct' 에러가 납니다. Apple clang 21에서도 같은 코드가 -std=c++17에서는 deprecated 경고만 내고, -std=c++20에서는 no member named 'construct' in 'std::allocator<int>' 오류로 멈추는 것을 확인했습니다. 표준 컨테이너가 실제로 하는 것처럼 std::allocator_traits의 정적 함수를 쓰면, 할당자가 construct를 제공하면 그것을, 없으면 placement new를 알아서 고릅니다.
컨테이너와 Allocator
vector<int>와 vector<int, MyAllocator<int>>는 겉보기엔 같은 컨테이너처럼 보이지만 실제로는 서로 완전히 다른 클래스 인스턴스화입니다 — std::vector의 두 번째 템플릿 매개변수가 기본값(std::allocator<T>)을 가진 것뿐이므로, 할당자를 다르게 지정하면 컴파일러는 이를 별개의 타입으로 취급합니다. 이것이 실무에서 갖는 의미는, vector<int>를 매개변수로 받는 함수에 vector<int, MyAllocator<int>>를 그대로 넘길 수 없다는 것입니다 — 두 타입 사이의 변환이 자동으로 이루어지지 않으므로, 커스텀 할당자를 도입하면 그 할당자를 쓰는 컨테이너 타입이 코드베이스 전반에 전파되는 효과가 있습니다. vector<int, MyAllocator<int>> v3(alloc)처럼 이미 만들어진 할당자 인스턴스를 생성자에 넘기는 것도 가능한데, 이는 뒤에서 다룰 “상태를 가진 할당자”(특정 메모리 풀을 가리키는 등)를 컨테이너에 연결할 때 필요한 경로입니다.
// 기본 할당자
vector<int> v1;
// 커스텀 할당자
vector<int, MyAllocator<int>> v2;
// 할당자 전달
MyAllocator<int> alloc;
vector<int, MyAllocator<int>> v3(alloc);
커스텀 Allocator 구현
로그를 남기는 이 최소 구현이 유용한 이유는 std::vector의 할당 패턴을 직접 관찰할 수 있게 해 준다는 데 있습니다 — push_back을 세 번 호출했을 때 실제로 몇 번의 allocate 호출이 일어나는지(용량 증가 전략에 따라 세 번보다 적을 수 있습니다) 콘솔 출력으로 직접 확인할 수 있습니다. template<typename U> MyAllocator(const MyAllocator<U>&)라는 변환 생성자가 필요한 이유는 컨테이너가 T 자체가 아니라 내부 구현 세부사항(연결 리스트의 노드 등)을 할당해야 할 때 MyAllocator<T>로부터 MyAllocator<U>를 유도해야 하기 때문이며, operator==가 항상 true를 반환하는 것은 이 할당자가 무상태(stateless)이므로 어떤 인스턴스로 할당한 메모리든 다른 인스턴스로 해제해도 무방하다는 뜻입니다.
#include <iostream>
#include <vector>
using namespace std;
template<typename T>
class MyAllocator {
public:
using value_type = T;
MyAllocator() noexcept {}
template<typename U>
MyAllocator(const MyAllocator<U>&) noexcept {}
T* allocate(size_t n) {
cout << "할당: " << n << "개" << endl;
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* ptr, size_t n) noexcept {
cout << "해제: " << n << "개" << endl;
::operator delete(ptr);
}
};
template<typename T, typename U>
bool operator==(const MyAllocator<T>&, const MyAllocator<U>&) {
return true;
}
template<typename T, typename U>
bool operator!=(const MyAllocator<T>&, const MyAllocator<U>&) {
return false;
}
int main() {
vector<int, MyAllocator<int>> v;
v.push_back(1); // 할당 발생
v.push_back(2);
v.push_back(3);
}
이 예제를 직접 실행하면 push_back 세 번에 할당이 세 번 일어납니다. 1개, 2개, 4개로 늘어나며 이전 버퍼는 새 버퍼로 옮긴 뒤 해제됩니다. (환경: macOS 27.0.1, Apple clang 21.0.0, libc++, -std=c++17. 증가 전략은 구현마다 다르므로 libstdc++나 MSVC에서는 횟수가 다를 수 있습니다.)
할당: 1개
할당: 2개
해제: 1개
할당: 4개
해제: 2개
해제: 4개
실전 예시
예시 1: 메모리 풀 할당자
아직 쓰이지 않은 블록의 메모리 자체에 다음 블록 포인터(next)를 저장해 자유 목록(free list)을 만드는 것이 이 구현의 핵심입니다. 블록 1024개를 한 번에 확보해 미리 연결해 두면, 이후 allocate()는 힙 할당자를 부르지 않고 목록에서 블록 하나를 떼어내는 O(1) 연산이 됩니다. 실제 컨테이너에 꽂아 쓰려면 두 가지를 지켜야 합니다. 첫째, list<int>는 int를 할당하지 않고 할당자를 내부 노드 타입으로 rebind해서 노드를 할당하므로, 풀은 sizeof(T)가 아니라 실제로 요청된 크기별로 자유 목록을 따로 둬야 합니다. 둘째, 컨테이너는 할당자를 수시로 복사하고 rebind하며, 어느 복사본이든 다른 복사본이 할당한 메모리를 해제할 수 있어야 합니다. 그래서 상태는 복사 불가능한 PoolResource에 두고 할당자는 shared_ptr로 공유하며, ==는 같은 풀을 가리키는지를 비교합니다. 자유 목록과 청크 목록을 할당자의 일반 멤버로 두고 소멸자에서 해제하는 흔한 구현은, 컨테이너가 만든 임시 복사본이 소멸하면서 아직 쓰이는 청크를 해제해 버려 첫 push_back에서 AddressSanitizer가 heap-use-after-free를 보고합니다. 원소 여러 개를 한 번에 요구하는 할당(vector의 증가 등)은 고정 크기 풀로 처리하기 어려우므로 operator new로 넘깁니다. 같은 설계를 표준 라이브러리가 std::pmr::unsynchronized_pool_resource로 제공하므로, 직접 만드는 것은 그것으로 안 되는 동작이 필요할 때로 한정하는 편이 낫습니다. alignas(64) 같은 과정렬 타입은 이 예제에서 다루지 않습니다.
#include <list>
#include <map>
#include <memory>
#include <vector>
using namespace std;
// 블록 크기별 free list를 관리하는 풀. 복사하면 소유권이 꼬이므로 복사 금지
class PoolResource {
struct FreeBlock { FreeBlock* next; };
static constexpr size_t BLOCKS_PER_CHUNK = 1024;
static constexpr size_t ALIGN = alignof(max_align_t);
map<size_t, FreeBlock*> freeLists; // 블록 크기 -> free list
vector<void*> chunks;
static size_t roundUp(size_t n) {
n = max(n, sizeof(FreeBlock));
return (n + ALIGN - 1) / ALIGN * ALIGN;
}
public:
PoolResource() = default;
PoolResource(const PoolResource&) = delete;
PoolResource& operator=(const PoolResource&) = delete;
~PoolResource() {
for (void* chunk : chunks) {
::operator delete(chunk);
}
}
void* allocate(size_t bytes) {
size_t size = roundUp(bytes);
FreeBlock*& head = freeLists[size];
if (!head) {
// 같은 크기 블록 1024개를 한 번에 확보해 free list로 연결
char* chunk = static_cast<char*>(::operator new(size * BLOCKS_PER_CHUNK));
chunks.push_back(chunk);
for (size_t i = 0; i < BLOCKS_PER_CHUNK; i++) {
auto* block = reinterpret_cast<FreeBlock*>(chunk + i * size);
block->next = head;
head = block;
}
}
FreeBlock* block = head;
head = block->next;
return block;
}
void deallocate(void* p, size_t bytes) noexcept {
FreeBlock*& head = freeLists.find(roundUp(bytes))->second;
auto* block = static_cast<FreeBlock*>(p);
block->next = head;
head = block;
}
};
template<typename T>
class PoolAllocator {
shared_ptr<PoolResource> pool; // 복사본과 rebind된 할당자가 같은 풀을 공유
template<typename U> friend class PoolAllocator;
public:
using value_type = T;
PoolAllocator() : pool(make_shared<PoolResource>()) {}
template<typename U>
PoolAllocator(const PoolAllocator<U>& other) noexcept : pool(other.pool) {}
T* allocate(size_t n) {
if (n != 1) {
return static_cast<T*>(::operator new(n * sizeof(T))); // 배열은 풀을 거치지 않음
}
return static_cast<T*>(pool->allocate(sizeof(T)));
}
void deallocate(T* p, size_t n) noexcept {
if (n != 1) {
::operator delete(p);
return;
}
pool->deallocate(p, sizeof(T));
}
friend bool operator==(const PoolAllocator& a, const PoolAllocator& b) noexcept {
return a.pool == b.pool;
}
};
int main() {
list<int, PoolAllocator<int>> myList;
for (int i = 0; i < 1000; i++) {
myList.push_back(i); // 노드는 풀에서 할당
}
}
예시 2: 스택 할당자
이 할당자의 deallocate가 사실상 아무 일도 하지 않는다는 점이 가장 눈에 띕니다 — 메모리는 스택에 놓인 Arena 객체의 고정 크기 배열에서 나오므로, 개별 할당을 추적해 되돌려주는 대신 Arena가 스코프를 벗어날 때 버퍼가 통째로 사라지는 방식에 의존합니다. 이는 “선입선출”이나 “임의 위치 해제” 같은 일반적인 할당자의 유연성을 포기하는 대신, used 포인터를 앞으로만 전진시키는(bump allocation) 극도로 단순하고 빠른 할당 전략을 얻습니다 — 개별 해제 로직 자체가 없으므로 해제 비용이 사실상 0입니다. 이런 트레이드오프가 유효한 상황은 “여러 객체를 할당하고, 그 객체들을 개별적으로가 아니라 한꺼번에 버릴 것”이 미리 정해진 경우(요청 하나를 처리하는 동안만 쓰이는 임시 객체들 등)이며, 개별 객체를 자유롭게 해제해야 하는 일반적인 용도에는 적합하지 않습니다.
#include <iostream>
#include <vector>
using namespace std;
template<size_t N>
class Arena {
alignas(max_align_t) char buffer_[N];
size_t used_ = 0;
public:
Arena() = default;
Arena(const Arena&) = delete; // 버퍼를 복사하면 포인터가 엉뚱한 곳을 가리킴
Arena& operator=(const Arena&) = delete;
void* allocate(size_t bytes, size_t align) {
size_t start = (used_ + align - 1) & ~(align - 1); // 정렬 맞추기
if (start + bytes > N) {
throw bad_alloc();
}
used_ = start + bytes;
return buffer_ + start;
}
};
template<typename T, size_t N>
class StackAllocator {
Arena<N>* arena_;
template<typename U, size_t M> friend class StackAllocator;
public:
using value_type = T;
template<typename U> struct rebind { using other = StackAllocator<U, N>; };
explicit StackAllocator(Arena<N>& a) noexcept : arena_(&a) {}
template<typename U>
StackAllocator(const StackAllocator<U, N>& other) noexcept : arena_(other.arena_) {}
T* allocate(size_t n) {
return static_cast<T*>(arena_->allocate(n * sizeof(T), alignof(T)));
}
void deallocate(T*, size_t) noexcept {
// 개별 해제 안 함: arena가 스코프를 벗어날 때 통째로 사라짐
}
friend bool operator==(const StackAllocator& a, const StackAllocator& b) noexcept {
return a.arena_ == b.arena_;
}
};
int main() {
Arena<1024> arena; // 스택에 있는 1KB 버퍼
vector<int, StackAllocator<int, 1024>> v{StackAllocator<int, 1024>(arena)};
v.push_back(1);
v.push_back(2);
v.push_back(3);
cout << v.size() << '\n'; // 3
// v가 먼저 소멸하고, 그다음 arena가 스코프를 벗어나며 버퍼가 사라짐
}
버퍼를 할당자 안이 아니라 별도의 Arena에 두고 할당자는 포인터만 갖게 한 데는 이유가 있습니다. 표준 컨테이너는 할당자를 값으로 복사해 보관하고, 복사본끼리 ==로 같다고 판정되면 한쪽이 할당한 메모리를 다른 쪽이 해제할 수 있다고 가정합니다. 버퍼가 할당자 객체 안에 있으면 vector를 이동하는 순간 원소 포인터는 원래 객체의 버퍼를 가리킨 채 남아 dangling이 됩니다. 또 std::allocator_traits는 StackAllocator<T, N>처럼 타입이 아닌 템플릿 인자가 있는 할당자의 rebind를 자동으로 만들어 주지 못하므로, rebind를 직접 정의하지 않으면 libc++ 같은 구현에서는 컴파일조차 되지 않습니다. 여기서 N은 원소 개수가 아니라 바이트 수이고, vector가 커질 때마다(1→2→4…) 이전 블록은 반환되지 않으므로 실제로 담을 수 있는 원소 수는 N / sizeof(T)보다 적습니다. reserve로 한 번에 필요한 만큼 잡아 두는 것이 이런 할당자를 쓰는 일반적인 방법입니다. 같은 아이디어를 표준으로 제공하는 것이 뒤에서 다룰 std::pmr::monotonic_buffer_resource입니다.
예시 3: 추적 할당자
이 할당자를 실제로 유용하게 만드는 것은 정적(static) 멤버로 카운터를 선언했다는 점입니다 — allocCount, bytesAllocated 등은 TrackingAllocator<T> 인스턴스마다 존재하는 것이 아니라 같은 T에 대한 모든 인스턴스가 공유하는 클래스 전체의 통계이므로, vector가 내부적으로 할당자를 복사해도 집계가 흩어지지 않습니다. 단 정적 멤버는 T마다 따로 생기므로, list나 map처럼 할당자를 노드 타입으로 rebind하는 컨테이너에서는 TrackingAllocator<int>가 아니라 TrackingAllocator<노드>의 카운터가 올라갑니다. main의 중괄호 블록이 끝나 v가 소멸된 뒤에도 printStats()가 유효한 값을 출력할 수 있는 이유가 바로 이것입니다 — 그 시점에 이미 v 자체는 사라졌지만, TrackingAllocator<int> 클래스 레벨의 통계는 계속 남아있습니다. 실무에서 이런 할당자를 컨테이너에 임시로 붙이면, 별도의 프로파일러 없이도 “이 자료구조가 정말 예상만큼만 메모리를 할당하고 있는가”를 몇 줄의 코드로 즉시 검증할 수 있습니다.
#include <iostream>
#include <vector>
using namespace std;
template<typename T>
class TrackingAllocator {
private:
static size_t allocCount;
static size_t deallocCount;
static size_t bytesAllocated;
public:
using value_type = T;
T* allocate(size_t n) {
allocCount++;
bytesAllocated += n * sizeof(T);
cout << "할당: " << n * sizeof(T) << " bytes" << endl;
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* ptr, size_t n) noexcept {
deallocCount++;
bytesAllocated -= n * sizeof(T);
cout << "해제: " << n * sizeof(T) << " bytes" << endl;
::operator delete(ptr);
}
static void printStats() {
cout << "할당 횟수: " << allocCount << endl;
cout << "해제 횟수: " << deallocCount << endl;
cout << "현재 사용: " << bytesAllocated << " bytes" << endl;
}
};
template<typename T>
size_t TrackingAllocator<T>::allocCount = 0;
template<typename T>
size_t TrackingAllocator<T>::deallocCount = 0;
template<typename T>
size_t TrackingAllocator<T>::bytesAllocated = 0;
int main() {
{
vector<int, TrackingAllocator<int>> v;
for (int i = 0; i < 100; i++) {
v.push_back(i);
}
}
TrackingAllocator<int>::printStats();
}
PMR (Polymorphic Memory Resources)
앞서 다룬 MyAllocator<T>, PoolAllocator<T>, StackAllocator<T, N>은 모두 서로 다른 타입이라는 공통의 근본적인 제약이 있었습니다 — vector<int, PoolAllocator<int>>와 vector<int, StackAllocator<int, 1024>>은 완전히 다른 컨테이너 타입이므로, 런타임에 할당 전략을 바꾸려면 애초에 코드를 다시 컴파일해야 합니다. PMR(C++17)은 이 문제를 반대로 뒤집습니다 — pmr::vector<int>는 항상 같은 타입(std::pmr::polymorphic_allocator<int>를 쓰는 vector)이고, 그 뒤에서 실제로 메모리를 공급하는 memory_resource(위 예시의 monotonic_buffer_resource)만 런타임에 다형적으로 교체됩니다. monotonic_buffer_resource(buffer, sizeof(buffer))는 스택에 있는 buffer라는 고정 크기 영역에서 순차적으로 메모리를 내주는(앞서 다룬 스택 할당자와 원리가 같은) 리소스이며, pmr::vector<int> v(&pool)이 그 리소스로부터 힙 할당 없이 값을 채워나갑니다 — 함수마다 다른 할당 전략을 쓰고 싶을 때, 템플릿 매개변수를 바꾸지 않고도 생성자에 다른 memory_resource를 넘기는 것만으로 전략을 교체할 수 있다는 것이 PMR이 커스텀 할당자 템플릿보다 유연한 지점입니다.
그림: 실측 결과 — monotonic 버퍼가 가장 빨랐지만 기본 allocator보다 16% 정도였고, unsynchronized_pool_resource는 오히려 두 배 느렸다. PMR은 바꾸기 전에 자기 할당 패턴으로 재 봐야 한다.
#include <memory_resource>
#include <vector>
using namespace std;
int main() {
// 단조 버퍼
char buffer[1024];
pmr::monotonic_buffer_resource pool(buffer, sizeof(buffer));
// PMR 벡터
pmr::vector<int> v(&pool);
for (int i = 0; i < 100; i++) {
v.push_back(i); // buffer에서 할당
}
}
자주 발생하는 문제
문제 1: 할당자 비교
operator==가 할당자에 존재하는 이유는 표준 컨테이너가 “두 컨테이너가 같은 할당자를 쓰는지”를 판단해야 하는 상황이 실제로 있기 때문입니다 — 예를 들어 std::list::splice(다른 리스트의 노드를 이 리스트로 옮기는 연산)는 두 리스트가 같은 할당자를 쓴다면 노드를 재할당 없이 그대로 이어붙일 수 있지만, 서로 다른 할당자(다른 메모리 풀 등)를 쓴다면 노드를 원래 풀에서 새 풀로 옮기는 추가 작업이 필요합니다. 이 비교 연산자를 정의하지 않으면 이런 최적화 경로 자체가 성립하지 않거나, 최악의 경우 서로 호환되지 않는 할당자로 할당된 메모리를 잘못된 할당자로 해제하려는 시도로 이어질 수 있으므로, 무상태 할당자라면 항상 true를 반환하는 형태로라도 이 연산자를 명시적으로 제공하는 것이 안전합니다.
// ❌ 할당자 비교 안함
template<typename T>
class BadAllocator {
// operator== 없음
};
// ✅ 할당자 비교 구현
template<typename T>
class GoodAllocator {
// ...
};
template<typename T, typename U>
bool operator==(const GoodAllocator<T>&, const GoodAllocator<U>&) {
return true;
}
문제 2: 할당자 전파
컨테이너를 복사·이동·swap할 때 “할당자도 함께 옮겨가야 하는지, 원래 컨테이너의 할당자를 유지해야 하는지”는 상태를 가진 할당자(특정 메모리 풀을 가리키는 등)를 쓸 때 반드시 결정해야 하는 문제입니다. 다만 위 코드처럼 std::allocator_traits를 사용자 타입에 대해 직접 특수화하는 방식은 실무에서 잘 쓰이지 않는데, 표준이 권장하는 정석적인 방법은 이 세 가지 전파 정책(propagate_on_container_copy_assignment 등)을 MyAllocator 클래스 내부의 중첩 타입 별칭으로 직접 제공하는 것입니다 — allocator_traits의 기본 구현이 사용자 할당자 안에 이런 멤버가 있는지 자동으로 찾아 반영하도록 설계되어 있기 때문입니다. 이 정책을 명시하지 않으면 기본값(대부분 false_type, 즉 전파하지 않음)이 적용되는데, 상태를 가진 할당자를 쓰면서 이 기본값이 실제 의도와 다르다면 컨테이너 대입 후 예상과 다른 할당자가 남아있는 미묘한 버그로 이어질 수 있습니다.
// ✅ 전파 정책은 할당자 클래스 안에 멤버 타입으로 선언한다
template<typename T>
struct MyAllocator {
using value_type = T;
using propagate_on_container_copy_assignment = std::true_type;
using propagate_on_container_move_assignment = std::true_type;
using propagate_on_container_swap = std::true_type;
using is_always_equal = std::false_type; // 상태가 있는 할당자
// allocate / deallocate / operator== ...
};
인터넷의 예제 중에는 std::allocator_traits<MyAllocator<T>>를 직접 특수화하는 것이 있는데, 표준은 allocator_traits를 사용자가 특수화하는 것을 허용하지 않습니다(C++23에서는 명시적으로 ill-formed). 컨테이너는 allocator_traits가 할당자 안의 멤버 타입을 읽어 오는 구조라, 할당자에 위처럼 선언하는 것이 정석입니다. propagate_on_container_move_assignment가 false이고 두 할당자가 서로 다르면(operator==가 false), 이동 대입조차 원소를 하나씩 이동·복사하는 느린 경로로 떨어진다는 점도 기억해 둘 만합니다. 풀별 할당자를 쓰는 컨테이너끼리 a = std::move(b)를 했는데 이동이 아니라 원소 복사가 일어나는 이유가 대개 이것입니다.
문제 2-1: 컨테이너는 T가 아니라 노드를 할당한다 (rebind)
template<class T>
struct LoggingAllocator {
using value_type = T;
LoggingAllocator() = default;
template<class U> LoggingAllocator(const LoggingAllocator<U>&) {} // rebind용 변환 생성자
T* allocate(std::size_t n) {
std::printf("alloc %zu x %zu bytes\n", n, sizeof(T));
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, std::size_t) { ::operator delete(p); }
};
template<class T, class U>
bool operator==(const LoggingAllocator<T>&, const LoggingAllocator<U>&) { return true; }
std::list<int, LoggingAllocator<int>> l;
l.push_back(1); // 출력: alloc 1 x 24 bytes ← int(4바이트)가 아니다
std::list<int>는 int를 할당하지 않습니다. 앞뒤 포인터를 가진 노드(64비트 GCC에서 24바이트)를 할당하고, 이를 위해 allocator_traits<LoggingAllocator<int>>::rebind_alloc<노드>로 LoggingAllocator<노드>를 만들어 씁니다. rebind 중첩 템플릿을 정의하지 않아도 되는 것은 allocator_traits가 템플릿 인자를 바꿔 끼워 자동으로 유도해 주기 때문이고, 대신 위의 변환 생성자(LoggingAllocator(const LoggingAllocator<U>&))는 있어야 합니다. int 크기 블록만 나눠 주는 고정 크기 풀 할당자를 만들어 std::list나 std::map에 꽂았다가 크기가 맞지 않아 메모리를 덮어쓰는 버그가 바로 이 지점에서 생깁니다. 풀 할당자는 sizeof(T)가 아니라 실제로 요청된 크기를 기준으로 만들어야 합니다.
문제 3: 정렬되지 않은 메모리
char buffer[100]은 char의 정렬 요구사항(대개 1바이트)만 만족하도록 배치될 뿐, int가 요구하는 정렬(대개 4바이트 경계)까지 보장하지는 않습니다 — 스택의 실제 배치는 컴파일러와 플랫폼에 따라 우연히 정렬이 맞을 수도 있지만, 이는 표준이 보장하는 동작이 아니라 순전히 운입니다. 정렬되지 않은 포인터를 통해 int에 접근하면 x86 계열에서는 성능 저하(정렬되지 않은 접근에 대한 추가 메모리 사이클)로 끝날 수 있지만, ARM 등 일부 아키텍처에서는 즉시 크래시(버스 에러)로 이어질 수 있어 이식성 있는 코드에서는 절대 가정해서는 안 되는 위험입니다. alignas(int) char buffer[100]은 컴파일러에게 이 버퍼를 int가 요구하는 정렬 경계에 맞춰 배치하도록 명시적으로 지시하며, 이는 앞서 다룬 Arena의 alignas(max_align_t) char buffer_[N]과 정확히 같은 이유로 필요한 처리입니다 — 커스텀 할당자에서 원시 바이트 배열을 다루는 코드라면 예외 없이 이 정렬 문제를 명시적으로 고려해야 합니다.
// ❌ 정렬 고려 안함
char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer); // 정렬 안 됨!
// ✅ alignas 사용
alignas(int) char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer);
참고 자료
FAQ
성능 이점은?
고정 크기 메모리 풀의 할당/해제는 프리 리스트에서 포인터 하나를 꺼내고 넣는 정도라서, 크기 클래스 탐색·잠금·OS 호출이 끼어들 수 있는 범용 malloc보다 훨씬 가볍습니다. 다만 최근 glibc·jemalloc·mimalloc 같은 할당자는 스레드 로컬 캐시로 작은 할당을 이미 빠르게 처리하므로, 실제 이득은 할당 패턴과 기본 할당자에 따라 크게 달라집니다. 할당이 프로파일에서 실제 병목으로 보일 때 도입 전후를 측정해 판단하세요.