.NET에서 GC 대기와 메모리 누수를 구별하는 법 ── 늘어나는 메모리를 관측·비교·증명하는 실무 절차

· · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, 운영, 기존자산활용

1. 먼저 알아둘 것

.NET 애플리케이션을 운영하다 보면, 메모리 사용량이 조금씩 증가하는 경우가 있습니다.

작업 관리자나 top을 보면 프로세스의 메모리가 증가하고 있다. 컨테이너의 메모리 사용량도 증가하고 있다. 모니터링에서 Working Set이나 RSS 그래프가 우상향하고 있다.

이런 상태를 보면 바로 「메모리 누수 아닐까」라고 생각하기 쉽습니다. 하지만 .NET에서는 프로세스의 메모리가 증가하는 것과 메모리 누수가 발생하는 것이 같지 않습니다.

.NET에는 가비지 컬렉션이 있습니다. 오브젝트가 불필요해진 순간 바로 OS에 메모리가 반환되는 것은 아닙니다. GC는 할당 상황, 힙의 임계값, 메모리 압박, 세대, 워크로드 상황을 보면서 동작합니다.

그래서 다음과 같은 상태가 발생합니다.

  • 불필요해진 오브젝트가 아직 GC되지 않았다
  • GC는 끝났지만 프로세스의 Working Set이 바로 내려가지 않는다
  • 첫 접근, JIT, 캐시, 커넥션 풀에서 한 번만 증가하고 그 후 안정된다
  • 매니지드 힙은 안정적이지만 네이티브 메모리나 스레드, 소켓, 이미지 처리 라이브러리 쪽에서 증가하고 있다
  • 정말로 불필요해졌어야 할 오브젝트가 어딘가에서 계속 참조되고 있다

이 글에서는 마지막의 「정말로 누수하고 있다」를 어떻게 구별하는지를 다룹니다. 봐야 할 것은 단순한 메모리 사용량이 아니라 다음 3가지입니다.

  1. GC 후에도 남는 메모리가 증가하고 있는가
  2. 증가하는 타입은 무엇인가
  3. 그 오브젝트를 누가 참조하고 있는가

.NET의 메모리 누수 조사는 「메모리가 증가하고 있습니다」로 끝내지 않고, 「이 타입의 오브젝트가 증가하고 있고, 이 루트에서 계속 참조되고 있습니다」까지 가져가는 작업입니다.

또한 이 글에 등장하는 코드는, 빌드·실행할 수 있는 샘플 세트(전형적인 누수 패턴 라이브러리, GC 대기와 생존의 차이를 관측하는 데모, WeakReference로 유지·회수를 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.

dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)

2. 먼저 「메모리 누수」의 의미를 맞춘다

.NET에서 말하는 메모리 누수는 C나 C++처럼 「확보한 메모리를 해제하는 것을 잊어버렸다」는 형태만이 아닙니다.

매니지드 코드에서는 GC가 오브젝트를 회수합니다. GC가 회수할 수 있는지 여부는 「그 오브젝트에 도달할 수 있는 참조가 아직 남아 있는가」로 결정됩니다.

즉, .NET의 전형적인 메모리 누수는 다음과 같습니다.

업무상으로는 더 이상 필요 없는데도, static 필드, 캐시, 이벤트, Timer, 컬렉션, DI의 라이프타임, 비동기 컨텍스트 등에서 계속 참조되고 있어서, GC 입장에서는 아직 사용 중인 것처럼 보이는 상태.

GC는 똑똑하지만, 업무상 불필요한지는 알지 못합니다. 참조되고 있다면 살아 있다고 판단합니다.

그래서 .NET에서는 「누수」라기보다 「의도하지 않은 유지」로 생각하면 이해하기 쉬워집니다.

한편, 다음 상태는 곧바로 메모리 누수라고는 할 수 없습니다.

상태 누수라고 단정할 수 없는 이유
Working Set / RSS가 증가하고 있다 OS가 프로세스에 할당한 메모리이며, 매니지드 힙의 생존 오브젝트량과는 일치하지 않는다
Total Allocated가 증가하고 있다 기동 후 할당한 누적량이므로, 앱이 동작하면 기본적으로 증가한다
순간적으로 GC Heap이 증가한다 다음 GC까지 미회수된 오브젝트가 남아 있을 뿐일 수도 있다
기동 직후에 증가한다 JIT, 타입 로드, 초기 캐시, 커넥션 풀, 템플릿 전개 등에서 흔히 발생한다
LOH가 크다 큰 배열이나 버퍼의 재사용, 단편화, 풀 전략의 영향일 수도 있다
메모리가 내려가지 않는다 GC가 회수해도 프로세스가 OS에 바로 메모리를 반환한다고는 할 수 없다

반대로, 다음 상태가 갖춰질수록 메모리 누수의 의심은 강해집니다.

관측 결과 의미
같은 작업을 반복할 때마다 GC 후의 힙이 증가한다 살아남는 오브젝트가 증가하고 있다
Gen 2나 LOH의 크기가 계속 증가한다 오래 사는 오브젝트, 또는 큰 오브젝트가 남아 있다
여러 번의 덤프에서 같은 타입의 Count / Size가 증가한다 증가하고 있는 타입을 특정할 수 있다
gcroot로 static, 이벤트, 캐시, 장수 서비스로부터의 참조가 보인다 GC가 회수할 수 없는 이유를 설명할 수 있다
부하를 멈춰도 충분한 시간, 또는 검증용 GC 후에도 돌아오지 않는다 단순한 일시적 할당이 아닐 가능성이 높다

3. 「무엇의 메모리」를 보고 있는지 구분한다

메모리 조사에서 처음에 혼란스러운 것은 여러 메모리 지표가 섞이는 것입니다. 같은 「메모리」라도 의미가 다릅니다.

지표 보는 대상 읽는 방법
Working Set / RSS 물리 메모리에 올라와 있는 프로세스 페이지 OS 관점의 메모리. GC 힙 그 자체는 아니다
Private Bytes / Commit 프로세스가 사유하고 있는 커밋된 메모리 네이티브 메모리, 스택, JIT 코드, GC 세그먼트 등도 포함
GC Heap Size 매니지드 힙 상의 오브젝트량 .NET의 GC 대상 메모리를 보는 입구
Total Allocated 기동 후 할당한 누적량 기본적으로 증가한다. 누수 판정에는 단독으로 쓰지 않는다
Gen 0 / Gen 1 / Gen 2 세대별 힙 Gen 2에 남는 것은 오래 살고 있다
LOH 85,000바이트 이상의 큰 오브젝트가 들어가는 힙 큰 배열, 문자열, 버퍼로 증가하기 쉽다
POH pin된 오브젝트를 위한 힙 네이티브 연계나 고정화의 영향을 보는 힌트가 된다
Finalization Queue 파이널라이즈 대기 중인 오브젝트 Dispose 누락, 파이널라이저 정체의 힌트가 된다

