WONMOCK LEE / GRAPHICS ENGINEER
프로젝트 03 · 독립 저장소

Memory Pool & GC

Unreal Engine의 Garbage Collector를 참고한 Mark-and-Sweep 방식이고, Cycle이 중간에 멈췄다가 이어서 재개될 수 있도록 시간 예산을 두었음.

01 / INTRO

핵심 내용

왜 이 방식: GC가 한 프레임 안에 통째로 끝나면 Frame-time Spike로 이어짐 — Mark/Sweep은 그대로 두고, 객체 단위 편차가 가장 큰 Purge 단계만 시간 예산을 둬서 여러 프레임에 나눠 실행하도록 설계.
상태 하나로 도는 전체 Cycle
eIdle → eMarking → eSweeping → ePurging → eIdle, eStatus 하나가 전부 결정. 각 단계가 빨리 끝나면 Collect() 한 번 호출로 여러 단계를 연속으로 통과할 수 있음.
Purge 단계에만 걸린 시간 예산
Mark와 Sweep은 항상 한 번에 끝까지 돎. 죽은 객체를 파괴하고 슬롯을 반환하는 Purge()만 객체 단위로 시간을 체크 — 부족하면 중간에 멈췄다가 다음 Collect()에서 그 지점부터 이어감.
의도적으로 뺀 Compaction
Accessor(객체 메타데이터)가 별도 Handle Table이 아니라 각 Instance 자신의 헤더 바로 앞에 있음 — 객체를 옮기면 그 객체를 가리키는 모든 Pointer를 찾아 다시 써야 함. 대신 버킷 점유율 기반 할당 정책으로 단편화를 관리.

특징

시간 예산 안에서 멈췄다 재개하는 Cycle
Purge가 객체 단위로 시간을 체크 — 큰 GC Cycle이 한 프레임에 몰려 Spike를 만드는 걸 구조적으로 막음.
Compaction 없는 단편화 관리문서화된 설계 근거
Accessor가 인라인이라 Pointer 하나로 메타데이터에 접근하는 대신 이동이 불가능 — 그 트레이드오프를 버킷 점유율 기반 할당 정책(eSparse~eFull)으로 보완한다는 설계 근거가 문서화되어 있음.
Thread 안전한 Root set
AddRoot/RemoveRoot는 Mutex로 보호되어 있음 — 최소한 "이 객체가 GC 대상에서 제외됐는지"는 여러 Thread에서 안전하게 확인 가능.

주요 내용

