메모리 누수 찾기: 프로파일러 선택과 재현 시나리오 | Valgrind·ASan·Heaptrack

메모리 누수 찾기: 프로파일러 선택과 재현 시나리오 | Valgrind·ASan·Heaptrack

이 글의 핵심

언어별로 Valgrind·ASan·Heaptrack·트레이스를 고르고, 재현 스크립트와 힙 스냅샷으로 메모리 누수 프로파일링을 체계화합니다.

들어가며

메모리 누수 프로파일링 방법은 “도구를 한 번 돌린다”로 끝나지 않습니다. 재현 가능한 입력·실행 시간·부하가 없으면 프로파일러도 추측만 늘립니다. 이 글은 C++ / Python / JavaScript에서 각각 어떤 도구가 적합한지, 재현 시나리오를 어떻게 적는지, 결과를 어떻게 읽는지 순서대로 정리합니다.

2026년 기준으로도 네이티브 코드는 ASan/LSan + Valgrind/Heaptrack, 스크립트는 트레이스·힙 스냅샷 조합이 실무의 중심입니다. 운영 이슈는 C++ 메모리 누수 사례와 연결해 보면 흐름이 잡힙니다.

이 글을 읽으면

  • 언어별로 프로파일러를 빠르게 고릅니다
  • 재현 시나리오(스크립트·환경 변수·시드)를 문서화하는 법을 익힙니다
  • 힙 그로스·리텐션 그래프를 보고 의심 지점을 좁힙니다

목차

  1. 개념 설명
  2. 실전 구현
  3. 고급 활용
  4. 성능과 비교
  5. 실무 사례
  6. 트러블슈팅
  7. 마무리

개념 설명

메모리 누수 유형

유형설명전형적 언어
진짜 누수할당한 블록에 도달 가능한 포인터가 영구히 사라짐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 분석

  1. Chrome DevTools 열기 (chrome://inspect)
  2. Memory 탭 → Load 버튼
  3. heap-before-*.heapsnapshotheap-after-*.heapsnapshot 로드
  4. 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
  1. Chrome에서 chrome://inspect 접속
  2. Open dedicated DevTools for Node 클릭
  3. 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/LSanC/C++2x높음개발·CI
ValgrindC/C++10-50x매우 높음심층 분석
HeaptrackC/C++1.5x높음할당 핫스팟
tracemallocPython낮음높음개발·프로덕션
objgraphPython낮음높음순환 참조
힙 스냅샷Node.js낮음높음개발·프로덕션
—inspectNode.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++ 사례 심화는 메모리 누수 디버깅 실전 사례와 함께 보시면 도구 출력을 실제 수정까지 연결하기 쉽습니다.

핵심 요약

  1. 도구 선택

    • C++: ASan (빠름) → Valgrind (정확) → Heaptrack (할당 분석)
    • Python: tracemalloc → objgraph (순환 참조)
    • Node.js: 힙 스냅샷 → —inspect (실시간)
  2. 재현 시나리오

    • 입력 데이터 고정 (시드 사용)
    • 환경 변수 문서화
    • 부하 패턴 재현 (wrk, ab)
  3. 분석 방법

    • 할당 스택 트레이스 확인
    • 시간에 따른 메모리 추세 그래프
    • 힙 스냅샷 비교 (before/after)
  4. 일반적 원인

    • C++: delete 누락, 순환 참조 (shared_ptr)
    • Python: 전역 캐시, 순환 참조
    • Node.js: 이벤트 리스너 미해제, 클로저 누수

다음 단계

  • C++ 심화: 메모리 누수 디버깅 실전 사례
  • 성능 최적화: C++ 성능 최적화 사례
  • Python 프로파일링: Python 성능 최적화

메모리 누수는 재현만 되면 절반은 해결입니다. 재현 스크립트를 먼저 만들고, 프로파일러 출력을 차근차근 읽어 나가세요.