처음부터 모든 것을 세밀하게 볼 필요는 없습니다. 먼저 다음 질문으로 분해합니다.

프로세스의 메모리가 증가하고 있다
  ↓
매니지드 힙도 증가하고 있는가?
  ↓
GC 후에도 살아남는 양이 증가하고 있는가?
  ↓
어느 타입이 증가하고 있는가?
  ↓
누가 참조하고 있는가?

이 순서를 지키면 「겉보기의 메모리 증가」와 「진짜 누수」를 혼동하기 어려워집니다.

4. 판단 흐름

실무에서는 다음 흐름으로 가려내면 진행하기 쉽습니다.

1. 재현 조건을 정한다
   - 어느 API, 화면, 잡, 배치에서 증가하는가
   - 몇 번 실행하면 증가하는가
   - 부하를 멈추면 어떻게 되는가

2. dotnet-counters로 추이를 본다
   - Working Set
   - GC Heap
   - Gen 2 / LOH
   - Total Allocated
   - GC 횟수

3. 시간차로 비교한다
   - 기동 직후
   - 워밍업 후
   - 부하 중
   - 부하 정지 후
   - 같은 작업을 N회 반복한 후

4. 덤프를 2회 이상 뜬다
   - before
   - after
   - 가능하면 부하 정지 후도 뜬다

5. 증가하고 있는 타입을 찾는다
   - dumpheap -stat
   - gcdump report
   - Visual Studio / PerfView

6. 참조원을 확인한다
   - gcroot
   - gchandles
   - finalizequeue

7. 판정한다
   - GC 대기
   - 정상적인 캐시 증가
   - 매니지드 메모리 누수
   - 네이티브 메모리 문제
   - LOH 단편화 또는 일시적인 대용량 할당

중요한 것은 1회의 수치로 판단하지 않는 것입니다. 메모리 누수는 「계속 증가하는 경향」이므로, 한 시점의 값이 아니라 같은 조건에서 시간차로 비교합니다.

5. 사용하는 도구

이 글에서는 주로 다음 도구를 사용합니다.

도구 사용처
dotnet-counters 실행 중인 프로세스의 GC나 Working Set 추이를 본다
dotnet-gcdump 살아 있는 매니지드 오브젝트의 통계를 가볍게 뜬다
dotnet-dump 힙을 자세히 보고, dumpheap이나 gcroot로 참조원까지 추적한다
Visual Studio Memory Usage Windows에서 GUI로 비교하고 싶을 때 사용한다
PerfView Windows에서 GC / heap / trace를 깊이 보고 싶을 때 사용한다
dotnet-trace 할당이나 GC 이벤트를 시계열로 추적하고 싶을 때 사용한다

먼저 CLI 도구를 설치합니다.

dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace

이미 설치되어 있다면 업데이트합니다.

dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace

조사 대상 프로세스를 찾습니다.

dotnet-counters ps

이후 예시에서는 대상 프로세스 ID를 <PID>로 표기합니다.

Linux나 macOS, 컨테이너 환경에서는 진단 도구와 대상 프로세스가 같은 사용자로 동작해야 합니다. 또한 환경에 따라 TMPDIR이나 진단 포트, 컨테이너의 PID 네임스페이스의 영향을 받습니다.

프로덕션 환경에서 실행하는 경우, 바로 덤프를 뜨지 말고 먼저 검증 환경에서 부하와 영향을 확인합니다.

6. 먼저 dotnet-counters로 추이를 본다

처음에 보는 것은 상세한 덤프가 아니라 추이입니다.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime

출력에는 .NET 버전에 따라 다소 차이가 있습니다. .NET 9 이후에서는 System.Runtime의 Meter 이름으로, .NET 8 이전에서는 기존의 EventCounter 이름으로 표시되는 경우가 있습니다.

주로 보는 것은 이 항목들입니다.

보는 항목 무엇을 보는가
dotnet.process.memory.working_set OS 관점의 프로세스 상주 메모리
dotnet.gc.last_collection.heap.size 최근 GC 후의 세대별 힙 크기
dotnet.gc.last_collection.memory.committed_size GC가 커밋하고 있는 메모리량
dotnet.gc.heap.total_allocated 기동 후의 누적 할당량
dotnet.gc.collections 세대별 GC 횟수
dotnet.gc.pause.time GC 정지 시간의 누적

먼저 대상을 좁혀서 모니터링해도 괜찮습니다.

dotnet-counters monitor \
  --process-id <PID> \
  --refresh-interval 3 \
  --counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]

나중에 다시 보고 싶다면 CSV로 저장합니다.

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output counters.csv \
  --counters System.Runtime

이 시점에서 보고 싶은 것은 다음의 차이입니다.

6.1 Total Allocated만 증가하는 경우

dotnet.gc.heap.total_allocated는 누적값입니다. 애플리케이션이 요청을 처리하면 오브젝트를 할당하고, 할당한 오브젝트가 바로 불필요해져 GC로 회수되어도 누적 할당량은 증가합니다.

그래서 Total Allocated가 증가하는 것만으로는 메모리 누수라고 할 수 없습니다. 봐야 할 것은 할당한 후에 남아 있는지입니다.

Total Allocated: 증가한다
GC Heap Size:    어느 정도 오르내리며 안정된다
Gen 2 / LOH:     계속 증가하지 않는다

이 경우는 누수라기보다 할당량이 많은 애플리케이션입니다.

대책은 누수 수정이 아니라, 할당 절감, 버퍼 재사용, LINQ 과다 사용 재검토, 문자열 생성 절감, 직렬화 처리 재검토 등이 됩니다.

6.2 Working Set은 증가하지만 GC Heap은 안정된 경우

Working Set이나 RSS가 증가하고 있는데 GC Heap이 안정적인 경우가 있습니다. 이 경우 매니지드 오브젝트의 누수라고는 할 수 없습니다.

