본문으로 건너뛰기 C++ Stack Allocator | '스택 할당자' 가이드

C++ Stack Allocator | '스택 할당자' 가이드

C++ Stack Allocator | '스택 할당자' 가이드

이 글의 핵심

C++ Stack Allocator - "스택 할당자" 가이드. C++ Stack Allocator의 Stack Allocator란?, 스택 기반 할당이란 무엇인가, 메모리 풀과 비교를 실전 코드와 함께 설명합니다.

Stack Allocator란?

일반적인 힙 할당(new/malloc)은 매번 운영체제나 할당자 내부의 복잡한 자료구조를 거쳐야 해서, 짧게 쓰고 버리는 임시 데이터에는 상대적으로 비용이 큽니다. 스택 할당자는 미리 확보해둔 고정 크기 버퍼 위에서 포인터 하나만 앞으로 밀어가며(bump pointer) 메모리를 내주는 방식으로, 이런 임시 할당을 극도로 빠르게 만들어주는 커스텀 할당자입니다. 아래 StackAllocatorchar buffer[N * sizeof(T)]라는 고정 버퍼 안에서 ptr을 계속 증가시키며 allocate를 처리하고, 버퍼가 꽉 차면 std::bad_alloc을 던져 한계를 명확히 알리는 최소한의 골격을 보여줍니다.

template<typename T, size_t N>
class StackAllocator {
    alignas(T) char buffer[N * sizeof(T)];
    char* ptr = buffer;
    
public:
    using value_type = T;
    
    T* allocate(size_t n) {
        if (ptr + n * sizeof(T) > buffer + sizeof(buffer)) {
            throw std::bad_alloc();
        }
        T* result = reinterpret_cast<T*>(ptr);
        ptr += n * sizeof(T);
        return result;
    }
    
    void deallocate(T* p, size_t n) {
        // 스택: 역순 해제만 지원
    }
};

스택 기반 할당이란 무엇인가

여기서 말하는 “스택 할당자”는 프로세스 스택 프레임이 아닌, 보통 스레드 스택 위에 깔거나 객체 안에 고정 버퍼를 두고 bump pointer로 쌓아 올리는 할당 방식을 가리키는 경우가 많습니다. std::vector<T, MyAlloc>에 붙이면, allocate미리 잡아 둔 연속 버퍼 안에서만 동작합니다. 정말 alloca처럼 가변 길이를 스택에 줄이아 쓰는 것과는 목적과 제약이 다릅니다.

표준적으로 비슷한 체험을 하려면 std::pmr::monotonic_buffer_resource에 스택 배열이나 thread_local 버퍼를 넘기는 패턴이 널리 사용됩니다(아래 “함수 로컬” 예시 참고).

alloca와 VLA(가변 길이 배열)

방식C++에서특징
alloca사실상 비표준 확장(컴파일러별)스택에서 런타임 크기만큼 공간 확보. 실수로 큰 크기면 스택 오버플로. 예외 안전·이식성 나쁨.
VLAC99. C++ 표준 아님GCC 등 확장으로 C++에서도 보이지만 이식성과 표준 준수 측면에서 비권장.

현대 C++에서는 고정 상한이 있으면 std::arrayalignas 버퍼 + 커스텀 할당자, 상한을 모르면 std::vector 또는 pmr이 더 안전합니다.

메모리 풀과 비교

스택 버퍼 / 모노토닉 할당메모리 풀(오브젝트 풀 등)
할당 속도보통 매우 빠름(bump)미리 할당해 두면 빠름
해제 패턴LIFO 또는 영역 단위 리셋에 강함특정 크기 블록 재사용에 강함
단편화연속 버퍼라 거의 없음풀 설계에 따라 다름
수명스코프/리셋에 묶임프로그램 수명 동안 유지 가능
적합한 경우파싱 버퍼, 프레임 단위 임시 컨테이너동일 크기 객체의 빈번한 new/delete

