Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)
안녕하세요, BlueByte입니다. 지난달까지 잘 돌던 빌드나 스크립트가 어느 날 FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory 한 줄과 그 위로 쏟아지는 <--- Last few GCs ---> 로그, 그리고 종료 코드 134 를 남기고 죽습니다. 머신에는 메모리가 충분히 남아 있으니, 바닥난 숫자는 여러분이 보고 있던 그 숫자가 아닙니다. 오늘은 V8 힙 한도가 무엇인지, 지금 쓰는 Node 가 실제로 어떤 한도로 돌고 있는지 읽는 법, 한도에 닿는 세 가지 상황과 각각의 해결, 그리고 비슷해 보이는 컨테이너 kill 과 구분하는 법까지 하나씩 짚어보겠습니다.
힙 한도가 무엇이고 16 GB 머신에서도 왜 부딪히는지
Node 는 V8 안에서 JavaScript 를 실행하고, V8 은 오래 사는 객체를 old space 라는 영역에 둡니다. 이 영역은 머신 메모리와 별개인 자기만의 천장을 갖습니다. Node CLI 문서는 --max-old-space-size 를 "V8 old memory 영역의 최대 메모리 크기"를 정하는 옵션으로 설명하면서, 사용량이 한도에 가까워질수록 V8 이 메모리를 비우려고 가비지 컬렉션에 더 많은 시간을 쓴다고 덧붙입니다. 크래시 직전에 보이는 Mark-Compact 폭풍이 바로 그것입니다. 기본 천장은 시스템 메모리와 Node 버전에서 유도되기 때문에, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 고장난 것은 없습니다. 프로세스가 자기 천장보다 많이 요구했고 V8 이 거절했을 뿐입니다.
메시지는 몇 가지 변형이 있습니다. 최근 릴리스는 Reached heap limit Allocation failed - JavaScript heap out of memory 를, 예전 버전은 Ineffective mark-compacts near heap limit Allocation failed 나 CALL_AND_RETRY_LAST Allocation failed 를 찍습니다. 셋 다 같은 사건이고, 아래 점검은 모두에 적용됩니다.
먼저, V8 이 실제로 쓰고 있는 숫자를 읽기
기본값을 추측하지 말고 V8 에 물어보세요. 저희가 확인한 24 GB 호스트의 Node 22.23.1 에서는 약 4 GiB 였습니다.
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"4144
여러분의 숫자는 다를 것입니다. Node 릴리스와 시스템이 보고하는 메모리 양에 따라 달라집니다. 이 확인에서 해결에 중요한 관찰이 둘 있습니다. --max-old-space-size=4096 을 넘겨도 같은 4144 가 찍혔는데, 그 머신의 기본값이 이미 4 GiB 였기 때문입니다. 즉 기본값 근처나 그 아래로 한도를 "올리는" 플래그는 아무것도 바꾸지 못합니다. 반면 NODE_OPTIONS=--max-old-space-size=2048 은 2096 을 찍어, 환경 변수로 넘긴 플래그가 그대로 반영된다는 것을 보여줍니다.
프로세스가 한도에 닿는 세 가지 이유
크지만 정당한 작업. 번들러, TypeScript 타입 검사, 프레임워크 빌드는 의존성 그래프 전체를 메모리에 올립니다. 빌드가 5 GiB 를 필요로 하는데 천장이 4 GiB 라면 매번 같은 지점에서 죽습니다.
힙보다 작은 컨테이너. 메모리 한도가 2 GiB 인 파드나 CI 잡 안에서 V8 은 자기가 보는 시스템 메모리로 힙 크기를 정하는데, 컨테이너 한도를 반영하는지는 Node 릴리스에 따라 다릅니다. 그러면 둘 중 하나가 일어납니다. cgroup 이 먼저 프로세스를 죽이거나(종료 코드 137, FATAL ERROR 줄 없음), 힙 천장에 먼저 닿아 이 오류가 뜨거나. 어느 쪽을 보게 되는지는 어느 한도가 더 낮은지에 달렸습니다.
오래 도는 프로세스의 누수. 몇 초가 아니라 몇 시간 뒤에 한도에 닿는 서버는 객체를 쌓고 있는 것입니다. 한도를 올려도 크래시가 미뤄질 뿐입니다.
실행 패턴이 셋을 가릅니다. 매번 같은 지점이면 작업량, 긴 가동 뒤라면 누수, 컨테이너 안에서만이라면 크기 설정입니다.
해결 1: 명령 하나 또는 셸 전체의 old space 한도 올리기
플래그는 MiB 단위 크기를 받습니다. 명령 하나에만 적용하려면:
node --max-old-space-size=6144 node_modules/.bin/next build셸이나 CI 스텝이 실행하는 모든 것에 적용하려면 NODE_OPTIONS 를 쓰세요. 문서는 이것을 명령줄보다 먼저 해석되는 공백 구분 옵션 목록으로 설명하고, 거기서 허용되는 V8 옵션 목록에 --max-old-space-size 를 올려 두었으므로 npm run build 와 자식 프로세스에도 똑같이 먹힙니다.
export NODE_OPTIONS=--max-old-space-size=6144
node -e "console.log(require('v8').getHeapStatistics().heap_size_limit / 1048576)"6192
여유를 남기세요. 문서의 예시도 2 GiB 머신에서 스왑을 피하려고 1536 으로 잡습니다.
해결 2: 컨테이너 안에서는 메모리 한도보다 낮게 힙을 잡기
cgroup 이 프로세스를 죽이기 전에 V8 이 가비지 컬렉션을 하도록, 컨테이너 메모리 한도보다 수백 MiB 낮게 플래그를 설정하세요. 현재 CLI 문서에는 --max-old-space-size-percentage 도 있는데, old space 를 가용 시스템 메모리의 백분율로 정하고 둘 다 주어지면 절대값 플래그보다 우선합니다. 직접 확인해 보면 위 24 GB 호스트에서 --max-old-space-size-percentage=50 은 12035 를 찍었습니다. 비교적 새 플래그이므로 Dockerfile 에 넣기 전에 여러분의 릴리스에서 node --help | grep percentage 로 있는지 보세요.
해결 3: 계속 자라는 프로세스는 한도 근처에서 스냅샷 찍기
누수는 무엇이 자라는지 봐야 합니다. --heapsnapshot-near-heap-limit=max_count 는 힙 사용량이 한도에 가까워질 때 V8 힙 스냅샷을 최대 max_count 개까지 디스크에 쓰며, 문서는 컬렉션이 사용량을 떨어뜨릴 수 있어 프로세스가 최종적으로 죽기 전에 여러 스냅샷이 남을 수 있다고 적어 두었습니다. v25.4.0, v24.13.1, v22.22.1 부터는 더 이상 실험 플래그가 아닙니다.
node --heapsnapshot-near-heap-limit=3 --max-old-space-size=1024 server.jsHeap.20200430.100036.49580.0.001.heapsnapshot
Heap.20200430.100037.49580.0.002.heapsnapshot
연속한 두 파일을 Chrome DevTools 의 Memory 탭에 올려 비교하세요. 스냅샷 사이에 늘어난 객체가 누수입니다. 문서는 스냅샷 자체가 메모리를 쓴다고 경고하니, 운영 프로세스가 아니라 더 작은 한도를 준 스테이징 사본에서 하세요.
실제 사례: 2 vCPU CI 러너에서 죽는 next build
한 팀의 next build 가 노트북에서는 통과하고 7 GB CI 러너에서는 Reached heap limit 으로 실패했습니다. 첫 반응은 --max-old-space-size=8192 추가였는데, 러너가 감당할 수 없는 값이었습니다. 아직 예전 Node 를 쓰던 러너에서 힙 통계 확인은 가용 7 GB 에 한참 못 미치는 값을 돌려주었고, V8 이 머신보다 훨씬 낮게 자기 크기를 잡았다는 뜻이었습니다. 잡 환경에 NODE_OPTIONS=--max-old-space-size=5120 을 넣자 빌드가 한 번에 끝났고, Dockerfile 빌드 스테이지에 같은 변수를 두어 이미지 빌드도 동일하게 맞췄습니다.
끝까지 확인하고 다시 겪지 않기
실패했던 바로 그 환경에서 같은 한 줄을 돌려 종료 코드가 아니라 숫자를 읽으세요. 그다음 설정을 명령이 사는 곳에 고정하세요. package.json 의 build 스크립트, CI 잡의 env, Dockerfile 의 ENV NODE_OPTIONS. 다음 머신이 다시 발견하지 않도록요. 서버라면 process.memoryUsage().heapUsed 를 그래프로 그리고 바닥이 올라가면 알림을 거세요.
종료 코드 134 와 137, 그리고 헷갈리기 쉬운 스택 오류
이 크래시는 V8 이 스스로 중단한 것이라 134 로 끝나고 FATAL ERROR 줄을 찍습니다. Node 출력 없이 137 로 끝나는 것은 커널이나 컨테이너 런타임이 메모리 한도 초과로 프로세스를 죽인 것이며, Kubernetes OOMKilled 가이드에서 다룹니다. RangeError: Maximum call stack size exceeded 는 또 다른 한도입니다. 대개 무한 재귀에서 오는 호출 스택이며, 어떤 힙 플래그도 도움이 되지 않습니다.
다음에 이 오류를 만나면 이 순서를 거꾸로 따라가 보세요. V8 이 쓰는 한도를 읽고, 실행 패턴으로 작업량·컨테이너·누수 중 무엇인지 정한 뒤, 명령이 사는 곳에 플래그를 두면 됩니다.
관련 질문
--max-old-space-size 를 올려도 왜 가끔 아무것도 안 바뀌나요?
넘긴 값이 V8 이 이미 고른 기본값과 같거나 그 아래이기 때문입니다. Node 22.23.1 이 도는 24 GB 호스트에서 기본값은 4144 MiB 였고 --max-old-space-size=4096 은 같은 숫자를 찍었습니다. heap_size_limit 을 먼저 읽고, 그보다 분명히 높으면서 머신이나 컨테이너가 줄 수 있는 것보다는 낮은 값을 잡으세요.
NODE_OPTIONS 로 줘도 되나요, 아니면 명령줄에 직접 써야 하나요?
Node CLI 문서는 NODE_OPTIONS 에서 허용되는 V8 옵션 목록에 --max-old-space-size 를 올려 두었고, 거기 있는 옵션은 명령줄보다 먼저 해석되므로 명령줄 플래그가 덮어씁니다. npm 스크립트와 CI 스텝에는 NODE_OPTIONS 가 맞는 자리입니다. 자식 프로세스까지 닿기 때문입니다.
컨테이너 안에서는 얼마로 잡아야 하나요?
cgroup 이 프로세스를 죽이기 전에 V8 이 가비지 컬렉션을 하도록 컨테이너 메모리 한도보다 수백 MiB 낮게 잡으세요. 문서의 예시는 2 GiB 머신에 1536 입니다. 여러분의 릴리스가 --max-old-space-size-percentage 를 지원한다면, 의지하기 전에 힙 통계 한 줄로 실제 어떤 값이 되는지 확인하세요.
서버가 시작할 때가 아니라 몇 시간 뒤에 죽습니다. 힙을 키우면 해결되나요?
아니요, 크래시가 미뤄질 뿐입니다. 그 패턴은 누수입니다. 스테이징 사본에 --heapsnapshot-near-heap-limit=3 과 더 작은 --max-old-space-size 를 주고 돌린 뒤 연속한 두 스냅샷을 Chrome DevTools 에서 비교해, 그 사이에 늘어난 것을 고치세요.
Kubernetes 의 OOMKilled 와는 어떻게 다른가요?
이 크래시는 V8 이 스스로 중단한 것입니다. 종료 코드 134 와 프로세스 출력의 FATAL ERROR 줄이 그 표시입니다. OOMKilled 는 커널이 cgroup 메모리 한도를 집행한 것이라 종료 코드 137 에 Node 출력이 없습니다. 137 이 보이면 컨테이너 한도가 힙보다 낮은 것이니 힙을 그 아래로 잡으세요.
참고 자료
- Node.js docs — Command-line API: --max-old-space-size (MiB, 2 GiB → 1536 example), --max-old-space-size-percentage, NODE_OPTIONS (allowed V8 options, precedence)
- Node.js docs — Command-line API: --heapsnapshot-near-heap-limit=max_count (snapshot near the heap limit, no longer experimental as of v25.4.0 / v24.13.1 / v22.22.1)
- Node.js docs — V8 module: v8.getHeapStatistics() (heap_size_limit, used_heap_size)
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
같은 카테고리 다른 글
Git: fatal: detected dubious ownership in repository
Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.
Kubernetes: Internal error occurred: failed calling webhook
admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.
Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트
컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.
curl: (60) SSL certificate problem: unable to get local issuer certificate
curl이 서버가 보낸 인증서 체인을 따라가다가, 자기가 읽는 CA 저장소에 발급자가 없는 인증서에 닿아 종료 코드 60으로 연결을 거부한 것입니다. 발급자가 없는 이유는 넷 중 하나입니다. 서버가 중간 인증서 없이 리프만 보내거나(브라우저는 스스로 받아 와서 가려 줍니다), TLS 검사 프록시가 컨테이너·러너가 신뢰하지 않는 회사 CA로 사이트를 다시 서명했거나, curl이 생각과 다른 CA 번들(CURL_CA_BUNDLE, SSL_CERT_FILE, 벤더 curl)을 읽고 있거나, ca-certificates 패키지가 너무 오래된 경우입니다. curl -v로 어느 저장소를 썼는지, openssl s_client로 서버가 무엇을 보냈는지 보고 고리가 빠진 쪽을 고친 뒤 -w '%{ssl_verify_result}'로 확인합니다.