Node.js async_hooks로 RangeError: Maximum call stack size exceeded 발생 후 서버가 종료됨

요청 1회로 프로세스가 즉시 종료되면 서비스 가용성이 0이 된다.

기술 상황 정의(Environment): Web / Node.js / async_hooks·AsyncLocalStorage / Next.js·RSC·APM( Datadog·NewRelic·OpenTelemetry ) 경로

2026 포인트: 2026년 1월 보안 이슈(CVE-2025-59466) 경로에서 RangeError가 “포착 불가(uncatchable)” 형태로 종료를 유발할 수 있다.

RangeError: Maximum call stack size exceeded → (로그 없이) 프로세스 종료 / Exit Code 7

결론 요약: async_hooks 기반 추적이 재귀적으로 훅을 다시 호출해 스택을 고갈시키므로, 가드로 즉시 차단하고 최종적으로 AsyncLocalStorage로 마이그레이션한다.

해결 흐름: 판단 → 재귀 가드 적용 → 스택/종료 관측 → AsyncLocalStorage로 전환

✅ 바로 적용 (Quick Fix)

결정 문장: “재귀적 훅 호출을 차단하는 가드 절차부터 적용한다.”

WHY: 훅이 훅을 다시 트리거하면 스택이 폭발하므로, 한 번의 진입만 허용해야 한다.

METRIC: 동일 트래픽에서 RangeError/Exit Code 7 재발이 멈춘다.

NEXT: 멈추면 Verification으로 관측을 붙이고, Recovery로 구조 전환한다.

강력 경고: 훅 내부에서 console.log·파일 I/O·네트워크 같은 비동기 작업을 호출하면 그 자체가 새로운 async 리소스를 만들어 무한 루프/재귀를 촉발할 수 있다.

/**
 * 반드시 프로덕션에 넣기 전 스테이징에서 검증.
 * 훅 내부에서 console.log / fs.writeFile / http 요청 금지(재귀 위험).
 */
const async_hooks = require('async_hooks');

let isHooking = false; // 재귀 방지 플래그

const hook = async_hooks.createHook({
  init(asyncId, type, triggerAsyncId) {
    if (isHooking) return; // 재귀 방지 핵심
    isHooking = true;
    try {
      // 추적 로직은 '동기'이며 부작용 없는 형태로 제한해야 함
      // 예: 카운터 증가, 메모리 내 링버퍼에 숫자만 기록 등
      // (여기서 console.log 호출 금지)
    } finally {
      isHooking = false;
    }
  }
});

hook.enable();

🧩 확인 코드 (Verification)

WHY: “진짜로 스택 고갈/uncatchable 종료”인지, 단순 예외인지 1회 관측으로 분리한다.

METRIC: stackTraceLimit 확장 후에도 RangeError가 재현되거나, 종료 코드가 7로 유지된다.

NEXT: 재현되면 Recovery로 즉시 이동한다.

/**
 * 스택 분석을 위해 제한 확장(디버깅 목적).
 * 과도한 값은 로그/메모리 부담이 될 수 있으므로 관측 후 원복 권장.
 */
if (process.stackTraceLimit < 100) process.stackTraceLimit = 1000;

// 종료 코드 관측(프로세스 매니저/컨테이너에서 함께 확인)
process.on('exit', (code) => {
  // 여기서도 비동기 작업 금지, 최소 동작만
  // 예: code만 저장 가능한 메커니즘으로 전달(프로세스 매니저 로그 등)
});

🧯 복구 코드 (Recovery)

WHY: async_hooks 직접 사용은 구현 실수로 재귀를 만들기 쉬우며, 표준 해법은 AsyncLocalStorage로 컨텍스트 전파를 옮기는 것이다.

METRIC: 훅 코드 제거 후에도 컨텍스트 전파가 유지되고 종료가 사라진다.

NEXT: APM/프레임워크가 ALS를 이미 사용한다면, “직접 async_hooks 사용”을 제거하는 쪽으로 결론을 낸다.

/**
 * 권장: async_hooks 직접 훅을 만들지 말고 AsyncLocalStorage로 마이그레이션.
 */
const { AsyncLocalStorage } = require('async_hooks');

const storage = new AsyncLocalStorage();

// 예시: 요청 단위 컨텍스트(트레이스/요청ID 등) 전파
function withRequestContext(reqId, handler) {
  storage.run({ reqId }, handler);
}

// 사용처 예시
withRequestContext('req-123', () => {
  const store = storage.getStore();
  // store.reqId 사용
});

✅ 발생 증상

특정 요청 또는 특정 트래픽 패턴 이후 RangeError가 발생한다.

에러 로그를 남기지 않고 프로세스가 즉시 종료(Exit Code 7)될 수 있다.

Next.js/RSC 또는 APM(예: Datadog, OpenTelemetry)을 “사용하는 것만으로도” 경로가 열릴 수 있다.

재시작 후에도 동일 요청에서 반복 재현된다.

❌ 원인 분석

Node.js async_hooks 재귀 호출로 인한 Maximum call stack size exceeded 발생 원리 도식