한 줄 정리: “이번 호출 안에서만 쓰고 버릴 연속 메모리”면 스택 버퍼·모노토닉, “같은 타입 인스턴스를 반복 재사용”이면 풀을 고려합니다.

기본 사용

StackAllocatorvalue_type을 정의하고 있어 표준 컨테이너의 할당자 요구사항(allocator requirements)을 만족하며, std::vector의 두 번째 템플릿 인자로 그대로 넘길 수 있습니다. 아래 예제처럼 std::vector<int, StackAllocator<int, 100>> vec{alloc};으로 벡터를 만들면, push_back이 내부적으로 메모리를 요청할 때마다 힙이 아니라 alloc이 들고 있는 고정 버퍼에서 공간을 받아오게 되어, 벡터의 인터페이스는 그대로 유지하면서 할당 방식만 바꿀 수 있습니다.

#include <vector>

int main() {
    StackAllocator<int, 100> alloc;
    std::vector<int, StackAllocator<int, 100>> vec{alloc};
    
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i);
    }
}

실전 예시

앞서 본 기본 골격을 실전에 가깝게 확장한 네 가지 변형을 살펴보겠습니다. LIFO 해제를 지원하는 고정 버퍼, 할당자 없이 자체적으로 요소를 관리하는 스택 벡터, 버퍼가 부족하면 힙으로 넘어가는 임시 할당자, 그리고 함수 지역 버퍼를 표준 라이브러리와 결합하는 방법입니다.

예시 1: 고정 버퍼

FixedStackAllocator는 앞서 본 기본형에 최소한의 해제 기능을 추가했습니다. deallocate는 해제하려는 블록이 현재 버퍼의 맨 끝(가장 최근에 할당된 블록)과 정확히 일치할 때만 used를 되돌려 공간을 회수하는데, 이는 스택 구조상 마지막에 할당한 것만 먼저 해제할 수 있는 LIFO(후입선출) 규칙을 그대로 반영한 것입니다. reset()으로 사용량을 한 번에 0으로 되돌리는 기능도 함께 제공해, 개별 해제 대신 “이번 작업이 끝나면 버퍼 전체를 통째로 비운다”는 더 흔한 사용 패턴을 지원합니다.

template<typename T, size_t N>
class FixedStackAllocator {
    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 std::bad_alloc();
        }
        T* ptr = reinterpret_cast<T*>(buffer + used * sizeof(T));
        used += n;
        return ptr;
    }
    
    void deallocate(T* p, size_t n) {
        // LIFO만 지원
        if (reinterpret_cast<char*>(p) + n * sizeof(T) == 
            buffer + used * sizeof(T)) {
            used -= n;
        }
    }
    
    void reset() {
        used = 0;
    }
};

예시 2: 스택 벡터

할당자를 표준 컨테이너에 끼워 넣는 대신, 아예 고정 버퍼를 내장한 자체 컨테이너를 만드는 방법도 있습니다. StackVectorstd::vector처럼 동작하지만 내부적으로 힙 할당을 전혀 하지 않고, push_back이 호출될 때마다 alignas(T) char buffer[...] 위에 placement new(::new (slot) T(value))로 객체를 직접 생성합니다. 소멸자에서 count만큼 반복하며 각 원소의 소멸자를 수동으로 호출해주는 부분도 눈여겨봐야 하는데, 이는 원시 바이트 버퍼 위에 놓인 객체는 컴파일러가 자동으로 소멸시켜주지 않기 때문에 직접 생명주기를 관리해야 한다는 점을 보여줍니다.

template<typename T, size_t N>
class StackVector {
    alignas(T) char buffer[N * sizeof(T)];
    size_t count = 0;
    
public:
    void push_back(const T& value) {
        if (count >= N) {
            throw std::bad_alloc();
        }
        void* slot = buffer + count * sizeof(T);
        ::new (slot) T(value);
        ++count;
    }
    