생각할 수 있는 요인은 다음과 같습니다.

  • JIT된 코드
  • 로드된 어셈블리
  • 스레드 스택
  • 네이티브 라이브러리의 메모리
  • Marshal.AllocHGlobal 등의 언매니지드 메모리
  • 이미지, 압축, 암호화, DB 드라이버 등의 네이티브 측 버퍼
  • 소켓, 파일 핸들, SSL, HTTP/2, gRPC 등의 내부 버퍼
  • OS가 프로세스로부터 물리 페이지를 바로 회수하지 않았을 뿐

이 상태에서 dumpheap을 아무리 봐도 주범을 찾지 못하는 경우가 있습니다.

판단 기준은 다음과 같습니다.

Working Set / RSS: 증가한다
GC Heap Size:      안정
Gen 2 / LOH:       안정

이 경우는 .NET의 매니지드 힙 누수가 아니라, 네이티브 메모리, 핸들, 스레드 수, 소켓, 외부 라이브러리를 의심합니다.

dotnet-counters만으로 끝내지 말고, OS 도구, 컨테이너 메트릭, 핸들 수, 스레드 수, 네이티브 힙, 외부 라이브러리의 메트릭도 봅니다.

6.3 GC Heap이 증가하지만 부하 정지 후 돌아오는 경우

부하 중에 GC Heap이 증가하는 것은 자연스러운 일입니다.

요청이 많다. 임시 오브젝트가 많다. 큰 JSON을 다룬다. 일시적으로 리스트나 배열을 만든다.

이런 경우, 다음 GC까지 힙은 증가합니다. 부하를 멈추면 GC가 돌아가고 힙이 돌아오는 경우가 있습니다.

부하 중:      GC Heap이 증가한다
부하 정지 후: GC Heap이 내려가거나 일정 값으로 돌아온다
반복 후:      베이스라인이 계속 증가하지 않는다

이 경우는 「GC되지 않았을 뿐」이거나 「일시적인 할당이 많다」고 판단할 수 있습니다.

다만, 부하 중의 일시 할당이 너무 많으면 GC 횟수나 정지 시간이 증가해 성능 문제가 됩니다. 누수가 아니더라도 성능 개선의 대상은 됩니다.

6.4 GC 후의 Gen 2 / LOH가 계속 증가하는 경우

주의해야 할 것은 이 패턴입니다.

같은 작업을 반복한다
  ↓
Gen 2가 증가한다
  ↓
LOH가 증가한다
  ↓
부하를 멈춰도 돌아오지 않는다
  ↓
다음 측정에서도 더 증가한다

Gen 2는 오래 사는 오브젝트가 들어가는 세대입니다. LOH는 큰 배열이나 문자열 등이 들어가기 쉬운 힙입니다.

여기가 계속 증가하는 경우는 누수, 무제한 캐시, 거대 버퍼 유지, 이벤트 구독 해제 누락, static 컬렉션, 장수 서비스에 의한 유지를 의심합니다.

이 단계에서 다음으로 진행합니다.

7. 「GC 대기」인지 확인하는 사고방식

「아직 GC되지 않았을 뿐인가」를 보려면, 충분한 GC 기회가 있었던 이후의 상태를 봅니다.

다만, 프로덕션 코드에 함부로 GC.Collect()를 넣어서는 안 됩니다.

GC.Collect()는 GC를 강제합니다. 특히 전 세대의 블로킹 GC는 애플리케이션의 정지 시간을 만듭니다. 일반적인 운영에서는 GC에 맡기는 것이 기본입니다.

그래도 조사에서는 관리된 검증 환경에서 「강제 GC 후에도 남는가」를 보는 경우가 있습니다.

검증용 콘솔 앱이나 재현 환경이라면, 다음과 같은 코드로 풀 GC 후의 상태를 확인할 수 있습니다.

static void ForceFullGcForDiagnosticsOnly()
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();
}

중요한 것은 이것을 해결책으로 쓰지 않는 것입니다. 어디까지나 조사용입니다.

확인하고 싶은 것은 다음의 흐름입니다.

작업 전
  ↓
작업을 N회 반복한다
  ↓
부하를 멈춘다
  ↓
충분히 기다리거나, 검증 환경에서 풀 GC를 유도한다
  ↓
GC 후의 힙이 작업 전에 가까운 값으로 돌아오는가

돌아온다면 GC 대기 또는 일시 할당일 가능성이 높습니다. 돌아오지 않고, 같은 작업을 반복할 때마다 베이스라인이 올라간다면 무언가가 살아남고 있습니다. 그 「무언가」를 덤프로 찾습니다.

8. dotnet-gcdump로 가볍게 비교한다

첫 비교에는 dotnet-gcdump가 편리합니다.

dotnet-gcdump는 실행 중인 .NET 프로세스로부터 GC dump를 취득하여, 힙 상의 타입별 통계를 보는 데 사용할 수 있습니다.

dotnet-gcdump collect --process-id <PID> --output before.gcdump

부하를 준 후, 다시 한번 뜹니다.

dotnet-gcdump collect --process-id <PID> --output after.gcdump

CLI에서 간단한 리포트를 볼 수도 있습니다.

dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump  > after-heap.txt

보는 것은 타입별 CountSize입니다.

예를 들어, after에서 다음과 같은 타입이 크게 증가했다면 조사 대상이 됩니다.

Size (Bytes)   Count       Type
============   =====       ====
180,000,000    2,000,000   System.String
120,000,000    1,000,000   MyApp.Models.Customer
 90,000,000       25,000   System.Byte[]

중요한 것은 「큰 타입」이 아니라 「증가한 타입」입니다.

System.String이나 System.Byte[]는 많은 앱에서 상위에 나타납니다. 상위에 있는 것만으로는 범인이라고 할 수 없습니다.

비교 관점은 다음과 같습니다.

before → after 읽는 방법
Count가 거의 같다 그 타입은 주범이 아닐 가능성이 높다
Count도 Size도 증가한다 후보가 된다
MyApp.* 타입이 증가한다 업무 로직상의 유지를 의심하기 쉽다
System.Byte[]가 증가한다 버퍼, 직렬화, 이미지, 압축, HTTP, DB 등을 의심한다
System.String이 증가한다 캐시, 로그, JSON, 딕셔너리 키, 중복 문자열을 의심한다
Task, Timer, CancellationTokenSource가 증가한다 비동기 처리, Timer, 취소 해제 누락을 의심한다

