· 18 min read
.NET 게임 서버 GC, 워크로드별로 측정해서 고르기 .NET Game-Server GC: Picking Settings per Workload, Measured
2코어 ARM 박스에서 .NET Server GC 설정을 워크로드별로 측정한 기록. 뭘 켜고, 어떻게 쟀고, 뭐가 나았는지. A record of measuring .NET Server GC settings per workload on a 2-core ARM box: what to turn on, how it was measured, and what came out ahead.
splanet.io의 알파 서버는 행성 하나를 2코어 ARM 박스 하나에서 돌립니다. 그 위에서 여러 .NET 컨테이너가 전부 Server GC로 함께 도는데, 어느 날 게이트웨이 컨테이너가 969 MiB / 1 GiB(자기 한도의 95%)에 상주하고 있었습니다.
.NET 서버를 만들어본 사람의 반사신경은 정해져 있습니다. GC 플래그를 켠다. gcServer, GCConserveMemory, GCHeapCount, DATAS 끄기. 문제는 어떤 걸, 왜, 그리고 켰다는 걸 어떻게 증명하는가입니다. “켰더니 나아진 것 같다”는 감이지 판단이 아닙니다.
이 글은 그 감을 데이터로 바꾼 기록입니다. 2코어 Azure VM에서 GC 조합을 하나씩 갈아끼우고, 처리량으로 정규화해 재고, 각 조합이 실제로 적용됐는지 로그로 증명하고, GC가 언제 무엇을 압축하는지까지 계측해서, 워크로드별로 뭘 켜야 하는지에 답합니다.
한 서버가 아니라 두 워크로드다
튜닝 이전에 통찰 하나가 있습니다. 게임 서버는 GC 관점에서 한 덩어리가 아닙니다. splanet.io의 서버는 2코어 박스 하나 위에서 여러 .NET 컨테이너가 함께 도는 구조입니다.
- 게이트웨이: 이벤트를 연결마다 복제해 뿌리고, I/O 바운드, 접속이 계속 붙었다 떨어지고, 플레이어를 마주 보니 지연에 민감합니다. 메모리 한도가 빠듯합니다(1 GiB).
- 셀 런타임: 픽셀 단위 시뮬레이션, 연산 위주에 할당량이 많고, 고정된 틱 주기가 처리량을 규제합니다. 메모리 한도는 여유롭습니다(2 GiB).
이 둘은 GC 요구가 다르니, 하나에 맞춘 세팅이 다른 하나엔 손해일 수 있습니다. 그래서 “서버의 GC 설정”이라는 단일 답을 찾는 대신 워크로드를 파악하고 유형별로 답을 낸다는 게 출발점입니다.
측정 규율 — 어디서, 무엇을, 어떻게, 그리고 증명
조합을 비교하려면 실험이 재현 가능하고, 재는 지표가 정확하고, 켰다는 게 증명돼야 합니다. 게임 서버 GC에서 이건 생각보다 까다롭습니다.
어디서: 진짜 코어 수의 박스에서. Server GC는 힙을 코어 수에 맞춰 만들고, 커밋 크기와 백그라운드 GC 스레드 경합이 거기에 종속됩니다. 다만 .NET 9부터 기본 on인 DATAS(동적 적응)는 힙 수를 고정하지 않고 부하에 따라 조정합니다. 이 서버의 실측 힙 수는 2개(=코어 수)였습니다. 로컬 16코어 노트북에서 재면 힙 수가 달라져 전혀 다른 그림을 그립니다. 그래서 실험은 플레이어가 붙지 않은 실제 2코어 Azure VM에서 했습니다.
어떻게: 환경변수로 갈아끼운다, 리빌드 없이. DOTNET_GCConserveMemory(0–9), DOTNET_GCDynamicAdaptationMode(DATAS on/off), DOTNET_gcServer, DOTNET_gcConcurrent, DOTNET_GCHeapCount는 전부 프로세스 시작 시 런타임 설정을 덮어씁니다. 조합 하나 = 컨테이너 환경변수 한 줄 바꾸고 재시작, 이미지 빌드는 0입니다.
증명: 켰다는 걸 로그로. 여기가 감과 판단을 가릅니다. 각 컨테이너가 시작 시 실제 GC 설정을 찍게 했습니다:
GC config: IsServerGC=True LatencyMode=Interactive ProcessorCount=2 Framework=.NET 10.0.9
GC config var: HeapCount=2 LOHThreshold=85000 GCHeapHardLimit=805306368 GCConserveMem=9 GCDynamicAdaptationMode=1
GC env: DOTNET_GCConserveMemory=9이 한 줄이 “설정이 실제로 먹었나”를 매 조합마다 확인해 줍니다. 겸사겸사 사실도 몇 개 확정됩니다: LOH 임계값은 정확히 85,000 bytes, 힙 하드리밋은 768 MiB(컨테이너 한도의 ~75%), 힙 수 2, DATAS on.
무엇을, 어떻게 정규화하나. 부하는 고정 봇으로 걸고, 절대 수치 대신 처리량을 분모로 비교합니다(게이트웨이 events/s, 셀 ticks/s). 그리고 메모리는 한 숫자가 아니라 여러 층으로 봅니다:
- live — GC가 살아있다고 본 객체 크기.
- committed — GC가 관리 힙으로 OS에 커밋한 메모리(다음 할당용 여유 공간 포함). 최근 GC 시점의 게이지.
- RSS — 프로세스가 물리 RAM에 실제 올려둔 전체(
mem_limit에 부딪혀 OOM-kill 나는 기준). - 단편화 — committed 안에서 객체 사이에 낀 빈틈.
이 층들은 단순한 등식(committed − live = 단편화)으로 엮이지 않습니다. committed엔 단편화뿐 아니라 다음 할당용 budget도 들어가고, RSS는 committed와 다른 축(네이티브·미반환 페이지 포함)입니다. 그래서 조합 판정은 uptime에 덜 오염되는 committed 게이지를 기준으로 삼되, RSS는 따로 봅니다.
그리고 pause는 분포로 잽니다. 내장 카운터는 GC pause의 누적 합만 줘서 평균밖에 못 냅니다. 지연 민감 서비스에서 평균은 거짓말을 할 수 있습니다 — 드문 긴 pause 하나가 문제니까요. 그래서 in-process로 매 GC의 실제 pause 시간·압축 여부·gen별 단편화를 히스토그램으로 모아 p95/p99까지 봤습니다(외부 프로파일러 없이, 프로파일러가 서버를 굶긴 전례가 있어서).
결과 — 조합별로 뭐가 이겼나
기본값(clean = Server + DATAS)에서 한 변수씩 바꾼 조합을 전부 새로 띄운 상태로, 게이트웨이는 clean↔ConserveMemory=9를 3번 번갈아 재고(baseline이 얼마나 흔들리는지 보려고), 나머지는 한 번씩 쟀습니다.
게이트웨이 (I/O 바운드, ~294 events/s)
| 조합 | 설정 | committed | RSS avg | pause p99 | 처리량 |
|---|---|---|---|---|---|
| clean | Server + DATAS | 387 | 522 | 30–100 ms | 294 |
| CM5 | ConserveMemory=5 | 399 | 545 | 50 ms | 286 |
| CM9 | ConserveMemory=9 | 294 | 447 | 50–75 ms | 297 |
| DATAS off | GCDynamicAdaptationMode=0 | 569 | 693 | 30 ms | 288 |
| Heap=1 | GCHeapCount=1 | 507 | 645 | 30 ms | 299 |
| Workstation+conc | gcServer=0 | 430 | 542 | 250 ms | 290 |
| Workstation−conc | gcServer=0, no bg | 454 | 567 | 250 ms | 297 |
ConserveMemory=9가 승자입니다. committed −24%(387→294), RSS −14%, 처리량 무변화. clean 기준선은 3번 재서 363/402/397(평균 387, ±5%)이라, −24%는 그 흔들림 밖의 실제 이득입니다.
눈에 띄는 것 둘. CM5는 baseline과 사실상 같습니다(399 vs 387) — DATAS가 내부적으로 ConserveMemory 5를 기본 budget으로 쓰기 때문에, 5로 올려봐야 새로울 게 없고 9라야 공격적 회수가 켜집니다. 그리고 DATAS를 끄거나(569) 힙을 1개로 강제하면(507) 오히려 크게 나빠집니다. Workstation GC는 메모리 이득이 없는데 pause 꼬리가 250 ms로 터집니다(background GC를 끄면 평균 pause도 31 ms로 최악).
셀 런타임 (연산 위주, 120 ticks/s 고정)
틱 주기가 처리량을 완전히 고정하니, 여기선 메모리/GC만 변수입니다.
| 조합 | 설정 | committed | RSS avg | pause p99 |
|---|---|---|---|---|
| clean | Server + DATAS | 339 | 458 | 100–150 ms |
| CM9 | ConserveMemory=9 | 298 | 424 | 100–250 ms |
| DATAS off | GCDynamicAdaptationMode=0 | 658 | 720 | 50 ms |
| Heap=1 | GCHeapCount=1 | 512 | 613 | 75 ms |
| Workstation+conc | gcServer=0 | 352 | 469 | 75 ms |
| Workstation−conc | gcServer=0, no bg | 366 | 459 | 250 ms |
셀도 CM9가 committed를 −12%(339→298) 낮춥니다. 부정 결과는 게이트웨이와 똑같습니다 — DATAS off는 여기서 +94%로 최악, Heap=1도 +51%. 다만 셀은 pause 꼬리가 원래 크고(연산 GC), CM9가 그걸 더 벌릴 여지가 있어(p99 250 ms 관측) 지연 민감도가 높다면 확인할 지점입니다.
왜 듣나 — 압축을 실제로 관측하다
여기가 개인적으로 가장 만족스러운 부분입니다. “ConserveMemory가 단편화를 압축으로 회수한다”는 흔한 설명인데, 보통은 그냥 믿고 넘어갑니다. 여기선 매 GC의 압축 여부와 압축 전후 gen2 단편화를 직접 계측했습니다.
각 조합에서 마지막 압축 gen2 GC의 단편화 before→after:
| 조합 | 압축 gen2 GC 단편화 before → after |
|---|---|
| clean | 0 → 0 KB (gen2 압축이 일어나지 않음) |
| CM5 | 0.3 → 0.5 KB (사실상 압축 없음) |
| CM9 | 16,425 → 95 KB · 13,997 → 218 KB · 15,020 → 282 KB |
| CM9 (75분 soak) | 16,208 → 294 KB |
읽히는 그대로입니다. ConserveMemory=9만 gen2를 압축하고, 압축할 때마다 ~15 MB의 단편화를 0.2 MB 수준으로 회수합니다. clean과 CM5는 gen2를 아예 압축하지 않습니다(0→0). 그래서 CM9의 committed가 낮은 겁니다 — 추측이 아니라, 그 압축 이벤트를 직접 셌습니다.
이게 왜 중요하냐면, 같은 committed 감소를 “더 자주 GC 도느라 힙이 작게 유지된 것”으로 오해할 수도 있는데, gen2 단편화가 15 MB→0.2 MB로 접히는 걸 보면 회수의 본체가 압축임이 분명해집니다.
재사용 규칙 — 다른 서비스에 이식
| 규칙 | 근거 |
|---|---|
| DATAS는 켜 둔다 | 끄면 committed 급증(게이트웨이 +47%, 셀 +94%). .NET 9+ 기본 on이 이 2코어 박스에서 옳다. |
단일 힙 강제(GCHeapCount=1) 금지 | 양쪽 다 악화(+31%, +51%). Server GC의 코어당 힙을 그대로 둔다. |
| background GC 끄지 마라 | Workstation−conc는 양쪽 다 pause 꼬리 최악(p99 250 ms, 평균 27–31 ms). |
타이트 한도 · pause 여유 있음 → ConserveMemory=9 | committed −24%(게이트웨이)/−12%(셀), 처리량 무변화. 압축을 켜는 값이라 9라야 듣는다(5는 baseline). |
판정 기준(이식할 때): 안정 상태의 committed / mem_limit 비율을 보고, 높으면(타이트) ConserveMemory가 회수할 여지가 있습니다. 단 ConserveMemory는 GC 압축 빈도를 올리는 값이라, pause 여유가 있을 때만(우리 서버는 %time-in-GC ≪ 1%) 안전합니다. 셀처럼 pause 꼬리가 이미 큰 워크로드라면 지연 민감도를 먼저 확인하세요. 그리고 미측정 컨테이너에 승자를 그대로 쓰지 말고 초기 후보로 적용한 뒤 같은 지표로 재검증하는 게 원칙에 맞습니다.
969 MiB는 결국 뭐였나
처음의 969 MiB로 돌아가 봅니다. 강제 힙 덤프를 떴을 때 살아있는 객체 합계는 217 MB뿐이었습니다 — 나머지와 RSS 사이엔 큰 간극이 있었죠. 이때 흔한 오해가 둘입니다. 하나는 “217 MB만 사니 GC로 잡을 게 없다”(→ 실제론 CM9가 100 MB 넘게 회수). 또 하나는 “나머지는 전부 관리 힙 밖”(→ 그 간극엔 GC의 단편화·빈 공간·커밋 여유도 들어갑니다). 어느 층을 보느냐가 결론을 가릅니다.
그리고 흔한 단정 하나를 바로잡습니다. LOH(85,000 bytes 초과 객체가 모이는 힙)는 기본적으로 압축은 안 하지만, 빈 공간을 free list로 재사용하고 인접한 틈을 병합합니다. “큰 객체가 영구 구멍을 남긴다”는 건 과장이고, 정확히는 크기가 안 맞는 할당이 쌓이면 힙이 커질 수 있다는 정도입니다. ConserveMemory=9는 바로 그 gen2/LOH를 압축해 그 여지를 줄입니다.
committed를 낮춘 게 반짝 효과가 아니라 지속되는지도 봐야 합니다. ConserveMemory=9로 75분 soak를 돌리니 RSS는 466 MiB 평균·493 MiB 최대로 바운드됐고 committed도 284로 평탄했습니다 — 회수가 시간이 지나도 유지됩니다. 그리고 이 서버는 그동안 OOM-kill이 한 번도 없었습니다. 969 MiB는 즉각적 위기가 아니라 헤드룸/최적화 이슈였습니다.
정리
GC 설정은 외워 쓰는 레시피가 아니라 워크로드에서 유도하고 데이터로 확인하는 판단입니다.
- 유형부터, 그리고 진짜 코어 수의 박스에서. Server+DATAS의 힙 거동은 코어 수에 종속된다.
- committed로 판정하고, 새로 띄운 것끼리, 처리량으로 정규화해 재고, 반복해서 흔들림을 보라.
- 켰다는 걸 증명하라. 시작 시 설정을 로그로 찍으면 “먹었나”를 매번 확인할 수 있다.
- 평균이 아니라 분포를 보라. pause는 p99가, 회수는 압축 이벤트가 진짜 이야기를 한다.
- 이 서버의 답: 타이트한 게이트웨이·셀 둘 다
ConserveMemory=9가 committed를 −24%/−12% 회수(압축으로), 처리량 무변화. DATAS off·단일 힙·Workstation은 전부 손해. 근본 개선은 여전히 큰 객체 할당 자체를 줄이는 코드 쪽이다.
이 측정 방법과 규칙표는 splanet.io 밑의 자체 프레임워크 OFF에 들어갔습니다 — pause 분포·압축·gen별 단편화·시작 시 config를 모든 노드가 자동으로 노출하도록. “어떤 GC를 켤까”를 감이 아니라 데이터로 답하는 게 그 프레임워크가 푸는 문제의 일부입니다.

