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. 기본 힙 한계는 1.4~4GB(버전·아키텍처마다 다름)
  2. 급하면 --max-old-space-size=4096으로 올린다
  3. 근본 해결은 큰 배열·스트림 처리 방식을 바꾸는 것
  4. pm2를 쓰면 node_args: ['--max-old-space-size=4096']로 지정한다
  5. 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 v2

2단계: 원인 파악하기 (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.js512MB ~ 1.4GB2GB 이하
64비트 Node.js1.4GB ~ 4GB4~8GB
Docker 제한 2GB컨테이너 제한 내1.5GB 이하
Docker 제한 4GB컨테이너 제한 내3GB 이하

자주 묻는 질문

Q: —max-old-space-size를 크게 잡으면 항상 안전한가?
아니다. 실제 RAM보다 크게 잡으면 스와핑이 심해져서 더 느려질 수 있다. 서버 전체 메모리의 50~70% 수준이 안전한 상한선이다.

Q: pm2 reload로는 node_args 변경이 반영되나?
반영되지 않는다. pm2 deletepm2 start로 다시 띄워야 한다.

Q: 빌드할 때만 나는데요?
webpack, Next.js, Vite 같은 빌드 도구는 NODE_OPTIONS=--max-old-space-size=4096 환경변수로 한 번에 지정하는 편이 편하다.

export NODE_OPTIONS="--max-old-space-size=4096"
npm run build

다음 읽기