Node.js · 심화
스트림·운영·프로파일링
메모리 누수 찾기 - 힙 스냅샷과 흔한 원인
process.memoryUsage 관찰, 전역 캐시·리스너·클로저 누수 재현, --inspect 와 힙 스냅샷 비교 절차, WeakMap/WeakRef
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서 다룬 HTTP 성능 최적화와 함께 서버의 안정성을 결정짓는 또 다른 핵심 요소는 메모리 관리다. Node.js는 가비지 컬렉터(Garbage Collector)를 통해 자동으로 메모리를 관리하지만, 참조가 끊기지 않은 객체는 메모리에서 해제되지 않는다. 이러한 데이터가 쌓이면 애플리케이션의 메모리 사용량이 계속 증가하는 메모리 누수(Memory Leak)가 발생한다.
이 장에서는 로그 수집 서버를 운영하며 마주칠 수 있는 메모리 누수 문제를 살펴보고, 이를 진단하고 해결하는 방법을 알아본다.
- process.memoryUsage 함수로 프로세스의 메모리 상태를 관찰하는 방법
- 전역 캐시, 이벤트 리스너, 클로저(Closure)에 의한 흔한 누수 유형 이해
- v8 모듈과 조사관(Inspector)을 활용한 힙 스냅샷(Heap Snapshot) 생성 및 비교 분석
- WeakMap과 WeakRef를 활용한 메모리 안전한 객체 참조 기법
문제 상황
여러 서버에서 전송되는 접속 로그를 모아 통계를 내는 작은 로그 수집 서버를 운영하고 있다. 처음 배포했을 때는 메모리 사용량이 50MB 수준으로 안정적이었으나, 3일이 지나자 500MB를 넘어서고 결국 운영체제에 의해 프로세스가 강제 종료(OOM, Out Of Memory)되는 현상이 발생했다.
pm2나 systemd 같은 프로세스 관리 도구가 서버를 다시 살려내긴 하지만, 재시작되는 동안 유입된 로그는 유실된다. 서버 코드를 살펴보니 최근 유입된 로그의 발신자 IP별로 요청 횟수를 메모리에 기록하는 기능을 추가한 것이 원인으로 의심된다. 언제 지워야 할지 명확하지 않은 데이터를 무작정 전역 변수에 쌓고 있었던 것이다. 메모리가 어디서 새고 있는지 정확히 찾아내고 코드를 수정해야 한다.
메모리와 가비지 컬렉터
메모리 누수를 찾으려면 먼저 Node.js가 메모리를 어떻게 쓰는지 알아야 한다. Node.js 프로세스의 전체 메모리 사용량은 V8 자바스크립트 엔진이 관리하는 영역과 운영체제 차원에서 할당받은 영역으로 나뉜다.
process 모듈의 memoryUsage 함수를 호출하면 현재 프로세스의 메모리 상태를 바이트 단위로 반환한다.
| 속성명 | 의미 | 설명 |
|---|---|---|
| rss | 상주 집합 크기(Resident Set Size) | 프로세스가 차지하는 물리적 메모리 전체 크기. C++ 바인딩, 스레드 스택, 코드 세그먼트를 포함한다. |
| heapTotal | 힙 전체 크기 | V8 엔진이 자바스크립트 객체를 저장하기 위해 미리 할당해 둔 메모리의 총량이다. |
| heapUsed | 사용 중인 힙 크기 | 현재 자바스크립트 객체들이 실제로 차지하고 있는 메모리 크기다. 누수 판단의 핵심 지표다. |
| external | 외부 메모리 | V8 외부의 C++ 객체(예: Buffer 객체의 실제 데이터)가 차지하는 메모리다. |
자바스크립트에서 객체를 생성하면 heapUsed 값이 증가한다. 가비지 컬렉터는 루트(Root, 전역 객체나 현재 실행 중인 함수의 지역 변수 등)에서 출발하여 참조를 따라갈 수 없는 객체, 즉 도달 불가능(Unreachable)해진 객체를 찾아내 메모리에서 지운다.
정상적인 애플리케이션은 객체 생성과 가비지 컬렉션이 반복되면서 메모리 사용량이 톱니바퀴 모양을 그리며 일정한 범위를 유지한다. 반면 누수가 발생한 애플리케이션은 가비지 컬렉션이 일어나도 해제되지 않는 객체가 계속 쌓이면서 톱니바퀴의 기저점이 우상향하게 된다.
메모리 누수의 주요 원인
가비지 컬렉터가 정상 작동함에도 메모리가 늘어나는 것은 개발자가 의도치 않게 객체의 참조를 살려두었기 때문이다. 실무에서 자주 만나는 세 가지 원인은 다음과 같다.
전역 변수와 무한정 커지는 캐시
가장 단순하고 흔한 원인이다. 전역 스코프에 배열이나 Map을 선언하고 데이터를 계속 넣기만 하면, 프로그램이 종료될 때까지 해당 데이터는 가비지 컬렉터의 대상이 되지 않는다. 로그 데이터나 사용자 세션 정보를 캐싱할 때는 반드시 최대 크기를 제한하거나 오래된 항목을 삭제하는 만료 정책(TTL, Time To Live)을 구현해야 한다.
해제되지 않은 이벤트 리스너
이벤트 기반 아키텍처에서 특정 객체의 상태 변화를 감지하기 위해 on 메서드로 리스너를 등록하는 경우가 많다. 짧은 생명주기를 가진 객체(예: 들어오는 HTTP 요청)가 긴 생명주기를 가진 객체(예: 전역 설정 객체나 프로세스 자체)에 이벤트 리스너를 등록하고 요청이 끝날 때 removeListener로 해제하지 않으면 누수가 발생한다. 클로저가 요청 관련 변수들을 물고 있으므로 메모리 소모가 극심해진다.
클로저가 품고 있는 무거운 객체
자바스크립트의 클로저는 함수가 생성될 당시의 렉시컬 환경(Lexical Environment)을 기억한다. 비동기 콜백 함수 안에서 바깥 스코프의 커다란 객체를 하나라도 참조하면, 콜백 함수가 살아있는 한 그 객체 전체가 메모리에 남는다. 사용이 끝난 큰 객체는 null을 대입하여 참조를 명시적으로 끊어주는 것이 좋다.
힙 스냅샷과 메모리 분석
메모리 누수가 의심될 때 가장 확실한 진단 방법은 힙 스냅샷을 찍어 분석하는 것이다. 힙 스냅샷은 특정 시점의 V8 힙 메모리 상태를 파일로 기록한 것으로, 어떤 객체가 메모리를 차지하고 있고 어떤 변수가 그 객체를 참조하고 있는지 보여준다.
스냅샷 분석 시 두 가지 크기 개념을 이해해야 한다.
- 얕은 크기(Shallow Size): 객체 자체가 차지하는 메모리 크기다. 배열의 경우 배열의 인덱스 구조 자체가 차지하는 크기이며 보통 매우 작다.
- 유지된 크기(Retained Size): 이 객체가 가비지 컬렉션되어 삭제될 때 함께 해제될 수 있는 모든 객체의 크기 합이다. 누수의 원인을 찾을 때는 항상 유지된 크기를 기준으로 정렬해야 범인을 찾기 쉽다.
효과적인 분석을 위해서는 스냅샷 3개 비교 기법을 사용한다.
- 서버를 띄우고 안정화된 직후 첫 번째 스냅샷을 찍는다.
- 누수를 유발할 것으로 의심되는 동작(예: 로그 대량 전송)을 수차례 반복한 뒤 두 번째 스냅샷을 찍는다.
- 잠시 대기하여 가비지 컬렉션을 유도한 뒤 세 번째 스냅샷을 찍는다.
크롬 브라우저의 개발자 도구(chrome://inspect)를 열고 Memory 탭에서 세 번째 스냅샷을 불러온 뒤, 관점(View)을 'Summary'에서 'Comparison'으로 바꾸고 첫 번째 스냅샷과 비교한다. 증가한 객체(Delta) 중 유지된 크기가 비정상적으로 큰 것을 찾아 참조 경로(Retainers)를 역추적하면 누수를 일으킨 변수 이름을 찾아낼 수 있다.
완성 코드
다음은 메모리 누수를 피하면서 각 HTTP 요청별로 메타데이터를 추적하는 로그 수집 서버다. 스냅샷 추출 엔드포인트와 메모리 사용량 관찰 기능이 포함되어 있다. WeakMap을 사용하여 요청 객체가 소멸할 때 메타데이터도 함께 가비지 컬렉션되도록 구현했다.
server.mjs
import { createServer } from 'node:http';
import { writeHeapSnapshot } from 'node:v8';
import { memoryUsage } from 'node:process';
// 요청 객체(req)를 키로 사용하여 메타데이터를 저장하는 WeakMap
// req 객체가 가비지 컬렉션되면 이 안의 값도 자동으로 소멸한다.
const requestMetadata = new WeakMap();
const server = createServer((req, res) => {
// 새 요청이 들어오면 처리 시작 시간을 기록한다.
requestMetadata.set(req, {
startTime: Date.now(),
url: req.url,
method: req.method
});
// 메모리 사용량 확인 엔드포인트
if (req.method === 'GET' && req.url === '/memory') {
const usage = memoryUsage();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(usage));
return;
}
// 힙 스냅샷 생성 엔드포인트
if (req.method === 'POST' && req.url === '/snapshot') {
try {
// 현재 프로세스의 작업 디렉터리에 .heapsnapshot 파일을 생성한다.
const fileName = writeHeapSnapshot();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(`{"message":"스냅샷 생성 완료","file":"${fileName}"}`);
} catch (error) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('스냅샷 생성 실패');
}
return;
}
// 로그 수집 엔드포인트
if (req.method === 'POST' && req.url === '/log') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
const meta = requestMetadata.get(req);
const duration = Date.now() - meta.startTime;
// 본문 크기와 처리 시간을 출력한다.
console.log(`[LOG] 크기: ${body.length}바이트, 처리 시간: ${duration}ms`);
res.writeHead(201, { 'Content-Type': 'text/plain' });
res.end('로그 수신 완료');
});
return;
}
res.writeHead(404);
res.end('Not Found');
});
// 10초마다 현재 메모리 상태를 콘솔에 출력하는 타이머
setInterval(() => {
const { rss, heapUsed } = memoryUsage();
const rssMb = Math.round(rss / 1024 / 1024);
const heapUsedMb = Math.round(heapUsed / 1024 / 1024);
console.log(`[모니터링] RSS: ${rssMb}MB, 사용 중인 힙: ${heapUsedMb}MB`);
}, 10000).unref(); // 프로세스 종료를 막지 않도록 unref 호출
server.listen(3000, () => {
console.log('로그 수집 서버가 3000번 포트에서 실행 중입니다.');
});
줄별 해설
핵심은 Map 대신 WeakMap을 사용한 부분이다. 자바스크립트의 기본 Map은 키와 값에 대해 강한 참조(Strong Reference)를 가진다. 만약 Map에 HTTP 요청 객체인 req를 키로 저장했다면, 클라이언트와의 연결이 끊겨 Node.js 내부적으로 req 객체를 버리려 해도 Map이 참조를 잡고 있어 메모리에서 해제되지 않는다.
WeakMap은 키로 오직 객체만 받을 수 있으며, 키에 대한 참조를 약하게(Weakly) 유지한다. 프로그램 내의 다른 어떤 곳에서도 req 객체를 참조하지 않게 되면, 가비지 컬렉터는 WeakMap에 들어있는 req 키와 그에 연결된 메타데이터 값을 안전하게 지워버린다. 요청마다 생겨나는 임시 데이터를 다룰 때 WeakMap은 메모리 누수를 원천 차단하는 훌륭한 도구다.
v8 모듈의 writeHeapSnapshot 함수는 호출 즉시 V8 엔진을 멈추고 메모리 상태를 순회하며 디스크에 파일을 쓴다. 운영 중인 서버에서 잦은 스냅샷 생성은 긴 지연 시간을 유발하므로 주의해야 한다.
실행 결과
터미널을 두 개 열고, 첫 번째 터미널에서 서버를 실행한다.
node server.mjs
두 번째 터미널에서 curl 명령어로 메모리 상태를 확인하고, 로그를 전송한 뒤 스냅샷을 찍어본다.
curl http://localhost:3000/memory
# {"rss":45617152,"heapTotal":6578176,"heapUsed":5193288,"external":1555567,"arrayBuffers":11330}
curl -X POST http://localhost:3000/log -d "error_code=500&user=hscho"
# 로그 수신 완료
curl -X POST http://localhost:3000/snapshot
# {"message":"스냅샷 생성 완료","file":"Heap-20260929T162137.heapsnapshot"}
첫 번째 서버 터미널에는 다음과 같이 주기적인 메모리 모니터링 로그와 요청 처리 로그가 남는다.
로그 수집 서버가 3000번 포트에서 실행 중입니다.
[모니터링] RSS: 43MB, 사용 중인 힙: 5MB
[LOG] 크기: 27바이트, 처리 시간: 2ms
[모니터링] RSS: 44MB, 사용 중인 힙: 5MB
실무에서 자주 틀리는 것
배열을 큐(Queue)로 쓸 때의 크기 제한 누락
가장 빈번한 실수다. 들어오는 로그를 나중에 데이터베이스에 한 번에 쓰기 위해 배열에 담아두고, 지우는 로직을 누락하거나 크기 상한선을 두지 않으면 메모리는 빠르게 고갈된다.
// 틀린 코드: 로그가 끝없이 쌓여 누수 발생
const logBuffer = [];
function addLog(data) {
logBuffer.push(data);
}
// 고친 코드: 최대 개수를 초과하면 오래된 항목 삭제
const MAX_BUFFER = 1000;
const logBuffer = [];
function addLog(data) {
logBuffer.push(data);
if (logBuffer.length > MAX_BUFFER) {
logBuffer.shift(); // 실무에서는 연결 리스트(Linked List) 등 효율적인 구조체 사용을 권장한다.
}
}
전역 객체에 등록한 이벤트 리스너 방치
요청이 들어올 때마다 전역 이벤트 에미터(Event Emitter)에 리스너를 달고 해제하지 않는 상황이다. Node.js는 한 이벤트에 리스너가 10개 이상 등록되면 메모리 누수 가능성을 경고(MaxListenersExceededWarning)하지만, 한도 수치를 임의로 올려 경고만 끄는 잘못된 대처를 하기도 한다.
// 틀린 코드: 요청마다 리스너가 누적됨
import { EventEmitter } from 'node:events';
const globalBus = new EventEmitter();
function handleRequest(req, res) {
globalBus.on('shutdown', () => {
res.end('서버가 종료됩니다.');
});
// 요청이 끝나도 리스너가 남아 req와 res 객체의 가비지 컬렉션을 막는다.
}
// 고친 코드: 응답 완료 시 리스너 명시적 해제
function handleRequest(req, res) {
const onShutdown = () => res.end('서버가 종료됩니다.');
globalBus.on('shutdown', onShutdown);
res.on('finish', () => {
globalBus.removeListener('shutdown', onShutdown);
});
}
클로저 내부의 불필요한 큰 객체 참조
타이머나 비동기 콜백에서 스코프 바깥의 거대한 버퍼나 텍스트를 참조하면, 해당 데이터 전체가 메모리에 묶인다. 콜백 안에서 필요한 최소한의 원시 값만 추출해서 쓰는 것이 좋다.
// 틀린 코드: 큰 텍스트 전체가 클로저에 묶임
function processFile(hugeText) {
setTimeout(() => {
console.log("파일 처리 완료: " + hugeText.length); // hugeText 전체 참조
}, 5000);
}
// 고친 코드: 필요한 데이터만 지역 변수로 추출하여 묶임 방지
function processFile(hugeText) {
const textLength = hugeText.length;
setTimeout(() => {
console.log("파일 처리 완료: " + textLength);
}, 5000);
}
한눈에 보기
| 누수 원인 | 진단 지표 및 도구 | 해결 전략 |
|---|---|---|
| 무한정 늘어나는 캐시 배열/Map | 힙 스냅샷의 배열 객체 Retained Size 급증 | 최대 크기 설정, 만료 시간(TTL) 구현, LRU 캐시 알고리즘 적용 |
| 객체 메타데이터 생명주기 불일치 | 특정 클래스 인스턴스가 비정상적으로 많이 남음 | 키 객체가 소멸할 때 값을 비워주는 WeakMap/WeakSet 사용 |
| 방치된 이벤트 리스너 | MaxListenersExceededWarning 콘솔 경고 | 작업 종료 시 removeListener 명시적 호출 |
| 클로저 변수 포획 | 스냅샷에서 함수(Closure) 컨텍스트의 메모리 점유 | 사용 후 변수에 null 대입, 필요한 원시 값만 지역 변수로 복사 |
연습 문제
- process.memoryUsage()가 반환하는 값 중 V8 자바스크립트 엔진이 현재 생성된 객체들을 유지하기 위해 실제로 사용 중인 메모리를 나타내는 속성 이름은 무엇인가?
- 크롬 개발자 도구의 힙 스냅샷 분석 탭에서, 누수의 근본 원인을 파악하기 위해 객체를 정렬할 때 기준으로 삼아야 하는 메모리 크기 개념은 무엇인가? 얕은 크기(Shallow Size)와 비교하여 설명하라.
- HTTP 요청 객체인 req와 그에 관련된 사용자 권한 정보를 메모리에 임시로 연결해두고 싶다. 일반 Map 대신 WeakMap을 사용하면 메모리 관리 측면에서 어떤 이점이 있는지 서술하라.
정답과 해설
- heapUsed. (해설: rss는 프로세스 전체의 메모리, heapTotal은 할당된 전체 힙 크기이며, 실제 활성화된 자바스크립트 객체의 누수를 추적할 때는 heapUsed 지표가 가장 정확하다.)
- 유지된 크기(Retained Size). (해설: 얕은 크기는 객체 자신의 순수한 크기일 뿐이라 누수 탐지에 부적합하다. 유지된 크기는 해당 객체가 가비지 컬렉터에 의해 지워질 때 함께 해방될 수 있는 모든 연관 객체의 크기 합이므로, 이 크기가 가장 큰 객체를 찾으면 메모리 누수의 중심점을 찾을 수 있다.)
- WeakMap은 키로 사용된 객체(여기서는 req)에 대해 약한 참조를 생성한다. 응답이 완료되어 Node.js가 req 객체를 메모리에서 정리할 때, WeakMap에 연결되어 있던 사용자 권한 정보도 가비지 컬렉터에 의해 자동으로 함께 삭제되므로 메모리 누수를 방지할 수 있다. 반면 일반 Map은 강한 참조를 유지하므로 개발자가 명시적으로 delete를 호출하지 않으면 영구적인 누수로 이어진다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.