23. GC 일시정지(pause)의 내부 메커니즘과 저지연 튜닝
난이도 중해설 — GC 일시정지의 내부 메커니즘과 저지연 튜닝
난이도: 중상
1. stop-the-world 와 safepoint
GC 가 도달 가능성(reachability)을 계산하려면 루트(스택·레지스터·정적 필드)에서 시작해 객체 그래프를 훑는다. 이 도중 뮤테이터(앱 스레드)가 참조를 바꾸면 그래프가 흔들려 살아있는 객체를 놓칠 수 있다. 또 compaction(단편화 제거를 위해 객체를 옮기고 포인터를 갱신)하는 동안 뮤테이터가 옛 주소를 읽으면 손상된다. 그래서 최소한 특정 구간은 stop-the-world(STW) 로 뮤테이터를 멈춘다.
safepoint: 뮤테이터를 아무 명령에서나 멈출 수 없다(레지스터/스택 상태가 GC 가 해석 가능한 형태여야 함). 런타임은 메서드 호출·루프 백엣지 등 특정 지점에 safepoint 폴을 심어, GC 요청 시 모든 스레드가 가까운 safepoint 까지 진행한 뒤 멈추게 한다. safepoint 도달이 늦은 스레드(긴 네이티브 호출·타이트 루프)가 있으면 STW 진입 자체가 지연된다.
2. 동시/증분 GC 의 핵심 장치
STW 를 짧게 하려면 마킹의 대부분을 뮤테이터와 동시에 수행해야 한다. 그러려면 마킹 도중 뮤테이터가 만드는 참조 변경을 GC 가 알아야 한다.
- tri-color(삼색) 마킹: 객체를 흰(미방문)·회(방문했으나 자식 미처리)·검(자식까지 처리)으로 분류. 불변식은 "검은 객체가 흰 객체를 직접 가리키지 않는다". 이 불변식이 깨지면 살아있는 흰 객체를 회수해버린다.
- write barrier: 뮤테이터가 참조 필드에 쓰기를 할 때 실행되는 짧은 훅. 동시 마킹 중 "검은 객체가 흰 객체를 가리키게 되는" 위반을 잡아, 대상 객체를 회색으로 다시 칠하거나(SATB/incremental-update 방식) 기록해 삼색 불변식을 유지한다. 모든 참조 쓰기에 붙으므로 아주 가벼워야 한다.
- card table: 힙을 카드(예: 512B~4KB) 단위로 나눠, 어떤 카드에 쓰기가 있었는지 비트/바이트로 표시. 세대 수집 시 "옛 세대 전체를 스캔" 대신 더럽혀진 카드만 스캔해 세대 간 참조(gen2→gen0)를 찾는다. write barrier 가 이 카드를 더럽힌다.
- 세대 간 참조 추적: gen0 수집 시 gen0 밖에서 gen0 안을 가리키는 참조를 루트로 취급해야 한다. card table + remembered set 이 이를 값싸게 제공해, gen0 수집이 전체 힙을 훑지 않게 한다.
3. 세대별 GC 와 일시정지 규모
- 약한 세대 가설: 대부분의 객체는 금방 죽는다. gen0 은 작고 생존율이 낮아, gen0 수집은 살아남은 소수만 gen1 로 승격시키고 나머지를 값싸게 회수 → 짧다.
- gen2(full)·LOH: gen2 는 오래 산 큰 그래프라 스캔량·이동량이 많아 길다. LOH(Large Object Heap, .NET 에서 ~85KB↑)는 기본적으로 compaction 하지 않아 단편화가 쌓이고, 수집 시 full GC 급 비용이 든다.
- 일시정지 = 스캔량 + 이동/포인터 갱신량에 비례. 할당률이 높으면 GC 빈도↑, 승격이 많으면 gen2 압박↑ → full GC 유발.
- .NET 모드:
- Workstation vs Server GC: Server GC 는 코어별 힙·전용 GC 스레드로 처리량이 높고 STW 를 병렬화해 짧게 하지만, 메모리·스레드를 더 쓴다. 서버 워크로드 기본 선택.
- Background/Concurrent GC: gen2 마킹의 대부분을 뮤테이터와 동시에 수행해 긴 STW 를 잘게 쪼갠다. gen0/1 은 여전히 짧은 STW.
4. 저지연 실무 튜닝
핵심은 "덜 할당하고, 오래 살리지 말고, 큰 것을 피하라":
- 할당률 억제: 틱 루프의 임시 객체를 없앤다(문자열 연결·LINQ·박싱·클로저 캡처 주의). 프로파일러로 gen0 할당 핫스팟을 잡는다.
- 오브젝트 풀·재사용: 빈번한 객체(패킷 버퍼·엔티티 뷰)를 풀링해 할당/수집 자체를 제거. 단 풀은 객체를 오래 살려 gen2 로 밀 수 있어 신중히.
struct·Span<T>·stackalloc: 값 타입·스택 할당으로 힙 할당을 회피.ArrayPool<T>로 대형 버퍼 재사용.- LOH 회피: 큰 배열을 반복 할당하지 말고 풀링. 필요하면 LOH compaction 을 드물게 명시 수행.
- 워밍/사전 할당: 서버 기동 시 힙·풀을 미리 데워 런타임 중 대형 수집을 줄인다.
GCSettings.LatencyMode(SustainedLowLatency)·GC.TryStartNoGCRegion: 짧은 임계 구간(매치 시작·보스전 페이즈)에 gen2 수집을 미룬다. 단 예산 초과 시 더 큰 수집으로 되돌아오므로 만능 아님.- Server GC + 힙 수 튜닝: 코어에 맞춰 병렬 수집. Background GC 활성.
- 트레이드오프: 풀링·수동 관리는 코드 복잡도·누수 위험↑, 저지연 모드는 메모리 사용↑. 과도한 최적화는 유지보수성을 해친다 — 측정 기반으로.
"GC 언어는 실시간 서버에 부적합한가?" — 아니다. 수많은 상용 게임 서버가 .NET/JVM 으로 운영된다. 관리 런타임의 생산성·안전성 이점이 크고, 위 기법으로 일시정지를 수 ms 이하·예측 가능 범위로 관리할 수 있다. 관건은 "GC 를 없애기"가 아니라 할당 프로파일을 통제해 GC 가 짧고 드물게 돌게 만드는 것이다. 하드 실시간(마감 위반이 곧 실패)이 요구되는 극단적 경우에만 네이티브(C++)·무할당 설계가 정당화된다.
면접 포인트
- STW 가 필요한 이유(그래프 안정성·compaction)와 safepoint 의 역할.
- write barrier·card table·삼색 불변식이 동시 마킹을 어떻게 가능케 하는가.
- 세대별 수집의 비대칭 비용, Server/Background GC, 그리고 할당률 통제 중심의 저지연 튜닝.