4. C# 가비지 컬렉션: 세대별 GC, LOH, GC 압박 줄이기
난이도 상내 답안
모범답안
모범답안 — C# GC: 세대별 GC, LOH, GC 압박 줄이기
난이도: 상
핵심 답변
- .NET GC는 세대별(generational) + 마크-스윕-컴팩트. 세대를 나누는 근거는 "대부분의 객체는 금방 죽는다(weak generational hypothesis)"는 경험칙. 짧게 사는 객체만 자주, 싸게 수집하려는 것.
- Gen 0: 갓 할당된 객체. 가장 자주, 가장 싸게 수집. 살아남으면 Gen 1로 승격.
- Gen 1: Gen 0 생존자. Gen 0과 Gen 2 사이의 완충.
- Gen 2: 오래 산 객체. 수집이 드물지만 전체 힙을 훑어 가장 비싸고, full GC는 stop-the-world 멈춤이 길다.
- LOH(Large Object Heap): 85,000바이트(약 85KB) 이상의 객체가 가는 별도 힙. 논리적으로 Gen 2로 취급되어 자주 수집되지 않고, 기본적으로 압축(compaction)을 안 해 외부 단편화가 쌓이기 쉽다.
- 박싱(boxing): 값 타입(struct/int 등)을
object로 다룰 때 힙에 래퍼 객체를 만들어 복사하는 것. 매번 힙 할당 → Gen 0 쓰레기 양산 → GC 압박.
깊이 있는 설명 (메커니즘, 왜)
- 왜 세대별인가: 모든 객체를 매번 추적하면 비싸다. 새 객체(Gen 0)만 자주 수집하면 적은 영역만 마킹/이동해 빠르다. 살아남은 것만 위 세대로 올려 추적 빈도를 낮춘다. Gen 0/1 수집은 보통 빠르지만, Gen 2(=full) GC는 LOH 포함 전체를 보므로 멈춤이 길다.
- 할당률(allocation rate)이 핵심: Gen 0가 가득 차면 GC가 트리거된다. 즉 매 틱 쓰레기를 많이 만들수록 GC가 자주 돌고, 그 중 일부 객체가 승격되면 Gen 2가 차올라 결국 비싼 full GC가 터진다. → 전투 구간 프리즈의 정체.
- LOH의 함정: 큰 배열/버퍼를 매번 새로 만들면 LOH에 쌓이고, LOH는 Gen 2급으로만 수집 + 비압축이라 단편화·full GC 비용을 키운다.
- 박싱 발생 지점:
object에 값 타입 대입, 제네릭 아닌ArrayList/Hashtable,string.Format/보간에 값 타입 인자, 값 타입을 인터페이스로 캐스팅,Dictionary의 키/값이 값 타입인데 비제네릭 비교 경로를 타는 경우 등. - 서버 GC:
<ServerGarbageCollection>true는 코어별 힙/GC 스레드를 두어 처리량을 높인다(멈춤을 더 잘 분산). Background GC는 Gen 2 수집을 백그라운드로 돌려 멈춤을 줄인다.
응용/실무 연결 (게임서버에서)
시나리오 개선책:
- 매 틱 임시 객체/리스트 → 풀링하거나 재사용. 리스트는
Clear()후 재활용, 임시 컨테이너는 멤버로 보유. 작은 임시값은struct로. - string 연결 →
StringBuilder재사용, 또는 패킷은 문자열이 아니라 바이트 버퍼/Span<byte>로 직접 직렬화. 핫패스 로깅은 구조화·지연 평가로. - object 컬렉션/값 타입 박싱 → 제네릭 컬렉션(
List<T>,Dictionary<TKey,TValue>)으로 박싱 제거. 값 타입 키엔IEquatable<T>구현으로 박싱 비교 회피. - LINQ 람다 남발 → 핫패스에선 LINQ 대신 명시적
for. 람다가 외부 변수를 캡처하면 클로저 객체가 힙 할당되고 이터레이터도 할당을 만든다. - 큰 배열 매번 할당(LOH) →
ArrayPool<T>로 대여/반납, 또는 고정 버퍼 재사용. 직렬화/네트워크 버퍼에 특히 효과적.
종합 전략:
- Server GC + Background GC 활성화(처리량/멈춤 분산).
- 할당률을 0에 가깝게: 핫 루프에서 "zero-allocation"을 목표.
struct,Span<T>/stackalloc,ArrayPool, 오브젝트 풀(문제 3 연계). - 승격 줄이기: 짧게 살 객체가 GC 사이를 넘겨 살아남아 Gen 1/2로 승격되지 않게, 수명을 명확히/짧게.
- 모니터링: GC 횟수·Gen 2 빈도·할당률·% time in GC를 상시 계측해 회귀 감지.
(C++ 대안) RAII로 스코프 종료 시 결정적 해제(스택 객체/스마트 포인터), 핫패스는 커스텀 풀/아레나 allocator로 할당 비용 제거. GC 자체가 없어 stop-the-world는 없지만, 수명/소유권 관리와 단편화는 직접 책임진다.
흔한 오답·함정
- LOH 임계를 모르거나 틀리게 말하기 — 정답 85,000바이트(≈85KB).
- "GC가 알아서 하니 신경 안 써도 된다" — 실시간 서버에선 멈춤이 곧 지연 스파이크.
- Gen 0 수집과 full GC 비용을 동일하게 취급. Gen 2/full GC가 훨씬 비싸다.
- struct를 무조건 답이라 외치다 큰 struct 복사 비용·박싱 함정을 놓침.
using/Dispose(비관리 자원 해제)와 GC(관리 메모리 수집)를 혼동.
꼬리질문 대비
- Q. struct를 쓰면 GC가 아예 없나요? A. 지역/임시 struct는 스택·인라인이라 GC 대상이 아니지만, 박싱되거나 클래스 필드/배열로 힙에 들어가면 그 컨테이너가 GC 대상이 된다.
- Q. Span
와 stackalloc은 왜 GC에 좋은가요? A. 힙 할당 없이 기존 메모리(스택/배열/네이티브)에 대한 뷰를 제공해 임시 버퍼 할당을 없앤다. 단, ref struct라 비동기 경계·필드 저장 제약이 있다. - Q. Workstation GC와 Server GC 중 게임 서버는? A. 보통 멀티코어 처리량을 위해 Server GC + Background GC. 다만 메모리 사용이 늘 수 있어 측정 후 결정.
- Q. 객체 풀이 GC와 어떤 관계인가요? A. 객체를 재사용해 할당률을 낮추면 Gen 0 수집·승격이 줄어 full GC 빈도까지 떨어진다. 단 풀에 오래 살아있는 객체는 Gen 2로 승격되어 카드 테이블 관리 비용이 생길 수 있다.