21. 메모리 사용량 진단: RSS와 힙 사용량의 괴리, 할당자 반환 정책, 누수 vs 단편화
난이도 상난이도: 상
모범답안 — 메모리 사용량 진단: RSS와 힙 사용량의 괴리
1. RSS 가 무엇을 세는가
| 지표 | 의미 |
|---|---|
| VSZ (Virtual Size) | 프로세스가 예약한 가상 주소 공간 전체. 실제 물리 메모리와 무관. mmap 으로 잡아만 두고 안 쓴 영역, 스레드 스택 예약분, 공유 라이브러리 매핑이 모두 포함된다. 값이 크다고 문제가 아니다. |
| RSS (Resident Set Size) | 현재 물리 메모리에 올라와 있는 페이지의 총합. 공유 라이브러리 페이지가 전체 크기로 중복 계산되므로, 여러 프로세스의 RSS 를 더하면 실제 물리 메모리보다 커진다. |
| PSS (Proportional Set Size) | 공유 페이지를 공유 프로세스 수로 나눠 배분한 값. 여러 프로세스의 PSS 합은 실제 사용량에 가깝다. /proc/<pid>/smaps 또는 smem 으로 확인. |
| 애플리케이션 힙 사용량 | 앱이 new/malloc 으로 요청해 아직 반환하지 않은 바이트의 합. 앱이 아는 것은 여기까지다. |
핵심은 네 값 사이에 여러 겹의 중간 계층이 있다는 것이다.
앱이 요청한 바이트
↓ (할당자 메타데이터, 정렬 패딩, 크기 클래스 반올림)
할당자가 관리하는 힙 영역
↓ (free 후에도 할당자가 붙잡고 있는 부분 = 반환되지 않은 메모리)
프로세스가 커밋한 가상 메모리
↓ (접근된 페이지만 물리 프레임 할당 = demand paging)
RSS
각 단계에서 값이 벌어진다.
- 공유 라이브러리: 코드 페이지는 read-only 로 공유되지만 RSS 에는 전체가 계산된다.
- 페이지 캐치·파일 매핑:
mmap한 파일의 읽은 페이지가 RSS 에 들어간다. - 스레드 스택: 스레드당 기본 8MB 를 예약하지만 실제 접근한 페이지만 RSS. 스레드 1000개면 VSZ 는 8GB 증가하지만 RSS 는 수십 MB 일 수 있다.
- 커밋했지만 접근 안 한 페이지:
malloc(1GB)후 쓰지 않으면 RSS 는 거의 늘지 않는다 (오버커밋). 처음 쓰는 순간 페이지 폴트로 물리 프레임이 붙는다.
2. 누수와 단편화의 구분
메커니즘
- 누수(leak): 앱이 해제하지 않아 살아 있는 객체 총량 자체가 증가한다. 할당자 입장에서는 정상적으로 사용 중인 메모리다.
- 단편화(fragmentation): 앱은 정상적으로 해제하지만, 해제된 조각들이 흩어져 있어
큰 연속 영역을 만들 수 없고, 할당자가 OS 에 페이지를 돌려줄 수도 없다. 살아 있는 객체
총량은 일정한데 할당자가 붙잡은 영역만 커진다.
- 외부 단편화: 빈 공간의 합은 충분한데 연속된 큰 블록이 없다.
- 내부 단편화: 크기 클래스 반올림으로 요청보다 큰 블록을 준다(40바이트 요청 → 48바이트 클래스).
관측으로 구분하기
| 관측 | 누수 | 단편화 |
|---|---|---|
| 앱이 추적하는 살아 있는 객체 총량 | 증가 | 일정 |
| RSS | 증가 | 증가 |
| RSS / 살아있는 객체 총량 비율 | 대체로 일정 | 계속 증가 |
| 힙 프로파일러의 "살아 있는 할당" 스택 | 특정 스택이 계속 누적 | 특정 스택 없음, 고르게 분포 |
malloc_trim() 호출 후 RSS |
거의 안 줄어듦 | 일부 줄어듦 |
| 부하 정지 후 시간 경과 | 그대로 | jemalloc 등은 서서히 감소 |
문제 상황("살아 있는 객체 총량은 일정한데 RSS 만 증가")은 명확히 단편화 또는 할당자의 반환 지연을 시사한다. 단, 세 번째 가능성을 반드시 배제해야 한다.
- 앱이 추적하지 못하는 할당: 네이티브 라이브러리 내부 할당, 소켓 버퍼, 스레드 스택,
mmap한 파일. 앱의 카운터는 자기가 아는 것만 센다. 앱 카운터가 일정하다는 사실 자체가 "누수가 없다"를 증명하지 않는다. - 스레드 수 증가: 스레드가 계속 생성되면 스택만으로 RSS 가 늘어난다.
ls /proc/<pid>/task | wc -l로 즉시 확인 가능한, 의외로 흔한 원인이다.
3. 할당자가 OS 에 메모리를 돌려주지 않는 이유
glibc malloc
두 가지 경로로 메모리를 얻는다.
brk(sbrk) 로 확장하는 메인 힙: 힙은 선형 영역이라 상단(top chunk)부터만 줄일 수 있다. 힙 맨 끝에 살아 있는 작은 객체 하나가 있으면 그 아래 전부가 비어 있어도 아무것도 반환할 수 없다. 이것이 "free 했는데 RSS 가 안 준다"의 가장 흔한 원인이다.mmap으로 얻는 큰 블록:M_MMAP_THRESHOLD(기본 128KB) 이상 요청은 별도mmap으로 처리하고,free시munmap으로 즉시 OS 에 반환된다.
관련 튜너블:
M_TRIM_THRESHOLD(기본 128KB): top chunk 의 빈 공간이 이 값을 넘으면free시 자동으로brk를 줄여 반환을 시도한다. 위에서 말한 이유로 top 이 아니면 소용없다.M_MMAP_THRESHOLD: 이 값 이상이면mmap사용. glibc 는 이 값을 동적으로 올린다 — 큰 블록이 free 될 때 임계값을 그 크기까지 자동 상향해서, 반복 할당/해제 시mmap/munmap시스템 콜 비용을 피한다. 부작용으로, 초반에 큰 블록을 몇 번 쓰면 이후 큰 할당들이mmap대신 힙으로 가서 반환이 안 된다.mallopt(M_MMAP_THRESHOLD, ...)로 고정할 수 있다.malloc_trim(pad): top chunk 의 남는 부분과, 각 아레나의 빈 페이지를madvise(MADV_DONTNEED)로 명시적으로 반환한다. 수동 호출이 필요하며 순회 비용이 있다. 유휴 시점에 주기적으로 호출하는 것이 실무적 완화책이다.- 아레나(arena): 멀티스레드 경합을 줄이려고 스레드별로 아레나를 여러 개 만든다
(기본 최대
8 * CPU 코어 수). 아레나마다 독립적으로 단편화되므로 아레나 수가 많을수록 RSS 오버헤드가 커진다.MALLOC_ARENA_MAX=2로 제한하는 것이 컨테이너 환경의 흔한 튜닝이다.
jemalloc / tcmalloc
설계 자체가 다르다.
- 크기 클래스 + 슬랩(run/span) 구조: 같은 크기 클래스끼리 모아 배치해 외부 단편화를 크게 줄인다. 내부 단편화는 대신 조금 늘어난다.
- decay 기반 반환: jemalloc 은
dirty_decay_ms(기본 10초),muzzy_decay_ms로, 일정 시간 쓰이지 않은 페이지를 점진적으로madvise하여 OS 에 돌려준다. glibc 와 달리 힙 상단 여부와 무관하게 반환할 수 있다(페이지 단위madvise). 그래서 "부하가 걷히면 RSS 가 서서히 내려가는" 패턴을 보인다. - tcmalloc 도 유사하게 백그라운드로 페이지를 반환하며,
MallocExtension::ReleaseFreeMemory()로 즉시 반환을 요청할 수 있다. - 프로파일링 내장: jemalloc 은
MALLOC_CONF=prof:true로 힙 프로파일을 덤프할 수 있어 프로덕션 진단에 유리하다.
장기 실행 게임 서버에서 glibc 대신 jemalloc/tcmalloc 으로 교체(LD_PRELOAD 만으로 가능)하는
것만으로 RSS 증가가 해결되는 경우가 실제로 많다.
4. 관리 언어(C#)의 경우
.NET 에서 "GC 힙 크기"와 RSS 가 벌어지는 이유는 여러 겹이다.
LOH (Large Object Heap)
- 85,000바이트 이상 객체는 LOH 로 간다. 기본적으로 압축(compaction)되지 않고 sweep 만 한다. 해제된 자리를 free list 로 재사용하지만 크기가 맞지 않으면 조각이 남는다.
- 게임서버에서 큰 배열(패킷 버퍼, 맵 데이터, 직렬화 버퍼)을 반복 할당/해제하면 LOH 가 계속
커진다.
byte[85000]근처 크기가 특히 위험하다. GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce후GC.Collect()로 1회 압축할 수 있다. 매우 비싸므로 점검 시간 등에만 쓴다.- 근본 대책은
ArrayPool<T>로 큰 버퍼를 풀링해 LOH 할당 자체를 없애는 것이다.
Server GC
- 코어마다 힙과 전용 GC 스레드를 만든다. 처리량은 좋지만 힙이 코어 수만큼 늘어나 기본 메모리 사용량이 Workstation GC 보다 훨씬 크다. 32코어 머신에서 힙 32개.
- 세그먼트를 OS 에 잘 돌려주지 않는다(재사용을 위해 붙잡는다). .NET 7 부터 기본이 된 Regions
(세그먼트 대신 작은 리전 단위 관리)와
GCConserveMemory설정으로 개선됐다. - 컨테이너에서는
DOTNET_gcServer,DOTNET_GCHeapHardLimit,DOTNET_GCHeapCount로 제한한다. cgroup 메모리 한계를 인식하게 하는 것이 중요하다.
네이티브 메모리
GC 힙 밖의 메모리는 GC 가 관여하지 않는다.
- 핀 고정(pinned) 버퍼:
fixed,GCHandle.Alloc(..., Pinned), 소켓 비동기 I/O 버퍼. 핀된 객체는 이동할 수 없어 GC 힙 압축을 방해해 단편화를 만든다. .NET 5+ 의 POH (Pinned Object Heap)가 핀 객체를 따로 모아 이 문제를 줄인다. - 네이티브 라이브러리 누수: P/Invoke 로 호출한 라이브러리 내부 할당은 GC 통계에 안 잡힌다.
SafeHandle/IDisposable미해제: 파일·소켓 핸들과 그에 딸린 커널 버퍼.
"GC 힙은 작은데 RSS 가 크다"면 의심할 것
- 네이티브 누수 (P/Invoke, 네이티브 라이브러리, 미해제 핸들)
- GC 가 세그먼트를 OS 에 반환하지 않음 (Server GC 특성)
- 스레드 수 폭증 (스레드풀 주입,
Task남용) - LOH/POH 단편화
- JIT 코드·타입 메타데이터 누적 (동적 어셈블리 생성, 제네릭 인스턴스화 폭발)
5. 진단 도구와 절차
절차의 원칙
싸고 비침습적인 것부터. 프로덕션에서 바로 무거운 도구를 붙이면 안 된다.
1) 지표 확인 (무비용) : RSS 추이, GC 힙, 스레드 수, 핸들 수, 앱 카운터
2) 가설 세우기 : 누수인가 단편화인가 네이티브인가
3) 스냅샷 (저비용) : gcdump, malloc_stats, jemalloc stats
4) 프로파일링 (고비용) : heaptrack, massif, ASAN — 가급적 스테이징에서
5) 덤프 분석 (일시 정지) : dotnet-dump + SOS, core dump
C++ 서버
| 도구 | 알려주는 것 | 한계 |
|---|---|---|
ASAN/LSAN (-fsanitize=address) |
종료 시점에 도달 불가능한 할당(진짜 누수)을 스택과 함께 보고 | 2배 메모리·2~3배 느림. 프로덕션 부적합. 여전히 참조되는 캐시 누수는 못 잡음 |
| valgrind massif | 시간에 따른 힙 사용량 프로파일, 어느 스택이 얼마를 차지하는지 | 10~50배 느림. 개발/재현 환경 전용 |
| heaptrack | massif 와 비슷하지만 훨씬 빠름(수 배). 할당 횟수·수명 분포·누수 후보 | 여전히 오버헤드 있음. 단기 재현용 |
jemalloc 프로파일링 (MALLOC_CONF=prof:true,lg_prof_interval:30) |
샘플링 기반이라 오버헤드가 낮아 프로덕션 적용 가능. jeprof 로 시각화 |
샘플링이라 드문 할당은 놓칠 수 있음 |
malloc_stats() / malloc_info() |
glibc 아레나별 사용량·free 량 → 단편화 판정에 직접 유용 | 스택 정보 없음 |
/proc/<pid>/smaps |
매핑별 RSS/PSS/Dirty. 어느 영역이 큰지(힙/스택/파일/익명) | 원인 코드는 알 수 없음 |
단편화 판정의 실전 조합: malloc_stats() 로 "할당자가 붙잡은 총량 vs 실제 사용 중" 비율을
보고, malloc_trim() 호출 전후 RSS 를 비교한다. trim 으로 크게 줄면 반환 정책 문제,
안 줄면 단편화나 진짜 누수다.
C# 서버
| 도구 | 알려주는 것 | 한계 |
|---|---|---|
dotnet-counters |
GC 힙 크기, 세대별 컬렉션 횟수, 할당률, 스레드풀 큐, 예외율. 오버헤드 거의 없음 — 항상 먼저 | 어떤 객체인지는 모름 |
dotnet-gcdump |
GC 힙의 타입별 개수·크기와 참조 경로. 누수 대상 타입 특정에 결정적 | GC 힙만. 네이티브 메모리는 안 보임. 수집 시 짧은 정지 |
dotnet-dump + SOS |
!dumpheap -stat, !gcroot(누가 붙잡고 있는지), !eeheap, !threads |
전체 덤프라 크고, 수집 중 정지. 분석에 숙련 필요 |
| ETW / EventPipe | 할당 스택까지 포함한 상세 추적 | 데이터 폭증, 오버헤드 큼 |
dotnet-trace |
CPU/할당 샘플링 | 단기 |
"GC 힙은 작은데 RSS 가 크다" 가 확인되면 gcdump 는 답을 주지 못한다. 이때는 네이티브 쪽을
봐야 한다 — 스레드 수, 핸들 수(/proc/<pid>/fd), smaps, 그리고 필요하면 리눅스에서
LD_PRELOAD 로 네이티브 할당 프로파일러를 붙인다.
프로덕션 제약과 대안
- 오버헤드: ASAN·valgrind 는 불가. 샘플링 기반(jemalloc prof, dotnet-counters, perf) 만 상시 사용.
- 정지 시간: gcdump/dump 수집은 정지를 유발한다. → 로드밸런서에서 뺀 인스턴스에서 수집하거나, 카나리 인스턴스 한 대에만 진단 도구를 붙인다.
- 재현: 프로덕션 트래픽을 스테이징에 섀도 트래픽으로 흘려 재현 환경을 만드는 것이 이상적.
- 상시 관측: 애초에 앱이 자체 카운터(풀 대여/반납 수, 캐시 엔트리 수, 세션 수)를 노출하게 설계하면 진단 시간이 극적으로 줄어든다. "앱 카운터 vs RSS" 비율을 대시보드에 상시 노출 하는 것이 가장 가성비 좋은 투자다.
6. 설계 차원의 예방
| 기법 | 효과 | 트레이드오프 |
|---|---|---|
| 크기 클래스별 오브젝트 풀 | 같은 크기 블록만 재사용해 외부 단편화 제거. 할당 비용도 상수 | 풀 크기 튜닝 필요. 이중 반납·미반납 버그 위험(별도 방어 필요). 피크 기준으로 잡으면 메모리 상시 점유 |
| 아레나/리전 할당자 | 한 요청·한 틱 동안의 할당을 한 영역에 몰아넣고 끝날 때 통째로 리셋. 단편화가 원리적으로 발생하지 않고 해제 비용이 O(1) | 개별 해제 불가. 수명이 아레나를 넘는 객체가 있으면 안 됨(설계 규율 필요). 피크 사용량만큼 영역 필요 |
| 슬랩 할당 | 커널 slab 처럼 동일 타입 객체를 전용 캐시에서 관리. 생성자/소멸자 비용도 절감 가능 | 타입별 캐시 관리 부담 |
고정 크기 버퍼 풀 (ArrayPool<T>, 네트워크 버퍼) |
LOH/큰 블록 할당을 완전히 없앰. 게임서버 패킷 경로에서 효과가 가장 큼 | 버퍼 수명 관리 실수 시 데이터 손상(in-flight 버퍼 조기 반납). 최대 크기 초과 요청 처리 정책 필요 |
| 할당자 교체 (jemalloc/tcmalloc) | 코드 변경 없이 단편화·반환 정책 개선. LD_PRELOAD 만으로 적용 |
메모리/CPU 특성 변화를 실측해야 함. 플랫폼별 검증 필요 |
| 롤링 리스타트 | 어떤 누수·단편화든 확실히 리셋 | 근본 해결이 아님. 상태 유지 서버는 세션 드레이닝·이관 설계가 필요. "왜 새는지 모른 채" 운영하는 부채가 쌓인다 |
| 사전 할당(pre-allocation) | 기동 시 피크 사용량만큼 잡아두고 그 안에서만 운영. 런타임 할당 0 → 지연 예측 가능 | 메모리 상시 점유. 상한을 잘못 잡으면 하드 실패. 콘솔/임베디드 게임에서 흔한 방식 |
실무 권장 조합
- 패킷·직렬화 버퍼는 반드시 풀링 (가장 효과 큰 단일 조치)
- 틱 단위 임시 할당은 아레나로 (프레임 아레나)
- jemalloc/tcmalloc 로 교체하고 decay 설정을 튜닝
- 앱 카운터와 RSS 비율을 상시 모니터링
- 롤링 리스타트는 안전망으로만 두고, 그것에 의존하지 않는다
핵심은 "할당을 줄이는 것"이 아니라 "할당의 수명 패턴을 단순하게 만드는 것" 이다. 수명이 뒤섞인 할당이 단편화를 만들고, 수명이 정렬된 할당(같이 태어나 같이 죽는)은 아레나·풀로 깔끔하게 관리된다.