C++ 스택 할당자 구현: bump pointer, LIFO 해제, pmr::monotonic_buffer_resource와 비교
이 글의 핵심
고정 버퍼 위에서 포인터만 밀어 올리며 할당하는 스택(bump) 할당자의 구현과 LIFO 해제·용량 제약을 정리합니다. 버퍼를 할당자 안에 넣으면 컨테이너 복사·이동에서 왜 깨지는지, alloca·VLA, 메모리 풀, std::pmr::monotonic_buffer_resource와 비교해 언제 이 방식이 유리한지도 다룹니다.
Stack Allocator란?
일반적인 힙 할당(new/malloc)은 크기별 자유 리스트 탐색, 멀티스레드 환경에서의 동기화나 스레드 캐시 관리, 가끔은 운영체제에 메모리를 더 요청하는 시스템 호출까지 거칩니다. 매번 시스템 호출을 하는 것은 아니지만, 짧게 쓰고 버리는 임시 데이터에 비하면 상대적으로 비용이 큽니다. 스택 할당자는 미리 확보해둔 고정 크기 버퍼 위에서 포인터 하나만 앞으로 밀어가며(bump pointer) 메모리를 내주는 방식으로, 이런 임시 할당을 덧셈과 비교 한 번 수준으로 줄여 주는 커스텀 할당자입니다. 아래 StackAllocator는 char 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) {
// 스택: 역순 해제만 지원
}
};
이 골격은 개념을 보여 주기 위한 것이고, 그대로 표준 컨테이너에 꽂으면 두 가지 문제가 생깁니다. 하나는 컴파일 단계의 문제로, template<typename T, size_t N>처럼 타입이 아닌 템플릿 인자(N) 가 있으면 std::allocator_traits가 rebind를 자동으로 만들지 못합니다. std::list나 std::map처럼 내부 노드 타입으로 할당자를 바꿔 끼우는 컨테이너는 물론, 표준 라이브러리 구현에 따라 std::vector도 rebind를 요구하므로 template<class U> struct rebind { using other = StackAllocator<U, N>; };를 직접 제공해야 합니다. 두 번째는 설계의 문제로, 버퍼가 할당자 객체 안에 들어 있다는 점입니다. 이 부분은 아래 “버퍼를 할당자 안에 두면 생기는 일”에서 자세히 다룹니다.
범위 검사 ptr + n * sizeof(T) > buffer + sizeof(buffer)도 엄밀히는 배열 끝을 넘는 포인터를 만들 수 있어 정의되지 않은 동작의 여지가 있습니다. 실무 코드에서는 n > (sizeof(buffer) - (ptr - buffer)) / sizeof(T)처럼 남은 바이트 수와 비교하는 편이 오버플로와 UB를 모두 피합니다.
스택 기반 할당이란 무엇인가
여기서 말하는 “스택 할당자”는 프로세스 스택 프레임을 직접 늘리는 것이 아니라, 보통 스레드 스택 위에 깔거나 객체 안에 고정 버퍼를 두고 bump pointer로 쌓아 올리는 할당 방식을 가리키는 경우가 많습니다. std::vector<T, MyAlloc>에 붙이면, allocate가 미리 잡아 둔 연속 버퍼 안에서만 동작합니다. 정말 alloca처럼 가변 길이를 스택에 잡아 쓰는 것과는 목적과 제약이 다릅니다.
이름에 “스택”이 붙는 이유는 메모리 위치보다 해제 규칙 때문입니다. 할당은 항상 꼭대기에서 일어나고, 효율적으로 되돌릴 수 있는 것도 꼭대기뿐이므로 자료구조의 스택과 같은 LIFO 규칙을 따릅니다. 게임 엔진에서 “프레임 할당자(frame allocator)“나 “아레나(arena)“라고 부르는 것도 같은 계열로, 한 프레임 동안 쌓기만 하다가 프레임 끝에 포인터를 처음으로 되돌리는 방식입니다.
표준적으로 비슷한 체험을 하려면 std::pmr::monotonic_buffer_resource에 스택 배열이나 thread_local 버퍼를 넘기는 패턴이 널리 사용됩니다(아래 “함수 지역 버퍼와 PMR” 참고).
alloca와 VLA(가변 길이 배열)
| 방식 | C++에서 | 특징 |
|---|---|---|
| alloca | 사실상 비표준 확장(컴파일러별) | 스택에서 런타임 크기만큼 공간 확보. 실수로 큰 크기면 스택 오버플로. 예외 안전·이식성 나쁨. |
| VLA | C99. C++ 표준 아님 | GCC 등 확장으로 C++에서도 보이지만 이식성과 표준 준수 측면에서 비권장. |
현대 C++에서는 고정 상한이 있으면 std::array나 alignas 버퍼 + 커스텀 할당자, 상한을 모르면 std::vector 또는 pmr이 더 안전합니다.
alloca의 진짜 위험은 크기가 외부 입력에 좌우될 때 드러납니다. 요청 크기가 스택의 남은 공간을 넘으면 bad_alloc 같은 알림 없이 가드 페이지를 건드려 세그멘테이션 폴트로 죽거나, 가드 페이지를 건너뛸 만큼 크면 다른 메모리 영역을 덮어쓸 수도 있습니다. 또 alloca로 얻은 메모리는 함수가 반환할 때 해제되므로, 루프 안에서 호출하면 반복할 때마다 스택이 계속 자랍니다. 고정 버퍼 할당자는 최소한 “버퍼가 모자라다”를 코드에서 감지할 기회라도 준다는 점에서 더 낫습니다.
메모리 풀과 비교
| 스택 버퍼 / 모노토닉 할당 | 메모리 풀(오브젝트 풀 등) | |
|---|---|---|
| 할당 속도 | 보통 매우 빠름(bump) | 미리 할당해 두면 빠름 |
| 해제 패턴 | LIFO 또는 영역 단위 리셋에 강함 | 특정 크기 블록 재사용에 강함 |
| 단편화 | 연속 버퍼라 거의 없음 | 풀 설계에 따라 다름 |
| 수명 | 스코프/리셋에 묶임 | 프로그램 수명 동안 유지 가능 |
| 적합한 경우 | 파싱 버퍼, 프레임 단위 임시 컨테이너 | 동일 크기 객체의 빈번한 new/delete |
한 줄 정리: “이번 호출 안에서만 쓰고 버릴 연속 메모리”면 스택 버퍼·모노토닉, “같은 타입 인스턴스를 반복 재사용”이면 풀을 고려합니다.
두 방식의 차이는 개별 해제를 할 수 있느냐에서 갈립니다. 풀은 블록마다 자유 리스트에 돌려놓을 수 있어서 오래 사는 서버 프로세스에서 객체가 무작위 순서로 생성·소멸해도 메모리가 재사용됩니다. 스택 버퍼는 개별 해제를 사실상 포기하는 대신 할당 경로에서 분기와 메타데이터를 없앴습니다. 그래서 수명이 제각각인 객체를 스택 버퍼에 넣으면 앞쪽 객체 하나가 살아 있는 한 그 뒤의 공간을 모두 회수할 수 없어 사용량이 계속 늘어납니다.
기본 사용
StackAllocator는 value_type을 정의하고 있어 표준 컨테이너의 할당자 요구사항 중 최소 조건을 갖추고, std::vector의 두 번째 템플릿 인자로 넘길 수 있습니다(앞서 말한 대로 구현에 따라 rebind 멤버를 추가해야 컴파일됩니다). 아래 예제처럼 std::vector<int, StackAllocator<int, 100>> vec{alloc};으로 벡터를 만들면, push_back이 내부적으로 메모리를 요청할 때마다 힙이 아니라 벡터가 들고 있는 할당자의 고정 버퍼에서 공간을 받아오게 되어, 벡터의 인터페이스는 그대로 유지하면서 할당 방식만 바꿀 수 있습니다.
#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);
}
}
여기서 놓치기 쉬운 점은 vec{alloc}이 alloc을 복사해서 벡터 안에 저장한다는 것입니다. 벡터가 실제로 쓰는 버퍼는 main의 alloc이 아니라 벡터 내부에 복사된 할당자의 버퍼입니다. 또 push_back을 열 번 하는 동안 벡터는 용량을 1, 2, 4, 8, 16처럼 늘려 가며 재할당하는데, 이 할당자는 해제된 옛 블록을 회수하지 않으므로 원소 10개를 넣는 데 버퍼를 원소 31개 분량(1+2+4+8+16)만큼 소비합니다. bump 할당자와 성장하는 컨테이너를 함께 쓸 때는 reserve()로 필요한 크기를 한 번에 잡는 것이 사실상 필수입니다.
버퍼를 할당자 안에 두면 생기는 일
표준 컨테이너는 할당자를 값처럼 다룹니다. 컨테이너를 복사하면 할당자도 select_on_container_copy_construction을 거쳐 복사되고, 이동하면 할당자도 함께 이동하며, 원소 버퍼 포인터는 그대로 새 컨테이너로 넘어갑니다. 버퍼가 할당자 객체 안에 들어 있으면 이 과정에서 문제가 생깁니다.
- 이동:
auto v2 = std::move(vec);을 하면v2는 원소 포인터를 넘겨받지만, 그 포인터가 가리키는 곳은vec안에 있던 할당자의 버퍼입니다.vec가 소멸하면v2는 사라진 메모리를 가리킵니다. - 비교: 할당자 요구사항은
a1 == a2가 참이면 한쪽이 할당한 메모리를 다른 쪽이 해제할 수 있어야 한다고 정합니다. 버퍼를 내장한 할당자는 복사본끼리도 메모리를 공유하지 않으므로operator==를 정직하게 구현하면 항상false이고, 그러면 컨테이너 간swap이나 이동 대입이 제대로 동작할 수 없습니다. - 크기: 할당자 크기가 버퍼 크기만큼 커져서, 할당자를 복사할 때마다 수 KB를 memcpy하게 됩니다.
그래서 실무에서 쓰이는 스택 할당자는 버퍼(arena)를 할당자 바깥에 두고, 할당자는 arena를 가리키는 포인터만 들고 있습니다. Howard Hinnant가 공개한 short_alloc이 이 구조의 대표적인 예입니다.
#include <cstddef>
#include <new>
template <std::size_t N, std::size_t Align = alignof(std::max_align_t)>
class Arena {
alignas(Align) char buf_[N];
char* ptr_ = buf_;
public:
Arena() = default;
Arena(const Arena&) = delete; // arena는 복사·이동 금지
Arena& operator=(const Arena&) = delete;
char* allocate(std::size_t n) {
n = (n + Align - 1) & ~(Align - 1); // 정렬 단위로 올림
if (static_cast<std::size_t>(buf_ + N - ptr_) >= n) {
char* r = ptr_;
ptr_ += n;
return r;
}
return static_cast<char*>(::operator new(n)); // 힙 폴백
}
void deallocate(char* p, std::size_t n) noexcept {
n = (n + Align - 1) & ~(Align - 1);
if (buf_ <= p && p <= buf_ + N) { // 스택 버퍼 안이면
if (p + n == ptr_) ptr_ = p; // 꼭대기일 때만 회수
} else {
::operator delete(p);
}
}
};
template <class T, std::size_t N>
class ShortAlloc {
public:
using value_type = T;
template <class U> struct rebind { using other = ShortAlloc<U, N>; };
explicit ShortAlloc(Arena<N>& a) noexcept : a_(&a) {}
template <class U>
ShortAlloc(const ShortAlloc<U, N>& o) noexcept : a_(o.a_) {}
T* allocate(std::size_t n) {
return reinterpret_cast<T*>(a_->allocate(n * sizeof(T)));
}
void deallocate(T* p, std::size_t n) noexcept {
a_->deallocate(reinterpret_cast<char*>(p), n * sizeof(T));
}
template <class U, std::size_t M> friend class ShortAlloc;
friend bool operator==(const ShortAlloc& x, const ShortAlloc& y) { return x.a_ == y.a_; }
friend bool operator!=(const ShortAlloc& x, const ShortAlloc& y) { return !(x == y); }
private:
Arena<N>* a_;
};
// 사용
// Arena<1024> arena;
// std::vector<int, ShortAlloc<int, 1024>> v{ShortAlloc<int, 1024>(arena)};
할당자를 복사해도 같은 Arena를 가리키므로 operator==가 의미 있게 동작하고, 컨테이너를 이동해도 원소 메모리는 Arena에 그대로 남습니다. 대신 Arena가 모든 컨테이너보다 오래 살아야 한다는 책임이 호출자에게 명시적으로 넘어옵니다. 이 트레이드오프가 싫다면, 표준 라이브러리가 같은 구조를 이미 구현해 둔 std::pmr을 쓰는 것이 가장 간단합니다. memory_resource가 arena 역할을, polymorphic_allocator가 포인터만 든 할당자 역할을 합니다.
실전 예시
앞서 본 기본 골격을 실전에 가깝게 확장한 네 가지 변형을 살펴보겠습니다. LIFO 해제를 지원하는 고정 버퍼, 할당자 없이 자체적으로 요소를 관리하는 스택 벡터, 버퍼가 부족하면 힙으로 넘어가는 임시 할당자, 그리고 함수 지역 버퍼를 표준 라이브러리와 결합하는 방법입니다. 앞의 세 가지는 버퍼를 내장한 구조라 위에서 설명한 복사·이동 문제를 그대로 안고 있으므로, 표준 컨테이너에 연결할 때는 arena 분리 구조로 바꾸어 적용해야 합니다.
고정 버퍼와 LIFO 해제
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;
}
};
reset()은 메모리만 되돌릴 뿐 그 위에 있던 객체의 소멸자를 호출하지 않습니다. int나 float처럼 소멸자가 하는 일이 없는 타입이라면 괜찮지만, std::string처럼 자기 힙 메모리를 가진 객체를 올려 두고 reset()하면 그 힙 메모리가 누수됩니다. 컨테이너가 아직 이 버퍼를 쓰는 중에 reset()을 부르면 다음 할당이 살아 있는 원소 위에 덮어쓰게 되므로, 리셋은 버퍼를 쓰던 모든 객체가 소멸한 뒤에만 해야 합니다.
스택 벡터
할당자를 표준 컨테이너에 끼워 넣는 대신, 아예 고정 버퍼를 내장한 자체 컨테이너를 만드는 방법도 있습니다. StackVector는 std::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);
}
이 스타일은 버퍼와 원소가 한 객체 안에 있으므로 앞의 할당자 복사 문제가 없고, Boost의 static_vector나 C++26에 채택된 std::inplace_vector가 정확히 이 모델입니다. 다만 직접 만들 때는 복사 생성자와 대입 연산자를 반드시 정의하거나 삭제해야 합니다. 위 코드처럼 컴파일러가 만든 복사 생성자를 그대로 두면 바이트 배열이 memcpy처럼 복사되어, std::string 같은 원소는 두 객체가 같은 힙 메모리를 소유하게 되고 소멸 시 이중 해제가 납니다. operator[]에서 쓴 reinterpret_cast도 C++17 이후 엄밀한 규칙으로는 std::launder가 필요한 자리여서, 직접 구현보다는 검증된 라이브러리를 쓰는 편이 안전합니다.
힙 폴백이 있는 임시 할당
고정 버퍼만 쓰는 할당자의 가장 큰 약점은 “버퍼 크기를 넘어서면 그냥 실패한다”는 것입니다. 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);
}
}
};
폴백은 편리하지만 성능 특성을 조용히 바꿉니다. 버퍼 크기를 실제 사용량보다 작게 잡으면 대부분의 할당이 힙으로 가는데도 코드는 정상 동작하므로, “스택 할당자를 붙였는데 빨라지지 않았다”는 결과만 남습니다. 폴백 경로에 카운터나 디버그 로그를 달아 얼마나 자주 넘치는지를 측정해 두는 것이 좋습니다. 서로 다른 배열을 가리키는 포인터를 >=, <로 비교하는 것은 표준상 결과가 지정되지 않은(unspecified) 연산이라, 이식성을 따진다면 std::less<const char*>로 비교하는 편이 안전합니다.
함수 지역 버퍼와 PMR
직접 할당자를 구현하지 않고도, 표준 라이브러리가 제공하는 std::pmr::monotonic_buffer_resource로 같은 효과를 낼 수 있습니다. 아래 processData 함수는 스택 위의 지역 배열 char buffer[4096]을 monotonic_buffer_resource에 넘겨, 그 위에서 동작하는 std::pmr::vector<int>가 함수 안에서만 유효한 빠른 임시 컨테이너로 쓰이도록 합니다. 함수가 끝나면 buffer도 함께 소멸되므로 별도의 해제 코드 없이 자동으로 정리되며, 이 패턴은 직접 할당자 클래스를 작성하는 수고 없이 표준 도구만으로 스택 기반 할당의 이점을 누릴 수 있는 실용적인 방법입니다.
#include <memory_resource>
#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);
}
// 함수 종료 시 자동 해제
}
monotonic_buffer_resource는 버퍼를 다 쓰면 업스트림 리소스(기본값은 new/delete를 쓰는 get_default_resource())에서 더 큰 블록을 받아 계속 동작합니다. 즉 앞의 TempAllocator와 같은 힙 폴백이 내장되어 있습니다. 힙을 절대 쓰면 안 되는 실시간 코드라면 세 번째 인자로 std::pmr::null_memory_resource()를 넘겨, 넘치는 순간 bad_alloc이 나도록 만들어야 합니다. 선언 순서도 중요합니다. tempVec이 mbr보다 나중에 선언되어야 먼저 소멸하므로, 순서를 바꾸면 벡터가 이미 사라진 리소스에 해제를 요청하게 됩니다. 이 예제에서 벡터는 재할당마다 옛 블록을 버리므로 100개를 넣는 동안 약 1KB(1+2+4+…+128개의 int)를 소비하며, 4096바이트 안에 들어갑니다.
제약사항 정리
- 용량 상한: 고정 버퍼를 넘으면
bad_alloc또는 힙 폴백 설계가 필요합니다. - LIFO/역순 해제: 범용
allocate/deallocate를 정확히 지원하려면 할당 순서 제약을 문서화해야 합니다. - 이동·복사:
std::vector와 커스텀 할당자를 쓸 때는 allocator-aware 요구사항과 propagate_on_container_move_assignment 등을 이해해야 합니다. 버퍼를 내장한 할당자는 이 요구사항을 만족시키기 어렵습니다. - 스레드: 스택 버퍼가 스레드 로컬이 아니면 동시 접근 시 데이터 레이스입니다.
monotonic_buffer_resource도 스레드 안전하지 않습니다. - 디버그:
reinterpret_cast와 수동 수명 관리(placement new)는 UB 가능성이 있으므로 코드 리뷰와 테스트가 중요합니다. AddressSanitizer는 커스텀 버퍼 안의 오버플로를 기본적으로 잡지 못하므로, 필요하면ASAN_POISON_MEMORY_REGION으로 미사용 영역을 표시해야 합니다.
실전 활용
- JSON/XML 파싱 등 한 번의
parse()호출 안에서만 필요한vector/string을 PMR 버퍼에 얹기. - 오디오·그래픽스: 프레임 단위 임시 샘플 버퍼(크기 상한 알 때).
- 임베디드·실시간: 힙 사용을 피하기 위해 정적 버퍼 + 모노토닉으로 STL 컨테이너 사용.
장점
스택 할당자가 힙 할당보다 유리한 지점은 명확합니다. 자유 리스트 탐색이나 락 없이 포인터 연산 몇 번으로 할당이 끝나 빠르고, 연속된 버퍼 안에서 메모리가 지역적으로 모여 있어 캐시 적중률이 높으며, 버퍼가 스코프를 벗어나거나 리셋될 때 한꺼번에 정리되어 개별 해제를 신경 쓸 필요가 없고, 조각난 블록을 재사용하는 구조가 아니라서 외부 단편화 문제 자체가 생기지 않습니다.
다만 이 장점이 실제 성능 차이로 이어지는지는 측정해 봐야 압니다. 최신 malloc 구현(glibc의 tcache, jemalloc, mimalloc 등)은 작은 할당을 스레드 로컬 캐시에서 처리해 이미 상당히 빠르기 때문에, 할당 횟수가 적은 코드에 스택 할당자를 붙여도 차이가 거의 없는 경우가 많습니다. 효과가 뚜렷한 것은 짧은 수명의 작은 할당이 핫 루프 안에서 대량으로 반복되는 경우이며, 프로파일러에서 malloc/free가 상위에 보일 때 적용을 검토하는 것이 순서입니다.
자주 발생하는 문제
스택 할당자는 힙 할당의 유연함을 포기하는 대가로 속도를 얻는 도구이기 때문에, 그 트레이드오프를 이해하지 못하면 실무에서 네 가지 함정에 부딪히기 쉽습니다.
버퍼 크기와 스택 한도
StackAllocator<T, N>의 버퍼는 클래스 정의 시점에 N * sizeof(T)만큼의 고정 크기로 확보됩니다. 이 버퍼가 지역 변수(스레드 스택 위)에 놓인다면, StackAllocator<int, 1000000>처럼 크기를 과도하게 키우는 것만으로 4MB에 달하는 공간을 스택에 올리게 됩니다. 기본 스레드 스택은 Windows가 1MB, 리눅스 메인 스레드가 보통 8MB이고 std::thread로 만든 스레드나 스레드 풀 워커는 더 작을 수 있어서, 같은 코드가 메인 스레드에서는 되고 워커 스레드에서는 스택 오버플로로 크래시하는 일이 생깁니다. 버퍼 크기는 실제로 필요한 최대 사용량을 가늠해 여유 있게, 하지만 과하지 않게 설정하고, 큰 버퍼가 필요하다면 static thread_local이나 멤버 변수로 옮기거나 힙 폴백을 갖춘 할당자를 쓰는 편이 안전합니다.
// ❌ 스택 오버플로우
StackAllocator<int, 1000000> alloc; // 너무 큼
// ✅ 적절한 크기
StackAllocator<int, 1000> alloc;
// 또는 힙 폴백
버퍼 수명
버퍼가 지역 변수라면, 그 버퍼를 사용하는 컨테이너는 절대로 버퍼보다 오래 살아남으면 안 됩니다. 아래 getVector()는 함수 안의 지역 할당자로 벡터를 만들어 반환합니다. 이 글의 StackAllocator처럼 버퍼를 내장한 설계라면 벡터가 할당자를 복사해 가지므로 당장은 동작하는 것처럼 보이지만, 이후 그 벡터를 이동하는 순간 앞 절에서 본 dangling이 생깁니다. arena를 바깥에 두는 설계(ShortAlloc, pmr)라면 문제는 더 직접적이어서, 함수가 끝나는 순간 지역 arena가 소멸하고 반환된 벡터는 사라진 메모리를 가리킵니다. 어느 쪽이든 할당자와 그 버퍼가 컨테이너보다 반드시 오래 살아있도록 스코프를 설계해야 하고, 스택 버퍼를 쓰는 컨테이너는 함수 밖으로 내보내지 않는다는 규칙을 두는 편이 단순합니다.
// ❌ 스코프 벗어남
std::vector<int, StackAllocator<int, 100>>* getVector() {
StackAllocator<int, 100> alloc; // 지역 변수
return new std::vector<int, StackAllocator<int, 100>>{alloc};
// alloc 소멸
}
// ✅ 수명 보장
LIFO 해제 순서
bump pointer 방식의 할당자는 포인터를 앞으로 밀기만 할 뿐 임의 위치의 메모리를 회수하는 방법을 알지 못하기 때문에, 가장 나중에 할당한 블록부터 순서대로 해제해야만 공간을 제대로 되돌려받을 수 있습니다. 아래 예제처럼 p1을 먼저, p2를 나중에 할당했다면 해제도 p2, p1 순서로 역순 진행해야 하며, 만약 p1을 먼저 해제해버리면 deallocate 내부의 “맨 끝 블록과 일치하는지” 검사가 실패해 공간이 회수되지 않고 버퍼가 낭비됩니다. 할당 순서가 예측 불가능하다면 스택 할당자 대신 자유 리스트를 갖춘 범용 할당자나 메모리 풀을 사용하는 것이 맞습니다.
// 스택 할당자는 LIFO만 효율적 (LIFO 회수를 구현한 FixedStackAllocator 기준)
FixedStackAllocator<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); // 회수되지 않음
순서가 틀려도 에러가 나지 않는다는 점이 이 함정을 늦게 발견하게 만듭니다. 처음 이런 할당자를 도입하면 흔히 겪는 증상이 “테스트에서는 멀쩡하다가 입력이 커지면 bad_alloc”인데, 원인을 추적해 보면 순서가 어긋난 해제 때문에 회수되지 않은 조각이 누적된 경우가 많습니다. 디버그 빌드에서는 LIFO 위반을 조용히 무시하지 말고 assert로 잡도록 해 두면 원인을 훨씬 빨리 찾습니다.
벡터 재할당
std::vector는 용량이 부족해지면 내부적으로 더 큰 새 버퍼를 할당하고 기존 원소를 옮긴 뒤 이전 버퍼를 해제하는 재할당(reallocation)을 수행합니다. 스택 할당자를 쓰는 벡터에서 이런 재할당이 일어나면, 새 블록을 할당하는 시점에는 옛 블록이 아직 살아 있으므로 옛 블록과 새 블록이 동시에 버퍼 공간을 차지하고, 옛 블록은 새 블록 아래에 깔려 있어 LIFO 회수도 되지 않습니다. 새로 요청하는 크기가 고정 버퍼의 남은 용량을 넘는 순간 allocate가 예외를 던지거나(힙 폴백이 없다면) 힙으로 넘어가게 됩니다. 아래 예제의 vec.reserve(200)처럼 애초에 설계한 버퍼 크기보다 큰 용량을 요구하는 코드는 스택 할당자의 장점(빠른 할당, 힙 회피)을 완전히 상실시키므로, 컨테이너에 담을 최대 크기를 미리 가늠해 첫 reserve에서 한 번에 잡는 것이 중요합니다.
// 벡터 재할당 시 문제
std::vector<int, StackAllocator<int, 100>> vec{alloc};
vec.reserve(50); // 스택 할당
vec.reserve(200); // 버퍼 부족 -> 예외 또는 힙
활용 패턴
지금까지 다룬 내용을 실무에서 바로 적용할 수 있는 네 가지 패턴으로 정리하면 다음과 같습니다. 함수 안에서만 쓰고 버릴 임시 데이터, 크기가 작다는 것이 확실한 컨테이너, 함수 지역 버퍼와 PMR의 조합, 그리고 할당·해제가 빈번해 힙 오버헤드가 병목이 되는 핫 패스의 성능 최적화입니다.
// 1. 임시 데이터 (arena를 바깥에 둔 구조)
void process() {
Arena<4096> arena;
std::vector<int, ShortAlloc<int, 4096>> temp{ShortAlloc<int, 4096>(arena)};
temp.reserve(1000);
}
// 2. 작은 컨테이너
StackVector<int, 10> small;
// 3. 함수 로컬
char buffer[4096];
std::pmr::monotonic_buffer_resource mbr{buffer, sizeof(buffer)};
// 4. 성능 최적화
// 빈번한 할당/해제
새 코드에서 고른다면 순서는 대체로 std::pmr::monotonic_buffer_resource → (원소 수 상한이 작고 명확하면) static_vector/inplace_vector 계열 → 그래도 부족할 때 직접 만든 arena 할당자입니다. 직접 구현은 정렬, rebind, 할당자 비교, 수명 규칙을 전부 스스로 책임져야 하므로, 표준 도구로 안 되는 이유가 분명할 때만 선택하는 것이 좋습니다.
같이 보면 좋은 글
- C++ 메모리 풀 직접 만들기: free list 고정 블록 풀, 정렬, fallback, 게임용 할당자
- C++ PMR(Polymorphic Memory Resource)
- C++ 캐시 최적화 실전 | 캐시 친화적 구조·프리페치·False Sharing·AoS vs SoA 가이드
- C++ Allocator