root set까지만인 Thread 안전성
Accessor의 마킹 상태와 각 Pool의 List는 Mutex 보호 밖(이슈 #16) — Collector Thread와 mutator Thread가 경합하면 Crash로 이어질 수 있음.
조건부로 스킵되던 종료 시점 정리수정됨 · #17
PoolBudget::~PoolBudget()이 버킷 하나만 비어 있어도 Release() 전체를 건너뛰어서, 대부분의 종료 상황에서 살아있는 Pool과 arena가 그냥 새어나갔음 → empty 플래그 초기값이 반대로 되어 있던 것을 바로잡음.
write barrier 부재
새 객체가 "흰색"으로 태어나는데 이미 마킹을 마친 부모 Graph에 연결됐을 때 강제로 살아있다고 표시할 장치가 없음 — 이론상 생성된 바로 그 Cycle에 수거될 수 있는 경로가 존재함.
02 / MODULES

상세 내용 & 다이어그램

클래스 다이어그램 — Collector, Storage, Smart Pointer
Memory 클래스 다이어그램
시퀀스 다이어그램 — Collect() 사이클 한 번
Collect 사이클 시퀀스 다이어그램

ObjectPtr vs. RootPtr

Root set의 내용물"Pointer를 가리키는 Pointer"
RootPtr의 생성자는 AddRoot(this)를 호출 — 관리 대상 객체의 주소가 아니라 RootPtr Wrapper 자신의 주소를 root set에 넣음. Mark()는 이 Wrapper를 거쳐 실제 Instance에 도달함.
비활성 Handle인 ObjectPtr
레이아웃은 같지만 생성자/소멸자 어디서도 Collector와 대화하지 않는 평범한 Handle임. RootPtr로 감싸야만 GC root가 됨.

사용 예시

#include "Memory.h"
using namespace Memory;

class MyClass
{
    GENERATE(MyClass); // Reflection 시스템과 함께 쓰면 GC 순회 대상이 됨
public:
    int value = 42;
};

int main()
{
    Init(1024 * 1024, 1000000); // Pool 크기 1MB, Sweep 시간 예산 1ms

    ObjectPtr<MyClass> obj  = MakePtr<MyClass>();      // 비활성 Handle — GC root 아님
    RootPtr<MyClass>   root = MakePtr<MyClass>();      // 생성자가 AddRoot(this) 호출 -> GC root
    RootPtr<int[]>     arr  = MakeArray<int>(5);

    obj = ObjectPtr<MyClass>();  // 참조 해제
    Collect();                    // obj가 가리키던 메모리는 회수 대상, root/arr는 보호됨
    Release();
}
03 / HISTORY

개발 히스토리

커밋 기록을 불러오는 중…

  1. 1기ECS용 Memory Pool → MemoryManager2023.05 — 2023.10
    태그 v1.0 → v4.x
  2. 2023.05.20
    MemoryECS용 MemoryPool 첫 버전
    엔진에서 겪은 Component Pointer 무효화 문제를 풀려고 만든 고정 크기 풀 — 같은 날 Scene · Node 기반 ECS용으로 맞춤(v1.0.0).
  3. 2023.05.26
    MemoryMemoryManager · OnlyMemoryPool · Demo로 프로젝트 분리 (v2.x)
  4. 2023.07.03
    MemoryMemoryPtr 도입 (v4.0)
    "생성 · 소멸 권한은 MemoryManager가 쥐어야 한다"는 이유로 weak_ptr 비슷한 Pointer를 만듦 — 커밋 메시지에 남긴 이유 그대로.
  5. 2023.10.02
    MemoryPool 전체 크기를 객체 크기의 배수로 맞춰 남는 공간 제거
  6. 2기Accessor · Collector 구조의 GC로 재작성2025.11 — 현재
    2년 넘는 공백 뒤 재시작
  7. 2025.11.30
    Memory구조를 갈아엎은 재시작
    옛 MemoryManager/, OnlyMemoryPool/, MemoryPtr/, ExternalProject_Add 기반 빌드를 전부 걷어내고 지금의 Accessor/Collector/Pointer/Storage 구조와 git submodule 기반 빌드로 다시 짬 — 그 사이 Reflection · Container를 만들며 익힌 설계를 그대로 가져옴.
  8. 2025.12.12
    MemoryContainer 라이브러리로 일원화GC 실동작 확인부터
    Memory 안에 따로 있던 List · Arena를 지우고 Container를 submodule로 연결. 데모에 GCExample()을 넣어 DynamicArray/HashMap에 담긴 ObjectPtr까지 Collect()가 실제로 훑고 회수하는지 확인.
  9. 2025.12.15
    Memory순환 참조 마킹 무한 루프직접 겪고 고친 문제
    커밋 메시지 그대로 — 서로 연결된 값들을 GC에 등록하면 순환이 생겨 마킹 로직이 끝나지 않음. 스택으로 그래프를 도는 GarbageCollector.cpp 루프에 IsMarked() 가드를 넣어 이미 마킹된 노드를 다시 쌓지 않게 고침.
  10. 2026.02.22
    MemoryRefPtr 설계 전환침투적 → 비침투적
    관리 대상이 RefCounted를 상속해야만 쓸 수 있던 구조가 너무 불편해서, 대상 타입을 감싸는 RefData<T>를 두는 std::shared_ptr 비슷한 방식으로 바꿈. 같은 커밋에서 ObjectPtr 기본 생성자가 Pointer를 nullptr로 초기화하지 않아 나던 런타임 에러도 잡음.
  11. 2기코드 리뷰 → 수정2026.08
  12. 2026.08.06
    Memory리뷰로 찾은 버그 4건 수정 + GC Prepare 단계 분리
    #17 PoolBudget 소멸자, #18 Purge 시간 예산, #19 미초기화 메모리 접근, #20 RefPtr 메모리 순서를 한 커밋에서 고치고, Sweep 전에 마킹 상태를 초기화하는 Prepare 단계를 따로 뗌 — Unreal의 reset-before-mark 방식 (PR #21).
04 / DESIGN

Compaction을 하지 않은 이유

Compaction을 배제한 이유: Accessor는 별도의 안정된 Handle Table이 아니라 각 Instance 헤더에 인라인으로 존재함. Compaction 중 객체를 옮기면 그 Accessor도 함께 옮겨지므로, 이미 그 Instance를 가리키고 있던 모든 ObjectPtr/RootPtr 을 찾아서 다시 써야 함. 참조자를 추적하는 목록이 따로 없다면, 이건 사실상 GC를 한 번 더 도는 것과 같은 비용임.
대신 할당 시점에서 단편화를 관리함: 각 PoolBudget은 버킷을 점유율로 분류하고(eSparse / eModerate / eDense / eFull), 새 할당은 가장 꽉 찬 비어있지 않은 버킷부터 찾음 — 어느 한 버킷이 eFull까지 차서 새 백킹 할당을 강제하는 상황을 줄이고, sparse 버킷은 완전히 비워져 회수될 여지를 남겨둠.
Sequence 다이어그램 — PoolBudget의 버킷 탐색과 밀도 재분류
PoolBudget 버킷 탐색 Sequence 다이어그램

GetPool()은 Dense부터 Sparse 순으로 역순 탐색해서 첫 번째로 여유 있는 버킷을 바로 씀 — eFull 버킷은 애초에 탐색 대상에서 빠짐. Update()는 Pool마다 GetUsedRatio()를 다시 재서 밀도가 바뀌었으면 List::Splice로 해당 버킷으로 옮김(포인터/Iterator 재계산 없이 노드만 다른 리스트로 이동).

05 / ISSUES

이슈

- 원인: AddRoot/RemoveRoot는 Mutex로 보호되지만, Accessor의 마킹 상태와 각 Pool의 used/free/dead List는 그렇지 않음 — mutator Thread가 ObjectPtr을 통해 Accessor 상태를 읽는 동안 Collector Thread가 같은 Accessor를 건드리면 경합이 발생할 수 있음.
- 개선 방향: 기존 root set Mutex 범위를 Accessor 상태와 pool List 변경까지 확장하거나, 상태 필드를 atomic으로 바꾸는 방향.
06 / BUGS

버그

실제 버그 8건 — 4건 수정 완료 클릭해서 펼치기
#17 · src/Storage/PoolBudget.cpp:52-72 PoolBudget 소멸자의 Release() 생략버킷 하나만 비어도 건너뛰어 대부분 누수 해결
- eSparse..eFull 네 버킷을 순회하다 하나라도 비면 empty = true; break;로 즉시 탈출하고, Release()는 모든 버킷이 비어있지 않을 때만 호출됨. 종료 시점에 버킷 하나(특히 eSparse)가 비어있는 게 흔한 상황이라, 살아있는 Pool<T>와 그 arena가 대부분의 경우 그냥 새어나감.
- 수정(2026.08.06): empty 플래그의 초기값이 반대로 되어 있던 것을 바로잡아, 버킷이 남아 있으면 Release()가 불리도록 고침.
include/Storage/Pool.h:161-166, 203-205 대량 소멸 시 O(n²)인 Sweepfree-list가 아닌 DynamicArray 성능
- m_usedList가 Intrusive List가 아니라 평범한 DynamicArray<uint8_t*>라서, Sweep()이 죽은 객체마다 부르는 Erase(itr)가 매번 뒤 원소를 전부 당기는 O(n) 연산임. 한 Cycle에 여럿이 동시에 죽으면 비용이 누적됨.
#19 · include/Storage/Array.h — Clear() / Sweep() 초기화 안 된 메모리의 IAccessor 취급m_used 체크 누락 해결
- Acquire()는 사용하기 전에 m_used를 확인한 뒤 Accessor<T>를 placement-new함. 그런데 Clear()/Sweep()은 이 체크 없이 m_memory를 곧바로 static_cast<IAccessor*>해서 가상 함수(Destruct(), GetStatus())를 호출함.
- 영향: Init()만 되고 Acquire()는 한 번도 안 된 Array<T>(즉 vtable이 아직 안 만들어진 날 것의 메모리)에 대해 Clear()/Sweep()이 불리면, 초기화되지 않은 메모리를 살아있는 다형성 객체처럼 다루는 정의되지 않은 동작임.
- 수정(2026.08.06): m_status(eUsed)를 확인한 뒤에만 Accessor로 다루도록 Sweep · Clear · Purge · Acquire를 고침.
include/Accessor/Accessor.h:19, src/Collector/GarbageCollector.cpp:82-126 "흰색"으로 태어나는 새 객체write barrier 부재 설계 노트
- Accessor의 기본 상태는 eUnreachable임. Mark는 Collect() 한 번 안에서 동기적으로 끝까지 도는데, 이미 마킹을 마친 부모 Graph에 그 이후 연결된 객체를 강제로 살아있다고 표시할 장치(write barrier)가 없음 — 생성된 바로 그 Cycle에 수거될 수 있는 경로가 이론상 존재함(#16의 Thread 경합과는 별개 문제).
src/Storage/ArrayBudget.cpp:45-52,130-138 배열을 재사용하지 않는 ArrayBudget매번 새로 생성 성능
- GetArray()는 free 목록을 확인하는 Pool<T>과 달리 항상 CreateArray()로 새 Instance를 만듦. Array<T> 자체도 bool m_used 하나로 지키는 슬롯 1개짜리 구조라, 배열 Storage는 커지기만 하고 Remove()가 빈 항목을 목록에서 걷어내는 것에만 의존함.
#20 · include/Pointer/RefPtr.h:22-33 RefPtr 참조 카운트의 acquire Fence 부재 해결
- AddRef/Release 모두 memory_order_release로만 증감하고, 마지막 Release()가 delete this를 호출하기 전에 acquire Fence가 없음. 약한 메모리 모델 하드웨어에서는 다른 Thread가 막판에 쓴 내용이 소멸자 실행 시점에 보이지 않을 수 있음.
- 수정(2026.08.06): GetRefCount()를 acquire로 바꿔, AddRef(relaxed) · Release(acq_rel)와 함께 delete 전 acquire 순서를 보장.
src/TimeLimit.cpp:32-55, GarbageCollector.cpp:141 10번에 한 번만 체크하는 Purge 시간 예산 설계 노트
- TimeLimit::HasTime()은 m_interval번마다 한 번씩만 시계를 샘플링하는데, Purge()는 SetInterval(10)으로 설정해서, 최대 9번의 destruction만큼 예산을 초과할 수 있음.
#18 · src/Storage/StorageManager.cpp:65-96 Purge()의 조기 반환Pool 버킷 하나의 시간 초과로 배열 버킷 · Clear() 전부 생략 해결
- m_poolMap을 순회하다 어떤 버킷의 Purge(timeLimit)이 false를 반환하면 그 자리에서 return false — 이후 배열 버킷 루프와 Clear()가 통째로 스킵됨. 게다가 배열 버킷 루프는 timeLimit을 아예 체크하지 않아서(ArrayBudget에는 시간 제한을 받는 Purge Overload 자체가 없음), 도달하면 항상 전체를 끝까지 처리함 — 시간 예산 시스템이 배열 쪽에는 사실상 적용되지 않음.
- 수정(2026.08.06): 배열 버킷에도 Pool 버킷과 같은 시간 예산을 적용.

GitHub에 지금 열려 있는 이슈 (실시간)

불러오는 중…