1. 문제 정의

Node.js로 파일시스템 인덱싱(RAID 파일 인덱스 갱신)을 수행하는 스크립트를 실행하던 중, 약 4시간 만에 프로세스가 다음과 같은 메시지와 함께 heap out of memory 로 크래시했다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[md5:]  241613/241627 97.5%
[md5:]  241614/241627 97.5%
[md5:]  241625/241627 98.1%
Creating missing list... (79570 files missing)
Creating new files list... (241627 new files)

<--- Last few GCs --->

11629672 ms: Mark-sweep 1174.6 (1426.5) -> 1172.4 (1418.3) MB, 659.9 / 0 ms [allocation failure] [GC in old space requested].
11630371 ms: Mark-sweep 1172.4 (1418.3) -> 1172.4 (1411.3) MB, 698.9 / 0 ms [allocation failure] [GC in old space requested].
11631105 ms: Mark-sweep 1172.4 (1411.3) -> 1172.4 (1389.3) MB, 733.5 / 0 ms [last resort gc].
11631778 ms: Mark-sweep 1172.4 (1389.3) -> 1172.4 (1368.3) MB, 673.6 / 0 ms [last resort gc].

<--- JS stacktrace --->

==== JS stack trace =========================================

Security context: 0x3d1d329c9e59 <JS Object>
1: SparseJoinWithSe

약 24만 개의 파일에 대한 MD5 해시와 리스트 생성을 수행하는 대용량 작업이었고, 인덱싱이 거의 끝나갈 무렵(97~98%) 메모리 부족으로 종료되었다.

에러 로그에서 주목할 키워드

로그 키워드의미
[allocation failure]새 메모리 할당이 실패함
[GC in old space requested]GC가 구(old space) 영역을 정리하도록 요청됨
[last resort gc]최후 수단으로 가비지 컬렉션을 수행함
Mark-sweepV8 구 영역의 표시-쓸기(마크 앤 스윕) GC
(1426.5) -> (1368.3) MB힙 크기 변화

2. 원인 탐구

로그를 보면 GC가 반복적으로 수행되고 있다. Mark-sweep이 여러 차례 실행되었는데도 힙 사용량(1174.6 -> 1172.4 MB)이 거의 줄어들지 않았다. 즉 메모리에 쌓인 데이터가 실질적으로 계속 살아남아 해제되지 않는 상황이라는 짐작이 가능하다.

그런데 핵심은 GC의 동작 자체보다 V8의 기본 메모리 한도에 있다. Node.js의 V8 엔진은 별도 설정 없이 실행하면 힙을 특정 크기까지만 할당할 수 있도록 제한한다. 파일 인덱싱처럼 수십만 개의 객체(각 파일의 경로, 해시, 리스트 등)를 동시에 쥐고 있는 작업은 이 한도에 빠르게 도달한다.

[GC in old space requested]라는 표현은 구(old space) 영역에 새 메모리가 더 필요하다는 의미다. 구 영역은 오래 살아남은 참조 데이터 구조가 위치하는 곳으로, 이 문제의 주인공이다.


3. 근본 원인 분석

출처 답변에 따르면 V8에는 메모리 사용량에 대한 기본 상한이 약 1.7GB로 정해져 있다. 이 값을 수동으로 늘려주지 않으면 대용량 작업이 이 한도에 도달하고, GC가 더 이상 메모리를 확보하지 못하는 시점에 프로세스가 크래시한다.

여기서 핵심은 어느 힙 영역의 한도를 늘려야 하는가다. V8 힙은 크게 두 영역으로 나뉜다:

영역역할
new space (신생 영역)생성 직후의 단기 데이터를 수집
old space (구 영역)오래 살아남은 모든 참조 데이터 구조를 보관

파일 인덱싱에서 만들어지는 인덱스·리스트·해시 값은 작업 내내 유지되는 장기 참조 데이터이므로 구(old space) 에 쌓인다. 따라서 new space 한도를 올리는 것으로는 부족하고, old space의 한도(--max-old-space-size)를 올려야 문제가 해결된다.

