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' 에러가 납니다. 표준 컨테이너가 실제로 하는 것처럼 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)이므로 어떤 인스턴스로 할당한 메모리든 다른 인스턴스로 해제해도 무방하다는 뜻입니다.

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);
}

실전 예시

예시 1: 메모리 풀 할당자

Block이라는 내부 구조체가 T data와 Block* next를 함께 갖는 것이 이 구현의 핵심 아이디어입니다 — 자유 목록(free list)의 링크 정보(next)를 별도로 관리하지 않고, 아직 사용되지 않은 슬롯의 메모리 자체를 링크드 리스트의 노드로 재활용합니다(어차피 사용 중이 아니므로 그 공간을 next 포인터로 써도 무방합니다). allocatePool()이 POOL_SIZE개의 Block을 한 번에 확보하고 그 사이를 next로 미리 연결해 두면, 이후 allocate()는 힙 할당자를 다시 부르지 않고 그저 freeList에서 노드 하나를 떼어내는 O(1) 연산이 됩니다. if (n != 1) throw bad_alloc()이라는 제약은 이 풀이 노드 하나씩(std::list, std::map처럼) 할당하는 컨테이너 전용으로 설계되었다는 뜻이며, std::vector처럼 한 번에 여러 원소 몫의 연속된 메모리를 요구하는 컨테이너에는 이 할당자를 그대로 쓸 수 없습니다 — main이 vector 대신 list를 쓴 것도 이 제약을 정확히 반영한 선택입니다.

template<typename T>
class PoolAllocator {
private:
    struct Block {
        T data;
        Block* next;
    };
    
    Block* freeList = nullptr;
    vector<Block*> pools;
    
    static constexpr size_t POOL_SIZE = 1024;
    
    void allocatePool() {
        Block* pool = static_cast<Block*>(::operator new(POOL_SIZE * sizeof(Block)));
        pools.push_back(pool);
        
        for (size_t i = 0; i < POOL_SIZE - 1; i++) {
            pool[i].next = &pool[i + 1];
        }
        pool[POOL_SIZE - 1].next = nullptr;
        
        freeList = pool;
    }
    
public:
    using value_type = T;
    
    PoolAllocator() {
        allocatePool();
    }
    
    ~PoolAllocator() {
        for (Block* pool : pools) {
            ::operator delete(pool);
        }
    }
    
    T* allocate(size_t n) {
        if (n != 1) {
            throw bad_alloc();
        }
        
        if (!freeList) {
            allocatePool();
        }
        
        Block* block = freeList;
        freeList = freeList->next;
        
        return &block->data;
    }
    
    void deallocate(T* ptr, size_t n) noexcept {
        if (n != 1) return;
        
        Block* block = reinterpret_cast<Block*>(ptr);
        block->next = freeList;
        freeList = block;
    }
};

int main() {
    list<int, PoolAllocator<int>> myList;
    
    for (int i = 0; i < 1000; i++) {
        myList.push_back(i);  // 풀에서 할당
    }
}

예시 2: 스택 할당자

이 할당자의 deallocate가 사실상 아무 일도 하지 않는다는 점이 가장 눈에 띕니다 — buffer는 StackAllocator 객체 자체에 내장된 고정 크기 배열이므로, 개별 할당을 추적해 되돌려주는 대신 StackAllocator 인스턴스 전체가 스코프를 벗어나며 소멸될 때 buffer도 함께 통째로 해제되는 방식에 의존합니다. 이는 “선입선출”이나 “임의 위치 해제” 같은 일반적인 할당자의 유연성을 포기하는 대신, used 포인터를 앞으로만 전진시키는(bump allocation) 극도로 단순하고 빠른 할당 전략을 얻습니다 — 개별 해제 로직 자체가 없으므로 해제 비용이 사실상 0입니다. 이런 트레이드오프가 유효한 상황은 “여러 객체를 할당하고, 그 객체들을 개별적으로가 아니라 한꺼번에 버릴 것”이 미리 정해진 경우(요청 하나를 처리하는 동안만 쓰이는 임시 객체들 등)이며, 개별 객체를 자유롭게 해제해야 하는 일반적인 용도에는 적합하지 않습니다.