dotnet-gcdump는 비교의 입구로 쓰기 편리한 한편, 취득 시 Gen 2 GC를 유발합니다. 힙이 큰 환경이나 지연에 엄격한 환경에서는 정지 시간이나 추가 메모리 소비에 주의합니다.

Windows라면 .gcdump를 Visual Studio나 PerfView에서 열어 비교할 수 있습니다. 비 Windows 환경에서는 CLI의 report로 타입 통계를 보고, 참조원의 심화는 dotnet-dump로 넘어가는 것이 실무적입니다.

9. dotnet-dump로 힙과 참조원을 본다

「증가하고 있는 타입」이 보이기 시작하면, 다음은 「왜 회수되지 않는가」를 봅니다.

이를 위해 dotnet-dump로 덤프를 뜨고, SOS 명령으로 분석합니다.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-1.dmp

시간을 두고 다시 한번 뜹니다.

dotnet-dump collect \
  --process-id <PID> \
  --type Heap \
  --output myapp-2.dmp

덤프 취득은 무거운 작업입니다. 특히 Full / Heap 덤프는 크기가 크고, 프로세스나 컨테이너에 부하를 줍니다. 프로덕션에서 뜰 경우는 시간대, 디스크 용량, 컨테이너의 메모리 제한, 개인정보나 기밀정보의 혼입에 주의합니다.

취득한 덤프를 분석합니다.

dotnet-dump analyze myapp-2.dmp

먼저 힙 전체의 통계를 봅니다.

> dumpheap -stat

출력은 타입별 건수와 크기입니다.

MT               Count       TotalSize Class Name
00007f...        120000      3840000   MyApp.Models.Order
00007f...        250000      8000000   System.String
00007f...         10000     40000000   System.Byte[]

특정 타입으로 좁힙니다.

> dumpheap -stat -type MyApp.Models.Order

또는 특정 MethodTable로 좁힙니다.

> dumpheap -mt <MT>

인스턴스의 주소를 알게 되면, 참조원을 조사합니다.

> gcroot <OBJECT_ADDRESS>

여기가 가장 중요한 부분입니다. gcroot로 그 오브젝트가 왜 살아 있는지를 확인합니다.

예를 들어, 다음과 같은 참조 경로가 보였다고 합시다.

static MyApp.CustomerCache._items
  -> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
  -> MyApp.Models.Customer
  -> System.String

이 경우, GC가 회수하지 않는 이유는 명확합니다. Customer는 static 캐시에서 참조되고 있어서, GC 입장에서는 아직 사용 중이기 때문입니다.

여기서 비로소 다음과 같은 판단을 할 수 있습니다.

  • 그 캐시는 정말로 필요한가
  • 상한이 있는가
  • 유효기간이 있는가
  • 키가 계속 증가하는 설계는 아닌가
  • 테넌트, 사용자, 날짜, 요청 ID 등을 키로 해서 무한히 증가하고 있지는 않은가

메모리 누수 조사에서 중요한 것은 dumpheap -stat에서 끝내지 않는 것입니다. dumpheap -stat은 「무엇이 많은가」를, gcroot는 「왜 남아 있는가」를 알려줍니다. 수정으로 이어지는 것은 후자입니다.

10. 구별법 요약표

실무에서 흔한 패턴을 정리합니다.

관측 가능성 다음에 볼 것
Total Allocated만 증가 통상적인 할당, 또는 할당 과다 Allocation Rate, GC 횟수, CPU, dotnet-trace
Working Set은 증가하지만 GC Heap은 안정 네이티브 메모리, JIT, 스택, OS 측 유지 스레드 수, 핸들 수, 네이티브 도구, 외부 라이브러리
GC Heap이 부하 중에만 증가하고 정지 후 돌아온다 GC 대기, 일시 할당 부하 정지 후의 Gen 2 / LOH, GC 횟수
GC 후의 Gen 2가 계속 증가 장수 오브젝트의 유지 dumpheap -stat, gcroot
LOH가 계속 증가 큰 배열, 버퍼, 단편화, 거대 문자열 System.Byte[], System.Char[], LOH, Free 영역
System.String이 크다 문자열 캐시, JSON, 로그, 딕셔너리 키 문자열을 보유하고 있는 자체 타입을 찾는다
System.Byte[]가 크다 버퍼, 직렬화, 이미지, 압축, 통신 소유하고 있는 타입, ArrayPool 반환 누락, 네이티브 연계
Task가 증가 완료되지 않는 비동기 처리, 대기 큐 async 대기, 취소, 채널, 큐
Timer가 증가 Timer 폐기 누락 Dispose, 등록 해제, 장수 서비스
CancellationTokenSource가 증가 CTS 폐기 누락, 링크 토큰 과다 Dispose, 링크 해제, 타임아웃 생성 위치
EventHandler나 delegate가 남는다 이벤트 구독 해제 누락 publisher / subscriber의 수명 차이
Finalization Queue가 증가 Dispose 누락, 파이널라이저 정체 finalizequeue, 파이널라이저 스레드
Pinned handle이 많다 pin된 버퍼, 네이티브 연계 gchandles, POH, 고정화 위치

11. 흔한 누수 형태

11.1 static 컬렉션

가장 알기 쉬운 형태입니다.

public static class CustomerStore
{
    private static readonly List<Customer> Customers = new();

    public static void Add(Customer customer)
    {
        Customers.Add(customer);
    }
}

이 코드에서는 Customers에 추가한 Customer는 프로세스가 살아 있는 한 계속 남습니다. 일시적인 저장 목적이라도, static에서 참조되고 있는 한 GC는 회수하지 않습니다.

수정 방향은 용도에 따라 달라집니다.

  • 상한을 둔다
  • 유효기간을 둔다
  • MemoryCache 등의 캐시 메커니즘을 사용한다
  • 명시적으로 삭제한다
  • static을 그만두고 적절한 라이프타임의 서비스로 옮긴다
  • 영속화가 목적이라면 DB나 외부 스토리지로 옮긴다

중요한 것은 「static이 나쁘다」는 이야기가 아니라, static에 둔 것은 장수하게 된다는 성질을 이해하고 사용하는 것입니다.

11.2 무제한 캐시

캐시는 의도적으로 메모리를 사용하는 것이므로, 설계대로의 증가라면 누수가 아닙니다. 하지만 상한이나 기한이 없는 캐시는 실질적인 메모리 누수가 됩니다.

public sealed class ReportCache
{
    private readonly Dictionary<string, Report> _cache = new();