정리하면 근본 원인은 다음과 같다.

  1. 수십만 개의 장기 데이터가 구(old space)에 누적되어 V8 기본 힙 한도(약 1.7GB)에 도달
  2. GC가 [allocation failure][last resort gc]까지 시도해도 새 메모리를 확보하지 못함
  3. V8이 힙 한도 초과로 프로세스를 강제 종료 → heap out of memory 크래시

4. 코드 해결책

방법 A — 실행 시 옵션으로 메모리 한도 늘리기 (권장)

가장 간단하고 안정적인 방법은 스크립트 실행 시 --max-old-space-size 옵션을 넘겨 구(old space) 메모리 한도를 늘리는 것이다.

1
2
# 구(old space) 메모리 한도를 4096MB(4GB)로 설정하고 실행
node --max-old-space-size=4096 yourFile.js

옵션 값의 단위는 MB이며, 4096은 4GB를 의미한다. 시스템 메모리가 넉넉하다면 더 큰 값을 줄 수 있다.

  • 시스템 전역 설정 (Linux/macOS)

    NODE_OPTIONS 환경 변수로 프로세스 생성 시 자동 적용되게 할 수도 있다.

    1
    2
    
    export NODE_OPTIONS="--max-old-space-size=4096"
    node yourFile.js
    
  • package.json의 npm 스크립트에 반영

    1
    2
    3
    4
    5
    
    {
      "scripts": {
        "start": "node --max-old-space-size=4096 yourFile.js"
      }
    }
    

방법 B — 런타임에 플래그 설정하기

코드 안에서 플래그를 동적으로 설정할 수도 있지만, 프로세스 초기(모듈 로드 초기)에 실행해야 적용된다.

1
2
3
4
5
// yourFile.js (최상단에서 실행)
const v8 = require('v8');
v8.setFlagsFromString('--max-old-space-size=4096');

// 이후 대용량 처리 코드...

참고: 방법 A(실행 시 옵션)가 더 명확하고 신뢰할 수 있으므로, 배포 스크립트 등에는 방법 A를 사용하는 것이 일반적이다. 출처 답변 역시 배포 스크립트에서 node --max-old-space-size=4096 yourFile.js로 해결했다고 밝히고 있다.

해결책 비교

방법명령/코드특징
실행 시 옵션node --max-old-space-size=4096 yourFile.js가장 간단·명확, 배포 스크립트 권장
환경 변수NODE_OPTIONS="--max-old-space-size=4096"시스템 전역 적용 가능
런타임 플래그v8.setFlagsFromString('--max-old-space-size=4096')코드 내 설정, 최상단 실행 필요

5. 향후 예방 조치

같은 크래시를 반복하지 않으려면 메모리 한도를 늘리는 것 외에 다음 사항도 점검하면 좋다.

  1. 메모리 한도 초과는 근본 원인이 아닐 수 있다. --max-old-space-size를 올리면 임시로 해결되지만, 대용량 처리 중 참조가 쌓여 불필요하게 메모리를 점유하는 진짜 메모리 누수(memory leak) 가 있다면 한도를 올려도 결국 다시 크래시한다. 작업이 끝난 객체의 참조를 명시적으로 정리(null 처리 등)하거나, 스트림·페이지네이션으로 처리량을 나눠보자.

  2. 큰 배열 한 번에 메모리에 올리지 않기. 수십만 개 요소를 전부 배열로 모은 뒤 처리하는 대신, 파일을 하나씩 읽고 결과를 즉시 저장하는 방식(스트림, 배치 처리)으로 바꾸면 힙 사용량이 급감한다.

  3. 실제 현재 메모리 사용량을 파악하자. --trace-gc 같은 플래그로 GC 로그를 확인하고, process.memoryUsage()로 힙 사용 추이를 관찰해 어느 시점에 한도에 도달하는지 파악한다.

  4. 메모리 상한을 환경에 맞게 책정하기. 4096은 예시일 뿐이며, 실제 동작 환경의 물리 메모리를 고려해 값을 정한다. 값을 너무 크게 주면 서버가 스왑/메모리 부족(FATAL ERROR: Ineffective mark-compacts near heap limit)으로 죽을 수 있다.

  5. 운영 도구를 활용하기. --inspect와 크롬 개발자 도구 메모리 프로파일러로 힙 스냅샷을 떠서 누수를 잡는 것도 효과적이다.


출처