← 문제로

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 수집을 백그라운드로 돌려 멈춤을 줄인다.

응용/실무 연결 (게임서버에서)

시나리오 개선책:

  1. 매 틱 임시 객체/리스트 → 풀링하거나 재사용. 리스트는 Clear() 후 재활용, 임시 컨테이너는 멤버로 보유. 작은 임시값은 struct로.
  2. string 연결StringBuilder 재사용, 또는 패킷은 문자열이 아니라 바이트 버퍼/Span<byte>로 직접 직렬화. 핫패스 로깅은 구조화·지연 평가로.
  3. object 컬렉션/값 타입 박싱 → 제네릭 컬렉션(List<T>, Dictionary<TKey,TValue>)으로 박싱 제거. 값 타입 키엔 IEquatable<T> 구현으로 박싱 비교 회피.
  4. LINQ 람다 남발 → 핫패스에선 LINQ 대신 명시적 for. 람다가 외부 변수를 캡처하면 클로저 객체가 힙 할당되고 이터레이터도 할당을 만든다.
  5. 큰 배열 매번 할당(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로 승격되어 카드 테이블 관리 비용이 생길 수 있다.