- Published on
C++20 PMR로 할당자 지옥 끝내는 패턴 5
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/게임/트레이딩/로그 파이프라인처럼 new/delete가 핫패스에 들어오는 C++ 코드베이스를 오래 운영하면, 결국 “할당자 지옥”을 만나게 됩니다. 컨테이너마다 커스텀 allocator 타입이 붙고, 템플릿 인자가 전파되며, 라이브러리 경계에서 allocator 호환이 깨지고, 최종적으로는 성능 튜닝이 아니라 타입 싸움이 됩니다.
C++17부터 들어온 std::pmr(Polymorphic Memory Resource)는 이 문제를 런타임 다형성으로 풀어줍니다. 즉, 컨테이너 타입은 std::pmr::vector처럼 고정하고, 실제 메모리 전략은 std::pmr::memory_resource로 주입합니다. C++20에서도 이 설계는 여전히 가장 현실적인 “대규모 코드베이스 메모리 전략 통일” 수단입니다.
이 글에서는 PMR을 도입할 때 효과가 큰 패턴 5개를 정리합니다. 각 패턴은 “코드베이스 확장성”과 “운영 디버깅”까지 고려했습니다.
참고: 메모리 문제는 결국 장애로 이어집니다. OOM 원인 추적 관점은 Spring Boot OOM 원인추적과 힙덤프 분석 실전처럼 다른 런타임에서도 공통으로 도움이 됩니다.
PMR 핵심 개념 3줄 요약
std::pmr::memory_resource: 할당/해제를 수행하는 추상 인터페이스std::pmr::polymorphic_allocator:memory_resource를 감싸 컨테이너에 주입std::pmr::*컨테이너: allocator 타입이 고정되어 템플릿 인자 폭발을 막음
가장 중요한 포인트는 “수명(lifetime)”입니다. memory_resource는 포인터로 참조되므로, 컨테이너보다 오래 살아 있어야 합니다.
패턴 1) 요청/작업 단위 Arena: monotonic_buffer_resource
가장 먼저 도입하기 좋은 패턴은 “요청 단위 메모리”입니다. HTTP 요청, RPC 호출, 배치 작업, 프레임 처리처럼 작업이 끝나면 관련 객체를 통째로 버려도 되는 경우가 많습니다. 이때 std::pmr::monotonic_buffer_resource는 개별 해제를 거의 하지 않고, 작업 끝에 한 번에 정리하는 방식으로 빠릅니다.
언제 쓰나
- 요청 처리 중 임시 문자열/벡터/파싱 트리
- 프레임 단위 ECS 임시 버퍼
- 쿼리 플래너/파서 같은 “한 번 만들고 버리는” 구조
예제
#include <memory_resource>
#include <string>
#include <vector>
struct RequestContext {
// 내부 버퍼를 먼저 제공하고, 부족하면 업스트림에서 추가 할당
std::array<std::byte, 64 * 1024> local;
std::pmr::monotonic_buffer_resource arena{local.data(), local.size()};
std::pmr::vector<std::pmr::string> tokens{&arena};
};
void handle_request(RequestContext& ctx, std::string_view input) {
// 파싱 중 만들어지는 문자열이 모두 ctx.arena로 간다
ctx.tokens.clear();
ctx.tokens.emplace_back(input.substr(0, 3), &ctx.arena);
ctx.tokens.emplace_back(input.substr(3), &ctx.arena);
}
운영 팁
monotonic_buffer_resource는 기본적으로 개별deallocate가 의미 없습니다. “작업 단위”로만 쓰세요.- 작업이 길어지거나 메모리 사용량이 상한을 넘어설 수 있으면, 상한을 강제하거나(패턴 4), 재사용 전략을 섞어야 합니다.
패턴 2) 타입 전파 차단: API 경계는 pmr 컨테이너로 고정
할당자 지옥의 본질은 “allocator 타입이 템플릿 인자로 전파”되는 것입니다. 예를 들어 std::vector<T, MyAlloc<T>> 같은 타입이 함수 인자/리턴/멤버에 퍼지면, 라이브러리 경계에서 ABI/ODR/빌드 시간이 망가집니다.
PMR 패턴은 경계를 이렇게 고정합니다.
- 외부로 노출하는 타입:
std::pmr::vector,std::pmr::string,std::pmr::unordered_map - 메모리 전략:
std::pmr::memory_resource*하나로 주입
예제: 파서 라이브러리의 깔끔한 인터페이스
#include <memory_resource>
#include <string>
#include <vector>
struct Ast {
std::pmr::vector<std::pmr::string> nodes;
explicit Ast(std::pmr::memory_resource* mr)
: nodes(mr) {}
};
Ast parse(std::string_view input, std::pmr::memory_resource* mr) {
Ast ast{mr};
ast.nodes.emplace_back(input, mr);
return ast;
}
이렇게 하면 호출자는 “어떤 할당자를 쓰는지”를 타입으로 알 필요가 없고, 구현 내부에서만 전략을 바꿀 수 있습니다.
주의점
std::pmr::string을 만들 때도mr를 전달해야 합니다. 실수로 기본 생성하면get_default_resource()로 갑니다.- 라이브러리 경계에서
memory_resource의 수명 규칙을 문서화하세요(호출자가 소유? 라이브러리가 소유?).
패턴 3) 수명 안전장치: memory_resource를 “컨텍스트 객체”로 소유
PMR을 도입할 때 가장 흔한 사고는 “컨테이너가 참조하는 memory_resource가 먼저 파괴”되는 것입니다. std::pmr 컨테이너는 내부에 memory_resource*만 들고 있으므로, dangling이 나면 즉시 UB입니다.
가장 안전한 패턴은 컨텍스트 객체가 리소스를 소유하고, 그 컨텍스트 안에서만 PMR 컨테이너를 쓰게 만드는 것입니다.
예제: SessionContext가 리소스를 소유
#include <memory_resource>
#include <string>
#include <vector>
class SessionContext {
std::pmr::unsynchronized_pool_resource pool_;
public:
std::pmr::memory_resource* mr() { return &pool_; }
std::pmr::vector<std::pmr::string> make_log_buffer() {
return std::pmr::vector<std::pmr::string>{mr()};
}
};
void do_work(SessionContext& s) {
auto logs = s.make_log_buffer();
logs.emplace_back("hello", s.mr());
}
여기서 핵심은 “PMR 컨테이너가 컨텍스트 밖으로 나가지 않게” 설계하는 겁니다. 만약 밖으로 내보내야 한다면, 다음 중 하나로 해결합니다.
- 컨텍스트 수명을 더 길게(예: 세션 전체)
- 반환 타입을
std::pmr가 아닌 소유 복사 타입으로 변환(예:std::string,std::vector) - 혹은 리소스를
shared_ptr로 공유(성능/복잡도 트레이드오프)
패턴 4) 상한 강제 및 관측: monotonic + 커스텀 memory_resource
운영에서 중요한 건 “빠르다”보다 “예측 가능하다”입니다. PMR은 할당 전략을 바꾸기 쉬운 대신, 잘못 쓰면 한 요청이 메모리를 끝없이 먹을 수도 있습니다.
이때는 memory_resource를 래핑해서 다음을 구현합니다.
- 총 할당량 상한(요청당
N바이트) - 태깅/계측(리소스별 사용량 로깅)
- 실패 시 graceful fallback 또는 예외
예제: 상한을 강제하는 리소스 래퍼
memory_resource는 가상 함수로 do_allocate/do_deallocate/do_is_equal을 구현합니다.
#include <memory_resource>
#include <atomic>
#include <cstddef>
#include <new>
class CappedResource final : public std::pmr::memory_resource {
std::pmr::memory_resource* upstream_;
std::size_t cap_;
std::atomic<std::size_t> used_{0};
protected:
void* do_allocate(std::size_t bytes, std::size_t alignment) override {
auto prev = used_.fetch_add(bytes, std::memory_order_relaxed);
if (prev + bytes > cap_) {
used_.fetch_sub(bytes, std::memory_order_relaxed);
throw std::bad_alloc{};
}
return upstream_->allocate(bytes, alignment);
}
void do_deallocate(void* p, std::size_t bytes, std::size_t alignment) override {
upstream_->deallocate(p, bytes, alignment);
used_.fetch_sub(bytes, std::memory_order_relaxed);
}
bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
return this == &other;
}
public:
CappedResource(std::pmr::memory_resource* upstream, std::size_t cap)
: upstream_(upstream), cap_(cap) {}
std::size_t used() const noexcept { return used_.load(std::memory_order_relaxed); }
};
이걸 요청 단위 arena의 업스트림에 끼우면 “요청이 메모리를 얼마까지 쓸 수 있는지”를 강제할 수 있습니다.
#include <array>
#include <memory_resource>
struct RequestContext {
std::array<std::byte, 64 * 1024> local;
std::pmr::monotonic_buffer_resource mono{local.data(), local.size()};
CappedResource capped{&mono, 256 * 1024};
std::pmr::vector<int> v{&capped};
};
실제로는 mono와 capped의 위치를 바꿔 “부족분을 업스트림에서 더 할당”하도록 설계할 수도 있습니다. 어떤 구성이든 핵심은 “관측/상한”을 넣는 것입니다.
운영에서 장애를 줄이는 방식은 메모리뿐 아니라 재시도/백오프에서도 유사합니다. 외부 API 호출이 폭주할 때의 설계는 OpenAI 429/Rate Limit 재시도·백오프 설계도 참고할 만합니다.
패턴 5) 풀 리소스 선택 가이드: pool_resource를 기본값으로 두지 마라
PMR에서 자주 보이는 선택지는 다음 3개입니다.
std::pmr::new_delete_resource(): 결국 전역new/deletestd::pmr::monotonic_buffer_resource: 작업 단위, 빠름, 개별 해제 없음std::pmr::unsynchronized_pool_resource/std::pmr::synchronized_pool_resource: 작은 할당을 풀링
여기서 많은 팀이 “그냥 pool 쓰면 빠르겠지” 하고 기본으로 박아버리는데, 풀은 만능이 아닙니다.
풀을 쓰기 좋은 경우
- 다양한 크기의 작은 객체를 자주 할당/해제
- 객체 수명이 제각각이며,
monotonic처럼 한 방에 정리하기 어려움 - 스레드 로컬 또는 단일 스레드 구간(가능하면
unsynchronized)
풀을 조심해야 하는 경우
- 메모리 상한/회수 정책이 중요할 때(풀은 캐시처럼 남겨둘 수 있음)
- 매우 큰 블록이 섞일 때(풀 효율 저하)
- 스레드 경쟁이 심한데
synchronized_pool_resource를 전역으로 공유할 때
추천 조합(현실적인 기본값)
- 요청/프레임 단위 임시 메모리:
monotonic_buffer_resource - 세션/커넥션 단위 재사용:
unsynchronized_pool_resource를 컨텍스트 소유로 - 전역 공유는 최소화하고, 필요하면 상위에서 리소스를 주입
예제: 스레드 로컬 풀 + 작업 단위 모노토닉
#include <memory_resource>
#include <array>
thread_local std::pmr::unsynchronized_pool_resource tl_pool;
struct FrameContext {
std::array<std::byte, 128 * 1024> buf;
std::pmr::monotonic_buffer_resource mono{buf.data(), buf.size(), &tl_pool};
std::pmr::vector<int> tmp{&mono};
};
이 패턴은 “프레임마다 빠르게 할당하고, 프레임 종료 시 대량 정리”를 하면서도, 버퍼가 부족할 때는 스레드 로컬 풀에서 확장합니다.
실전 체크리스트: PMR 도입 시 실패를 줄이는 규칙
- 경계부터 바꾼다: 라이브러리/모듈 API 타입을
std::pmr::*로 고정 - 수명을 먼저 설계한다:
memory_resource소유자를 컨텍스트로 명확히 - 핫패스는
monotonic우선: “작업 단위 정리”가 가능하면 가장 단순하고 빠름 - 풀은 스코프를 좁힌다: 전역 공유 풀은 경쟁/메모리 잔류 문제를 키움
- 관측 가능하게 만든다: 상한, 사용량 카운터, 태깅을 래퍼로 추가
마무리: PMR은 성능보다 ‘설계 비용’을 줄인다
PMR의 진짜 가치는 벤치마크 수치보다 코드베이스의 형태를 바꾸는 데 있습니다.
- allocator 타입이 템플릿 인자에서 사라져 빌드/타입 복잡도가 줄고
- 메모리 전략을 런타임으로 교체할 수 있어 실험이 쉬워지고
- 컨텍스트 단위 수명/상한/계측을 넣어 운영 안정성이 올라갑니다.
메모리 문제는 결국 “병목과 장애의 원인 추적”으로 이어집니다. 병목을 찾는 관점은 메모리뿐 아니라 렌더링/IO에서도 동일하게 중요하니, 성능 진단 접근이 필요하다면 INP 개선 - React 렌더링 병목 찾는 7가지처럼 다른 영역의 진단 글도 함께 보는 것을 권합니다.
다음 단계로는, 팀 코드베이스에서 “가장 임시 할당이 많은 경로” 하나를 골라 패턴 1과 2를 먼저 적용해 보세요. PMR의 효과는 대개 그 지점에서 가장 빨리 체감됩니다.