메모리 누수 찾기: 프로파일러 선택과 재현 시나리오 | Valgrind·ASan·Heaptrack
이 글의 핵심
로컬에서는 재현되지 않고 서버에서만 메모리가 조금씩 늘어나는 누수는 도구보다 재현 조건을 먼저 잡아야 풀립니다. 이 글은 shared_ptr 순환 참조, 이벤트 리스너 누적, 전역 캐시 같은 실제 패턴을 언어별로 보여주고, Valgrind가 너무 느리거나 ASan 빌드를 운영에 올릴 수 없을 때의 대안도 설명합니다.
들어가며
메모리 누수 조사는 “도구를 한 번 돌린다”로 끝나지 않습니다. 재현 가능한 입력, 실행 시간, 부하가 없으면 프로파일러도 추측만 늘립니다. 이 글은 C++, Python, JavaScript(Node.js)에서 각각 어떤 도구가 적합한지, 재현 시나리오를 어떻게 적는지, 결과를 어떻게 읽는지 순서대로 정리합니다. 운영 중 실제로 겪은 C++ 사례는 C++ 메모리 누수 디버깅 실전 사례에서 이어서 볼 수 있습니다.
도구를 고르기 전에 먼저 정해야 할 것은 “무엇을 찾는가”입니다. ASan·Valgrind 같은 누수 검사기는 종료 시점에 아무도 가리키지 않는 블록을 찾아 주고, Heaptrack·tracemalloc·힙 스냅샷 같은 힙 프로파일러는 시간이 지나며 어떤 할당 위치가 커지는지를 보여 줍니다. 운영 서버에서 메모리가 계속 오르는 문제의 상당수는 여전히 참조되고 있는 캐시나 리스너 목록이라서, 누수 검사기로는 “누수 없음”이 나오고 힙 프로파일러에서야 원인이 보입니다. 이 차이를 모르고 검사기만 돌리면 “도구는 깨끗하다는데 메모리는 계속 오른다”는 상황에서 멈추게 됩니다.
누수의 종류와 단편화
| 유형 | 설명 | 전형적 언어 |
|---|---|---|
| 진짜 누수 | 할당한 블록을 가리키는 포인터가 모두 사라짐 | C/C++ |
| 의도치 않은 누적 | 캐시·전역 맵·이벤트 리스너가 항목을 계속 붙잡음 | JS, Python, Java |
| 순환 참조 | 참조 카운트만으로 수명을 관리할 때 서로 가리키는 객체가 회수되지 않음 | C++ shared_ptr, Swift/Obj-C ARC |
| 단편화·할당자 보유 | RSS는 높지만 실제 사용 중인 힙은 안정적 | 모든 언어 |
진짜 누수와 의도치 않은 누적은 힙 스냅샷에서 살아 있는 할당의 수와 크기가 시간에 따라 계속 늘어나는 것으로 드러납니다. 단편화는 살아 있는 할당의 총량은 일정한데 프로세스의 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)
g++ -fsanitize=address -fno-omit-frame-pointer -g -O1 main.cpp -o app # clang++도 동일
ASAN_OPTIONS=detect_leaks=1 ./app
ASAN_OPTIONS=detect_leaks=1:log_path=asan.log ./app # asan.log.<pid>에 기록
#include <iostream>
#include <vector>
void leak_example() {
int* p = new int[100]; // delete[] 누락
std::cout << "allocated at " << p << '\n'; // 포인터를 실제로 사용
}
void no_leak_example() {
std::vector<int> v(100); // RAII, 자동 해제
}
int main() {
leak_example();
no_leak_example();
return 0;
}
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 400 byte(s) in 1 object(s) allocated from:
#0 0x... in operator new[](unsigned long)
#1 0x... in leak_example() /path/to/main.cpp:4
#2 0x... in main /path/to/main.cpp:11
SUMMARY: AddressSanitizer: 400 byte(s) leaked in 1 allocation(s).
보고서는 main.cpp:4의 new int[100](int 100개, 400바이트)가 해제되지 않았다고 알려 줍니다. Direct leak은 그 블록을 가리키는 포인터가 하나도 남지 않은 경우이고, Indirect leak은 누수된 블록을 통해서만 도달할 수 있던 블록입니다. 먼저 Direct를 고치면 Indirect는 함께 사라지는 경우가 대부분입니다. 예제에서 포인터를 출력하는 줄은 장식이 아닙니다. 할당한 포인터를 전혀 쓰지 않으면 C++14부터 허용된 할당 생략(allocation elision) 때문에 Clang이 -O1에서도 new int[100] 자체를 지워 버리고, LSan은 아무것도 보고하지 않습니다. 누수 검사기를 시험하는 작은 예제가 “누수 없음”으로 끝난다면 이 최적화부터 의심하세요. 누수가 보고되면 프로세스의 종료 코드도 0이 아니게 되므로, CI에서는 로그를 grep하지 않고 종료 코드만으로 실패를 판정할 수 있습니다.
-O1은 ASan 문서가 적당한 성능을 위해 권하는 수준이고, 너무 높은 최적화는 인라이닝 때문에 스택 트레이스에서 함수가 사라져 할당 위치를 읽기 어렵게 만듭니다. 스택에 ??만 나온다면 -g가 빠졌거나 llvm-symbolizer를 찾지 못한 것이므로 ASAN_SYMBOLIZER_PATH를 확인하세요. macOS의 Apple Clang과 Windows MSVC의 ASan은 누수 탐지(LSan)를 지원하지 않으니, 누수 검사는 Linux에서 하는 것이 확실합니다.
Valgrind (memcheck)
valgrind --leak-check=full ./app
valgrind --leak-check=full --show-leak-kinds=all ./app # 모든 종류 표시
valgrind --leak-check=full --log-file=valgrind.log ./app < input.txt
Valgrind는 다시 빌드하지 않은 바이너리에도 쓸 수 있다는 것이 가장 큰 장점이고, 대신 프로그램이 수십 배 느려집니다. 종료 시점의 LEAK SUMMARY에서 남은 블록을 네 가지로 나누며, 이 분류가 어떤 순서로 고칠지를 정해 줍니다.
- definitely lost는 그 블록을 가리키는 포인터가 어디에도 남아 있지 않은 경우입니다. 함수 안에서
malloc/new한 지역 포인터를 해제하지 않고 반환한 경우가 전형이며, 가장 먼저 고칩니다. - indirectly lost는 definitely lost 블록을 통해서만 닿을 수 있던 블록입니다(누수된 노드가 가리키던 자식 노드 등). 위의 원인을 고치면 대개 함께 사라집니다.
- possibly lost는 블록의 시작이 아니라 중간을 가리키는 포인터만 남은 경우입니다. 전역이나 살아 있는 객체가
base + 10같은 내부 포인터를 들고 있을 때 나옵니다. - still reachable은 종료 시점에도 전역·정적 변수 등에서 도달 가능한 블록입니다. 한 번만 할당되는 싱글턴이면 보통 무시해도 되지만, 서버가 도는 동안 계속 커진다면 캐시나 레지스트리가 붙잡고 있는 논리적 누수일 수 있습니다.
실제 출력 예시, --errors-for-leak-kinds로 CI에서 누수를 실패로 만드는 법, LSan 억제 파일, 누수 원인별 해결은 C++ 메모리 누수 찾기: Valgrind·ASan에서 C++만 놓고 자세히 다룹니다. 이 글은 여러 언어에서 어떤 도구를 고를지와 재현 시나리오에 집중합니다.
Heaptrack
heaptrack ./app # 기록, heaptrack.app.<pid>.zst(또는 .gz) 생성
heaptrack_gui heaptrack.app.<pid>.zst # GUI 분석
heaptrack_print heaptrack.app.<pid>.zst # 텍스트 요약
heaptrack -p <PID> # 실행 중인 프로세스에 붙기
#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);
}
}
이 프로그램을 Heaptrack으로 기록하면, process_item에서 시작하는 std::string 버퍼 할당이 10만 번 일어나 약 100MB(1,001바이트 × 10만)를 차지하고, 해시맵 노드 할당도 같은 경로에서 쌓이는 것이 보입니다. heaptrack_print의 요약은 할당 횟수가 많은 함수, 최대 메모리를 차지한 함수, 종료 시점에 해제되지 않은(leaked) 할당을 호출 스택과 함께 보여 줍니다.
Heaptrack은 malloc/free를 가로채 모든 할당을 호출 스택과 함께 기록하고, 종료 뒤 시간 축으로 보여 줍니다. 그래서 “무엇이 해제되지 않았는가”뿐 아니라 “어느 시점에 어떤 경로로 메모리가 늘었는가”, “임시 할당이 가장 많은 함수가 무엇인가”까지 볼 수 있습니다. 종료 시점에 정리하지 않는 전역 캐시도 leaked로 잡히므로, 장기 실행 서버라면 종료 시점 값보다 heaptrack_gui의 Consumed 그래프에서 어떤 호출 경로가 계속 우상향하는지를 보는 쪽이 더 유용합니다. 실행 중인 프로세스에 -p로 붙을 수 있어서, 재시작 없이 스테이징 서버를 잠깐 관찰할 때도 쓸 수 있습니다.
Python 도구
tracemalloc
import tracemalloc
tracemalloc.start(10) # 할당마다 최대 10프레임의 스택을 기록
cache = []
def handle_request():
cache.append([0] * 1000) # 요청마다 누적
def main():
for _ in range(100): # 워밍업: 일회성 할당을 먼저 치움
handle_request()
snapshot1 = tracemalloc.take_snapshot()
for _ in range(10000):
handle_request()
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, 'lineno')[:10]:
print(stat)
if __name__ == '__main__':
main()
compare_to 결과의 첫 줄은 cache.append([0] * 1000) 줄을 가리키며, 그 줄에서 늘어난 크기와 할당 수를 size=... (+...), count=... (+...) 형태로 보여 줍니다. 64비트 CPython에서 원소 1,000개짜리 리스트 하나는 약 8KB(sys.getsizeof([0] * 1000)이 8,056바이트)이므로, 1만 번 반복하면 그 줄에서만 약 77MB가 늘어난 것으로 나옵니다.
tracemalloc 비교에서 중요한 것은 두 스냅샷 사이에 같은 작업을 여러 번 반복하는 것입니다. 한 번만 실행하면 모듈 import, 첫 요청의 캐시 워밍업 같은 일회성 할당이 섞여 상위에 올라옵니다. 같은 요청을 100번 처리한 뒤의 차이와 1,000번 처리한 뒤의 차이가 비례해서 커지는 줄이 진짜 의심 지점입니다. tracemalloc은 모든 할당에 스택 정보를 붙이므로 메모리와 CPU 오버헤드가 작지 않고, start(25)처럼 깊이를 늘릴수록 커집니다. 운영 환경에서 계속 켜 두기보다 짧은 구간만 켜는 방식으로 쓰는 것이 일반적입니다.
objgraph로 “누가 붙잡고 있는가” 찾기
import gc
import objgraph
class Node:
def __init__(self, value):
self.value = value
self.next = None
registry = [] # 실수로 모든 노드를 등록해 두는 전역 목록
def create_pair():
a = Node(1)
b = Node(2)
a.next = b
b.next = a
registry.append(a)
for _ in range(1000):
create_pair()
gc.collect()
objgraph.show_most_common_types(limit=5) # Node 2000 등 타입별 개수
node = objgraph.by_type('Node')[0]
objgraph.show_backrefs(node, max_depth=5, filename='refs.png') # graphviz 필요
Python의 GC는 고립된 순환 참조를 회수하므로, registry가 없다면 gc.collect() 뒤에 Node는 남지 않습니다. 이 예제에서 2,000개가 남는 것은 순환 때문이 아니라 전역 registry가 한쪽 노드를 붙잡고 있기 때문입니다. objgraph.show_backrefs()는 객체에서 거꾸로 올라가며 “누가 이 객체를 붙잡고 있는가”를 그려 주는데, 대개 경로 끝에 모듈 전역 변수, 클래스 속성, 등록된 콜백, 로거 핸들러 같은 뿌리가 보입니다. 그 뿌리를 찾는 것이 수정의 출발점입니다.
memory_profiler
pip install memory_profiler
python -m memory_profiler app.py
from memory_profiler import profile
@profile
def build():
cache = []
for i in range(10000):
cache.append([0] * 1000)
return cache
if __name__ == '__main__':
result = build()
@profile을 붙인 함수에 대해 줄마다 실행 후의 메모리 사용량(RSS)과 증가분을 표로 보여 줍니다. 이 예제라면 cache.append 줄에서 약 77MB가 늘어난 것으로 나옵니다. RSS 기준이라 할당자가 이미 확보해 둔 메모리를 재사용하면 증가가 0으로 보이기도 하므로, 줄 단위의 정확한 할당량이 필요하면 tracemalloc을, 함수 전체의 메모리 추세를 빠르게 보고 싶을 때 memory_profiler를 씁니다. 현재 memory_profiler는 활발히 관리되지 않고 있으니, 새 프로젝트라면 Memray 같은 도구도 검토할 만합니다.
Node.js: 힙 스냅샷
// app.js
const v8 = require('v8');
let cache = [];
function leakExample() {
for (let i = 0; i < 10000; i++) {
cache.push(new Array(1000).fill(0));
}
}
function takeSnapshot(tag) {
if (global.gc) global.gc(); // 스냅샷 직전에 GC
const filename = v8.writeHeapSnapshot(`heap-${tag}-${Date.now()}.heapsnapshot`);
console.log(`Snapshot saved: ${filename}`);
}
takeSnapshot('before');
leakExample();
takeSnapshot('after');
console.log('Memory usage:', process.memoryUsage());
node --expose-gc app.js # global.gc를 쓰려면 필요
Chrome DevTools의 Memory 탭에서 Load로 두 .heapsnapshot 파일을 불러온 뒤, 나중 스냅샷을 선택하고 Comparison 뷰에서 이전 스냅샷과 비교합니다. 이 예제라면 (array) 항목의 개수가 1만 개 늘고, 그 배열들이 전역 cache를 통해 붙잡혀 있다는 경로(Retainers)가 보입니다.
Comparison 뷰에서는 Shallow Size(객체 자체 크기)보다 Retained Size(이 객체가 사라지면 함께 해제될 수 있는 크기)를 봐야 합니다. 작은 클로저 하나가 거대한 요청 객체를 붙잡고 있으면 Shallow는 수십 바이트지만 Retained는 수백 MB일 수 있습니다. 스냅샷을 찍기 직전에 GC를 돌리는 것도 중요합니다. 그렇지 않으면 아직 수거되지 않았을 뿐인 쓰레기가 차이에 섞입니다. 힙 스냅샷은 찍는 동안 프로세스가 멈추고 힙 크기에 비례하는 메모리를 추가로 쓰므로, 메모리가 이미 한계에 가까운 운영 프로세스에서 찍으면 그 자체로 OOM이 날 수 있다는 점도 주의해야 합니다.
실행 중인 프로세스를 직접 보려면 node --inspect app.js로 띄우고 Chrome에서 chrome://inspect의 “Open dedicated DevTools for Node”로 붙은 뒤 Memory 탭에서 스냅샷을 찍습니다. inspector 포트는 프로세스를 완전히 제어할 수 있으므로 운영 서버에서 외부에 열면 안 됩니다. 운영 프로세스에서는 --heapsnapshot-signal=SIGUSR2로 신호를 받을 때 스냅샷을 쓰게 하거나, --heapsnapshot-near-heap-limit으로 OOM 직전의 힙을 남기는 방식이 더 안전합니다.
재현 시나리오 만들기
메모리 누수는 재현 가능한 입력이 없으면 고쳤는지 확인할 방법도 없습니다. 재현 절차를 스크립트로 만들어 두면 수정 전후를 같은 조건으로 비교할 수 있습니다.
#!/bin/bash
# reproduce_leak.sh
set -u
export ASAN_OPTIONS=detect_leaks=1
python generate_input.py --size 10000 --seed 42 > input.txt # 시드로 입력 고정
if ./app_asan < input.txt; then
echo "No leak detected"
else
echo "Leak (or other ASan error) detected" # LSan이 누수를 보고하면 종료 코드가 0이 아님
exit 1
fi
재현 조건 문서에는 OS와 컴파일러 버전, 빌드 플래그, 입력을 만드는 명령과 시드, 실행 명령, 기대하는 결과(어느 위치에서 몇 바이트), 그리고 몇 번 중 몇 번 재현되는지를 적습니다. 마지막 항목이 중요한데, 10번 중 3번만 재현되는 누수라면 타이밍이나 스레드 순서에 의존한다는 뜻이고, 그 자체가 원인을 좁히는 단서가 됩니다.
장기 실행에서 추세 보기
#!/bin/bash
# load_test.sh: 1시간 동안 초당 1회 요청, 10분마다 RSS(KB) 기록
PID=$(pgrep -x app)
for i in $(seq 1 3600); do
curl -s http://localhost:8080/api/process > /dev/null
sleep 1
if [ $((i % 600)) -eq 0 ]; then
ps -o rss= -p "$PID" >> memory.log
fi
done
ps aux | grep app처럼 프로세스를 찾으면 grep 자신이나 이름이 비슷한 다른 프로세스가 섞이므로, pgrep으로 PID를 정하고 ps -o rss= -p로 그 프로세스의 RSS만 기록합니다. 기록된 값을 그래프로 그렸을 때, 부하가 일정한데도 계속 우상향하면 누수나 누적을, 초반에 오르다가 평평해지면 캐시 워밍업이나 할당자의 메모리 보유를 의심합니다.
운영 모니터링
import time
import psutil
from prometheus_client import Gauge, start_http_server
rss_bytes = Gauge('app_memory_rss_bytes', 'Resident set size of this process')
def collect_metrics():
process = psutil.Process()
while True:
rss_bytes.set(process.memory_info().rss)
time.sleep(10)
if __name__ == '__main__':
start_http_server(8000)
collect_metrics()
# 1시간 동안의 증가 기울기 (gauge에는 rate 대신 deriv)
deriv(app_memory_rss_bytes[1h])
# 임계값 알림
app_memory_rss_bytes > 1e9
Python 프로세스라면 prometheus_client가 기본으로 내보내는 process_resident_memory_bytes를 그대로 써도 됩니다. Python 객체가 차지하는 힙 크기를 따로 보고 싶다면 tracemalloc을 켠 상태에서 tracemalloc.get_traced_memory()를 쓰거나 gc.get_objects()의 개수 추이를 봐야 하며, sys.getsizeof(globals()) 같은 값은 딕셔너리 객체 하나의 크기일 뿐 힙 크기가 아닙니다.
도구 비교
| 도구 | 언어 | 대략적인 오버헤드 | 적합한 곳 |
|---|---|---|---|
| ASan/LSan | C/C++ | 약 2배 느림, 메모리 약 3배 | 개발·CI |
| Valgrind memcheck | C/C++ | 10~50배 느림 | 재빌드가 어려운 바이너리, 심층 분석 |
| Heaptrack | C/C++ | 할당 빈도에 비례 (Valgrind보다 훨씬 가벼움) | 할당 핫스팟, 시간에 따른 증가 |
| tracemalloc | Python | 스택 깊이에 비례 | 개발·스테이징, 운영은 짧게 |
| objgraph | Python | 호출할 때만 비용 | 객체를 붙잡은 참조 경로 |
| 힙 스냅샷 | Node.js | 찍는 동안 멈춤, 힙 크기만큼 추가 메모리 | 개발·스테이징, 운영은 신호로 짧게 |
ASan과 Valgrind 수치는 각 프로젝트 문서에서 안내하는 일반적인 범위이고, 실제 비용은 프로그램의 할당 패턴에 따라 달라집니다. C/C++에서는 빠른 검증에 ASan, 재빌드할 수 없거나 ASan으로 재현되지 않을 때 Valgrind, “어디서 메모리가 늘어나는가”를 볼 때 Heaptrack을 씁니다. Python은 tracemalloc으로 늘어나는 줄을 찾고 objgraph로 붙잡고 있는 참조를 찾으며, Node.js는 힙 스냅샷 비교가 기본입니다.
실무 사례
사례 1: C++ 서버의 shared_ptr 순환 참조
연결이 끊긴 뒤에도 메모리가 줄지 않는 경우입니다.
#include <memory>
struct Session;
struct Connection {
std::shared_ptr<Session> session;
};
struct Session {
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로 남아 둘 다 해제되지 않음
}
이 경우 LSan이나 Valgrind는 프로그램 종료 시 두 객체를 누수로 보고하고, Heaptrack에서는 handle_connection에서 시작하는 make_shared 할당이 호출 횟수만큼 해제되지 않고 쌓이는 것이 보입니다. 소유 방향을 정해 한쪽을 weak_ptr로 바꾸면 해결됩니다.
struct Session {
std::weak_ptr<Connection> connection; // 소유하지 않는 역참조
};
// 필요할 때 auto c = connection.lock(); if (c) { ... }
연결이 세션을 소유하고 세션은 연결을 참조만 하는 구조가 되므로, conn이 사라지면 Connection이 해제되고 그 안의 shared_ptr<Session>도 함께 해제됩니다. 순환 참조를 찾고 끊는 기준은 shared_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); // 요청마다 리스너 추가
// ... 응답이 끝나도 리스너를 떼지 않음
}
요청마다 전역 emitter에 리스너가 하나씩 붙고, 각 리스너 클로저가 그 요청의 res를 붙잡으므로 응답이 끝난 요청 객체들이 해제되지 않습니다. 힙 스냅샷에서는 (closure)의 Retained Size가 크고, Retainers를 따라가면 EventEmitter의 리스너 배열에 도달합니다.
이 패턴은 Node.js가 경고로 먼저 알려 주는 경우가 많습니다. 같은 이벤트에 리스너가 기본 한도(10개)를 넘게 등록되면 MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 data listeners added to [EventEmitter]... 같은 경고가 한 번 출력됩니다. 이 경고를 setMaxListeners(0)으로 끄는 것은 증상만 숨기는 것이고, 요청마다 전역 emitter에 리스너를 붙이는 구조 자체를 고쳐야 합니다.
function handleRequest(req, res) {
const handler = (data) => {
res.write(data);
};
emitter.on('data', handler);
// 'finish'는 응답을 정상적으로 다 보냈을 때만 발생하므로,
// 클라이언트가 중간에 끊어도 발생하는 'close'에서 해제한다
res.on('close', () => {
emitter.off('data', handler);
});
}
한 번만 받으면 되는 이벤트라면 once()로 등록해 자동으로 해제되게 하는 방법도 있습니다.
사례 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 비교에서는 load_user_data 안의 할당 줄이, 요청한 고유 사용자 수에 비례해 커지는 항목으로 나타납니다. 크기 제한이 있는 캐시로 바꾸면 해결됩니다.
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)
사용자 데이터 하나가 약 48KB라면 캐시의 상한은 약 48MB(1000 × 48KB)로 고정되고, 가장 오래 쓰이지 않은 항목부터 밀려납니다. lru_cache를 쓸 때 알아 둘 함정이 있습니다. 인스턴스 메서드에 붙이면 self가 캐시 키에 들어가서, 캐시가 객체를 붙잡아 인스턴스가 해제되지 않습니다. 또 maxsize=None은 크기 제한이 없는 캐시라 원래의 전역 딕셔너리와 똑같이 커집니다. 캐시 적중률과 크기는 get_user_data.cache_info()로 확인할 수 있으니, 운영 메트릭에 함께 노출해 두면 “캐시가 커지는 것인지 다른 것이 커지는 것인지”를 바로 구분할 수 있습니다.
트러블슈팅
로컬에선 안 나는데 서버에서만 증가한다
대개 스레드 수와 풀 크기, 스레드별 버퍼, 동시 요청 수가 로컬과 다르기 때문입니다. 로컬에서 요청을 하나씩 보내면 드러나지 않는 누적이 동시 요청 수백 개에서는 나타납니다. 서버와 비슷한 부하를 만들어 놓고 RSS 추세를 봅니다.
wrk -t12 -c400 -d30s http://localhost:8080/api/process
watch -n 1 "ps -o rss= -p $(pgrep -x app)"
Valgrind가 너무 느리다
Valgrind는 프로그램을 가상 CPU 위에서 실행하므로 10~50배 느려지고, 큰 입력으로는 몇 시간씩 걸리기도 합니다. 입력을 반씩 줄여 가며 누수가 계속 재현되는 가장 작은 입력을 찾으면 실행 시간을 크게 줄일 수 있습니다.
head -n 5000 input.txt > input_half.txt
valgrind --leak-check=full ./app < input_half.txt
# 재현되면 다시 절반으로
head -n 2500 input_half.txt > input_quarter.txt
valgrind --leak-check=full ./app < input_quarter.txt
Python은 GC가 있으니 괜찮다?
cache = {}
def add_to_cache(key, value):
cache[key] = value # 전역 cache가 참조하므로 GC가 회수하지 않음
GC는 도달할 수 없는 객체만 회수합니다. 전역 딕셔너리에 넣은 값은 계속 도달 가능하므로 프로그램이 끝날 때까지 남습니다. weakref.WeakValueDictionary로 바꾸면 다른 곳에서 값을 참조하지 않을 때 항목이 자동으로 사라지지만, 이는 “다른 곳에서 쓰는 동안만 공유하는” 용도에 맞고 일반적인 캐시를 대신하지는 못합니다. 값에 대한 강한 참조가 캐시밖에 없으면 넣자마자 사라질 수 있어 적중률이 거의 0이 되고, int, str, tuple, dict 같은 내장 타입은 약한 참조를 지원하지 않아 TypeError: cannot create weak reference to 'dict' object 오류가 납니다. 크기를 제한하는 캐시가 목적이라면 앞의 lru_cache나 TTL이 있는 캐시 라이브러리가 맞는 선택입니다.
ASan 빌드를 프로덕션에 올릴 수 없다
ASan은 실행 속도와 메모리를 모두 크게 늘리므로 운영 배포용으로는 맞지 않습니다. 대신 CI나 스테이징에 ASan 빌드로 재현용 부하를 돌리는 단계를 두고, 운영 빌드는 그대로 둡니다.
# GitLab CI 예시
stages:
- test
- deploy
leak-check:
stage: test
script:
- clang++ -fsanitize=address -fno-omit-frame-pointer -g -O1 main.cpp -o app_asan
- ASAN_OPTIONS=detect_leaks=1 ./app_asan < tests/replay_input.txt # 누수 시 종료 코드로 실패
deploy:
stage: deploy
script:
- clang++ -O2 -g main.cpp -o app
- ./deploy.sh
운영에서는 RSS 추세를 메트릭으로 보고, 의심 구간이 생기면 같은 버전을 스테이징에서 ASan이나 Heaptrack으로 재현하는 흐름이 현실적입니다.