    public Report GetOrCreate(string userId, DateTime date)
    {
        var key = $"{userId}:{date:O}";

        if (_cache.TryGetValue(key, out var report))
        {
            return report;
        }

        report = BuildReport(userId, date);
        _cache[key] = report;
        return report;
    }
}

이 예시에서는 userIddate의 조합이 계속 증가하면 캐시도 계속 증가합니다.

특히 위험한 것은 키에 다음과 같은 값을 포함하는 경우입니다.

  • 요청 ID
  • 현재 시각
  • GUID
  • 세션 ID
  • 사용자 입력을 정규화하지 않고 사용한 문자열
  • SQL이나 검색 조건을 그대로 문자열화한 것

캐시는 다음 조건을 정해 둡니다.

조건
최대 건수 10,000건까지
최대 크기 256MB까지
유효기간 마지막 접근으로부터 30분
절대 기한 생성으로부터 6시간
폐기 조건 테넌트 삭제, 사용자 삭제, 설정 변경
모니터링 항목 건수, 추정 크기, 히트율, 축출 횟수

「캐시니까 증가해도 된다」가 아니라 「어디까지 증가해도 되는가」를 정해야 합니다.

11.3 이벤트 구독 해제 누락

이벤트는 수명이 긴 publisher가 수명이 짧은 subscriber를 계속 참조하면 누수가 됩니다.

public sealed class OrderViewModel
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

OrderService가 singleton이고 OrderViewModel이 화면마다 만들어지는 경우, OrderService의 이벤트가 OrderViewModel을 계속 참조합니다. 화면을 닫아도 구독을 해제하지 않으면 ViewModel은 남습니다.

수정 예시입니다.

public sealed class OrderViewModel : IDisposable
{
    private readonly OrderService _service;

    public OrderViewModel(OrderService service)
    {
        _service = service;
        _service.OrderChanged += OnOrderChanged;
    }

    public void Dispose()
    {
        _service.OrderChanged -= OnOrderChanged;
    }

    private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
    {
        // update view model
    }
}

gcroot에서는 delegate나 event handler를 경유한 참조로 보이는 경우가 있습니다.

이 패턴은 WPF, WinForms, 장수 서비스, 메시지 브로커, 이벤트 애그리게이터에서 자주 나타납니다.

11.4 Timer 폐기 누락

System.Threading.TimerPeriodicTimer, Reactive Extensions의 subscription 등도 폐기하지 않으면 남습니다.

public sealed class PollingWorker
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    private void Poll()
    {
        // polling
    }
}

PollingWorker가 일시적인 오브젝트라는 의도라면, Timer를 폐기하는 설계가 필요합니다.

public sealed class PollingWorker : IDisposable
{
    private readonly Timer _timer;

    public PollingWorker()
    {
        _timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
    }

    public void Dispose()
    {
        _timer.Dispose();
    }

    private void Poll()
    {
        // polling
    }
}

Timer는 콜백의 delegate를 가지고 있고, 거기서 대상 오브젝트로 참조가 이어지는 경우가 있습니다.

11.5 IDisposable 해제 누락

IDisposable 해제 누락은 반드시 매니지드 힙의 누수로 보이는 것은 아닙니다.

파일, 소켓, DB 연결, 네이티브 핸들, 버퍼 등의 리소스 문제로 나타나는 경우가 있습니다.

public async Task<string> ReadAsync(string path)
{
    var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

이 예시에서는 StreamReaderstream을 닫으므로 큰 문제가 되지 않는 경우가 많지만, 소유권이 모호한 코드에서는 누락이 발생합니다.

기본은 using / await using으로 소유권을 명확히 합니다.

public async Task<string> ReadAsync(string path)
{
    await using var stream = File.OpenRead(path);
    using var reader = new StreamReader(stream);
    return await reader.ReadToEndAsync();
}

Dispose 누락은 다음과 같은 증상으로 나타납니다.

  • 핸들 수가 증가한다
  • 소켓이 증가한다
  • 파일이 닫히지 않는다
  • 네이티브 메모리가 증가한다
  • Finalization Queue가 증가한다
  • GC Heap은 안정적인데 프로세스 메모리가 증가한다

이 경우는 dumpheap만으로는 부족합니다. OS의 핸들이나 소켓, 외부 라이브러리의 상태도 봅니다.

11.6 AsyncLocal이나 컨텍스트 유지

AsyncLocal<T>는 편리하지만, 담는 것이 크면 오래 남는 경우가 있습니다.

로그의 상관 ID 같은 작은 값이라면 문제가 되기 어렵습니다. 하지만 사용자 정보, 요청 본문, 큰 DTO, DB 컨텍스트 같은 것을 담으면 의도하지 않은 유지로 이어집니다.

public static class RequestContext
{
    public static readonly AsyncLocal<RequestInfo?> Current = new();
}

AsyncLocal은 비동기 흐름을 따라가기 때문에, 단순한 static 필드보다 발견하기 어려운 경우가 있습니다.

담는 것은 작고 명확하게 하고, 불필요해지면 null로 되돌리는 설계를 검토합니다.

11.7 DI 라이프타임 착오

ASP.NET Core 등의 DI에서는 singleton, scoped, transient의 수명이 다릅니다.

수명이 긴 singleton이 요청마다의 데이터를 유지하면, 요청이 끝나도 오브젝트가 남는 경우가 있습니다.

public sealed class AuditBuffer
{
    private readonly List<RequestAudit> _items = new();

    public void Add(RequestAudit item)
    {
        _items.Add(item);
    }
}

이것이 singleton이라면 _items는 앱의 수명과 같습니다.

설계상 버퍼링한다면 상한, 전송, 삭제, 백프레셔가 필요합니다. 단순히 「나중에 볼지도 모른다」 정도라면 로그나 외부 스토리지로 내보내야 합니다.

12. LOH는 특히 오해하기 쉽다

LOH는 Large Object Heap의 약자입니다. .NET에서는 큰 오브젝트가 일반적인 작은 오브젝트와는 별도의 힙에 배치됩니다. 대표적인 예는 큰 배열입니다.

var buffer = new byte[1024 * 1024 * 10]; // 10MB

LOH에서 흔한 문제는 다음 3가지입니다.

  1. 큰 오브젝트를 자주 만든다
  2. 큰 오브젝트를 오래 유지한다
  3. 큰 오브젝트의 생성과 폐기로 단편화된다

LOH가 증가하고 있다고 해서 바로 누수라고는 할 수 없습니다. 큰 버퍼를 재사용하는 설계라면 일정 크기까지 증가한 후 안정되는 경우가 있고, GC가 회수해도 Working Set이 바로 내려간다고는 할 수 없습니다.

다만, 다음 상태는 의심해야 합니다.

  • System.Byte[]가 작업마다 증가한다
  • System.Char[]나 거대 String이 증가한다
  • 이미지, PDF, Excel, ZIP, 암호화, 압축 처리 후에 돌아오지 않는다
  • ArrayPool<T>.Rent한 배열을 반환하지 않고 있다
  • 큰 응답을 그대로 메모리에 올리고 있다
  • MemoryStream.ToArray()를 다용하고 있다

ArrayPool<T>를 사용할 경우는 반드시 반환합니다.

var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);

