콘텐츠로 이동

.NET GC & 메모리 관리 심화

.NET GC는 자동으로 메모리를 관리하지만 잘못된 코드는 GC 압박(GC pressure)과 Stop-the-World 정지를 유발합니다. GC 동작 원리를 이해하면 불필요한 할당을 줄이고 지연 시간을 낮출 수 있습니다.


Generation 0 (Gen0) — 단기 객체 (최근 할당)
Generation 1 (Gen1) — Gen0 생존자 (중간)
Generation 2 (Gen2) — 장기 객체 (전체 수집)
Large Object Heap (LOH) — 85,000 바이트 이상 객체
  • Gen0 수집: 매우 빠름 (수 ms), 자주 발생
  • Gen2 수집: 느림 (수십 ms~초), Stop-the-World
  • LOH: 기본적으로 압축 없음 → 단편화 유발

using System;
using System.Runtime;
// GC 수집 횟수
Console.WriteLine($"Gen0: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2: {GC.CollectionCount(2)}");
// 총 할당 메모리
Console.WriteLine($"총 할당: {GC.GetTotalAllocatedBytes(precise: false) / 1024 / 1024} MB");
// 힙 정보
var info = GC.GetGCMemoryInfo();
Console.WriteLine($"힙 크기: {info.HeapSizeBytes / 1024 / 1024} MB");
Console.WriteLine($"단편화: {info.FragmentedBytes / 1024} KB");

using System.Buffers;
// 나쁜 예: 매 요청마다 배열 할당
byte[] ProcessRequest(Stream stream)
{
byte[] buffer = new byte[4096]; // GC 압박
stream.Read(buffer, 0, buffer.Length);
return buffer;
}
// 좋은 예: ArrayPool 재사용
byte[] ProcessRequest(Stream stream)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try
{
stream.Read(buffer, 0, buffer.Length);
return buffer.ToArray(); // 복사 후 풀에 반납
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
}
}

using System;
// 나쁜 예: 매번 새 문자열 할당
string[] parts = input.Split(','); // 힙 할당
// 좋은 예: Span으로 Zero-allocation 파싱
void ParseCsv(ReadOnlySpan<char> line)
{
while (!line.IsEmpty)
{
int comma = line.IndexOf(',');
ReadOnlySpan<char> field = comma < 0 ? line : line[..comma];
ProcessField(field); // 할당 없음
line = comma < 0 ? default : line[(comma + 1)..];
}
}

using Microsoft.Extensions.ObjectPool;
// 무거운 객체 풀링
public class ExpensiveObject
{
public byte[] Buffer { get; } = new byte[64 * 1024];
public void Reset() { /* 상태 초기화 */ }
}
public class ExpensiveObjectPolicy : IPooledObjectPolicy<ExpensiveObject>
{
public ExpensiveObject Create() => new ExpensiveObject();
public bool Return(ExpensiveObject obj)
{
obj.Reset();
return true;
}
}
// DI 등록
services.AddSingleton<ObjectPool<ExpensiveObject>>(sp =>
{
var provider = new DefaultObjectPoolProvider();
return provider.Create(new ExpensiveObjectPolicy());
});
// 사용
var obj = pool.Get();
try { /* 작업 */ }
finally { pool.Return(obj); }

// LOH 압축 활성화 (단발성 수집 전)
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
// 85KB 이상 배열은 ArrayPool 또는 NativeMemory 사용
using System.Runtime.InteropServices;
nint ptr = NativeMemory.Alloc(1024 * 1024); // LOH 우회
try { /* 사용 */ }
finally { NativeMemory.Free(ptr); }

7. GC 서버 모드 vs 워크스테이션 모드

섹션 제목: “7. GC 서버 모드 vs 워크스테이션 모드”
runtimeconfig.json
{
"configProperties": {
"System.GC.Server": true, // 서버 모드 (멀티코어 최적화)
"System.GC.Concurrent": true, // 백그라운드 GC
"System.GC.HeapHardLimit": 536870912 // 최대 힙 512MB
}
}
항목워크스테이션서버
GC 스레드1개코어당 1개
처리량낮음높음
지연 시간낮음높을 수 있음
적합 대상데스크톱 앱ASP.NET Core, 서비스

Terminal window
# dotnet-counters로 실시간 GC 모니터링
dotnet-counters monitor --process-id <PID> System.Runtime
# dotnet-trace로 GC 이벤트 수집
dotnet-trace collect --process-id <PID> --providers Microsoft-Windows-DotNETRuntime
# PerfView / dotnet-dump로 힙 스냅샷 분석

.NET GC 최적화의 핵심은 Gen2 수집과 LOH 할당을 줄이는 것입니다. 루프 내 단기 배열은 ArrayPool로, 문자열 파싱은 Span<T>으로, 무거운 객체는 ObjectPool로 교체하면 GC 압박이 크게 줄어듭니다. GC.CollectionCount(2) 증가 속도가 빠르면 메모리 프로파일러로 원인을 찾으세요.