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

이 글의 핵심

로컬에서는 재현되지 않고 서버에서만 메모리가 조금씩 늘어나는 누수는 도구보다 재현 조건을 먼저 잡아야 풀립니다. 이 글은 shared_ptr 순환 참조, 이벤트 리스너 누적, 전역 캐시 같은 실제 패턴을 언어별로 보여주고, Valgrind가 너무 느리거나 ASan 빌드를 운영에 올릴 수 없을 때의 대안도 설명합니다.

들어가며

메모리 누수 프로파일링 방법은 “도구를 한 번 돌린다”로 끝나지 않습니다. 재현 가능한 입력·실행 시간·부하가 없으면 프로파일러도 추측만 늘립니다. 이 글은 C++ / Python / JavaScript에서 각각 어떤 도구가 적합한지, 재현 시나리오를 어떻게 적는지, 결과를 어떻게 읽는지 순서대로 정리합니다. 2026년 기준으로도 네이티브 코드는 ASan/LSan + Valgrind/Heaptrack, 스크립트는 트레이스·힙 스냅샷 조합이 실무의 중심입니다. 운영 이슈는 C++ 메모리 누수 사례와 연결해 보면 흐름이 잡힙니다.

도구를 고르기 전에 먼저 정해야 할 것은 “무엇을 찾는가”입니다. ASan·Valgrind 같은 누수 검사기는 종료 시점에 아무도 가리키지 않는 블록을 찾아 주고, Heaptrack·tracemalloc·힙 스냅샷 같은 힙 프로파일러는 시간이 지나며 어떤 할당 위치가 커지는지를 보여 줍니다. 운영 서버에서 메모리가 계속 오르는 문제의 상당수는 여전히 참조되고 있는 캐시나 리스너 목록이라서, 누수 검사기로는 “누수 없음”이 나오고 힙 프로파일러에서야 원인이 보입니다. 이 차이를 모르고 검사기만 돌리면 “도구는 깨끗하다는데 메모리는 계속 오른다”는 상황에서 멈추게 됩니다.


개념 설명

메모리 누수 유형

유형설명전형적 언어
정말 누수할당한 블록에 도달 가능한 포인터가 영구히 사라짐C/C++
의도치 않은 누적캐시·전역 맵·이벤트 리스너가 해제되지 않음JS, Python, Java
순환 참조참조 카운트만으로 수명을 관리할 때 서로 가리키는 객체가 회수되지 않음C++ shared_ptr, Swift/Obj-C ARC
단편화RSS는 올라가지만 실제 사용 메모리는 안정적모든 언어

메모리 누수 vs 단편화

정말 누수:
시간 →
메모리 ↑  ┌────────────────────  (계속 증가, 해제 안 됨)
         │
         └─────────────────────→
단편화:
시간 →
메모리 ↑  ┌─┐ ┌─┐ ┌─┐
         │ │ │ │ │ │  (할당/해제 반복, RSS는 높지만 안정)
         └─┘ └─┘ └─┘
         └─────────────────────→

구분 방법:

  • 정말 누수: 힙 스냅샷에서 할당 수·크기가 계속 증가
  • 단편화: 할당 수는 안정, RSS만 증가

표의 순환 참조 행은 언어별로 사정이 다릅니다. Python은 참조 카운트에 더해 순환을 찾는 세대별 GC가 있고, JavaScript 엔진은 도달 가능성으로 판단하는 마킹 방식 GC를 쓰기 때문에, 서로만 가리키는 고립된 순환은 두 언어 모두 회수됩니다. 이 언어들에서 “순환 참조 때문에 누수”처럼 보이는 경우는 대개 그 순환 중 하나가 전역 변수, 캐시, 이벤트 리스너, 클로저를 통해 여전히 도달 가능한 경우입니다. 반면 C++의 shared_ptr처럼 참조 카운트만 쓰는 방식은 고립된 순환도 영원히 회수하지 못하므로 weak_ptr로 한쪽을 끊어야 합니다.

단편화 쪽은 glibc malloc의 동작을 알아 두면 판단이 쉬워집니다. glibc는 스레드마다 별도의 arena를 두고, 해제된 메모리를 곧바로 운영체제에 돌려주지 않고 재사용을 위해 보관합니다. 스레드가 많은 서버에서 RSS가 예상보다 크게 유지되는 경우 MALLOC_ARENA_MAX=2 같은 환경 변수로 arena 수를 줄이거나, jemalloc·tcmalloc으로 바꿨을 때 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[] 누락으로 누수 발생