try
{
    // use buffer
}
finally
{
    pool.Return(buffer);
}

다만, 풀에 반환했다고 해서 프로세스의 메모리가 바로 내려간다고는 할 수 없습니다. 풀은 재사용을 위해 메모리를 유지하는 경우가 있습니다.

여기서도 봐야 할 것은 「계속 증가하는가」, 「상한이 있는가」, 「재사용되고 있는가」입니다.

13. gcroot 읽는 법

gcroot는 어떤 오브젝트가 어디서 참조되고 있는지를 표시합니다.

전형적인 루트를 표로 정리합니다.

루트 의미
static field 타입의 static 필드에서 참조되고 있다
local variable / stack 실행 중인 스레드의 스택에서 참조되고 있다
GC handle GCHandle, pin, delegate, interop 등에서 참조되고 있다
finalization queue 파이널라이즈 대기 상태로 유지되고 있다
thread / async state machine 실행 중 또는 대기 중인 비동기 처리가 유지하고 있다

조사에서 자주 봐야 할 포인트는 수명의 차이입니다.

장수 오브젝트
  -> 원래는 단명이어야 할 오브젝트

이 형태가 나타나면 누수 후보입니다.

예를 들어, 다음은 의심스럽습니다.

SingletonService
  -> List<RequestContext>
  -> RequestContext
  -> LargeDto

SingletonService는 앱 전체에서 살아 있습니다. 그 안에 요청 단위의 RequestContext가 쌓여 있다면 설계를 재검토할 필요가 있습니다.

한편, 다음과 같은 루트는 타이밍에 따라 정상입니다.

Thread stack
  -> Controller action local variable
  -> RequestDto

요청 처리 중이라면 로컬 변수가 남아 있는 것은 당연합니다.

그래서 덤프는 타이밍이 중요합니다.

부하 중뿐만 아니라, 부하 정지 후, 큐가 비워진 후, 일정 시간 유휴 상태로 둔 후의 덤프도 뜨면 판단하기 쉬워집니다.

14. 「강제 GC로 내려갔으니 해결」이 아니다

조사 중에 GC.Collect()를 호출했더니 메모리가 내려갔다. 이때 「그럼 정기적으로 GC.Collect()를 하면 되겠다」고 생각하는 것은 위험합니다.

강제 GC는 근본 원인을 제거하지 않습니다. 단지 미회수 상태였던 오브젝트를 그 자리에서 회수했을 뿐입니다.

만약 할당률이 높은 것이 문제라면, 강제 GC는 정지 시간을 늘려 성능을 악화시킵니다. 만약 실제 누수라면, 참조가 남아 있는 오브젝트는 강제 GC로도 회수되지 않습니다.

조사에서 봐야 할 것은 다음의 차이입니다.

강제 GC 후 판단
크게 내려가고, 그 후 베이스라인이 안정된다 GC 대기, 또는 일시 할당이 주원인
조금 내려가지만 반복할 때마다 바닥이 올라간다 일부가 살아남고 있다. 누수 후보
거의 내려가지 않는다 계속 참조되고 있거나, GC 힙 이외가 주원인
GC Heap은 내려가지만 Working Set은 내려가지 않는다 OS / GC 세그먼트 / 네이티브 측 유지의 가능성

프로덕션에서 GC.Collect()를 정기 실행하기 전에, 반드시 「무엇이 증가하고 있는지」를 특정합니다.

15. 실무에서 사용하는 조사 절차

여기서부터는 실제로 조사할 때의 절차로 정리합니다.

15.1 재현 시나리오를 고정한다

먼저 조사 조건을 고정합니다.

대상:       /api/report/export
작업:       같은 조건으로 100회 실행
측정 간격:  5초
관측 시간:  워밍업 5분 + 부하 10분 + 유휴 5분
환경:       staging / Release build / Production 상당 설정

메모리 조사에서는 매번 다른 작업을 하면서 보면 판단할 수 없습니다. 「무엇을 했더니 증가했는가」를 고정합니다.

15.2 베이스라인을 뜬다

기동 직후가 아니라 워밍업 후를 베이스라인으로 합니다.

이유는 기동 직후에는 이런 일회성 증가가 있기 때문입니다.

  • JIT
  • DI 컨테이너 구축
  • 설정 로드
  • 첫 DB 연결
  • 첫 TLS / HTTP 연결
  • JSON 직렬화기의 메타데이터 생성
  • Razor / 템플릿 초기화
  • 로거나 메트릭 초기화

순서는 다음과 같이 합니다.

1. 앱 기동
2. 헬스체크나 대표 API를 몇 번 호출
3. 1~5분 정도 대기
4. 베이스라인으로 counters와 dump를 뜬다

15.3 부하 중에 counters를 뜬다

dotnet-counters collect \
  --process-id <PID> \
  --refresh-interval 5 \
  --format csv \
  --output report-export-counters.csv \
  --counters System.Runtime

병행해서 재현 작업을 합니다. 보고 싶은 것은 그래프의 형태입니다.

정상에 가까운 형태:
  부하 중에 증가한다
  GC로 오르내린다
  부하 정지 후 돌아온다
  베이스라인이 계속 증가하지 않는다

의심스러운 형태:
  작업 횟수에 비례해서 증가한다
  Gen 2 / LOH의 바닥이 올라간다
  부하 정지 후에도 돌아오지 않는다
  다음 부하에서 더 바닥이 올라간다

15.4 덤프를 2회 뜬다

부하 전후로 뜹니다.

dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# 부하를 준다
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp

여유가 있으면 부하 정지 후에도 뜹니다.