    T& operator[](size_t i) {
        return *reinterpret_cast<T*>(&buffer[i * sizeof(T)]);
    }
    
    size_t size() const { return count; }
    
    ~StackVector() {
        for (size_t i = 0; i < count; ++i) {
            (*this)[i].~T();
        }
    }
};

int main() {
    StackVector<int, 100> vec;
    vec.push_back(1);
    vec.push_back(2);
}

예시 3: 임시 할당

고정 버퍼만 쓰는 할당자의 가장 큰 약점은 “버퍼 크기를 넘어서면 그냥 실패한다”는 것입니다. TempAllocator는 이 문제를 힙 폴백(fallback)으로 완화합니다. 요청한 크기가 남은 버퍼 공간에 들어가면 지금까지와 같이 bump pointer로 빠르게 할당하고, 공간이 부족하면 ::operator new로 힙에서 할당해 예외를 던지는 대신 계속 동작하도록 합니다. deallocate에서도 해제하려는 포인터가 버퍼 범위 안에 있는지 주소 비교로 판단해, 버퍼 안의 메모리는 그냥 무시하고 힙에서 온 메모리만 ::operator delete로 실제 해제하는 방식으로 두 출처를 구분해서 처리합니다.

template<typename T, size_t N>
class TempAllocator {
    alignas(T) char buffer[N * sizeof(T)];
    char* ptr = buffer;
    
public:
    using value_type = T;
    
    T* allocate(size_t n) {
        if (ptr + n * sizeof(T) <= buffer + sizeof(buffer)) {
            T* result = reinterpret_cast<T*>(ptr);
            ptr += n * sizeof(T);
            return result;
        }
        // 버퍼 부족 시 힙 사용
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    
    void deallocate(T* p, size_t n) {
        if (reinterpret_cast<char*>(p) >= buffer && 
            reinterpret_cast<char*>(p) < buffer + sizeof(buffer)) {
            // 스택: 해제 안 함
        } else {
            ::operator delete(p);
        }
    }
};

예시 4: 함수 로컬

직접 할당자를 구현하지 않고도, 표준 라이브러리가 제공하는 std::pmr::monotonic_buffer_resource로 같은 효과를 낼 수 있습니다. 아래 processData 함수는 스택 위의 지역 배열 char buffer[4096]monotonic_buffer_resource에 넘겨, 그 위에서 동작하는 std::pmr::vector<int>가 함수 안에서만 유효한 빠른 임시 컨테이너로 쓰이도록 합니다. 함수가 끝나면 buffer도 함께 소멸되므로 별도의 해제 코드 없이 자동으로 정리되며, 이 패턴은 직접 할당자 클래스를 작성하는 수고 없이 표준 도구만으로 스택 기반 할당의 이점을 누릴 수 있는 실용적인 방법입니다.

#include <vector>

void processData() {
    // 로컬 버퍼
    char buffer[4096];
    std::pmr::monotonic_buffer_resource mbr{buffer, sizeof(buffer)};
    
    std::pmr::vector<int> tempVec{&mbr};
    
    // 임시 데이터 처리
    for (int i = 0; i < 100; ++i) {
        tempVec.push_back(i * i);
    }
    
    // 함수 종료 시 자동 해제
}

제약사항 정리

  1. 용량 상한: 고정 버퍼를 넘으면 bad_alloc 또는 힙 폴백 설계가 필요합니다.
  2. LIFO/역순 해제: 범용 allocate/deallocate를 정확히 지원하려면 할당 순서 제약을 문서화해야 합니다.
  3. 이동·복사: std::vector와 커스텀 할당자를 쓸 때는 allocator-aware 요구사항과 propagate_on_container_move_assignment 등을 이해해야 합니다(표준 컨테이너는 구현이 복잡함).
  4. 스레드: 스택 버퍼가 스레드 로컬이 아니면 동시 접근 시 데이터 레이스입니다.
  5. 디버그: reinterpret_cast와 수동 수명 관리(placement new)는 UB 가능성이 있으므로 코드 리뷰와 테스트가 중요합니다.

실전 활용(추가 아이디어)

