수정 이력(4건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조해 주십시오.
- 패턴 목록(증상과 먼저 볼 지표)을 추가했습니다. 프로덕션에서 dump를 뜨기 전에 관계자와 정해 둘 것의 절, 조사 대상이 .NET Framework 4.x인 경우의 대응표(`dotnet-` 계열은 .NET Core 3.0 이후가 전제입니다), 메모리 지표의 관계도를 추가했습니다. 그림에서는 Working Set과 commit이 중첩 관계가 아님을 정확하게 나타냅니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635302)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「.NET에서 GC 미회수와 메모리 누수를 가려내기 ── 늘어나는 메모리를 관측·비교·증명하는 실무 절차」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635302 https://comcomponent.com/ko/blog/dotnet-gc-or-memory-leak/
- DOI(최신 버전)
- 10.5281/zenodo.21635302
- DOI(이 버전)
- 10.5281/zenodo.21635303
1. 먼저 알아둘 것
.NET 애플리케이션을 운영하다 보면 메모리 사용량이 조금씩 늘어나는 장면이 있습니다.
작업 관리자나 top을 보면 프로세스 메모리가 늘어나 있습니다.
컨테이너 메모리 사용량도 늘어나 있습니다.
모니터링에서 Working Set이나 RSS 그래프가 우상향합니다.
이 상태를 보면 바로 메모리 누수가 아닐까 생각하기 쉽습니다. 그러나 .NET에서는 프로세스 메모리가 늘어난 것과 메모리 누수는 같은 말이 아닙니다.
.NET에는 garbage collection이 있습니다. 객체가 불필요해진 순간 OS로 메모리가 바로 돌아가지는 않습니다. GC는 할당 상황, heap 임계값, 메모리 압박, generation, workload 상태를 보고 동작합니다.
그래서 다음 같은 상태가 생깁니다.
- 불필요해진 객체가 아직 GC되지 않았다
- GC는 끝났지만 프로세스 Working Set이 바로 내려가지 않는다
- 첫 접근, JIT, cache, connection pool에서 한 번만 늘고 이후에는 안정된다
- managed heap은 안정적인데 native memory나 thread, socket, 이미지 처리 라이브러리 쪽에서 늘어난다
- 정말로 불필요해졌어야 할 객체가 어딘가에서 계속 참조된다
이 글에서는 마지막의 “정말로 누수인지”를 어떻게 가려내는지를 다룹니다. 볼 것은 단순한 메모리 사용량이 아니라 다음 세 가지입니다.
- GC 이후에도 남는 메모리가 늘어나는가
- 늘어나는 타입은 무엇인가
- 그 객체를 누가 참조하는가
.NET 메모리 누수 조사는 “메모리가 늘어납니다”에서 끝내지 않고, “이 타입의 객체가 늘어나고, 이 root에서 계속 참조됩니다”까지 가져가는 작업입니다.
또한 이 글에 나오는 코드는 빌드·실행할 수 있는 샘플 세트(전형적인 누수 패턴 라이브러리, GC 미회수와 생존의 차이를 관측하는 데모, WeakReference로 유지·회수를 검증하는 유닛 테스트)로 GitHub에 공개하고 있습니다.
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
이 글의 지식 맵
.NET 앱의 메모리 증가는 가비지 컬렉션이 아직 회수하지 않았을 뿐인 경우와, static 컬렉션이나 상한이 없는 캐시, 이벤트 구독 해제 누락, Timer Dispose 누락, IDisposable 해제 누락, DI 수명 혼동 등으로 참조가 실제로 계속 남아 있는 경우로 나뉩니다. 구별하려면 먼저 dotnet-counters로 GC 힙과 Gen 2, LOH의 경향을 확인하고, 누적값인 Total Allocated나 OS 관점의 Working Set 증가만으로는 판단하지 않는 것이 중요합니다. 다음으로 dotnet-gcdump로 부하 전후의 타입별 Count와 Size를 비교해 늘어난 타입을 특정하고, dotnet-dump의 dumpheap과 gcroot 명령으로 누가 그 객체를 계속 참조하고 있는지까지 추적합니다. 강제 GC로 메모리가 줄어도 근본 원인은 사라지지 않으며, 프로덕션에 넣을 대책이 되지 않습니다.
flowchart LR
accTitle: .NET의 GC 대기와 메모리 누수 구별법의 지식 맵
accDescr: .NET에서 메모리 증가가 GC 대기인지 진짜 메모리 누수인지를, dotnet-counters·dotnet-gcdump·dotnet-dump와 gcroot로 원인을 분리하는 관계를 보여주는 그림
dotnet_garbage_collection[".NET 가비지 컬렉션(GC)"]
dotnet_memory_leak[".NET 메모리 누수(의도하지 않은 유지)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["gcroot 명령"]
gc_heap["GC 힙(매니지드 힙)"]
dumpheap_command["dumpheap -stat 명령"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2(2세대 힙)"]
total_allocated_memory["Total Allocated(누적 할당량)"]
working_set_rss["Working Set / RSS"]
induced_gc["강제 GC(Induced Collection)"]
static_collection_leak["static 컬렉션에 의한 유지"]
unbounded_cache["상한·기한 없는 캐시"]
event_subscription_leak["이벤트 구독 해제 누락"]
timer_disposal_leak["Timer의 미해제"]
idisposable_leak["IDisposable 해제 누락"]
di_lifetime_mismatch["DI 수명 혼동"]
large_object_heap["LOH(Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET(Core 이후)"]
dotnet_garbage_collection -->|"에서 확인할 수 있다"| dotnet_counters
dotnet_memory_leak -->|"에서 확인할 수 있다"| dotnet_gcdump
dotnet_memory_leak -->|"에서 확인할 수 있다"| dotnet_dump
dotnet_memory_leak -->|"에서 확인할 수 있다"| gcroot_command
gc_heap -->|"에서 확인할 수 있다"| dumpheap_command
dotnet_memory_leak -.->|"에서 확인할 수 있다"| dotnet_trace
dotnet_memory_leak -->|"에서 확인할 수 있다"| gen2_heap
total_allocated_memory -->|"사용은 비권장"| dotnet_memory_leak
working_set_rss -->|"사용은 비권장"| dotnet_memory_leak
induced_gc -->|"사용은 비권장"| dotnet_memory_leak
static_collection_leak -.->|"원인이 될 수 있다"| dotnet_memory_leak
unbounded_cache -.->|"원인이 될 수 있다"| dotnet_memory_leak
event_subscription_leak -.->|"원인이 될 수 있다"| dotnet_memory_leak
timer_disposal_leak -.->|"원인이 될 수 있다"| dotnet_memory_leak
idisposable_leak -.->|"원인이 될 수 있다"| dotnet_memory_leak
di_lifetime_mismatch -.->|"원인이 될 수 있다"| dotnet_memory_leak
large_object_heap -.->|"원인이 될 수 있다"| dotnet_memory_leak
dotnet_counters -->|"보다 먼저 해야 한다"| dotnet_gcdump
dotnet_gcdump -->|"보다 먼저 해야 한다"| dotnet_dump
dotnet_counters -->|"양립하지 않는다"| dotnet_framework
dotnet_dump -->|"양립하지 않는다"| dotnet_framework
dotnet_garbage_collection -->|"이용한다"| gc_heap
dotnet_garbage_collection -->|"전제로 한다"| dotnet
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 23건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. 먼저 「메모리 누수」의 뜻을 맞춰 둡니다
.NET에서 말하는 메모리 누수는, C나 C++처럼 「확보한 메모리를 해제하는 것을 잊었다」는 형태만은 아닙니다.
매니지드 코드에서는 GC가 객체를 회수합니다. GC가 회수할 수 있는지는 「그 객체에 도달할 수 있는 참조가 아직 남아 있는지」로 결정됩니다.
즉, .NET의 전형적인 메모리 누수는 이런 상태입니다.
업무상으로는 이미 불필요한데, static 필드, cache, event, Timer, 컬렉션, DI lifetime, 비동기 컨텍스트 등에서 계속 참조되므로, GC 입장에서는 아직 사용 중으로 보이는 상태.
GC는 똑똑하지만, 업무상 불필요한지는 알지 못합니다. 참조되고 있으면 살아 있다고 판단합니다.
그래서 .NET에서는 「누수」보다 「의도하지 않은 유지」로 생각하는 편이 이해하기 쉽습니다.
한편 다음 상태는, 곧바로 메모리 누수라고 말할 수 없습니다.
| 상태 | 누수라고 단정할 수 없는 이유 |
|---|---|
| Working Set / RSS가 늘어난다 | OS가 프로세스에 할당한 메모리이며, managed heap의 생존 객체량과 일치하지 않는다 |
| Total Allocated가 늘어난다 | 기동 이후 할당한 누적량이므로, 앱이 동작하면 기본적으로 늘어난다 |
| 순간적으로 GC Heap이 늘어난다 | 다음 GC까지 아직 회수되지 않은 객체가 남아 있을 수 있다 |
| 기동 직후에 늘어난다 | JIT, 타입 로드, 초기 cache, connection pool, 템플릿 전개 등에서 흔히 일어난다 |
| LOH가 크다 | 큰 배열이나 버퍼의 재사용, 단편화, pool 전략의 영향일 수 있다 |
| 메모리가 내려가지 않는다 | GC가 회수해도, 프로세스가 OS에 바로 메모리를 돌려준다는 보장은 없다 |
반대로, 다음 상태가 겹칠수록 메모리 누수 의심은 강해집니다.
| 관측 결과 | 의미 |
|---|---|
| 같은 조작을 반복할 때마다, GC 이후 heap이 늘어난다 | 살아남는 객체가 늘어나고 있다 |
| Gen 2나 LOH 크기가 계속 늘어난다 | 오래 사는 객체, 또는 큰 객체가 남아 있다 |
| 여러 차례 dump에서 같은 타입의 Count / Size가 늘어난다 | 늘어나는 타입을 특정할 수 있다 |
gcroot에서 static, event, cache, 장수명 서비스로부터의 참조가 보인다 |
GC가 회수하지 못하는 이유를 설명할 수 있다 |
| 부하를 멈춰도, 충분한 시간 또는 검증용 GC 이후에도 돌아가지 않는다 | 단순한 일시 할당이 아닐 가능성이 높다 |
3. 「어떤 메모리」를 보고 있는지를 나눕니다
메모리 조사에서 처음에 헷갈리는 것은, 여러 메모리 지표가 섞여 있다는 점입니다. 같은 「메모리」라도 의미가 다릅니다.
| 지표 | 보는 것 | 읽는 법 |
|---|---|---|
| Working Set / RSS | 물리 메모리에 올라가 있는 프로세스의 페이지 | OS 관점의 메모리. GC heap 그 자체가 아니다 |
| Private Bytes / Commit | 프로세스가 전용(private)으로 commit한 메모리 | native memory, 스택, JIT 코드, GC 세그먼트 등도 포함한다 |
| GC Heap Size | managed heap 위의 객체량 | .NET의 GC 대상 메모리를 보는 입구 |
| Total Allocated | 기동 이후 할당한 누적량 | 기본적으로 늘어난다. 누수 판정에는 단독으로 쓰지 않는다 |
| Gen 0 / Gen 1 / Gen 2 | generation별 heap | Gen 2에 남는 것은 오래 살고 있다 |
| LOH | 85,000바이트 이상의 큰 객체가 들어가는 heap | 큰 배열, 문자열, 버퍼에서 늘어나기 쉽다 |
| POH | pin된 객체용 heap | native 연동이나 pinning의 영향을 보는 실마리가 된다 |
| Finalization Queue | finalize 대기 객체 | Dispose 누락, finalizer 적체의 실마리가 된다 |
어느 지표가 어디를 보는지 그림으로 그리면, 이렇게 됩니다.
flowchart TB
PROC["프로세스가 쓰는 메모리"] --> MANAGED["매니지드 쪽<br/>GC Heap Size로 보이는 범위"]
PROC --> NATIVE["네이티브 쪽<br/>GC Heap Size에는 나오지 않는 범위"]
MANAGED --> G01["Gen 0 / Gen 1<br/>수명이 짧은 할당"]
MANAGED --> G2["Gen 2<br/>살아남은 장수명 객체"]
MANAGED --> LOH["LOH<br/>85,000바이트 이상의 큰 객체"]
MANAGED --> POH["POH<br/>pin된 객체"]
NATIVE --> STK["스레드 스택"]
NATIVE --> JITC["JIT한 코드, 로드된 어셈블리"]
NATIVE --> INTEROP["P/Invoke, COM, 외부 라이브러리의 버퍼"]
MANAGED -.합산된다.-> COMMIT["Private Bytes / Commit<br/>프로세스가 전용(private)으로 commit한 메모리"]
NATIVE -.합산된다.-> COMMIT
COMMIT -.물리 메모리에 올라가 있는 분만.-> WS["Working Set / RSS"]
이 그림에서 잡아 두었으면 하는 것은 다음 두 가지입니다.
dumpheap으로 보이는 것은 매니지드 쪽뿐입니다. 네이티브 쪽이 늘어나고 있으면, heap을 아무리 들여다봐도 범인은 나오지 않습니다.- Working Set과 Commit은 중첩 관계가 아닙니다. commit되어 있어도 물리 메모리에 올라가 있지 않으면 Working Set에는 나타나지 않고, 반대로 공유 라이브러리 페이지처럼 전용이 아닌 것이 Working Set에 잡히기도 합니다. 「Working Set이 내려가지 않으니 GC가 회수하지 않은 것이다」라고 말할 수 없는 것은, 이 때문입니다.
처음부터 모두 자세히 볼 필요는 없습니다. 우선 다음 물음으로 나눕니다.
프로세스 메모리가 늘어나고 있다
↓
managed heap도 늘어나고 있는가?
↓
GC 이후에도 남는 양이 늘어나고 있는가?
↓
어느 타입이 늘어나고 있는가?
↓
누가 참조하고 있는가?
이 순서를 지키면, 「겉보기의 메모리 증가」와 「진짜 누수」를 혼동하기 어려워집니다.
4. 판단 흐름
실무에서는 다음 흐름으로 가려내면 진행하기 쉽습니다.
1. 재현 조건을 정한다
- 어느 API, 화면, job, batch에서 늘어나는가
- 몇 번 실행하면 늘어나는가
- 부하를 멈추면 어떻게 되는가
2. dotnet-counters로 추이를 본다
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- GC 횟수
3. 시간 차를 두고 비교한다
- 기동 직후
- warmup 이후
- 부하 중
- 부하 정지 후
- 같은 조작을 N회 반복한 뒤
4. dump를 2회 이상 뜬다
- before
- after
- 가능하면 부하 정지 후에도 뜬다
5. 늘어난 타입을 찾는다
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. 누가 참조하는지 확인한다
- gcroot
- gchandles
- finalizequeue
7. 판정한다
- GC 미회수
- 정상적인 cache 증가
- managed memory leak
- native memory 문제
- LOH 단편화 또는 일시적인 대용량 할당
중요한 것은, 한 번의 숫자로 판단하지 않는 것입니다. 메모리 누수는 「계속 늘어나는 경향」이므로, 한 점의 값이 아니라 같은 조건에서 시간 차를 두고 비교합니다.
5. 사용할 도구
이 글에서는 주로 다음 도구를 사용합니다.
| 도구 | 쓰는 곳 |
|---|---|
dotnet-counters |
실행 중 프로세스의 GC나 Working Set 추이를 본다 |
dotnet-gcdump |
살아 있는 매니지드 객체의 통계를 비교적 가볍게 뜬다 |
dotnet-dump |
heap을 자세히 보고, 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 네임스페이스 영향을 받습니다.
프로덕션에서 실행할 때는, 바로 dump를 뜨지 말고 먼저 검증 환경에서 부하와 영향을 확인합니다.
5.1 조사 대상이 .NET Framework 4.x인 경우
dotnet-counters나 dotnet-dump, dotnet-gcdump는 .NET Core 3.0 이후 런타임이 가진 진단 기능을 쓰는 도구입니다. 조사 대상이 .NET Framework 4.x 애플리케이션이면 이들을 쓸 수 없습니다. 유지 대상이 Windows Forms나 WPF, ASP.NET의 기존 앱인 경우가 여기에 해당합니다.
대체는 다음 대응으로 생각합니다.
| 이 글의 도구 | .NET Framework 4.x에서의 대체 |
|---|---|
dotnet-counters로 추이를 본다 |
성능 모니터, 또는 Get-Counter로 .NET CLR Memory 카테고리의 카운터를 본다 |
dotnet-gcdump로 타입 통계를 비교한다 |
PerfView의 GC heap dump, 또는 Visual Studio의 「메모리 사용량」으로 스냅샷을 비교한다 |
dotnet-dump collect로 dump를 뜬다 |
ProcDump, 작업 관리자의 「덤프 파일 만들기」, 또는 Windows 오류 보고 설정으로 dump를 낸다 |
dotnet-dump analyze로 dumpheap / gcroot |
WinDbg에서 .loadby sos clr을 실행한 뒤 !dumpheap -stat이나 !gcroot를 쓴다 |
dotnet-trace로 할당을 따라간다 |
PerfView의 GC heap allocation 수집 |
사고방식은 완전히 같고, 「추이를 본다」「타입 통계를 2회 비교한다」「참조 경로를 따라간다」의 3단 구성입니다. 도구만 바뀝니다.
성능 모니터로 볼 때, .NET CLR Memory 카테고리 안에서 먼저 보고 싶은 카운터는 이 부근입니다.
| 카운터 | 보는 것 |
|---|---|
# Bytes in all Heaps |
Gen 1, Gen 2, LOH의 합계. 이 글에서 말하는 GC Heap Size에 가깝다 |
Gen 2 heap size |
Gen 2의 현재 바이트 수. 계속 늘어나면 누수를 의심한다 |
Large Object Heap size |
LOH의 현재 크기 |
# Gen 2 Collections |
풀 GC 횟수. 늘어나는 속도가 급하면 할당 과다를 의심한다 |
% Time in GC |
직전 GC 사이클에서 GC에 쓰인 시간 비율 |
Finalization Survivors |
finalize 대기로 살아남은 객체 수. Dispose 누락의 실마리 |
# Total committed Bytes |
GC가 commit하고 있는 가상 메모리량 |
카운터 이름은 한국어 환경에서는 한국어로 표시되는 경우가 있습니다. 찾지 못하면 영어 이름으로만 검색하지 말고, .NET CLR 메모리처럼 로컬라이즈된 카테고리 이름도 찾아 보십시오.
WinDbg로 분석할 때의 입구는 SOS 로드입니다.
0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>
.NET Framework에서는 SOS 명령에 !를 붙입니다. 이 글 9장 이후에 나오는 dumpheap -stat이나 gcroot는 !dumpheap -stat, !gcroot로 바꿔 읽으면, 그대로 같은 절차로 쓸 수 있습니다.
6. 먼저 dotnet-counters로 추이를 봅니다
처음에 볼 것은 상세 dump가 아니라 추이입니다.
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 이후 generation별 heap 크기 |
dotnet.gc.last_collection.memory.committed_size |
GC가 commit하고 있는 메모리량 |
dotnet.gc.heap.total_allocated |
기동 이후 누적 할당량 |
dotnet.gc.collections |
generation별 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은 안정되어 있는 경우가 있습니다. 이 경우 managed 객체의 누수라고 단정할 수 없습니다.
생각할 수 있는 요인은 이 부근입니다.
- JIT된 코드
- 로드된 어셈블리
- 스레드 스택
- 네이티브 라이브러리의 메모리
Marshal.AllocHGlobal같은 unmanaged memory- 이미지, 압축, 암호, DB 드라이버 등의 네이티브 쪽 버퍼
- 소켓, 파일 핸들, SSL, HTTP/2, gRPC 등의 내부 버퍼
- OS가 프로세스에서 물리 페이지를 바로 회수하지 않은 것뿐
이 상태에서 dumpheap을 아무리 봐도 주범이 나오지 않을 수 있습니다.
판단의 가늠은 이렇게 둡니다.
Working Set / RSS: 늘어난다
GC Heap Size: 안정
Gen 2 / LOH: 안정
이 경우는 .NET의 managed heap leak이 아니라, native memory, 핸들, 스레드 수, 소켓, 외부 라이브러리를 의심합니다.
dotnet-counters만으로 끝내지 말고, OS 도구, 컨테이너 메트릭, 핸들 수, 스레드 수, native heap, 외부 라이브러리 메트릭도 봅니다.
6.3 GC Heap이 늘어나지만, 부하 정지 후에 돌아온다
부하 중에 GC Heap이 늘어나는 것은 자연스럽습니다.
요청이 많다. 일시 객체가 많다. 큰 JSON을 다룬다. 일시적으로 리스트나 배열을 만든다.
이런 경우, 다음 GC까지 heap은 늘어납니다. 부하를 멈추면 GC가 돌고 heap이 돌아오는 일이 있습니다.
부하 중: GC Heap이 늘어난다
부하 정지 후: GC Heap이 내려가거나, 일정 값으로 돌아온다
반복 후: baseline이 계속 올라가지 않는다
이 경우는 「아직 GC되지 않은 것뿐」이거나 「일시 할당이 많다」고 판단할 수 있습니다.
다만 부하 중의 일시 할당이 너무 많으면, GC 횟수나 정지 시간이 늘어나 성능 문제가 됩니다. 누수가 아니어도 성능 개선의 대상은 됩니다.
6.4 GC 이후의 Gen 2 / LOH가 계속 늘어난다
주의할 것은 이 패턴입니다.
같은 조작을 반복한다
↓
Gen 2가 늘어난다
↓
LOH가 늘어난다
↓
부하를 멈춰도 돌아오지 않는다
↓
다음 측정에서도 더 늘어난다
Gen 2는 오래 사는 객체가 들어가는 generation입니다. LOH는 큰 배열이나 문자열 등이 들어가기 쉬운 heap입니다.
여기가 계속 늘어나면 누수, 무제한 cache, 거대 버퍼 유지, event 구독 해제 누락, static 컬렉션, 장수명 서비스에 의한 유지를 의심합니다.
이 단계에서 다음으로 진행합니다.
7. 「GC 미회수」인지 확인하는 방법
「아직 GC되지 않은 것뿐인가」를 보려면, 충분한 GC 기회가 있은 뒤의 상태를 봅니다.
다만 프로덕션 코드에 쉽게 GC.Collect()를 넣어서는 안 됩니다.
GC.Collect()는 GC를 강제합니다. 특히 전 generation의 blocking GC는 애플리케이션의 정지 시간을 만듭니다. 통상 운영에서는 GC에 맡기는 것이 기본입니다.
그래도 조사에서는, 통제된 검증 환경에서 「강제 GC 이후에도 남는가」를 보는 경우가 있습니다.
검증용 콘솔 앱이나 재현 환경이라면, 다음 같은 코드로 풀 GC 이후 상태를 확인할 수 있습니다.
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
핵심은, 이것을 해결책으로 쓰지 않는 것입니다. 어디까지나 조사용입니다.
확인하고 싶은 것은 이 흐름입니다.
조작 전
↓
조작을 N회 반복한다
↓
부하를 멈춘다
↓
충분히 기다리거나, 검증 환경에서 풀 GC를 유발한다
↓
GC 이후 heap이 조작 전에 가까운 값으로 돌아오는가
돌아오면 GC 미회수 또는 일시 할당일 가능성이 높습니다. 돌아오지 않고, 같은 조작을 반복할 때마다 baseline이 올라가면 무언가가 살아남아 있습니다. 그 「무언가」를 dump로 찾습니다.
8. dotnet-gcdump로 가볍게 비교합니다
첫 비교에는 dotnet-gcdump가 편리합니다.
dotnet-gcdump는 실행 중인 .NET 프로세스에서 GC dump를 얻어, heap 위 타입별 통계를 보는 데 쓸 수 있습니다.
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
보는 것은 타입별 Count와 Size입니다.
예를 들어 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이 늘어난다 |
cache, 로그, JSON, 사전 키, 중복 문자열을 의심한다 |
Task, Timer, CancellationTokenSource가 늘어난다 |
비동기 처리, Timer, 취소 해제 누락을 의심한다 |
dotnet-gcdump는 비교의 입구로 쓰기 쉬운 반면, 취득 시 Gen 2 GC를 유발합니다. heap이 큰 환경이나 latency에 민감한 환경에서는 정지 시간과 추가 메모리 소비에 주의합니다.
Windows라면 .gcdump를 Visual Studio나 PerfView로 열어 비교할 수 있습니다.
비 Windows 환경에서는 CLI의 report로 타입 통계를 보고, 참조 경로의 심층 추적은 dotnet-dump로 넘어가는 것이 실무적입니다.
9. dotnet-dump로 heap과 참조 경로를 봅니다
「늘어나는 타입」이 보이기 시작하면, 다음은 「왜 회수되지 않는가」를 봅니다.
그러려면 dotnet-dump로 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
dump 취득은 무거운 조작입니다. 특히 Full / Heap dump는 크기가 크고, 프로세스나 컨테이너에 부하를 줍니다. 프로덕션에서 뜰 때는 시간대, 디스크 용량, 컨테이너 메모리 제한, 개인정보나 기밀 정보 혼입에 주의합니다.
취득한 dump를 분석합니다.
dotnet-dump analyze myapp-2.dmp
우선 heap 전체 통계를 봅니다.
> 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 cache에서 참조되고 있어, GC 입장에서는 아직 사용 중이기 때문입니다.
여기서 비로소 다음 판단을 할 수 있습니다.
- 그 cache는 정말 필요한가
- 상한이 있는가
- 유효 기간이 있는가
- 키가 계속 늘어나는 설계가 되어 있지 않은가
- 테넌트, 사용자, 날짜, 요청 ID 등을 키로 써서 무한히 늘어나고 있지 않은가
메모리 누수 조사에서 중요한 것은 dumpheap -stat에서 끝내지 않는 것입니다.
dumpheap -stat은 「무엇이 많은가」를, gcroot는 「왜 남아 있는가」를 알려 줍니다. 수정으로 이어지는 것은 후자입니다.
10. 가려내는 법 한눈에 보기
실무에서 흔한 패턴을 정리합니다.
| 관측 | 가능성 | 다음에 볼 것 |
|---|---|---|
| Total Allocated만 늘어난다 | 통상 할당, 또는 할당 과다 | Allocation Rate, GC 횟수, CPU, dotnet-trace |
| Working Set은 늘어나지만 GC Heap은 안정 | native memory, JIT, 스택, OS 쪽 유지 | 스레드 수, 핸들 수, 네이티브 도구, 외부 라이브러리 |
| GC Heap이 부하 중에만 늘어나고, 정지 후 돌아온다 | GC 미회수, 일시 할당 | 부하 정지 후 Gen 2 / LOH, GC 횟수 |
| GC 이후 Gen 2가 계속 늘어난다 | 장수명 객체의 유지 | dumpheap -stat, gcroot |
| LOH가 계속 늘어난다 | 큰 배열, 버퍼, 단편화, 거대 문자열 | System.Byte[], System.Char[], LOH, Free 영역 |
System.String이 크다 |
문자열 cache, JSON, 로그, 사전 키 | 문자열을 붙잡고 있는 자체 타입을 찾는다 |
System.Byte[]가 크다 |
버퍼, 직렬화, 이미지, 압축, 통신 | 소유하는 타입, ArrayPool 반환 누락, native 연동 |
Task가 늘어난다 |
끝나지 않는 비동기 처리, 대기 큐 | async 대기, 취소, 채널, 큐 |
Timer가 늘어난다 |
Timer Dispose 누락 | Dispose, 등록 해제, 장수명 서비스 |
CancellationTokenSource가 늘어난다 |
CTS Dispose 누락, linked token 과다 | Dispose, 링크 해제, 타임아웃 생성 지점 |
EventHandler나 delegate가 남는다 |
event 구독 해제 누락 | publisher / subscriber의 수명 차 |
| Finalization Queue가 늘어난다 | Dispose 누락, finalizer 적체 |
finalizequeue, finalizer 스레드 |
| Pinned handle이 많다 | pin된 버퍼, native 연동 | gchandles, POH, pinning 지점 |
11. 흔한 누수의 형태
패턴을 7가지 들지만, 처음부터 순서대로 읽을 필요는 없습니다. 지금 보고 있는 증상에 가까운 행부터 들어가십시오.
| 절 | 패턴 | 전형적인 증상 | 먼저 볼 지표 |
|---|---|---|---|
| 11.1 | static 컬렉션 | 조작 횟수에 비례해 늘어나고, 부하를 멈춰도 돌아오지 않는다 | Gen 2. gcroot에서 static field가 나오는지 |
| 11.2 | 무제한 cache | 가동 시간에 비례해 늘어난다. 재시작하면 돌아온다 | Gen 2. cache 건수와 System.String의 늘어나는 방식 |
| 11.3 | event 구독 해제 누락 | 화면이나 scope를 열고 닫을 때마다 늘어난다 | 해당 ViewModel이나 핸들러 타입의 Count. delegate를 경유한 gcroot |
| 11.4 | Timer Dispose 누락 | 짧게 살 줄 알았던 객체가 사라지지 않고, 콜백도 계속 움직인다 | System.Threading.Timer나 TimerQueueTimer의 Count |
| 11.5 | IDisposable 해제 누락 |
GC Heap은 안정인데, 핸들 수나 프로세스 메모리가 늘어난다 | 핸들 수, Finalization Queue, Working Set |
| 11.6 | AsyncLocal이나 컨텍스트 유지 |
요청 처리가 끝나도 DTO가 남는다 | async state machine을 경유한 gcroot |
| 11.7 | DI lifetime 착오 | 요청 수에 비례해 늘어난다 | singleton 타입으로부터의 gcroot |
「먼저 볼 지표」열은 10장의 한눈에 보기 표와 짝으로 쓰십시오. 10장이 「관측 결과에서 가능성을 좁히는 표」, 이 표가 「패턴에서 확인할 지표로 돌아가는 표」입니다.
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같은 cache 기구를 쓴다- 명시적으로 삭제한다
- static을 그만두고, 적절한 lifetime의 서비스로 옮긴다
- 영속화가 목적이면 DB나 외부 스토리지로 옮긴다
중요한 것은 「static이 나쁘다」는 이야기가 아니라, static에 둔 것은 장수명이 된다는 성질을 이해하고 쓰는 것입니다.
11.2 무제한 cache
cache는 의도적으로 메모리를 쓰는 것이므로, 설계대로의 증가라면 누수가 아닙니다. 그러나 상한이나 기한이 없는 cache는 실질적인 메모리 누수가 됩니다.
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;
}
}
이 예에서는 userId와 date의 조합이 계속 늘어나면 cache도 계속 늘어납니다.
특히 위험한 것은, 키에 이런 값을 넣는 경우입니다.
- 요청 ID
- 현재 시각
- GUID
- 세션 ID
- 사용자 입력을 정규화하지 않고 쓴 문자열
- SQL이나 검색 조건을 그대로 문자열화한 것
cache는 다음 조건을 정해 둡니다.
| 조건 | 예 |
|---|---|
| 최대 건수 | 10,000건까지 |
| 최대 크기 | 256MB까지 |
| 유효 기간 | 마지막 접근부터 30분 |
| 절대 기한 | 생성부터 6시간 |
| 폐기 조건 | 테넌트 삭제, 사용자 삭제, 설정 변경 |
| 감시 항목 | 건수, 추정 크기, 히트율, 축출 횟수 |
「cache이니까 늘어나도 된다」가 아니라, 「어디까지 늘어나도 되는가」를 정해야 합니다.
11.3 event 구독 해제 누락
event는 수명이 긴 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의 event가 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, 장수명 서비스, 메시지 브로커, event aggregator에서 자주 나옵니다.
11.4 Timer Dispose 누락
System.Threading.Timer나 PeriodicTimer, Reactive Extensions의 subscription 등도, Dispose하지 않으면 남습니다.
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를 Dispose하는 설계가 필요합니다.
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 해제 누락은, 반드시 managed heap 누수로 보인다고는 할 수 없습니다.
파일, 소켓, DB 연결, native handle, 버퍼 같은 리소스 문제로 나오는 경우가 있습니다.
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
이 예에서는 StreamReader가 stream을 닫으므로 큰 문제가 되지 않는 경우가 많지만, 소유권이 모호한 코드에서는 누락이 일어납니다.
기본은 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 누락은 이런 증상으로 나옵니다.
- 핸들 수가 늘어난다
- 소켓이 늘어난다
- 파일이 닫히지 않는다
- native memory가 늘어난다
- 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 lifetime 착오
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는 앱 수명과 같습니다.
설계로서 버퍼한다면 상한, 송신, 삭제, backpressure가 필요합니다. 단지 「나중에 볼지도 모른다」 정도라면 로그나 외부 스토리지로 내보내야 합니다.
12. LOH는 특히 오해하기 쉽습니다
LOH는 Large Object Heap의 약어입니다. .NET에서는 큰 객체가 보통의 작은 객체와는 다른 heap에 놓입니다. 대표 예는 큰 배열입니다.
var buffer = new byte[1024 * 1024 * 10]; // 10MB
LOH에서 흔한 문제는 다음 세 가지입니다.
- 큰 객체를 자주 만든다
- 큰 객체를 오래 붙잡는다
- 큰 객체의 생성과 폐기로 단편화한다
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);
}
다만 pool에 반환했다고 해서 프로세스 메모리가 바로 내려간다는 보장은 없습니다. pool은 재사용을 위해 메모리를 붙잡고 있는 경우가 있습니다.
여기에서도 볼 것은 「계속 늘어나는가」「상한이 있는가」「재사용되고 있는가」입니다.
13. gcroot 읽는 법
gcroot는 어떤 객체가 어디에서 참조되고 있는지를 표시합니다.
전형적인 root를 표로 정리합니다.
| root | 의미 |
|---|---|
| static field | 타입의 static 필드에서 참조되고 있다 |
| local variable / stack | 실행 중 스레드의 스택에서 참조되고 있다 |
| GC handle | GCHandle, pin, delegate, interop 등에서 참조되고 있다 |
| finalization queue | finalize 대기로 유지되고 있다 |
| thread / async state machine | 실행 중 또는 대기 중인 비동기 처리가 붙잡고 있다 |
조사에서 자주 볼 포인트는 수명의 차입니다.
장수명 객체
-> 단수명이었어야 할 객체
이 형태가 나오면 누수 후보입니다.
예를 들어 다음은 수상합니다.
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService는 앱 전체에서 살아 있습니다.
그 안에 요청 단위의 RequestContext가 쌓여 있다면 설계를 다시 봐야 합니다.
한편 이런 root는 타이밍에 따라서는 정상입니다.
Thread stack
-> Controller action local variable
-> RequestDto
요청 처리 중이라면 지역 변수가 남아 있는 것이 당연합니다.
그래서 dump는 타이밍이 중요합니다.
부하 중뿐 아니라, 부하 정지 후, 큐가 비운 뒤, 일정 시간 idle로 둔 뒤의 dump도 뜨면 판단하기 쉬워집니다.
14. 「강제 GC로 내려갔으니 해결」이 아닙니다
조사 중에 GC.Collect()를 호출했더니 메모리가 내려갔다. 이때 「그러면 주기적으로 GC.Collect()하면 된다」고 생각하는 것은 위험합니다.
강제 GC는 근본 원인을 없애지 않습니다. 단지 아직 회수되지 않았던 객체를 그 자리에서 거뒀을 뿐입니다.
할당률이 높은 것이 문제라면, 강제 GC는 정지 시간을 늘려 성능을 악화시킵니다. 진짜 누수라면, 참조가 남은 객체는 강제 GC로도 회수되지 않습니다.
조사에서 볼 것은 다음 차이입니다.
| 강제 GC 이후 | 판단 |
|---|---|
| 크게 내려가고, 이후 baseline이 안정된다 | GC 미회수, 또는 일시 할당이 주원인 |
| 조금 내려가지만, 반복할 때마다 바닥이 올라간다 | 일부가 살아남아 있다. 누수 후보 |
| 거의 내려가지 않는다 | 계속 참조되고 있거나, GC heap 이외가 주원인 |
| GC Heap은 내려가지만 Working Set은 내려가지 않는다 | OS / GC 세그먼트 / 네이티브 쪽 유지 가능성 |
프로덕션에서 GC.Collect()를 주기 실행하기 전에, 반드시 「무엇이 늘어나고 있는가」를 특정합니다.
15. 실무에서 쓰는 조사 절차
여기서부터는 실제로 조사할 때의 절차로 정리합니다.
15.1 재현 시나리오를 고정합니다
먼저 조사 조건을 고정합니다.
대상: /api/report/export
조작: 같은 조건으로 100회 실행
측정 간격: 5초
관측 시간: warmup 5분 + 부하 10분 + idle 5분
환경: staging / Release build / 프로덕션 상당 설정
메모리 조사에서는, 매번 다른 조작을 하면서 보면 판단할 수 없습니다. 「무엇을 했더니 늘어났는가」를 고정합니다.
15.2 baseline을 잡습니다
기동 직후가 아니라, warmup 이후를 baseline으로 둡니다.
이유는, 기동 직후에는 이런 한 번의 증가가 있기 때문입니다.
- JIT
- DI 컨테이너 구축
- 설정 읽기
- 첫 DB 연결
- 첫 TLS / HTTP 연결
- JSON serializer의 메타데이터 생성
- Razor / 템플릿 초기화
- 로거나 메트릭 초기화
순서는 이렇게 둡니다.
1. 앱 기동
2. 헬스 체크나 대표 API를 몇 번 친다
3. 1〜5분 정도 기다린다
4. baseline으로 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로 오르내린다
부하 정지 후에 돌아온다
baseline이 계속 올라가지 않는다
수상한 형태:
조작 횟수에 비례해 늘어난다
Gen 2 / LOH의 바닥이 올라간다
부하 정지 후에도 돌아오지 않는다
다음 부하에서 바닥이 더 올라간다
15.4 dump를 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
비교할 때는 before와 after뿐 아니라 idle-after가 중요합니다.
부하 중에 늘어났어도, idle 후에 돌아오면 누수가 아닐 수 있습니다.
15.5 늘어난 타입을 봅니다
dotnet-dump analyze after.dmp
> dumpheap -stat
before 쪽도 같이 봅니다.
수작업이어도 됩니다. 우선 상위 타입을 비교합니다.
볼 관점을 듭니다.
- 자사 네임스페이스의 타입이 늘어나고 있는가
System.String뒤에 자사 타입이 없는가System.Byte[]를 누가 가지고 있는가List<T>나Dictionary<TKey,TValue>가 늘어나지 않았는가Task나 async state machine이 늘어나지 않았는가Timer나CancellationTokenSource가 늘어나지 않았는가
15.6 누가 참조하는지를 봅니다
후보 객체 주소를 집어 gcroot합니다.
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
gcroot 결과에서 붙잡고 있는 부모를 찾습니다.
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
여기까지 오면 코드 리뷰 대상이 보입니다.
ReportCache는 singleton인가- 상한이 있는가
- 삭제되는가
- 키가 계속 늘어나는가
- ReportResult가 너무 크지 않은가
- cache가 아니라 DB나 파일로 빼야 하는가
16. dotnet-trace를 쓰는 장면
dotnet-dump는 어떤 순간의 스냅샷으로 「결과로서 무엇이 남아 있는가」를 보는 데 맞습니다. 한편 「언제, 어디에서 대량으로 할당하고 있는가」를 보고 싶을 때는 dotnet-trace를 씁니다.
예를 들어 GC 관련 이벤트를 포함해 trace합니다.
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
trace는 dump와는 다른 각도로 도움이 됩니다.
| 보고 싶은 것 | 맞는 도구 |
|---|---|
| 무엇이 남아 있는가 | 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. 「메모리 누수」라고 단정하기 위한 기준
조사의 마지막에는 다음 형태로 설명할 수 있게 합니다. 보고서에 옮길 때의 형으로, 채워야 할 칸을 먼저 듭니다.
| 칸 | 쓸 내용 |
|---|---|
| 현상 | 누가 곤란한가. 어느 조작에서 얼마나 늘어나는가 |
| 관측 조건 | 환경, 빌드 구성, 데이터량, 실행 횟수, warmup 시간, 관측 간격, 사용한 도구와 버전 |
| 관측 | counters 값이 어떻게 움직였는가. Working Set과 GC Heap을 나눠 쓴다 |
| 비교 | before와 after dump에서, 어느 타입이 몇 건 늘어났는가 |
| 참조 경로 | gcroot에서 보인 유지 경로 |
| 원인 | 코드상 어느 설계가 그 유지를 만들고 있는가 |
| 대책 | 무엇을 바꿀 것인가. 효과를 어떻게 잴 것인가 |
| 남은 과제 | 필요하면. 이번 관측으로 설명하지 못한 것과, 다음에 볼 것 |
관측 조건을 빠뜨리지 않는 것이 특히 중요합니다. 조건이 적혀 있지 않으면 수정 후 재측정과 비교할 수 없고, 20장의 확인이 의미를 잃습니다.
이 형에 맞춰 채우면, 예를 들어 다음처럼 됩니다.
현상:
/api/report/export를 100회 실행하면, 부하 정지 후에도 GC Heap이 300MB 늘어난 채로 돌아오지 않는다.
관측 조건:
staging 환경 / Release build / 프로덕션 상당의 설정과 데이터량.
warmup 5분 뒤, 같은 조건으로 100회 실행하고, 그다음 5분 idle.
dotnet-counters를 5초 간격으로 수집.
관측:
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로 바꾸고, 크기 상한과 유효 기간을 설정했다.
cache 건수를 메트릭화했다.
여기까지 설명할 수 있으면, 단순한 「메모리가 늘어납니다」가 아니라, 재현 조건, 관측값, 늘어난 타입, 참조 경로, 원인, 대책이 이어진 보고가 됩니다.
19. 조사할 때의 주의점
19.1 Release 빌드로 봅니다
Debug 빌드는 최적화나 지역 변수 수명, 디버그 정보의 영향으로, 실제 운영과 보이는 모습이 달라질 수 있습니다.
프로덕션 상당의 조사에서는 Release 빌드, 운영에 가까운 설정, 가까운 데이터량으로 확인합니다.
19.2 기동 직후만으로 판단하지 않습니다
기동 직후에는 여러 초기화로 메모리가 늘어납니다.
warmup 이후 baseline을 잡고, 거기에서 늘어나는지를 봅니다.
19.3 한 번의 dump로 범인 취급하지 않습니다
heap 상위에 있는 타입이 반드시 범인은 아닙니다.
System.String이나 System.Byte[]는 많은 앱에서 크게 보입니다.
중요한 것은, 시간 차를 두고 늘어났는지, 그리고 누가 가지고 있는지입니다.
19.4 dump에는 기밀 정보가 들어갑니다
메모리 dump에는 요청, 인증 정보, 연결 문자열, 개인정보, 업무 데이터가 포함될 수 있습니다.
저장 장소, 반출, 공유, 삭제 규칙을 정합니다.
19.5 컨테이너에서는 dump 취득이 위험이 됩니다
컨테이너 메모리 제한이 빡빡하면, dump 취득에 따른 추가 메모리나 page-in으로 OOM Kill될 수 있습니다.
프로덕션 컨테이너에서 뜨기 전에 staging에서 시험하고, 제한값, 디스크 용량, 권한, PID 네임스페이스를 확인합니다.
19.6 GC Heap 이외의 누수도 있습니다
.NET 조사라고 해서 모든 것이 GC heap에 나오는 것은 아닙니다.
다음 같은 문제에서는 GC Heap이 안정이어도 프로세스 메모리가 늘어날 수 있습니다.
- 네이티브 라이브러리
- P/Invoke
- COM
- 이미지 처리
- 압축 라이브러리
- 암호 처리
- DB 드라이버
- 소켓
Marshal.AllocHGlobalNativeMemory.Alloc- 스레드 과다
이 경우는 dotnet-dump의 dumpheap만으로는 부족합니다.
OS 쪽 진단, 외부 라이브러리 메트릭, 핸들, 스레드, native memory를 봐야 합니다.
19.7 프로덕션에서 dump를 뜨기 전에, 관계자와 정해 둘 것
프로덕션 환경에서의 dump 취득은, 기술 조작이기 전에 조정이 필요한 작업입니다. 여기를 건너뛰고 「조사이니 뜨게 해 주십시오」라고만 말하면, 대개 멈춥니다.
먼저 영향으로 설명해야 할 것을 정리합니다.
- dump 취득은 대상 프로세스에 무거운 조작이며, 취득 중에는 응답이 멈춘 것처럼 보일 수 있습니다. 얼마나 멈추는지는 heap 크기, 디스크 속도, 환경에 따라 달라지므로, staging에서 같은 절차를 한 번 통과시켜 실측해 둡니다.
- 응답이 멈추는 시간이 길면, 헬스 체크 타임아웃, 로드 밸런서로부터의 분리, 클러스터 failover를 유발할 수 있습니다.
Full이나Heapdump는 프로세스 메모리 사용량에 따라 커집니다. 내보낼 곳의 빈 디스크 용량을 먼저 확인합니다.- 컨테이너에서는 dump 취득에 따른 page-in으로 메모리 제한을 넘어, 컨테이너가 강제 종료되는 경우가 있습니다(19.5).
- dump에는 개인정보나 자격 정보가 포함될 수 있습니다(19.4).
그다음, 취득 전에 다음을 정해 둡니다.
| 정할 것 | 구체 예 |
|---|---|
| 누가 승인하는가 | 서비스 책임자와 정보시스템 부서. 양쪽 합의를 먼저 받는다 |
| 언제 뜨는가 | 업무 시간 외 등, 일시적인 응답 지연을 허용할 수 있는 시간대 |
| 어디에 쓰는가 | 여유가 충분한 로컬 디스크. 공유 폴더에 바로 쓰지 않는다 |
| 누가 접근할 수 있는가 | 보관처 접근 권한을 조사 담당으로 한정한다 |
| 언제 지우는가 | 조사 완료 후 삭제 기한을 먼저 정해 기록한다 |
| 실패하면 어떻게 하는가 | 응답이 돌아오지 않을 때의 재시작 절차와, 판단하는 사람 |
| 사전 검증 | staging에서 같은 절차를 한 번 통과시키고, 소요 시간과 파일 크기를 적어 둔다 |
의뢰를 낼 때는 구두가 아니라, 이 내용을 한 장으로 정리해 넘기는 것이 확실합니다. 서식 예입니다.
목적: /api/report/export 실행 후 메모리가 돌아오지 않는 원인을 특정한다
방법: dotnet-dump collect --type Heap을 부하 전후로 2회 취득한다
영향: 취득 중에는 대상 프로세스의 응답이 늦어진다.
staging에서의 실측값을 별지에 첨부
시간대: 업무 시간 외. 담당과 정보시스템이 함께할 수 있는 시간
내보낼 곳: 대상 서버의 로컬 디스크. 사전에 빈 용량을 확인
포함 정보: 메모리상의 요청 데이터, 연결 문자열 등이 포함될 수 있다
취급: 보관처 접근 권한은 조사 담당만. 반출하지 않는다
삭제: 조사 완료 후, 늦어도 1개월 이내에 삭제하고 삭제 기록을 남긴다
되돌리기: 취득 후 응답이 돌아오지 않으면 프로세스를 재시작한다
「무엇을 위해」「얼마나 멈추는가」「어디에 두고, 언제 지우는가」가 갖춰져 있으면, 판단하는 쪽도 가부를 돌려주기 쉬워집니다. 반대로 여기가 모호하면 조사 자체가 멈춥니다.
20. 수정 후 확인
누수로 보이는 지점을 수정했다면, 같은 절차로 재측정합니다.
수정 전:
100회 실행 후, Gen 2가 +300MB
ReportResult가 +12,000건
수정 후:
100회 실행 후, Gen 2가 +20MB 이내에서 안정
ReportResult는 부하 정지 후 baseline으로 돌아온다
cache 건수는 상한 1,000건에서 안정
수정 확인에서는 반드시 같은 조건으로 비교합니다.
- 같은 데이터량
- 같은 횟수
- 같은 부하 시간
- 같은 warmup
- 같은 관측 간격
- 같은 도구
메모리 조사는 수정 전후 비교가 약하면 설득력이 나오지 않습니다.
21. 정리
.NET에서 메모리가 늘어날 때, 바로 누수로 단정하지 말고 다음 순서로 가려냅니다.
- Working Set / RSS만으로 판단하지 않는다
dotnet-counters로 GC Heap, Gen 2, LOH, GC 횟수를 본다- 부하 중, 부하 정지 후, 시간 차를 두고 비교한다
dotnet-gcdump나dotnet-dump로 늘어난 타입을 본다gcroot로 참조 경로를 본다- static, cache, event, Timer, DI lifetime, 비동기 컨텍스트를 확인한다
- GC Heap이 안정이면, native memory나 OS 쪽 문제도 의심한다
「아직 GC되지 않은 것뿐」과 「메모리 누수」의 차이는, 마지막에는 참조로 결정됩니다.
불필요해진 객체가 참조되지 않으면, GC 타이밍에 회수됩니다. 불필요해야 하는데 계속 참조되면, GC는 회수할 수 없습니다.
즉 조사의 목표는 이것입니다.
무엇이 늘어나고 있는가.
어느 GC 이후에도 남아 있는가.
누가 참조하고 있는가.
그 참조는 설계상 필요한가.
여기까지 알면, 메모리 그래프에 휘둘리지 않고 코드상의 수정점으로 떨어뜨릴 수 있습니다.
참고 자료
- 이 글의 샘플 코드 세트(라이브러리, 데모, 유닛 테스트) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows impersonation token을 올바르게 다루기 ── 스레드 단위 권한 차용과 안전한 되돌리기
Windows impersonation token에 대해 access token, primary token, thread token, impersonation level, RevertToSelf, .NET의 WindowsIdentity.RunIm...
TCP에서 Send한 단위마다 Receive할 수 있다는 오해 ── 바이트 스트림으로 다루기 위한 수신 설계
TCP 통신에서 Send나 Write한 단위마다 수신할 수 있다고 생각하면 분할·결합·문자 깨짐·프로토콜 손상이 발생합니다. TCP를 바이트 스트림으로 다루고 애플리케이션 쪽에서 프레이밍하는 사고방식과 .NET/C# 구현 예를 정리합니다.
PDB(프로그램 데이터베이스)란 무엇인가 - 디버그 정보·심볼·Source Link 이해하기
PDB(Program Database / 프로그램 데이터베이스)란 무엇인지, 무엇이 들어 있고 무엇이 들어 있지 않은지, Debug / Release, Portable PDB, Source Link, 심볼 서버, 덤프 분석과의 관계까지 실무 관점...
Roslyn이란 무엇인가 ── 컴파일러의 시각으로 C# 코드를 읽고 고치고 생성하기
Roslyn(.NET Compiler Platform)의 개요, Syntax Tree, SemanticModel, Workspace, Analyzer, Source Generator, 실무에서의 활용처와 주의점을 정리합니다.
.NET Framework / .NET에서 대수적 데이터 타입 사용하기 ── 상태와 결과를 타입으로 표현하는 설계
대수적 데이터 타입, 특히 합 타입·discriminated union을 .NET Framework와 .NET에서 쓰는 방법과 이점을 F#, C#의 클래스 계층, record, OneOf, C# 15 union preview까지 포함해 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기존 자산 활용 & 이관 지원
COM / ActiveX / OCX 자산, 네이티브 코드, 32비트 의존성을 유지하면서 단계적인 이관 계획을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- .NET 앱의 메모리 사용량이 늘어나면 메모리 누수인가요?
- 프로세스 메모리가 늘어난 것과 메모리 누수는 같은 말이 아닙니다. .NET에서는 GC가 할당 상황과 heap 임계값을 보고 동작하므로, 불필요해진 객체가 아직 GC되지 않은 경우나, GC 이후에도 OS가 바로 메모리를 거두지 않는 경우가 있습니다. 볼 것은 GC 이후에도 남는 메모리가 늘어나는지, 늘어나는 타입은 무엇인지, 그 객체를 누가 참조하는지 세 가지입니다.
- .NET 메모리 누수는 어떤 도구로 조사하나요?
- 먼저 dotnet-counters로 Working Set, GC Heap, Gen 2/LOH, GC 횟수의 추이를 봅니다. 이어서 dotnet-gcdump로 부하 전후의 GC dump를 떠서 타입별 Count와 Size를 비교하고 늘어난 타입을 찾습니다. 마지막으로 dotnet-dump로 heap dump를 뜬 뒤, dumpheap -stat과 gcroot로 회수되지 않는 이유의 참조 경로까지 따라갑니다. 한 시점의 숫자가 아니라, 같은 조건에서 시간 차를 두고 비교하는 것이 중요합니다.
- .NET에서 흔한 메모리 누수 패턴은 무엇인가요?
- 전형적인 예는 static 컬렉션에 계속 추가만 하는 경우, 상한이나 만료가 없는 무제한 cache, 수명이 긴 publisher에 대한 event 구독 해제 누락, Timer Dispose 누락, IDisposable 해제 누락, singleton이 요청 단위 데이터를 붙잡고 있는 DI lifetime 착오입니다. .NET의 누수는 해제를 잊었다기보다, 불필요한데도 계속 참조되는 의도하지 않은 유지로 보는 편이 이해하기 쉽습니다.
- GC.Collect()를 주기적으로 호출하면 메모리 문제가 해결되나요?
- 해결되지 않습니다. 강제 GC는 아직 회수되지 않았던 객체를 그 자리에서 거둘 뿐, 근본 원인을 없애지 않습니다. 할당률이 높은 것이 문제라면 pause를 늘려 성능을 오히려 해치고, 진짜 누수라면 참조가 남은 객체는 강제 GC로도 회수되지 않습니다. 조사용으로 통제된 검증 환경에서 강제 GC 이후에도 남는지 보는 경우는 있지만, 대책으로 프로덕션에 넣기 전에 무엇이 늘어나는지부터 특정해야 합니다.