# 부하 정지 후, 큐가 비워지고, 일정 시간 대기한 후에
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp

비교할 때는 beforeafter뿐만 아니라 idle-after가 중요합니다.

부하 중에 증가해도 유휴 후에 돌아온다면 누수가 아닐 가능성이 있습니다.

15.5 증가한 타입을 본다

dotnet-dump analyze after.dmp
> dumpheap -stat

before 쪽도 같은 방식으로 봅니다. 수작업이어도 괜찮지만, 먼저 상위 타입을 비교합니다.

보는 관점을 들어 봅니다.

  • 자사 네임스페이스의 타입이 증가하고 있는가
  • System.String의 뒤에 자사 타입이 있지 않은가
  • System.Byte[]를 누가 가지고 있는가
  • List<T>Dictionary<TKey,TValue>가 증가하고 있지 않은가
  • Task나 async state machine이 증가하고 있지 않은가
  • TimerCancellationTokenSource가 증가하고 있지 않은가

15.6 참조원을 본다

후보 오브젝트 주소를 채집하고 gcroot합니다.

> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>

gcroot의 결과로부터 유지하고 있는 부모를 찾습니다.

MyApp.Services.ReportCache
  -> Dictionary<string, ReportResult>
  -> ReportResult

여기까지 오면 코드 리뷰의 대상이 보이기 시작합니다.

  • ReportCache는 singleton인가
  • 상한이 있는가
  • 삭제되는가
  • 키는 계속 증가하는가
  • ReportResult는 너무 크지 않은가
  • 캐시가 아니라 DB나 파일로 옮겨야 하는가

16. dotnet-trace를 사용하는 장면

dotnet-dump는 어느 순간의 스냅샷으로 「결과적으로 무엇이 남아 있는가」를 보는 데 적합합니다. 한편, 「언제, 어디서 대량으로 할당하고 있는가」를 보고 싶은 경우는 dotnet-trace를 사용합니다.

예를 들어, GC 관련 이벤트를 포함해서 트레이스합니다.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:01:00 \
  --clrevents gc+gchandle \
  --clreventlevel informational \
  --output gc-trace.nettrace

할당 샘플링까지 보고 싶은 경우는 이벤트 양이 증가하므로, 검증 환경에서 짧은 시간부터 시작합니다.

dotnet-trace collect \
  --process-id <PID> \
  --duration 00:00:00:30 \
  --clrevents gc+gcsampledobjectallocationhigh \
  --clreventlevel informational \
  --output allocation-trace.nettrace

트레이스는 덤프와는 다른 각도에서 도움이 됩니다.

보고 싶은 것 적합한 도구
무엇이 남아 있는가 dump / gcdump
누가 참조하고 있는가 dump + gcroot
언제 대량으로 할당했는가 trace
GC가 언제 일어났는가 counters / trace
정지 시간이 문제인가 counters / trace

누수 조사에서는 먼저 dump로 「남아 있는 것」을 보고, 필요하면 trace로 「만들고 있는 위치」를 보면 효율적입니다.

17. 코드 상에서 확인용 메트릭을 출력한다

본격적인 진단은 외부 도구로 해야 하지만, 앱 쪽에 간단한 진단 로그를 넣어두면 도움이 됩니다.

예를 들어, 관리자용 엔드포인트나 정기 로그에서 GC 정보를 출력하는 방법입니다.

public static class GcDiagnostics
{
    public static object Snapshot()
    {
        var info = GC.GetGCMemoryInfo();

        return new
        {
            TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
            HeapSizeBytes = info.HeapSizeBytes,
            FragmentedBytes = info.FragmentedBytes,
            MemoryLoadBytes = info.MemoryLoadBytes,
            HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
            Gen0Collections = GC.CollectionCount(0),
            Gen1Collections = GC.CollectionCount(1),
            Gen2Collections = GC.CollectionCount(2)
        };
    }
}

이 정보만으로는 누수 판정을 할 수 없습니다. 다만, 장애 시에 다음과 같은 판단이 쉬워집니다.

  • Gen 2가 급증하고 있는가
  • HeapSize가 증가하고 있는가
  • FragmentedBytes가 증가하고 있는가
  • TotalMemory와 프로세스 메모리의 차이가 큰가
  • 배포 후부터 추이가 바뀌었는가

앱 로그에 넣을 경우는 너무 많이 출력하지 않도록 주의합니다. 높은 빈도로 무거운 진단을 수행하면 그 자체가 부하가 됩니다.

18. 「메모리 누수」로 단정하기 위한 기준

조사의 마지막에는 다음과 같은 형태로 설명할 수 있게 합니다.

사건:
  /api/report/export를 100회 실행하면, 부하 정지 후에도 GC Heap이 300MB 증가한 채 돌아오지 않는다.

관측:
  dotnet-counters에서 Gen 2의 heap size가 작업 횟수에 비례해서 증가했다.
  Working Set뿐만 아니라 GC Heap도 증가했다.

비교:
  before.dmp와 after.dmp를 비교한 결과, MyApp.Models.ReportResult가 12,000건 증가했다.

참조원:
  gcroot로 MyApp.Services.ReportCache._items에서 참조되고 있었다.

원인:
  ReportCache가 singleton이며, 사용자ID + 현재 시각을 키로 하고 있어, 삭제·기한·상한이 없었다.

대책:
  MemoryCache로 교체하고, 크기 상한과 유효기간을 설정했다.
  캐시 건수를 메트릭화했다.

여기까지 설명할 수 있으면, 단순한 「메모리가 증가하고 있습니다」가 아니라, 재현 조건, 관측값, 증가한 타입, 참조원, 원인, 대책이 이어진 보고가 됩니다.

19. 조사 시 주의점

19.1 Release 빌드로 본다

Debug 빌드는 최적화나 로컬 변수의 수명, 디버그 정보의 영향으로 실제 운영과 보이는 방식이 달라지는 경우가 있습니다.

프로덕션 상당의 조사에서는 Release 빌드, 운영에 가까운 설정, 가까운 데이터양으로 확인합니다.

19.2 기동 직후만으로 판단하지 않는다

기동 직후에는 여러 초기화로 메모리가 증가합니다.

워밍업 후의 베이스라인을 잡고, 거기서부터 증가하는지를 봅니다.

19.3 1회의 덤프로 범인으로 취급하지 않는다

힙 상위에 있는 타입이 반드시 범인은 아닙니다.