  • JSON/XML 파싱 등 한 번의 parse() 호출 안에서만 필요한 vector/string을 PMR 버퍼에 얹기.
  • 오디오·그래픽스: 프레임 단위 임시 샘플 버퍼(크기 상한 알 때).
  • 임베디드·실시간: 힙 사용을 피하기 위해 정적 버퍼 + 모노토닉으로 STL 컨테이너 사용.

장점

스택 할당자가 힙 할당보다 우월한 지점은 명확합니다. 시스템 호출 없이 포인터 연산 몇 번으로 할당이 끝나 매우 빠르고, 연속된 버퍼 안에서 메모리가 지역적으로 모여 있어 캐시 적중률이 높으며, 버퍼가 스코프를 벗어나거나 리셋될 때 한꺼번에 정리되어 개별 해제를 신경 쓸 필요가 없고, 애초에 조각난 블록을 재사용하는 구조가 아니라서 단편화 문제 자체가 생기지 않습니다. 아래는 이 네 가지 장점을 코드 주석으로 요약한 것입니다.

// 1. 빠른 할당
// - 스택 메모리
// - 시스템 호출 없음

// 2. 캐시 친화적
// - 지역 메모리

// 3. 자동 해제
// - 스코프 종료 시

// 4. 단편화 없음

자주 발생하는 문제

스택 할당자는 힙 할당의 유연함을 포기하는 대가로 속도를 얻는 도구이기 때문에, 그 트레이드오프를 이해하지 못하면 실무에서 네 가지 함정에 부딪히기 쉽습니다.

문제 1: 크기 제한

StackAllocator<T, N>의 버퍼는 클래스 정의 시점에 N * sizeof(T)만큼의 고정 크기로 확보됩니다. 이 버퍼가 지역 변수(스레드 스택 위)에 놓인다면, StackAllocator<int, 1000000>처럼 크기를 과도하게 키우는 것만으로 4MB에 달하는 공간을 스택에 올리게 되어 실제 스레드 스택 한계를 넘어 프로그램이 크래시할 수 있습니다. 버퍼 크기는 실제로 필요한 최대 사용량을 가늠해 여유 있게, 하지만 과하지 않게 설정하고, 크기가 불확실하다면 앞서 본 것처럼 힙 폴백을 갖춘 할당자를 쓰는 편이 안전합니다.

// ❌ 스택 오버플로우
StackAllocator<int, 1000000> alloc;  // 너무 큼

// ✅ 적절한 크기
StackAllocator<int, 1000> alloc;

// 또는 힙 폴백

문제 2: 수명

스택 할당자의 버퍼가 지역 변수라면, 그 할당자를 사용하는 컨테이너는 절대로 할당자보다 오래 살아남으면 안 됩니다. 아래 getVector()는 함수 내부의 지역 변수 alloc을 사용하는 벡터를 힙에 new로 만들어 반환하는데, 함수가 끝나는 순간 alloc(과 그 버퍼)은 소멸되지만 반환된 벡터는 여전히 그 버퍼를 가리키고 있어 이후 벡터에 접근하면 이미 사라진 메모리를 건드리는 정의되지 않은 동작이 발생합니다. 이런 함정을 피하려면 할당자와 그 버퍼가 컨테이너보다 반드시 오래 살아있도록 스코프를 설계해야 합니다.

// ❌ 스코프 벗어남
std::vector<int, StackAllocator<int, 100>>* getVector() {
    StackAllocator<int, 100> alloc;  // 지역 변수
    return new std::vector<int, StackAllocator<int, 100>>{alloc};
    // alloc 소멸
}

// ✅ 수명 보장

문제 3: LIFO

bump pointer 방식의 할당자는 포인터를 앞으로 밀기만 할 뿐 임의 위치의 메모리를 회수하는 방법을 알지 못하기 때문에, 가장 나중에 할당한 블록부터 순서대로 해제해야만 공간을 제대로 되돌려받을 수 있습니다. 아래 예제처럼 p1을 먼저, p2를 나중에 할당했다면 해제도 p2, p1 순서로 역순 진행해야 하며, 만약 p1을 먼저 해제해버리면 deallocate 내부의 “맨 끝 블록과 일치하는지” 검사가 실패해 공간이 회수되지 않고 버퍼가 낭비됩니다. 할당 순서가 예측 불가능하다면 스택 할당자 대신 자유 리스트를 갖춘 범용 할당자나 메모리 풀을 사용하는 것이 맞습니다.

// 스택 할당자는 LIFO만 효율적
StackAllocator<int, 100> alloc;

int* p1 = alloc.allocate(10);
int* p2 = alloc.allocate(10);

// ✅ 역순 해제
alloc.deallocate(p2, 10);
alloc.deallocate(p1, 10);

// ❌ 순서 바뀜
// alloc.deallocate(p1, 10);  // 비효율

문제 4: 재할당

std::vector는 용량이 부족해지면 내부적으로 더 큰 새 버퍼를 할당하고 기존 원소를 옮긴 뒤 이전 버퍼를 해제하는 재할당(reallocation)을 수행합니다. 스택 할당자를 쓰는 벡터에서 이런 재할당이 일어나면, 새로 요청하는 크기가 고정 버퍼의 남은 용량을 넘는 순간 allocate가 예외를 던지거나(힙 폴백이 없다면) 미리 준비한 힙으로 넘어가게 됩니다. 아래 예제의 vec.reserve(200)처럼 애초에 설계한 버퍼 크기보다 큰 용량을 요구하는 코드는 스택 할당자의 장점(빠른 할당, 힙 회피)을 완전히 상실시키므로, 컨테이너에 담을 최대 크기를 미리 가늠해 버퍼를 설계하는 것이 중요합니다.

// 벡터 재할당 시 문제
std::vector<int, StackAllocator<int, 100>> vec{alloc};

vec.reserve(50);   // 스택 할당
vec.reserve(200);  // 버퍼 부족 -> 예외 또는 힙

활용 패턴

지금까지 다룬 내용을 실무에서 바로 적용할 수 있는 네 가지 패턴으로 정리하면 다음과 같습니다. 함수 안에서만 쓰고 버릴 임시 데이터, 크기가 작다는 것이 확실한 컨테이너, 함수 지역 버퍼와 PMR의 조합, 그리고 할당·해제가 빈번해 힙 오버헤드가 병목이 되는 핫 패스의 성능 최적화입니다.

// 1. 임시 데이터
void process() {
    StackAllocator<int, 1000> alloc;
    std::vector<int, StackAllocator<int, 1000>> temp{alloc};
}

// 2. 작은 컨테이너
StackVector<int, 10> small;

// 3. 함수 로컬
char buffer[4096];
std::pmr::monotonic_buffer_resource mbr{buffer, sizeof(buffer)};

// 4. 성능 최적화
// 빈번한 할당/해제

FAQ

Q1: Stack Allocator?

A: 스택 메모리 할당자.

Q2: 장점?

A:

  • 빠른 할당
  • 캐시 친화적
  • 자동 해제

Q3: 크기?

A: 고정. 스택 제한.

Q4: LIFO?

A: 역순 해제만 효율적.

Q5: 용도?

A: 임시 데이터, 작은 컨테이너.

Q6: 학습 리소스는?

A:

  • “Effective C++”
  • “Game Programming Patterns”
  • cppreference.com

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

관련 글


이 글에서 다루는 키워드 (관련 검색어)

C++, stack, allocator, memory, performance 등으로 검색하시면 이 글이 도움이 됩니다.