Direct leak은 그 블록을 가리키는 포인터가 하나도 남지 않은 경우이고, Indirect leak은 누수된 블록을 통해서만 도달할 수 있던 블록입니다. 먼저 Direct를 고치면 Indirect는 함께 사라지는 경우가 대부분입니다. -O1로 빌드한 이유는 ASan 문서가 권하는 절충점이기 때문입니다. -O0은 느리고, -O2 이상은 인라이닝 때문에 스택 트레이스가 짧아져 할당 위치를 읽기 어려워집니다. 스택에 ??만 나온다면 -g가 빠졌거나 llvm-symbolizer를 찾지 못한 것이므로 ASAN_SYMBOLIZER_PATH를 확인하세요. macOS의 Apple Clang과 Windows MSVC의 ASan은 누수 탐지(LSan)를 지원하지 않으니, 누수 검사는 Linux에서 하는 것이 확실합니다.

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

결과 읽는 법

Valgrind는 종료 시점의 HEAP SUMMARY와 LEAK SUMMARY에서 남은 블록을 네 가지로 나눕니다. 이 분류가 어떤 순서로 고칠지를 정해 줍니다.

  • definitely lost: 그 블록을 가리키는 포인터가 어디에도 남아 있지 않습니다. 함수 안에서 malloc/new한 지역 포인터를 해제하지 않고 반환한 경우가 전형입니다. 가장 먼저 고칩니다.
  • indirectly lost: definitely lost 블록을 통해서만 닿을 수 있던 블록입니다(누수된 노드가 가리키던 자식 노드 등). 위의 원인을 고치면 대개 함께 사라집니다.
  • possibly lost: 블록의 시작이 아니라 중간을 가리키는 포인터만 남은 경우입니다. 전역이나 살아 있는 객체가 base + 10 같은 내부 포인터를 들고 있을 때 나옵니다. 지역 포인터를 옮긴 뒤 함수가 끝났다면 포인터 자체가 사라지므로 possibly가 아니라 definitely lost로 나옵니다.
  • still reachable: 종료 시점에도 전역·정적 변수 등에서 도달 가능한 블록입니다. 한 번만 할당되는 싱글턴이면 보통 무시해도 되지만, 서버가 도는 동안 계속 커진다면 캐시나 레지스트리가 붙잡고 있는 논리적 누수일 수 있습니다.

같은 네 분류의 실제 출력 예시, --errors-for-leak-kinds로 CI에서 누수를 실패로 만드는 법, LSan 억제 파일, 누수 원인별(짝 안 맞는 new/delete, 예외 경로, 순환 참조, 컨테이너 속 포인터) 해결은 C++ 메모리 누수 찾기: Valgrind·ASan에서 C++만 놓고 자세히 다룹니다. 이 글은 여러 언어에서 어떤 도구를 고를지와 재현 시나리오에 집중합니다.

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가 해제되지 않음

Heaptrack은 malloc/free를 가로채 모든 할당을 호출 스택과 함께 기록하고, 종료 뒤 시간 축으로 보여 줍니다. 그래서 “무엇이 해제되지 않았는가”뿐 아니라 “어느 시점에 어떤 경로로 메모리가 늘었는가”, “임시 할당이 가장 많은 함수가 무엇인가”까지 볼 수 있습니다. 위 출력의 leaked는 종료 시점에 해제되지 않은 양이라, 프로그램이 끝날 때 정리하지 않는 전역 캐시도 여기에 잡힙니다. 장기 실행 서버라면 종료 시점 값보다 heaptrack_gui의 Consumed 그래프에서 어떤 호출 경로가 계속 우상향하는지를 보는 쪽이 더 유용합니다. 실행 중인 프로세스에 heaptrack -p <PID>로 붙일 수도 있어서, 재시작 없이 스테이징 서버를 잠깐 관찰할 때도 쓸 수 있습니다.

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 증가

tracemalloc 비교에서 중요한 것은 두 스냅샷 사이에 같은 작업을 여러 번 반복하는 것입니다. 한 번만 실행하면 모듈 import, 첫 요청의 캐시 워밍업 같은 일회성 할당이 섞여 상위에 올라옵니다. 같은 요청을 100번 처리한 뒤의 차이와 1,000번 처리한 뒤의 차이가 비례해서 커지는 줄이 진짜 의심 지점입니다. tracemalloc은 모든 할당에 스택 정보를 붙이므로 메모리와 CPU 오버헤드가 작지 않고, start(25)처럼 깊이를 늘릴수록 커집니다. 운영 환경에서 계속 켜 두기보다 짧은 구간만 켜는 방식으로 쓰는 것이 일반적입니다.

고급: 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개 남아 있음
  • refs.png에서 참조 경로 확인

Python의 GC는 고립된 순환 참조를 회수하므로, gc.collect() 뒤에도 Node가 남아 있다면 어딘가에서 아직 도달 가능하다는 뜻입니다. objgraph.show_backrefs()는 그 객체에서 거꾸로 올라가며 “누가 이 객체를 붙잡고 있는가”를 그려 주는데, 대개 경로 끝에 모듈 전역 변수, 클래스 속성, 등록된 콜백, 로거 핸들러 같은 뿌리가 보입니다. 그 뿌리를 찾는 것이 수정의 출발점입니다.

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-*.heapsnapshot와 heap-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 배열이 원인