System.String이나 System.Byte[]는 많은 앱에서 크게 보입니다.

중요한 것은 시간차로 증가했는가, 그리고 누가 가지고 있는가입니다.

19.4 덤프에는 기밀정보가 들어간다

메모리 덤프에는 요청, 인증 정보, 연결 문자열, 개인정보, 업무 데이터가 포함될 가능성이 있습니다.

보관 위치, 반출, 공유, 삭제 규칙을 정합니다.

19.5 컨테이너에서는 덤프 취득이 리스크가 된다

컨테이너의 메모리 제한이 엄격한 경우, 덤프 취득으로 인한 추가 메모리나 페이지인으로 OOM Kill되는 경우가 있습니다.

프로덕션 컨테이너에서 뜨기 전에 스테이징에서 시도하고, 제한값, 디스크 용량, 권한, PID 네임스페이스를 확인합니다.

19.6 GC Heap 이외의 누수도 있다

.NET 조사라고 해서 모든 것이 GC 힙에 나타나는 것은 아닙니다.

다음과 같은 문제에서는 GC Heap이 안정적이어도 프로세스 메모리가 증가하는 경우가 있습니다.

  • 네이티브 라이브러리
  • P/Invoke
  • COM
  • 이미지 처리
  • 압축 라이브러리
  • 암호화 처리
  • DB 드라이버
  • 소켓
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • 스레드 과다

이 경우는 dotnet-dumpdumpheap만으로는 부족합니다. OS 측 진단, 외부 라이브러리의 메트릭, 핸들, 스레드, 네이티브 메모리를 볼 필요가 있습니다.

20. 수정 후의 확인

누수로 보이는 부분을 수정했다면, 같은 절차로 다시 측정합니다.

수정 전:
  100회 실행 후, Gen 2가 +300MB
  ReportResult가 +12,000건

수정 후:
  100회 실행 후, Gen 2가 +20MB 이내로 안정
  ReportResult는 부하 정지 후 베이스라인으로 돌아온다
  캐시 건수는 상한 1,000건으로 안정

수정 확인에서는 반드시 같은 조건으로 비교합니다.

  • 같은 데이터양
  • 같은 횟수
  • 같은 부하 시간
  • 같은 워밍업
  • 같은 관측 간격
  • 같은 도구

메모리 조사는 수정 전후의 비교가 약하면 설득력이 없습니다.

21. 정리

.NET에서 메모리가 증가하고 있을 때, 바로 누수로 단정하지 말고 다음 순서로 가려냅니다.

  1. Working Set / RSS만으로 판단하지 않는다
  2. dotnet-counters로 GC Heap, Gen 2, LOH, GC 횟수를 본다
  3. 부하 중, 부하 정지 후, 시간차로 비교한다
  4. dotnet-gcdumpdotnet-dump로 증가한 타입을 본다
  5. gcroot로 참조원을 본다
  6. static, 캐시, 이벤트, Timer, DI 라이프타임, 비동기 컨텍스트를 확인한다
  7. GC Heap이 안정적이라면 네이티브 메모리나 OS 측 문제도 의심한다

「GC되지 않았을 뿐」과 「메모리 누수하고 있다」의 차이는 결국 참조로 결정됩니다.

불필요해진 오브젝트가 참조되고 있지 않다면 GC 타이밍에 회수됩니다. 불필요해졌어야 하는데 계속 참조되고 있다면 GC는 회수할 수 없습니다.

즉, 조사의 목표는 다음과 같습니다.

무엇이 증가하고 있는가.
어느 GC 후에도 남아 있는가.
누가 참조하고 있는가.
그 참조는 설계상 필요한가.

여기까지 알면, 메모리 그래프에 휘둘리지 않고 코드 상의 수정 지점으로 정리할 수 있습니다.

참고 자료

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

.NET 앱의 메모리 사용량이 증가하는 것은 메모리 누수인가요?
프로세스의 메모리가 증가하는 것과 메모리 누수가 발생하는 것은 같지 않습니다. .NET에서는 GC가 할당 상황과 힙의 임계값을 보면서 동작하기 때문에, 불필요해진 오브젝트가 아직 GC되지 않았을 뿐인 경우나, GC 후에도 OS가 바로 메모리를 회수하지 않는 경우가 있습니다. 봐야 할 것은 「GC 후에도 남는 메모리가 증가하고 있는가」, 「증가하는 타입은 무엇인가」, 「그 오브젝트를 누가 참조하고 있는가」 이 3가지입니다.
.NET의 메모리 누수를 조사하려면 어떤 도구를 사용해야 하나요?
먼저 dotnet-counters로 Working Set, GC Heap, Gen 2/LOH, GC 횟수의 추이를 봅니다. 다음으로 dotnet-gcdump로 부하 전후의 GC dump를 떠서, 타입별 Count와 Size를 비교해 증가한 타입을 찾아냅니다. 마지막으로 dotnet-dump로 힙 덤프를 뜨고, dumpheap -stat과 gcroot로 「왜 회수되지 않는가」의 참조원까지 추적합니다. 1회의 수치가 아니라, 같은 조건에서의 시간차 비교가 중요합니다.
.NET에서 흔한 메모리 누수 패턴은 무엇인가요?
대표적인 예로는 static 컬렉션에 추가만 계속하는 경우, 상한이나 유효기간이 없는 무제한 캐시, 장수하는 publisher에 대한 이벤트 구독 해제 누락, Timer 폐기 누락, IDisposable 해제 누락, singleton이 요청 단위 데이터를 보유하는 DI 라이프타임 착오 등이 있습니다. .NET의 누수는 「해제를 잊어버린 것」이라기보다, 불필요한데도 계속 참조되는 「의도하지 않은 유지」로 생각하면 이해하기 쉽습니다.
GC.Collect()를 정기적으로 실행하면 메모리 문제가 해결되나요?
해결되지 않습니다. 강제 GC는 미회수 상태였던 오브젝트를 그 자리에서 회수할 뿐, 근본 원인을 제거하지 않습니다. 할당률이 높은 것이 문제라면 정지 시간을 늘려 성능을 악화시키고, 실제 누수라면 참조가 남아 있는 오브젝트는 강제 GC로도 회수되지 않습니다. 조사용으로 관리된 검증 환경에서 「강제 GC 후에도 남는가」를 보는 경우는 있지만, 해결책으로 프로덕션에 넣기 전에 반드시 무엇이 증가하고 있는지를 특정해야 합니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기