당연히 발생한다. 근데 진짜 질문은 그게 아니다.
Node.js도 V8 엔진 위에서 돌아가는 JavaScript다. call stack이 있고, 함수를 호출하면 stack frame이 쌓이고, 너무 깊으면 터진다.
function boom() {
return boom();
}
boom();
// RangeError: Maximum call stack size exceeded
V8의 call stack 크기는 대략 10,000-15,000 frame 정도다. 함수 안에 지역변수가 많으면 frame 크기가 커지니까 이 숫자는 줄어든다.
여기까진 C나 Python이나 다를 게 없다. 재귀가 깊으면 스택이 터진다. 끝.
근데 Node.js에서 진짜 흥미로운 질문은 이거다:
“async 함수에서도 stack overflow가 발생할까?”
동기 재귀 vs 비동기 재귀
동기 재귀는 당연히 터진다.
// 동기 재귀 - 터짐
function countDown(n) {
if (n === 0) return;
return countDown(n - 1);
}
countDown(100000); // RangeError: Maximum call stack size exceeded
call stack이 이렇게 쌓인다:
call stack:
│ countDown(0) │ ← 10만번째
│ countDown(1) │
│ countDown(2) │
│ ... │
│ countDown(99999) │
│ countDown(100000) │ ← 첫번째
└──────────────────────┘
↑ 계속 쌓이다가 터짐
그런데 이걸 async로 바꾸면?
// 비동기 재귀 - 안 터짐!
async function countDown(n) {
if (n === 0) return;
await Promise.resolve();
return countDown(n - 1);
}
countDown(100000); // 정상 동작
10만번이든 100만번이든 stack overflow가 안 난다. 왜?
Event Loop이 call stack을 비워준다
핵심은 await가 하는 일을 정확히 이해하는 것이다.
await를 만나면 async 함수는 실행을 중단한다. 나머지 코드는 microtask queue에 등록되고, call stack에서 빠진다. 그리고 이벤트 루프가 다음 차례에 그 microtask를 꺼내서 새로운 call stack에서 실행한다.
동기 재귀:
call stack: 시간 →
│ countDown(3) │ [countDown(3) 호출]
│ countDown(4) │ [countDown(4) 위에 쌓임]
│ countDown(5) │ [countDown(5) 위에 쌓임]
│ ... │ [계속 쌓임... 터짐!]
└──────────────┘
비동기 재귀:
call stack: microtask queue:
[tick 1]
│ countDown(5) │ → await 만남 → [countDown(4) 예약]
└──────────────┘ stack 비워짐!
[tick 2]
│ countDown(4) │ → await 만남 → [countDown(3) 예약]
└──────────────┘ stack 비워짐!
[tick 3]
│ countDown(3) │ → await 만남 → [countDown(2) 예약]
└──────────────┘ stack 비워짐!
→ call stack이 매번 비워지니까, 절대 overflow가 안 난다!
이걸 “trampoline” 패턴이라고 부르기도 한다. 함수가 직접 자기 자신을 호출하는 게 아니라, 이벤트 루프라는 “트램펄린” 위에서 통통 튀면서 실행되는 것이다.
setTimeout vs setImmediate vs process.nextTick
call stack을 비우는 방법은 여러 가지가 있다. 각각 동작이 다르다.
// 방법 1: setTimeout - macrotask queue에 넣음
function recurse1(n) {
if (n === 0) return;
setTimeout(() => recurse1(n - 1), 0);
}
// 방법 2: setImmediate - check phase에 넣음
function recurse2(n) {
if (n === 0) return;
setImmediate(() => recurse2(n - 1));
}
// 방법 3: process.nextTick - nextTick queue에 넣음
function recurse3(n) {
if (n === 0) return;
process.nextTick(() => recurse3(n - 1));
}
// 방법 4: Promise - microtask queue에 넣음
async function recurse4(n) {
if (n === 0) return;
await Promise.resolve();
return recurse4(n - 1);
}
4개 다 stack overflow는 안 난다. 하지만 이벤트 루프에서의 우선순위가 다르다.
이벤트 루프의 실행 순서:
┌───────────────────────────────────┐
│ call stack 실행 │
└──────────────┬────────────────────┘
▼
┌───────────────────────────────────┐
│ 1. process.nextTick queue │ ← 최고 우선순위
│ 2. microtask queue (Promise) │
│ (둘 다 완전히 비울 때까지) │
└──────────────┬────────────────────┘
▼
┌───────────────────────────────────┐
│ 3. macrotask 하나 실행 │
│ (setTimeout, setImmediate, │
│ I/O callback 등) │
└──────────────┬────────────────────┘
▼
다시 1번으로 돌아감
여기서 중요한 차이가 나온다.
Stack Overflow는 안 나는데, 더 무서운 게 있다
process.nextTick으로 재귀를 돌리면 어떻게 될까?
// 이거 돌리면 어떻게 될까?
function evilRecurse() {
process.nextTick(() => evilRecurse());
}
evilRecurse();
// Stack overflow? → 안 남
// 그럼 정상 동작? → 아니, 더 나쁨
stack overflow는 안 난다. 하지만 이벤트 루프가 굶어 죽는다.
이벤트 루프 상태:
nextTick queue: [evilRecurse] → 실행 → [evilRecurse] → 실행 → [evilRecurse] → ...
영원히 비워지지 않음!
macrotask queue: [setTimeout 콜백, I/O 콜백, HTTP 요청 처리...]
→ 영원히 실행 안 됨! 😱
결과: 서버가 새 요청을 못 받음. 타이머가 안 돌아감.
프로세스는 살아있는데 아무것도 안 됨.
Node.js 공식 문서에서도 이걸 명시적으로 경고한다. process.nextTick으로 재귀를 돌리면 이벤트 루프가 poll phase에 도달하지 못해서 I/O가 완전히 멈춘다고.
Promise도 마찬가지다. microtask queue에서 microtask가 또 microtask를 무한히 등록하면, 같은 현상이 벌어진다.
// 이것도 이벤트 루프를 굶긴다
function evilPromise() {
Promise.resolve().then(() => evilPromise());
}
evilPromise();
반면 setTimeout이나 setImmediate는?
// 이건 괜찮다
function safeRecurse() {
setTimeout(() => safeRecurse(), 0);
}
safeRecurse();
// macrotask는 한 번에 하나씩만 실행되므로,
// 다른 macrotask (I/O, 타이머 등)도 실행될 기회가 있다.
setTimeout은 macrotask queue에 들어가고, macrotask는 하나 실행될 때마다 microtask queue를 전부 비운 뒤 다음 macrotask로 넘어간다. 그래서 다른 작업도 실행될 기회를 얻는다.
async/await의 무한 재귀가 진짜 위험한 이유
Node.js GitHub에 재밌는 이슈가 있다. async 함수의 무한 재귀는 --stack-size 옵션으로도 잡을 수 없다는 내용이다.
const recurse = async () => {
await someAsyncWork();
// await 이후에는 V8의 call stack이
// 긴 재귀 호출 목록이 아님!
recurse(); // 이게 무한 반복되어도 stack overflow 안 남
}
동기 재귀는 RangeError: Maximum call stack size exceeded라는 에러를 내면서 멈추기라도 한다. 안전장치가 있는 거다.
그런데 async 재귀는 이 안전장치를 우회한다. call stack이 매번 비워지니까 V8 입장에서는 “재귀"가 아니다. 그냥 매번 새로운 함수 호출일 뿐이다.
결과적으로:
- Stack overflow → 에러 나고 멈춤 (오히려 안전)
- Async 무한 재귀 → 에러 안 남, 메모리만 서서히 증가, 이벤트 루프 굶김 (더 위험)
메모리 관점:
동기 재귀:
stack: [frame][frame][frame]...[frame] → 터짐! (빠른 실패)
async 재귀:
heap: Promise → Promise → Promise → ... (느린 죽음)
각 await마다 Promise 객체가 heap에 남음
GC가 정리 못하면 메모리가 계속 늘어남
실전에서 자주 보는 패턴
1. 콜백 기반 라이브러리의 동기 콜백
// async 라이브러리의 eachSeries 같은 것
data.forEach((item, i) => {
processSync(item);
next(); // next가 동기적으로 호출되면 stack이 쌓인다!
});
// 해결: setTimeout으로 stack을 끊어준다
data.forEach((item, i) => {
processSync(item);
setTimeout(() => next(), 0); // stack 리셋
});
2. 깊은 JSON 객체 순회
// 10만 depth의 nested 객체를 재귀로 순회하면 터진다
function traverse(obj) {
if (typeof obj !== 'object') return;
for (const key in obj) {
traverse(obj[key]); // 동기 재귀 → stack overflow
}
}
// 해결: 명시적 stack을 heap에 만든다
function traverseIterative(root) {
const stack = [root]; // heap에 있는 배열 = 크기 제한 없음
while (stack.length > 0) {
const obj = stack.pop();
if (typeof obj !== 'object' || obj === null) continue;
for (const key in obj) {
stack.push(obj[key]);
}
}
}
여기서 핵심 인사이트: call stack은 크기가 고정되어 있지만, heap은 (메모리가 허락하는 한) 무한하다. 그래서 재귀를 반복문 + 배열(heap)로 바꾸면 stack overflow를 피할 수 있다.
3. spread operator의 함정
// 이것도 stack overflow가 난다!
const hugeArray = new Array(1000000).fill(1);
Math.max(...hugeArray);
// RangeError: Maximum call stack size exceeded
spread operator는 내부적으로 각 원소를 함수의 인자로 펼치는데, 인자가 너무 많으면 call stack이 감당을 못한다.
// 해결
const max = hugeArray.reduce((a, b) => Math.max(a, b), -Infinity);
Tail Call Optimization은 어떻게 됐나?
ES6(ES2015) 스펙에는 TCO(Tail Call Optimization)가 포함되어 있다. 함수의 마지막 동작이 함수 호출이면, 새 stack frame을 만들지 않고 기존 frame을 재사용하는 최적화다.
// tail call - 이론적으로 TCO가 적용되면 stack overflow 안 남
function countDown(n) {
if (n === 0) return;
return countDown(n - 1); // 마지막 동작이 함수 호출
}
// non-tail call - TCO 불가
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1); // 곱하기가 마지막 동작
}
Safari(JavaScriptCore)는 TCO를 구현했다. 하지만 V8(Chrome, Node.js)은 한때 구현했다가 제거했다. 이유는 여러 가지인데, 디버깅 시 stack trace가 사라지는 문제가 가장 컸다.
그래서 Node.js에서는 TCO에 의존할 수 없다. tail recursion으로 짜도 stack overflow가 난다.
정리
stack overflow? 다른 문제?
────────────────────────────────────────────────────────────
동기 재귀 O (터짐) 빠른 실패 (오히려 낫다)
setTimeout 재귀 X 느림 (매번 macrotask 대기)
setImmediate 재귀 X 괜찮음 (I/O 끼어들 여지 있음)
process.nextTick 재귀 X 이벤트 루프 굶김 (위험)
Promise/await 재귀 X 이벤트 루프 굶김 + 메모리 누수
Stack overflow는 Node.js에서 당연히 발생한다. 동기 코드에서는 다른 언어와 똑같다.
하지만 Node.js의 진짜 함정은 “stack overflow가 안 나는 상황"에 있다. async/await나 process.nextTick으로 재귀를 돌리면 V8의 안전장치(stack size limit)를 우회하게 되고, 에러 메시지 없이 서버가 서서히 죽어간다. 에러가 나면서 터지는 게 차라리 디버깅하기 쉽다.
결국 핵심은, call stack과 event loop은 서로 다른 메커니즘이고, async 패턴이 stack overflow를 “해결"하는 게 아니라 문제를 call stack에서 heap과 event loop으로 옮기는 것이라는 점이다. 문제가 사라진 게 아니라 다른 형태로 바뀐 거다.