Comparison 뷰에서는 Shallow Size(객체 자체 크기)보다 Retained Size(이 객체가 사라지면 함께 해제될 수 있는 크기)를 봐야 합니다. 작은 클로저 하나가 거대한 요청 객체를 붙잡고 있으면 Shallow는 수십 바이트지만 Retained는 수백 MB입니다. 스냅샷을 찍기 직전에 global.gc()를 호출하는 것도 중요합니다. 그렇지 않으면 아직 수거되지 않았을 뿐인 쓰레기가 차이에 섞입니다. 힙 스냅샷은 찍는 동안 프로세스가 멈추고 힙 크기만큼 메모리를 더 쓰므로, 메모리가 이미 한계에 가까운 운영 프로세스에서 찍으면 그 자체로 OOM이 날 수 있다는 점도 주의해야 합니다.

고급: —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가 원인

고급 활용

재현 시나리오 문서화

메모리 누수는 재현 가능한 입력이 없으면 디버깅이 불가능합니다.

재현 스크립트 예시

#!/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%)

장기 실행 누수 탐지

부하 테스트 스크립트

#!/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])

프로덕션 환경 모니터링

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시간 기준)
deriv(app_memory_bytes[1h])  # gauge에는 rate 대신 deriv
# 메모리 사용량 추세
app_memory_bytes
# 임계값 초과 알림
app_memory_bytes > 1e9  # 1GB 초과

성능과 비교

도구언어오버헤드정확도사용 시점
ASan/LSanC/C++2x높음개발·CI
ValgrindC/C++10-50x매우 높음심층 분석
HeaptrackC/C++할당 빈도에 비례 (Valgrind보다 훨씬 가벼움)높음할당 핫스팟
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 객체를 계속 참조

이 패턴은 Node.js가 경고로 먼저 알려 주는 경우가 많습니다. 같은 이벤트에 리스너가 기본 한도(10개)를 넘게 등록되면 MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]. MAX_LISTENERS is 10. 같은 경고가 한 번 출력됩니다. 이 경고를 setMaxListeners(0)으로 끄는 것은 증상만 숨기는 것이고, 요청마다 전역 emitter에 리스너를 붙이는 구조 자체를 고쳐야 합니다. 요청 단위 리스너는 once()를 쓰거나, 응답이 끝날 때(res.on('close', ...)) 반드시 off()로 떼어 내는 짝을 만들어 두는 것이 안전합니다.

해결

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)
  • 오래된 항목 자동 제거

lru_cache를 쓸 때 알아 둘 함정이 있습니다. 인스턴스 메서드에 붙이면 self가 캐시 키에 들어가서, 캐시가 객체를 붙잡아 인스턴스가 해제되지 않습니다. 또 maxsize=None은 크기 제한이 없는 캐시라 원래의 전역 딕셔너리와 똑같이 커집니다. 캐시 적중률과 크기는 get_user_data.cache_info()로 확인할 수 있으니, 운영 메트릭에 함께 노출해 두면 “캐시가 커지는 것인지 다른 것이 커지는 것인지”를 바로 구분할 수 있습니다.


트러블슈팅

문제 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 회수 가능

WeakValueDictionary는 “다른 곳에서 쓰는 동안만 공유하는” 용도에 맞고, 일반적인 캐시를 대신하지는 못합니다. 값에 대한 강한 참조가 캐시밖에 없으면 넣자마자 사라질 수 있어 적중률이 거의 0이 되고, int, str, tuple, dict 같은 내장 타입은 약한 참조를 지원하지 않아 TypeError: cannot create weak reference to 'dict' object 오류가 납니다. 크기를 제한하는 캐시가 목적이라면 앞의 lru_cache나 TTL이 있는 캐시 라이브러리가 맞는 선택입니다.

문제 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: 이벤트 리스너 미해제, 클로저 누수

다음 단계


자주 묻는 질문 (FAQ)

Q. Python은 GC가 있는데도 메모리가 계속 늘어나는 이유는 무엇인가요?

A. GC는 더 이상 참조되지 않는 객체만 회수하므로, 전역 딕셔너리 캐시처럼 어딘가에서 계속 참조하고 있는 객체는 회수 대상이 아닙니다. 크기 제한 없이 항목을 쌓는 캐시나 해제하지 않은 이벤트 리스너, 클로저가 붙잡은 큰 객체가 대표적인 원인입니다. tracemalloc으로 스냅샷 두 개를 비교하면 어느 코드 위치에서 할당이 늘어나는지 찾을 수 있고, 캐시에는 functools.lru_cache의 maxsize 같은 상한을 두어야 합니다.


같이 보면 좋은 글