async_hooks 훅은 비동기 리소스 생성 시점마다 호출되며, 훅 내부의 행동이 다시 비동기 리소스를 만들면 훅이 훅을 다시 호출한다.

이 재귀는 호출 깊이를 폭발시키며 “Maximum call stack size exceeded”로 이어진다.

2026년 1월 이슈(CVE-2025-59466) 경로에서는 이 종료가 예외 처리로 제어되지 않는 형태로 나타날 수 있다.

따라서 “try/catch로 잡는다”가 아니라 “재귀 자체를 차단”해야 한다.

🧠 기술적 배경 이해 (감리사 1차 판정 구역)

async_hooks는 런타임의 비동기 리소스 생성/실행을 관측하기 위한 저수준 훅이다.

APM, 트레이싱, Next.js의 일부 서버 경로는 요청 단위 컨텍스트를 유지하기 위해 이 계열 API를 활용한다.

하지만 저수준 훅은 “훅 내부 부작용”이 곧바로 재귀로 이어지는 구조적 위험이 있다.

AsyncLocalStorage는 엔진 수준에서 컨텍스트 전파를 제공해, 직접 훅을 작성할 때보다 재귀 위험을 크게 줄인다.

📊 빠른 구분표 (Decision Table)

async_hooks 훅 내부에서 console.log/I/O 수행 → 재귀 루프라고 판단한다.

Next.js·RSC·APM 사용 + Exit Code 7 반복 → 취약 경로 노출로 판단한다.

이 경우에는 A로 판단한다.

A면 Quick-Fix, 아니면 Recovery로 이동한다.

✅ 해결 방법 (WHY / METRIC / NEXT)

이 문서는 “훅 재귀”를 확정 원인으로 두고, 가드로 즉시 차단한 뒤 구조를 표준(AsyncLocalStorage)로 옮기는 것을 목표로 한다.

WHY: 가드가 없으면 같은 코드가 항상 다시 폭발한다.

METRIC: 동일 요청에서 RangeError가 사라지고 종료 코드가 정상화된다.

NEXT: 안정화 후에는 async_hooks 직접 사용을 제거하고 ALS 기반으로 전환한다.

1) 가드 적용(Quick Fix)으로 즉시 재귀 차단

WHY: 재귀 진입을 끊어 스택 폭발을 멈춘다.

METRIC: 종료/RangeError 빈도 감소 또는 소멸

NEXT: 소멸하면 2단계로 이동

const async_hooks = require('async_hooks');
let isHooking = false;

const hook = async_hooks.createHook({
  init() {
    if (isHooking) return;
    isHooking = true;
    try {
      // side-effect free
    } finally {
      isHooking = false;
    }
  }
});
hook.enable();

2) 관측 추가(Verification)로 “잡히지 않는 종료” 확인

WHY: try/catch로 통제 가능한 오류인지, 프로세스 종료인지 분리한다.

METRIC: exit code 7 반복 여부

NEXT: 반복이면 3단계로 즉시 이동

node server.js
echo $?

3) AsyncLocalStorage로 마이그레이션(Recovery) 결론

WHY: 저수준 훅 직접 사용은 재귀 위험이 구조적으로 높다.

METRIC: 훅 제거 후에도 요청 컨텍스트가 유지된다.

NEXT: APM/프레임워크 설정에서 “async_hooks 직접 훅”을 비활성화하거나 제거한다.

const { AsyncLocalStorage } = require('async_hooks');
const storage = new AsyncLocalStorage();

function runWithContext(ctx, fn) {
  return storage.run(ctx, fn);
}

function getContext() {
  return storage.getStore();
}

⚠️ 그래도 안 될 경우 체크리스트

훅 내부에서 console.log / fs / 네트워크 호출이 있는지 코드 검색

APM(Datadog/NewRelic/OpenTelemetry) 활성화 여부 확인

Next.js RSC 관련 서버 실행 모드 확인

프로세스 매니저(PM2/systemd/Docker)에서 종료 코드 기록 확인

훅을 전부 제거했는데도 재현되는지 A/B 비교

AsyncLocalStorage로 전환 후 컨텍스트 전파가 유지되는지 확인

❓ FAQ

Q: 훅 내부에서 로그를 꼭 남겨야 한다.

A: 훅 내부에서 로그는 재귀를 부를 수 있으므로, 메모리 버퍼에 숫자/카운터만 쌓고 별도 타이머/루프에서 출력하도록 분리해야 한다.

Q: 버전만 올리면 끝나나?

A: 버전 패치는 필요하지만, 구조적으로는 AsyncLocalStorage 전환을 강력 권고한다.

✅ 요약 및 마무리

이 문제의 핵심은 “재귀적 훅 호출”이며 가드가 없으면 다시 터진다.

즉시 가드로 차단한 뒤, 최종 해법은 async_hooks 직접 사용을 제거하고 AsyncLocalStorage로 마이그레이션하는 것이다.

참고 자료

Node.js 보안 권고(2026-01), The Hacker News

👉 2026 생산성 도구 & 워크플로우 완벽 가이드(P1)

👉 개발자 필수 연결 글: Zapier vs Make 자동화 비교