template<typename T, size_t N>
class StackAllocator {
private:
    alignas(T) char buffer[N * sizeof(T)];
    size_t used = 0;
    
public:
    using value_type = T;
    
    T* allocate(size_t n) {
        if (used + n > N) {
            throw bad_alloc();
        }
        
        T* result = reinterpret_cast<T*>(buffer + used * sizeof(T));
        used += n;
        return result;
    }
    
    void deallocate(T* ptr, size_t n) noexcept {
        // 스택 할당자는 해제 안함 (스코프 종료 시 자동)
    }
};

int main() {
    // 스택에 할당
    vector<int, StackAllocator<int, 100>> v;
    
    v.push_back(1);
    v.push_back(2);
    v.push_back(3);
    
    // 스코프 종료 시 자동 해제
}

예시 3: 추적 할당자

이 할당자를 실제로 유용하게 만드는 것은 정적(static) 멤버로 카운터를 선언했다는 점입니다 — allocCount, bytesAllocated 등은 TrackingAllocator<T> 인스턴스마다 존재하는 것이 아니라 같은 T에 대한 모든 인스턴스가 공유하는 클래스 전체의 통계이므로, vector가 내부적으로 할당자를 복사하거나 재바인딩해도 집계가 흩어지지 않습니다. main의 중괄호 블록이 끝나 v가 소멸된 뒤에도 printStats()가 유효한 값을 출력할 수 있는 이유가 바로 이것입니다 — 그 시점에 이미 v 자체는 사라졌지만, TrackingAllocator<int> 클래스 레벨의 통계는 계속 남아있습니다. 실무에서 이런 할당자를 컨테이너에 임시로 붙이면, 별도의 프로파일러 없이도 “이 자료구조가 정말 예상만큼만 메모리를 할당하고 있는가”를 몇 줄의 코드로 즉시 검증할 수 있습니다.

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, 100>>은 완전히 다른 컨테이너 타입이므로, 런타임에 할당 전략을 바꾸려면 애초에 코드를 다시 컴파일해야 합니다. 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이 커스텀 할당자 템플릿보다 유연한 지점입니다.

#include <memory_resource>

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가 요구하는 정렬 경계에 맞춰 배치하도록 명시적으로 지시하며, 이는 앞서 다룬 StackAllocator의 alignas(T) char buffer[...]와 정확히 같은 이유로 필요한 처리입니다 — 커스텀 할당자에서 원시 바이트 배열을 다루는 코드라면 예외 없이 이 정렬 문제를 명시적으로 고려해야 합니다.

// ❌ 정렬 고려 안함
char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer);  // 정렬 안 됨!

// ✅ alignas 사용
alignas(int) char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer);

FAQ

Q1: 커스텀 할당자는 언제 사용하나요?

A:

  • 메모리 풀
  • 특수 메모리 영역 (GPU, 공유 메모리)
  • 메모리 추적/디버깅
  • 성능 최적화

Q2: 성능 이점은?

A: 고정 크기 메모리 풀의 할당/해제는 프리 리스트에서 포인터 하나를 꺼내고 넣는 정도라서, 크기 클래스 탐색·잠금·OS 호출이 끼어들 수 있는 범용 malloc보다 훨씬 가볍습니다. 다만 최근 glibc·jemalloc·mimalloc 같은 할당자는 스레드 로컬 캐시로 작은 할당을 이미 빠르게 처리하므로, 실제 이득은 할당 패턴과 기본 할당자에 따라 크게 달라집니다. 할당이 프로파일에서 실제 병목으로 보일 때 도입 전후를 측정해 판단하세요.

Q3: PMR이란?

A: C++17의 다형적 메모리 리소스. 런타임에 할당자를 변경할 수 있습니다.

Q4: 할당자 구현은 어렵나요?

A: 기본 구현은 간단하지만, 완전한 구현은 복잡합니다. allocator_traits를 활용하세요.

Q5: 할당자 디버깅은?

A:

  • 추적 할당자 사용
  • Valgrind
  • AddressSanitizer

Q6: Allocator 학습 리소스는?

A:

  • cppreference.com
  • “The C++ Standard Library” (Nicolai Josuttis)
  • Boost.Pool 소스 코드

같이 보면 좋은 글