C#·C++ 모던

C++ std::pmr로 게임 프레임 아레나 만들기: 한 프레임 메모리를 통째로 잡고 통째로 버리기

kr-gamedev 2026. 6. 21. 09:02

게임 루프는 매 프레임 똑같은 일을 반복한다. 시야에 들어온 엔티티 목록을 모으고, 충돌 후보를 추리고, 경로탐색 노드를 펼치고, UI 레이아웃을 다시 계산한다. 이 임시 데이터의 대부분은 딱 한 프레임만 살고, 다음 프레임이 시작되면 아무 의미가 없다. 그런데도 std::vectorstd::unordered_map을 그냥 쓰면 컨테이너가 내부에서 매 프레임 malloc과 free를 호출한다.

이 반복 할당은 세 가지 비용을 만든다. 첫째, malloc 자체의 호출 오버헤드와 멀티스레드 락 경합. 둘째, 힙이 잘게 쪼개지며 생기는 캐시 미스. 셋째, 해제 시점이 사방에 흩어져 메모리 접근 패턴이 예측 불가능해진다. 60fps를 지키려면 한 프레임 예산이 16.6ms인데, 할당자가 여기서 몇 밀리초를 갉아먹으면 그대로 프레임 드랍이다.

전통적인 해법은 frame allocator 또는 linear allocator를 직접 만드는 것이었다. 프레임 시작에 큰 버퍼를 한 번 잡고, 할당 요청은 포인터를 앞으로 밀기만 하고, 프레임이 끝나면 포인터를 처음으로 되감아 전부 통째로 버린다. 문제는 이 커스텀 할당자를 표준 컨테이너에 붙이기가 번거로웠다는 점이다. C++17의 std::pmr(polymorphic memory resources)이 정확히 이 지점을 표준화했다.

문제부터 눈으로 보기

#include <vector>
#include <unordered_map>

void UpdateFrame() {
    std::vector<EntityId> visible;
    visible.reserve(1024);                 // malloc 1회

    std::unordered_map<EntityId, Hit> hits;  // 버킷마다 추가 malloc
    for (auto e : world) {
        if (IsVisible(e)) visible.push_back(e);
    }
    // ...
}   // 소멸자에서 free, free, free...

엔티티가 늘면 push_back이 재할당을 부르고, 해시맵은 리해시할 때마다 또 할당한다. 이 함수가 프레임마다 불리면 할당과 해제가 수백 번씩 쌓인다.

monotonic_buffer_resource: 표준이 된 선형 할당자

std::pmr::memory_resource는 "메모리를 어디서 어떻게 가져올지"를 추상화한 인터페이스다. 그중 monotonic_buffer_resource는 가장 단순한 구현으로, 주어진 버퍼 안에서 포인터를 앞으로만 밀며 할당하고 deallocate 요청은 무시한다. 진짜 반환은 release()를 호출하거나 객체가 소멸할 때 단 한 번에 일어난다. 이게 바로 linear allocator의 동작이다.

#include <memory_resource>
#include <array>

class FrameArena {
    std::array<std::byte, 1 << 20> buffer_;          // 1MB 멤버 버퍼
    std::pmr::monotonic_buffer_resource resource_{
        buffer_.data(), buffer_.size()
    };
public:
    std::pmr::memory_resource* resource() { return &resource_; }
    void Reset() { resource_.release(); }            // 포인터만 되감기
};

Reset은 소멸자를 하나씩 도는 게 아니라 포인터를 버퍼 시작으로 되돌릴 뿐이다. 그래서 비용이 O(1)이다.

pmr 컨테이너를 그 위에 올리기

void UpdateFrame(FrameArena& arena) {
    std::pmr::vector<EntityId> visible{ arena.resource() };
    visible.reserve(1024);                 // 아레나 안에서 포인터 bump, malloc 0

    std::pmr::unordered_map<EntityId, Hit> hits{ arena.resource() };
    for (auto e : world) {
        if (IsVisible(e)) visible.push_back(e);
    }
    // ... 프레임 작업 ...
}   // 컨테이너 소멸자는 반환을 시도하지만 monotonic에선 no-op

std::pmr::vectorstd::vectorstd::pmr::polymorphic_allocator를 끼운 별칭일 뿐이다. 생성자에 memory_resource* 하나만 넘기면 그 컨테이너의 모든 내부 할당이 아레나에서 일어난다. reservepush_back이든 리해시든 전부 포인터를 앞으로 미는 것으로 끝난다.

프레임 루프에 녹이기

FrameArena g_frameArena;        // 1MB, 프로그램 시작에 딱 한 번 확보

while (running) {
    PollInput();
    UpdateFrame(g_frameArena);
    Render();
    g_frameArena.Reset();       // 프레임 끝: 통째로 되감기, O(1)
}

컨테이너를 수천 개 만들었든, 경로탐색 노드를 수만 개 펼쳤든 상관없다. Reset 한 번이면 다음 프레임은 깨끗한 버퍼에서 다시 시작한다.

발을 헛디디기 쉬운 곳

  • monotonic은 중간 해제를 절대 안 한다. 프레임 끝까지 사는 단명 데이터 전용이다. 여러 프레임을 살아야 하는 객체를 여기 넣으면 Reset 전까지 메모리가 계속 쌓인다.
  • 버퍼를 초과하면 조용히 힙으로 넘어간다. 기본 upstream이 new_delete_resource라 의도치 않은 malloc이 슬쩍 발생한다. 이걸 잡으려면 upstream을 막아 초과 시 바로 던지게 해두고 버퍼 크기를 실측으로 튜닝한다.
std::pmr::monotonic_buffer_resource resource_{
    buffer_.data(), buffer_.size(),
    std::pmr::null_memory_resource()   // 초과 시 조용한 malloc 대신 bad_alloc
};
  • std::pmr::vector<T>std::vector<T>는 다른 타입이다. API 경계를 넘을 때는 복사나 명시적 변환이 필요하다.
  • monotonic_buffer_resource는 thread-safe가 아니다. 워커 스레드마다 아레나를 따로 두거나, 풀링이 필요하면 synchronized_pool_resource를 쓴다.

정리

std::pmr은 게임 엔진이 십수 년간 직접 짜온 frame allocator를 표준 도구로 바꿔준다. monotonic_buffer_resource로 프레임 버퍼를 한 번 잡고, std::pmr 컨테이너로 그 위에서 작업하고, 프레임 끝에 release로 통째로 되감으면 끝이다. 매 프레임 수백 번 일어나던 malloc이 0으로 떨어지고 해제는 O(1)이 된다. 직접 만든 할당자와 달리 std::vector, std::unordered_map, std::string을 그대로 올릴 수 있다는 게 가장 큰 실전 이점이다. 다만 monotonic의 "중간에 해제 안 함" 성질만 잊지 말자. 아레나에는 단명 데이터만 올린다는 규칙 하나만 지키면 된다.