메모리 누수 찾기: 프로파일러 선택과 재현 시나리오 | Valgrind·ASan·Heaptrack
이 글의 핵심
언어별로 Valgrind·ASan·Heaptrack·트레이스를 고르고, 재현 스크립트와 힙 스냅샷으로 메모리 누수 프로파일링을 체계화합니다.
들어가며
메모리 누수 프로파일링 방법은 “도구를 한 번 돌린다”로 끝나지 않습니다. 재현 가능한 입력·실행 시간·부하가 없으면 프로파일러도 추측만 늘립니다. 이 글은 C++ / Python / JavaScript에서 각각 어떤 도구가 적합한지, 재현 시나리오를 어떻게 적는지, 결과를 어떻게 읽는지 순서대로 정리합니다.
2026년 기준으로도 네이티브 코드는 ASan/LSan + Valgrind/Heaptrack, 스크립트는 트레이스·힙 스냅샷 조합이 실무의 중심입니다. 운영 이슈는 C++ 메모리 누수 사례와 연결해 보면 흐름이 잡힙니다.
이 글을 읽으면
- 언어별로 프로파일러를 빠르게 고릅니다
- 재현 시나리오(스크립트·환경 변수·시드)를 문서화하는 법을 익힙니다
- 힙 그로스·리텐션 그래프를 보고 의심 지점을 좁힙니다
목차
개념 설명
메모리 누수 유형
| 유형 | 설명 | 전형적 언어 |
|---|---|---|
| 진짜 누수 | 할당한 블록에 도달 가능한 포인터가 영구히 사라짐 | C/C++ |
| 의도치 않은 누적 | 캐시·전역 맵·이벤트 리스너가 해제되지 않음 | JS, Python, Java |
| 순환 참조 | 객체들이 서로 참조해 GC가 회수 못함 | Python (GC 전), JS (WeakMap 전) |
| 단편화 | RSS는 올라가지만 실제 사용 메모리는 안정적 | 모든 언어 |
메모리 누수 vs 단편화
진짜 누수:
시간 →
메모리 ↑ ┌──────────────────── (계속 증가, 해제 안 됨)
│
└─────────────────────→
단편화:
시간 →
메모리 ↑ ┌─┐ ┌─┐ ┌─┐
│ │ │ │ │ │ (할당/해제 반복, RSS는 높지만 안정)
└─┘ └─┘ └─┘
└─────────────────────→
구분 방법:
- 진짜 누수: 힙 스냅샷에서 할당 수·크기가 계속 증가
- 단편화: 할당 수는 안정, RSS만 증가
실전 구현
C++: AddressSanitizer (ASan) + LeakSanitizer (LSan)
빌드 및 실행
# Clang/GCC 빌드
clang++ -fsanitize=address -g -O1 main.cpp -o app
# 실행 (누수 검사 활성화)
ASAN_OPTIONS=detect_leaks=1 ./app
# 더 자세한 출력
ASAN_OPTIONS=detect_leaks=1:log_path=asan.log:verbosity=1 ./app
예제 코드
#include <iostream>
#include <vector>
void leak_example() {
int* p = new int[100]; // 누수!
// delete[] p; 누락
}
void no_leak_example() {
std::vector<int> v(100); // RAII, 자동 해제
}
int main() {
leak_example();
no_leak_example();
return 0;
}
ASan 출력
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
#0 0x7f8b9c in operator new[](unsigned long) (/usr/lib/x86_64-linux-gnu/libasan.so.5+0x10c9c)
#1 0x4012a3 in leak_example() /path/to/main.cpp:5
#2 0x401345 in main /path/to/main.cpp:13
#3 0x7f8b8d in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x270b3)
SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s).
분석:
main.cpp:5에서new int[100](400바이트) 할당delete[]누락으로 누수 발생
C++: Valgrind (memcheck)
실행
# 기본 누수 검사
valgrind --leak-check=full ./app
# 모든 누수 종류 표시
valgrind --leak-check=full --show-leak-kinds=all ./app
# 입력 파일 사용
valgrind --leak-check=full ./app < input.txt
# 로그 파일 저장
valgrind --leak-check=full --log-file=valgrind.log ./app
예제 코드
#include <cstdlib>
#include <cstring>
void definitely_lost() {
char* p = (char*)malloc(100);
// free(p); 누락
}
void possibly_lost() {
char* p = (char*)malloc(100);
p += 10; // 포인터 이동
// free(p - 10); 필요
}
void still_reachable() {
static char* global = (char*)malloc(100);
// 프로그램 종료까지 도달 가능
}
int main() {
definitely_lost();
possibly_lost();
still_reachable();
return 0;
}
Valgrind 출력
==12345== HEAP SUMMARY:
==12345== in use at exit: 300 bytes in 3 blocks
==12345== total heap usage: 3 allocs, 0 frees, 300 bytes allocated
==12345==
==12345== 100 bytes in 1 blocks are definitely lost in loss record 1 of 3
==12345== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x108654: definitely_lost() (main.cpp:5)
==12345== by 0x1086A3: main (main.cpp:20)
==12345==
==12345== 100 bytes in 1 blocks are possibly lost in loss record 2 of 3
==12345== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x108670: possibly_lost() (main.cpp:10)
==12345== by 0x1086A8: main (main.cpp:21)
==12345==
==12345== 100 bytes in 1 blocks are still reachable in loss record 3 of 3
==12345== at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==12345== by 0x10868C: still_reachable() (main.cpp:16)
==12345== by 0x1086AD: main (main.cpp:22)
==12345==
==12345== LEAK SUMMARY:
==12345== definitely lost: 100 bytes in 1 blocks
==12345== indirectly lost: 0 bytes in 0 blocks
==12345== possibly lost: 100 bytes in 1 blocks
==12345== still reachable: 100 bytes in 1 blocks
==12345== suppressed: 0 bytes in 0 blocks
분석:
- definitely lost: 확실한 누수 (우선 수정)
- possibly lost: 포인터 이동으로 의심
- still reachable: 전역 변수 등 (보통 무시)
C++: Heaptrack
실행
# 프로파일링 시작
heaptrack ./app
# GUI로 분석
heaptrack_gui heaptrack.app.12345.gz
# 텍스트 출력
heaptrack_print heaptrack.app.12345.gz
예제 코드
#include <vector>
#include <string>
#include <unordered_map>
std::unordered_map<int, std::string> cache;
void process_item(int id) {
// 캐시에 계속 추가 (해제 안 됨)
cache[id] = std::string(1000, 'x');
}
int main() {
for (int i = 0; i < 100000; i++) {
process_item(i);
}
return 0;
}
Heaptrack 출력 (텍스트)
MOST CALLS TO ALLOCATION FUNCTIONS
calls peak leaked function
100000 95.4M 95.4M std::string::_M_create(unsigned long&, unsigned long)
at /usr/include/c++/11/bits/basic_string.tcc:144
at process_item(int) at main.cpp:8
at main at main.cpp:13
분석:
process_item에서std::string할당이 100,000번- 총 95.4MB 누수
cache가 해제되지 않음
Python: tracemalloc
기본 사용
import tracemalloc
# 프로파일링 시작 (스택 깊이 10)
tracemalloc.start(10)
def leak_example():
global cache
cache = []
for i in range(100000):
cache.append([0] * 1000) # 누수
def snapshot_diff():
snapshot1 = tracemalloc.take_snapshot()
leak_example()
snapshot2 = tracemalloc.take_snapshot()
# 차이 분석
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("[ Top 10 differences ]")
for stat in top_stats[:10]:
print(stat)
if __name__ == '__main__':
snapshot_diff()
출력
[ Top 10 differences ]
main.py:8: size=381 MiB (+381 MiB), count=100000 (+100000), average=4 KiB
main.py:7: size=781 KiB (+781 KiB), count=1 (+1), average=781 KiB
분석:
main.py:8(리스트 할당)에서 381MB 증가main.py:7(cache 리스트)에서 781KB 증가
고급: objgraph로 참조 추적
import objgraph
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
def create_cycle():
a = Node(1)
b = Node(2)
a.next = b
b.next = a # 순환 참조
# 누수 생성
for _ in range(1000):
create_cycle()
# GC 실행
gc.collect()
# 가장 많은 객체 타입
objgraph.show_most_common_types(limit=10)
# 특정 객체로의 참조 경로
node_instances = objgraph.by_type('Node')
if node_instances:
objgraph.show_backrefs(node_instances[0], max_depth=5, filename='refs.png')
출력
Node 2000
dict 1523
list 892
...
분석:
Node객체가 2000개 (예상: 순환 참조로 GC 회수 안 됨)refs.png에서 참조 경로 확인
JavaScript (Node.js): 힙 스냅샷
기본 사용
// app.js
const v8 = require('v8');
const fs = require('fs');
let cache = [];
function leakExample() {
for (let i = 0; i < 100000; i++) {
cache.push(new Array(1000).fill(0));
}
}
function takeSnapshot(tag) {
if (global.gc) global.gc(); // 강제 GC
const filename = `heap-${tag}-${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(filename);
console.log(`Snapshot saved: ${filename}`);
}
// 스냅샷 1
takeSnapshot('before');
// 누수 발생
leakExample();
// 스냅샷 2
takeSnapshot('after');
console.log('Memory usage:', process.memoryUsage());
실행
# --expose-gc로 global.gc 활성화
node --expose-gc app.js
Chrome DevTools 분석
- Chrome DevTools 열기 (
chrome://inspect) - Memory 탭 → Load 버튼
heap-before-*.heapsnapshot와heap-after-*.heapsnapshot로드- Comparison 뷰에서 차이 확인
분석 예시:
Constructor | # New | # Deleted | # Delta | Size Delta
(array) | 100000| 0 | +100000 | +381 MB
(string) | 523 | 12 | +511 | +2.3 MB
(array)객체가 100,000개 증가- 381MB 증가 →
cache배열이 원인
고급: —inspect로 실시간 프로파일링
# 디버그 모드로 실행
node --inspect app.js
# 또는 중단점 대기
node --inspect-brk app.js
- Chrome에서
chrome://inspect접속 - Open dedicated DevTools for Node 클릭
- Memory 탭에서 실시간 힙 스냅샷 촬영
Python: memory_profiler
설치 및 사용
pip install memory_profiler
from memory_profiler import profile
@profile
def leak_example():
cache = []
for i in range(100000):
cache.append([0] * 1000)
return cache
if __name__ == '__main__':
result = leak_example()
실행
python -m memory_profiler app.py
출력
Line # Mem usage Increment Occurrences Line Contents
=============================================================
3 38.5 MiB 38.5 MiB 1 @profile
4 def leak_example():
5 38.5 MiB 0.0 MiB 1 cache = []
6 419.9 MiB 381.4 MiB 100001 for i in range(100000):
7 419.9 MiB 381.4 MiB 100000 cache.append([0] * 1000)
8 419.9 MiB 0.0 MiB 1 return cache
분석:
- 6-7번 줄에서 381.4MB 증가
cache.append가 원인
고급 활용
1) 재현 시나리오 문서화
메모리 누수는 재현 가능한 입력이 없으면 디버깅이 불가능합니다.
재현 스크립트 예시
#!/bin/bash
# reproduce_leak.sh
# 환경 변수 설정
export ASAN_OPTIONS=detect_leaks=1:log_path=asan.log
export MALLOC_CONF=prof:true,lg_prof_interval:30
# 입력 데이터 생성
python generate_input.py --size 10000 --seed 42 > input.txt
# 애플리케이션 실행
./app < input.txt
# 결과 확인
if grep -q "LeakSanitizer" asan.log.*; then
echo "Memory leak detected!"
exit 1
else
echo "No leak detected"
exit 0
fi
재현 조건 문서
# 메모리 누수 재현 조건
## 환경
- OS: Ubuntu 22.04
- 컴파일러: GCC 11.3
- 빌드 플래그: `-fsanitize=address -g -O1`
## 재현 단계
1. `generate_input.py --size 10000 --seed 42` 실행
2. `ASAN_OPTIONS=detect_leaks=1 ./app < input.txt` 실행
3. 약 30초 후 누수 보고 확인
## 예상 결과
- 누수 크기: ~400 bytes
- 누수 위치: `main.cpp:5` (leak_example 함수)
## 재현율
- 10회 중 10회 재현 (100%)
2) 장기 실행 누수 탐지
부하 테스트 스크립트
#!/bin/bash
# load_test.sh
# 1시간 동안 요청 반복
for i in {1..3600}; do
curl http://localhost:8080/api/process
sleep 1
# 10분마다 메모리 사용량 기록
if [ $((i % 600)) -eq 0 ]; then
ps aux | grep app | awk '{print $6}' >> memory.log
fi
done
# 메모리 증가 추세 분석
python analyze_memory.py memory.log
메모리 분석 스크립트
# analyze_memory.py
import sys
import matplotlib.pyplot as plt
def analyze_memory(log_file):
with open(log_file) as f:
memory_kb = [int(line.strip()) for line in f]
# 메모리 증가율 계산
if len(memory_kb) < 2:
print("Not enough data")
return
initial = memory_kb[0]
final = memory_kb[-1]
growth_rate = (final - initial) / initial * 100
print(f"Initial memory: {initial / 1024:.2f} MB")
print(f"Final memory: {final / 1024:.2f} MB")
print(f"Growth rate: {growth_rate:.2f}%")
# 그래프 생성
plt.plot(memory_kb)
plt.xlabel('Time (10 min intervals)')
plt.ylabel('Memory (KB)')
plt.title('Memory Usage Over Time')
plt.savefig('memory_trend.png')
print("Graph saved: memory_trend.png")
if __name__ == '__main__':
analyze_memory(sys.argv[1])
3) 프로덕션 환경 모니터링
Prometheus + Grafana
# Python 예시 (prometheus_client)
from prometheus_client import Gauge, start_http_server
import psutil
import time
# 메트릭 정의
memory_usage = Gauge('app_memory_bytes', 'Application memory usage in bytes')
heap_size = Gauge('app_heap_bytes', 'Heap size in bytes')
def collect_metrics():
process = psutil.Process()
while True:
# RSS 메모리
memory_usage.set(process.memory_info().rss)
# Python 힙 크기
import sys
heap_size.set(sys.getsizeof(globals()))
time.sleep(10)
if __name__ == '__main__':
start_http_server(8000)
collect_metrics()
Grafana 쿼리
# 메모리 증가율 (1시간 기준)
rate(app_memory_bytes[1h])
# 메모리 사용량 추세
app_memory_bytes
# 임계값 초과 알림
app_memory_bytes > 1e9 # 1GB 초과
성능과 비교
| 도구 | 언어 | 오버헤드 | 정확도 | 사용 시점 |
|---|---|---|---|---|
| ASan/LSan | C/C++ | 2x | 높음 | 개발·CI |
| Valgrind | C/C++ | 10-50x | 매우 높음 | 심층 분석 |
| Heaptrack | C/C++ | 1.5x | 높음 | 할당 핫스팟 |
| tracemalloc | Python | 낮음 | 높음 | 개발·프로덕션 |
| objgraph | Python | 낮음 | 높음 | 순환 참조 |
| 힙 스냅샷 | Node.js | 낮음 | 높음 | 개발·프로덕션 |
| —inspect | Node.js | 낮음 | 높음 | 실시간 디버깅 |
도구 선택 플로우차트
언어가 무엇인가?
├─ C/C++
│ ├─ 빠른 검증? → ASan/LSan
│ ├─ 미정의 동작 포함? → Valgrind
│ └─ 할당 핫스팟? → Heaptrack
├─ Python
│ ├─ 할당 추적? → tracemalloc
│ └─ 순환 참조? → objgraph
└─ JavaScript (Node.js)
├─ 개발 환경? → 힙 스냅샷
└─ 프로덕션? → --inspect + 원격 디버깅
실무 사례
사례 1: C++ 웹 서버 - shared_ptr 순환 참조
증상: 연결 종료 후에도 메모리 증가
문제 코드
class Connection {
public:
std::shared_ptr<Session> session;
};
class Session {
public:
std::shared_ptr<Connection> connection; // 순환 참조!
};
void handle_connection() {
auto conn = std::make_shared<Connection>();
auto sess = std::make_shared<Session>();
conn->session = sess;
sess->connection = conn; // 순환 참조 발생
// 함수 종료 시 둘 다 해제 안 됨 (참조 카운트 1 유지)
}
Heaptrack 출력
MOST CALLS TO ALLOCATION FUNCTIONS
calls peak leaked function
10000 960K 960K std::make_shared<Connection>
10000 1.2M 1.2M std::make_shared<Session>
해결
class Connection {
public:
std::shared_ptr<Session> session;
};
class Session {
public:
std::weak_ptr<Connection> connection; // weak_ptr로 변경
};
void handle_connection() {
auto conn = std::make_shared<Connection>();
auto sess = std::make_shared<Session>();
conn->session = sess;
sess->connection = conn; // weak_ptr는 참조 카운트 증가 안 함
// 함수 종료 시 정상 해제
}
사례 2: Node.js 서버 - 이벤트 리스너 누수
증상: 요청 처리 후 메모리 증가
문제 코드
const EventEmitter = require('events');
const emitter = new EventEmitter();
function handleRequest(req, res) {
const handler = (data) => {
res.write(data);
};
// 리스너 등록
emitter.on('data', handler);
// 응답 전송
res.end('OK');
// 리스너 해제 누락!
}
// 10,000번 요청 → 10,000개 리스너 누적
힙 스냅샷 분석
Constructor | Retained Size | Distance
(closure) | 381 MB | 3
context | 381 MB | 4
handler | 381 MB | 5
분석:
handler클로저가 381MB 유지res객체를 계속 참조
해결
function handleRequest(req, res) {
const handler = (data) => {
res.write(data);
};
emitter.on('data', handler);
res.on('finish', () => {
emitter.off('data', handler); // 리스너 해제
});
res.end('OK');
}
사례 3: Python 웹 앱 - 전역 캐시 누수
증상: 장기 실행 시 메모리 계속 증가
문제 코드
# 전역 캐시 (해제 안 됨)
cache = {}
def process_request(user_id):
if user_id not in cache:
# 대용량 데이터 로드
cache[user_id] = load_user_data(user_id)
return cache[user_id]
# 사용자 수 증가 → 캐시 무한 증가
tracemalloc 출력
app.py:6: size=2.3 GiB (+2.3 GiB), count=50000 (+50000), average=48 KiB
해결: LRU 캐시
from functools import lru_cache
@lru_cache(maxsize=1000) # 최대 1000개 유지
def get_user_data(user_id):
return load_user_data(user_id)
def process_request(user_id):
return get_user_data(user_id)
개선 효과:
- 메모리: 무제한 → 최대 48MB (1000 × 48KB)
- 오래된 항목 자동 제거
트러블슈팅
문제 1: “로컬에선 안 나는데 서버만 증가한다”
원인
- 스레드 수·풀 크기·TLS 버퍼 차이
- 부하 패턴 차이 (로컬: 단일 요청, 서버: 동시 1000개)
해결
# 서버와 동일한 부하 생성
wrk -t12 -c400 -d30s http://localhost:8080/api/process
# 또는 Apache Bench
ab -n 10000 -c 100 http://localhost:8080/api/process
# 메모리 모니터링
watch -n 1 'ps aux | grep app | awk "{print \$6}"'
문제 2: “Valgrind가 너무 느리다”
원인
- Valgrind는 10-50배 느림
- 큰 입력으로 실행 시 수 시간 소요
해결: 최소 재현 케이스
# 이진 탐색으로 입력 크기 줄이기
# 1. 입력 절반으로 줄이기
head -n 5000 input.txt > input_half.txt
valgrind --leak-check=full ./app < input_half.txt
# 2. 누수 재현되면 계속 줄이기
head -n 2500 input_half.txt > input_quarter.txt
valgrind --leak-check=full ./app < input_quarter.txt
# 3. 최소 재현 케이스 확보
# 예: 100줄로 재현 가능 → Valgrind 실행 시간 대폭 단축
문제 3: “Python이 GC라서 괜찮다?”
오해
# GC가 있어도 누수 가능
cache = {} # 전역 변수
def add_to_cache(key, value):
cache[key] = value # GC가 회수 못함 (전역에서 참조)
해결
import weakref
# WeakValueDictionary 사용
cache = weakref.WeakValueDictionary()
def add_to_cache(key, value):
cache[key] = value # 다른 참조 없으면 GC 회수 가능
문제 4: “ASan 빌드가 프로덕션에서 안 돌아간다”
원인
- ASan은 2배 메모리 오버헤드
- 프로덕션 배포 불가
해결: 스테이징 환경
# CI/CD 파이프라인
stages:
- build
- test
- staging
- production
staging:
script:
- clang++ -fsanitize=address -g main.cpp -o app_asan
- ASAN_OPTIONS=detect_leaks=1 ./app_asan
- if grep -q "LeakSanitizer" asan.log.*; then exit 1; fi
production:
script:
- clang++ -O3 main.cpp -o app # ASan 없음
- ./deploy.sh
마무리
메모리 누수 프로파일링 방법의 공통분모는 재현 시나리오와 할당 관점(스택·호출 경로)입니다. C++ 사례 심화는 메모리 누수 디버깅 실전 사례와 함께 보시면 도구 출력을 실제 수정까지 연결하기 쉽습니다.
핵심 요약
-
도구 선택
- C++: ASan (빠름) → Valgrind (정확) → Heaptrack (할당 분석)
- Python: tracemalloc → objgraph (순환 참조)
- Node.js: 힙 스냅샷 → —inspect (실시간)
-
재현 시나리오
- 입력 데이터 고정 (시드 사용)
- 환경 변수 문서화
- 부하 패턴 재현 (wrk, ab)
-
분석 방법
- 할당 스택 트레이스 확인
- 시간에 따른 메모리 추세 그래프
- 힙 스냅샷 비교 (before/after)
-
일반적 원인
- C++:
delete누락, 순환 참조 (shared_ptr) - Python: 전역 캐시, 순환 참조
- Node.js: 이벤트 리스너 미해제, 클로저 누수
- C++:
다음 단계
- C++ 심화: 메모리 누수 디버깅 실전 사례
- 성능 최적화: C++ 성능 최적화 사례
- Python 프로파일링: Python 성능 최적화
메모리 누수는 재현만 되면 절반은 해결입니다. 재현 스크립트를 먼저 만들고, 프로파일러 출력을 차근차근 읽어 나가세요.