Node.js를 돌리다 보면 가끔 이런 메시지와 함께 프로세스가 죽는다.
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
또는
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
이 글에서는 왜 이 에러가 나는지, 어떻게 급하게 막는지, 그리고 근본적으로 어떻게 줄이는지를 3단계로 정리한다. Node.js 18~22 기준이고, V8 엔진의 메모리 관리 방식을 최소한만 알아도 해결이 훨씬 빨라진다.
이 글은 생성형 AI를 활용해 초안을 만들고, Node.js 공식 CLI 문서와 V8 블로그를 다시 확인해 정리했다.
flowchart LR A[heap out of memory 에러] --> B{급한가?} B -- 예 --> C[--max-old-space-size로 상향] B -- 아니오 --> D[메모리 사용 패턴 점검] C --> E[운영 안정화] D --> E
칠판 치트시트
- 기본 힙 한계는 1.4~4GB(버전·아키텍처마다 다름)
- 급하면
--max-old-space-size=4096으로 올린다- 근본 해결은 큰 배열·스트림 처리 방식을 바꾸는 것
- pm2를 쓰면
node_args: ['--max-old-space-size=4096']로 지정한다- Docker에서는 컨테이너 메모리 제한도 같이 확인해야 한다
왜 “heap out of memory”가 나는가
Node.js는 V8 엔진 위에서 돈다. V8은 JavaScript 객체를 “힙(heap)“이라는 메모리 공간에 할당하는데, 이 힙 크기에 기본 한계가 있다. 공식 Node.js CLI 문서에 따르면 --max-old-space-size 기본값은 시스템에 따라 다르지만, 보통 1.4GB(32비트) ~ 4GB(64비트) 수준이다.
문제는 이 한계를 넘어서는 순간 V8이 더 이상 메모리를 할당하지 못하고 프로세스를 바로 죽여버린다. 에러 메시지가 “FATAL”인 이유가 있다. 복구가 아니라 종료다.
자주 발생하는 장면:
- 수만~수십만 건의 데이터를 한 번에 배열에 담을 때
- 대용량 파일을 통째로 읽어서 메모리에 올릴 때
- 빌드 도구(webpack, Next.js, Vite 등)가 프로젝트 규모에 비해 힙이 부족할 때
- 오래 돌던 서버에서 메모리 누수가 누적됐을 때
1단계: 급하게 막기 (5분)
A. 명령어에 플래그 추가
node --max-old-space-size=4096 app.js숫자는 MB 단위다. 서버 메모리가 충분하다면 8192(8GB)까지 올려도 된다. 단, 서버 전체 메모리보다 크게 잡으면 시스템이 OOM killer에 의해 프로세스를 죽일 수 있으니 실제 RAM 여유를 먼저 확인한다.
B. pm2를 쓰는 경우
ecosystem.config.js(또는 .json)에 추가:
module.exports = {
apps: [{
name: 'my-app',
script: 'app.js',
node_args: ['--max-old-space-size=4096'],
}]
}이미 실행 중이면 pm2 delete my-app && pm2 start ecosystem.config.js로 다시 띄워야 반영된다. pm2 restart만으로는 node_args 변경이 안 먹을 수 있다.
C. Docker 환경에서의 주의점
Docker 컨테이너에 메모리 제한(--memory=2g 등)이 걸려 있으면, --max-old-space-size를 컨테이너 제한보다 크게 잡으면 안 된다. Node.js 힙 한계를 올려도 컨테이너가 먼저 OOM killer에 의해 죽을 수 있다.
확인 방법:
# 컨테이너 내에서
cat /sys/fs/cgroup/memory/memory.limit_in_bytes # cgroup v1
cat /sys/fs/cgroup/memory.max # cgroup v22단계: 원인 파악하기 (15분)
힙을 늘리는 건 임시방편이다. 실제로 어디서 메모리를 많이 쓰는지 찾아야 한다.
A. 힙 스냅샷 찍기
const v8 = require('v8');
const fs = require('fs');
// 특정 시점에 스냅샷 저장
const snapshot = v8.writeHeapSnapshot();
console.log('Heap snapshot:', snapshot);Chrome DevTools에서 Memory 탭 → Load로 .heapsnapshot 파일을 열어 어떤 객체가 메모리를 많이 차지하는지 볼 수 있다.
B. 실행 중 메모리 모니터링
# pm2 monit
pm2 monit
# 또는 node 내장
node -e "setInterval(() => {
const used = process.memoryUsage();
console.log(Math.round(used.heapUsed / 1024 / 1024) + ' MB');
}, 5000)"3단계: 근본적으로 줄이기
A. 큰 데이터는 스트림으로 처리
// ❌ 나쁜 예: 파일 전체를 메모리에 올림
const data = fs.readFileSync('large.json', 'utf8');
const parsed = JSON.parse(data);
// ✅ 좋은 예: 스트림으로 조금씩 처리
const fs = require('fs');
const readline = require('readline');
const rl = readline.createInterface({
input: fs.createReadStream('large.ndjson'),
});
rl.on('line', (line) => {
const item = JSON.parse(line);
// 한 줄씩 처리
});B. 배열 대신 제네레이터
// ❌ 전체 결과를 배열에 담음
function allRecords() {
return db.query('SELECT * FROM big_table');
}
// ✅ 제네레이터로 하나씩 꺼냄
function* recordGenerator() {
let offset = 0;
while (true) {
const batch = db.query(`SELECT * FROM big_table LIMIT 1000 OFFSET ${offset}`);
if (batch.length === 0) break;
yield* batch;
offset += 1000;
}
}C. 이벤트 리스너 정리
이벤트 리스너가 계속 쌓이면 메모리 누수가 된다.
// ❌ 매 요청마다 리스너 추가
app.on('request', () => {
emitter.on('data', handler); // 계속 쌓임
});
// ✅ 한 번만 등록하거나 명시적으로 제거
emitter.on('data', handler);
// 작업이 끝나면
emitter.off('data', handler);메모리 한도 빠른 참고표
| 환경 | 기본 힙 한계(약) | 권장 상향값 |
|---|---|---|
| 32비트 Node.js | 512MB ~ 1.4GB | 2GB 이하 |
| 64비트 Node.js | 1.4GB ~ 4GB | 4~8GB |
| Docker 제한 2GB | 컨테이너 제한 내 | 1.5GB 이하 |
| Docker 제한 4GB | 컨테이너 제한 내 | 3GB 이하 |
자주 묻는 질문
Q: —max-old-space-size를 크게 잡으면 항상 안전한가?
아니다. 실제 RAM보다 크게 잡으면 스와핑이 심해져서 더 느려질 수 있다. 서버 전체 메모리의 50~70% 수준이 안전한 상한선이다.
Q: pm2 reload로는 node_args 변경이 반영되나?
반영되지 않는다. pm2 delete 후 pm2 start로 다시 띄워야 한다.
Q: 빌드할 때만 나는데요?
webpack, Next.js, Vite 같은 빌드 도구는 NODE_OPTIONS=--max-old-space-size=4096 환경변수로 한 번에 지정하는 편이 편하다.
export NODE_OPTIONS="--max-old-space-size=4096"
npm run build