콜 스택은 JavaScript 엔진이 함수 호출 순서를 관리하는 핵심 메커니즘입니다. 함수가 호출되면 새로운 실행 컨텍스트가 생성되어 콜 스택에 쌓이고, 함수 실행이 완료되면 스택에서 제거되는 방식으로 동작합니다. 이 과정을 이해하면 JavaScript의 단일 스레드 실행 모델과 함수 호출이 어떻게 순차적으로 처리되는지 명확히 알 수 있습니다. 실행 컨텍스트와 콜 스택은 분리할 수 없는 관계이며, 콜 스택의 동작 원리를 파악하는 것은 비동기 처리나 재귀 함수의 동작을 이해하는 기초가 됩니다.
핵심 특징
- 스택 자료구조: 콜 스택은 후입선출(LIFO) 방식으로 동작하여 가장 최근에 호출된 함수가 먼저 완료됩니다
- 실행 컨텍스트 관리: 함수 호출 시 실행 컨텍스트가 스택에 push되고, 함수 종료 시 pop됩니다
- 단일 스레드 실행: JavaScript는 하나의 콜 스택만 가지므로 한 번에 하나의 작업만 처리할 수 있습니다
- 스택 오버플로우: 재귀 호출이나 무한 루프로 인해 콜 스택이 최대 크기를 초과하면 에러가 발생합니다
- 실행 순서 추적: 콜 스택을 통해 현재 실행 중인 함수와 호출된 경로를 정확히 추적할 수 있습니다
실무에서의 영향
콜 스택의 동작 원리를 이해하면 디버깅 효율성이 크게 향상됩니다. 브라우저 개발자 도구의 스택 트레이스는 콜 스택의 상태를 보여주는 것이므로, 에러 발생 시 함수 호출 경로를 역추적하여 문제의 원인을 빠르게 파악할 수 있습니다. 또한 재귀 함수를 작성할 때 스택 오버플로우를 방지하기 위한 종료 조건을 설계하거나, 꼬리 재귀 최적화를 고려하는 등 실용적인 해결책을 적용할 수 있습니다. 비동기 프로그래밍에서도 콜 스택과 이벤트 루프의 상호작용을 이해해야 콜백 함수나 Promise의 실행 타이밍을 정확히 예측할 수 있습니다. 성능 최적화 측면에서도 깊은 중첩 호출이나 과도한 재귀를 피하여 스택 사용량을 줄이는 것이 중요하며, 이는 메모리 효율성과 직결됩니다. 콜 스택의 원리를 숙지하면 복잡한 함수 호출 흐름을 예측하고 제어할 수 있어 더욱 안정적이고 유지보수하기 쉬운 코드를 작성할 수 있습니다.
핵심 개념
콜 스택의 LIFO 구조
입문
콜 스택은 접시를 쌓아 올리는 것처럼 함수를 차곡차곡 쌓았다가 위에서부터 하나씩 꺼내는 구조예요.
📚 책을 쌓는다고 생각해보세요 여러분이 책상 위에 책을 쌓는다고 상상해보세요. 첫 번째 책을 놓고, 그 위에 두 번째 책을 놓고, 또 그 위에 세 번째 책을 놓아요. 이제 책을 치우려면 어떻게 해야 할까요? 맨 위에 있는 세 번째 책부터 치워야 하죠! 콜 스택도 이와 똑같이 동작해요.
🥞 팬케이크를 쌓는 순서 팬케이크를 만들 때를 떠올려보세요. 첫 번째 팬케이크를 접시에 놓고, 두 번째를 그 위에, 세 번째를 또 그 위에 쌓아요. 먹을 때는 맨 위에 있는 팬케이크부터 먹죠? 가장 나중에 쌓은 것을 가장 먼저 꺼내는 거예요. 이것을 ‘후입선출(LIFO)‘이라고 해요.
🎯 함수 호출도 마찬가지예요 함수 A가 함수 B를 부르고, 함수 B가 함수 C를 부르면, 콜 스택에는 A, B, C 순서로 쌓여요. 그런데 실행이 끝나는 순서는 반대예요! C가 먼저 끝나고, B가 끝나고, 마지막으로 A가 끝나요. 맨 위에서부터 하나씩 치우는 거죠.
🔄 왜 이런 순서로 동작할까요? 함수 C가 끝나야 함수 B가 다음 작업을 할 수 있고, 함수 B가 끝나야 함수 A가 다음 작업을 할 수 있기 때문이에요. 마치 계단을 올라갔다가 내려올 때, 올라간 순서의 반대로 내려와야 하는 것과 같아요.
중급
콜 스택은 후입선출(LIFO, Last In First Out) 자료구조로 동작합니다. 함수가 호출되면 실행 컨텍스트가 스택의 맨 위(top)에 push되고, 함수 실행이 완료되면 스택에서 pop됩니다.
LIFO 동작 원리 스택은 한쪽 끝에서만 데이터를 추가하고 제거할 수 있는 선형 자료구조입니다. 콜 스택에서는 새로운 함수 호출이 발생할 때마다 스택의 맨 위에 실행 컨텍스트를 추가하며, 현재 실행 중인 함수(스택의 최상단)가 완료되어야만 제거됩니다.
function first() {
console.log('first 시작');
second();
console.log('first 종료');
}
function second() {
console.log('second 시작');
third();
console.log('second 종료');
}
function third() {
console.log('third 시작');
console.log('third 종료');
}
first();
// 출력 순서:
// first 시작
// second 시작
// third 시작
// third 종료
// second 종료
// first 종료
스택 상태 변화
위 코드 실행 시 콜 스택의 상태는 다음과 같이 변화합니다:
first()호출 → 스택: [first]second()호출 → 스택: [first, second]third()호출 → 스택: [first, second, third]third()완료 → 스택: [first, second]second()완료 → 스택: [first]first()완료 → 스택: []
심화
콜 스택의 LIFO 구조는 ECMAScript 명세의 실행 컨텍스트 스택(Execution Context Stack) 모델에서 정의되며, JavaScript 엔진의 함수 호출 규약(Calling Convention)과 스택 프레임(Stack Frame) 관리 메커니즘을 통해 구현됩니다.
ECMAScript 명세 기반 실행 컨텍스트 스택 ECMAScript 2023, Section 9.4 (Execution Contexts)에 따르면, 실행 컨텍스트는 코드가 평가되고 실행되는 환경을 추적하는 명세 장치(specification device)입니다. 실행 컨텍스트 스택은 LIFO 구조로 관리되며, 스택의 최상단(running execution context)만이 실제로 코드를 실행합니다.
함수 호출 시 발생하는 명세 동작:
- PrepareForOrdinaryCall: 새로운 함수 실행 컨텍스트 생성
- OrdinaryCallBindThis: this 값 바인딩
- PushExecutionContext: 실행 컨텍스트를 스택에 push
- 함수 본문 실행
- PopExecutionContext: 실행 컨텍스트를 스택에서 pop
V8 엔진의 스택 프레임 구현 V8 엔진은 네이티브 스택(Native Stack)과 JavaScript 콜 스택을 통합하여 관리합니다. 각 함수 호출은 스택 프레임(Stack Frame)을 생성하며, 다음 정보를 포함합니다:
Frame Layout (64-bit 아키텍처 기준):
- Return Address (8 bytes): 함수 종료 후 돌아갈 주소
- Previous Frame Pointer (8 bytes): 이전 프레임의 베이스 주소
- Context Pointer (8 bytes): 실행 컨텍스트 참조
- Function Object (8 bytes): 호출된 함수 객체
- Arguments (가변): 함수 인자들
- Local Variables (가변): 지역 변수들
TurboFan 최적화 컴파일러는 인라이닝(Inlining)을 통해 함수 호출 오버헤드를 제거할 수 있습니다. 작은 함수는 호출 위치에 직접 삽입되어 스택 프레임 생성을 생략하므로, 콜 스택 깊이가 줄어들고 성능이 향상됩니다.
스택 메모리 할당과 한계 JavaScript 엔진마다 스택 크기 제한이 다릅니다:
- V8 (Chrome): 약 984KB (플랫폼에 따라 다름)
- SpiderMonkey (Firefox): 약 1MB
- JavaScriptCore (Safari): 약 512KB
각 스택 프레임의 크기는 함수의 지역 변수 개수와 인자 개수에 비례합니다. 평균적으로 프레임당 수십 바이트에서 수백 바이트를 사용하므로, 최대 재귀 깊이는 수천에서 수만 회 정도입니다.
실행 컨텍스트의 Push와 Pop
입문
함수가 호출되면 새로운 실행 컨텍스트가 콜 스택에 ‘올라가고’(push), 함수가 끝나면 스택에서 ‘내려가요’(pop).
📦 상자를 쌓고 치우기 택배 상자를 정리한다고 생각해보세요. 상자가 하나씩 도착할 때마다 기존 상자 위에 쌓아요(push). 상자를 정리할 때는 맨 위에 있는 상자부터 치워야 하죠(pop). 콜 스택도 똑같이 동작해요!
🎮 게임 속 캐릭터의 행동 게임에서 캐릭터가 ‘걷기’ 동작을 하다가 ‘점프’ 동작을 시작하면, ‘점프’가 ‘걷기’ 위에 쌓여요. 점프가 끝나면 다시 걷기 동작으로 돌아가죠. 만약 점프 중에 ‘공격’을 하면 ‘공격’이 또 그 위에 쌓이고, 공격이 끝나면 다시 점프로 돌아가요.
🔢 계산기가 계산하는 방법 계산기가 (2 + 3) × 4를 계산한다고 생각해보세요. 먼저 괄호 안의 2 + 3을 계산해야 하니까 이 작업을 스택에 올려요. 2 + 3 = 5가 나오면 이 작업을 치우고(pop), 이제 5 × 4를 계산해요. 함수도 이런 식으로 순서대로 처리돼요.
⏱️ 시작과 끝이 짝을 이뤄요 함수가 시작되면(push) 반드시 끝나야(pop) 다음 작업을 할 수 있어요. 마치 문을 열었으면(push) 반드시 닫아야(pop) 하는 것처럼요. 콜 스택은 모든 함수가 제대로 시작되고 끝나는지 확인해요.
중급
실행 컨텍스트의 push와 pop은 함수 호출과 종료를 관리하는 핵심 메커니즘입니다. 함수가 호출되면 새로운 실행 컨텍스트가 생성되어 콜 스택에 push되고, 함수가 return 문을 만나거나 본문이 끝나면 스택에서 pop됩니다.
Push 동작 (함수 호출 시) 함수 호출이 발생하면 다음 순서로 진행됩니다:
- 새로운 실행 컨텍스트 생성 (변수 환경, 렉시컬 환경, this 바인딩 포함)
- 실행 컨텍스트를 콜 스택의 맨 위에 추가
- 제어권이 새로운 컨텍스트로 이동
- 함수 본문 실행 시작
function outer() {
console.log('outer 실행 컨텍스트 push');
const x = 10;
inner(x);
console.log('outer로 제어권 복귀');
}
function inner(value) {
console.log('inner 실행 컨텍스트 push');
console.log('value:', value);
console.log('inner 실행 컨텍스트 pop');
}
outer();
// 출력:
// outer 실행 컨텍스트 push
// inner 실행 컨텍스트 push
// value: 10
// inner 실행 컨텍스트 pop
// outer로 제어권 복귀
Pop 동작 (함수 종료 시)
함수 실행이 완료되면 다음 순서로 진행됩니다:
- 함수의 반환 값 계산 (return 문이 있는 경우)
- 현재 실행 컨텍스트를 콜 스택에서 제거
- 제어권이 이전 실행 컨텍스트로 복귀
- 반환 값을 호출 위치로 전달
function calculate(a, b) {
const result = a + b;
return result; // 여기서 pop 발생
}
function main() {
const sum = calculate(5, 3); // calculate 컨텍스트 pop 후 sum에 8 할당
console.log(sum); // 8
}
main();
심화
실행 컨텍스트의 push와 pop은 ECMAScript 명세의 추상 연산(Abstract Operations)인 PushExecutionContext와 PopExecutionContext를 통해 정의되며, JavaScript 엔진은 이를 네이티브 스택 프레임 관리와 통합하여 구현합니다.
ECMAScript 명세의 컨텍스트 스택 연산 ECMAScript 2023, Section 9.4.1 (ResolveBinding)과 9.4.2 (GetThisEnvironment)에서 실행 컨텍스트 스택은 LIFO 데이터 구조로 정의됩니다. 주요 추상 연산은 다음과 같습니다:
PushExecutionContext(EC):
- 입력: 실행 컨텍스트 EC
- 동작: EC를 실행 컨텍스트 스택의 맨 위에 추가
- EC는 running execution context가 됨
PopExecutionContext():
- 동작: 스택의 최상단 실행 컨텍스트 제거
- 이전 컨텍스트가 running execution context가 됨
- 제거된 컨텍스트는 더 이상 활성화되지 않음
함수 호출 시퀀스의 명세 동작 OrdinaryCallEvaluateBody (Section 10.2.1.3) 추상 연산은 함수 호출 시 다음 단계를 수행합니다:
- PrepareForOrdinaryCall(F, newTarget): 새로운 실행 컨텍스트 calleeContext 생성
- OrdinaryCallBindThis(F, calleeContext, thisArgument): this 바인딩 설정
- PushExecutionContext(calleeContext): 컨텍스트를 스택에 push
- EvaluateBody(F.[[ECMAScriptCode]], F): 함수 본문 평가
- PopExecutionContext(): 컨텍스트를 스택에서 pop
- 반환 값을 호출자에게 전달
V8 엔진의 프레임 포인터와 스택 관리 V8 엔진은 Frame Pointer (FP)와 Stack Pointer (SP)를 사용하여 스택을 관리합니다:
Frame Pointer (rbp 레지스터):
- 현재 스택 프레임의 베이스 주소를 가리킴
- 함수 호출 시 이전 FP 값을 스택에 저장(push)
- 함수 종료 시 저장된 FP 값을 복원(pop)
Stack Pointer (rsp 레지스터):
- 스택의 최상단 주소를 가리킴
- push 연산 시 감소 (스택은 아래로 성장)
- pop 연산 시 증가
어셈블리 레벨 함수 호출 시퀀스 (x64):
; 함수 호출 전 (caller)
push rbp ; 이전 프레임 포인터 저장
mov rbp, rsp ; 현재 스택 포인터를 프레임 포인터로 설정
sub rsp, 16 ; 로컬 변수를 위한 공간 할당
call function_name ; 함수 호출
; 함수 종료 (callee)
mov rsp, rbp ; 스택 포인터 복원
pop rbp ; 이전 프레임 포인터 복원
ret ; 반환 주소로 점프
최적화와 인라이닝 TurboFan 컴파일러는 다음 조건에서 함수 호출을 인라이닝하여 push/pop 오버헤드를 제거합니다:
인라이닝 조건:
- 함수 크기가 작을 것 (일반적으로 600 AST 노드 이하)
- 호출 빈도가 높을 것 (히트 카운터 임계값 초과)
- 타입이 단형성(monomorphic)일 것
인라이닝 시 실행 컨텍스트 생성 없이 함수 본문이 호출 위치에 직접 삽입되므로, 콜 스택 깊이가 줄어들고 실행 속도가 향상됩니다. 벤치마크 결과, 인라이닝 가능한 함수는 약 80-90% 성능 개선을 보입니다.
단일 스레드와 콜 스택
입문
JavaScript는 한 번에 하나의 일만 처리할 수 있어요. 콜 스택이 하나뿐이라서 동시에 여러 함수를 실행할 수 없어요.
🚶 한 줄로 서기 놀이공원에서 놀이기구를 타기 위해 줄을 선다고 생각해보세요. 놀이기구는 하나뿐이고, 한 번에 한 사람씩만 탈 수 있어요. 아무리 많은 사람이 기다려도 앞사람이 다 타고 내려야 다음 사람이 탈 수 있죠. JavaScript도 이와 똑같아요!
📞 전화 통화는 하나씩 여러분이 친구와 전화 통화를 하고 있다고 생각해보세요. 다른 친구가 전화를 걸어와도 지금 통화를 끝내야 다음 전화를 받을 수 있어요. 두 통화를 동시에 할 수 없죠? JavaScript도 한 번에 하나의 작업만 처리해요.
🎯 왜 한 번에 하나만 할까요? JavaScript는 콜 스택이 딱 하나만 있어요. 마치 계산기가 하나의 화면에서 한 번에 하나의 계산만 보여주는 것처럼요. 여러 작업을 동시에 하려면 콜 스택이 여러 개 필요한데, JavaScript는 그렇게 설계되지 않았어요.
⏰ 그럼 느리지 않나요? 맞아요! 만약 어떤 작업이 오래 걸리면 다른 모든 작업이 기다려야 해요. 그래서 JavaScript는 ‘비동기’라는 특별한 방법을 사용해서 시간이 오래 걸리는 작업을 따로 처리해요. 하지만 기본적으로는 한 번에 하나씩만 처리해요.
중급
JavaScript는 단일 스레드(single-threaded) 언어로, 하나의 콜 스택만을 사용하여 코드를 실행합니다. 이는 한 번에 하나의 작업만 처리할 수 있음을 의미하며, 동시성(concurrency)이 아닌 순차성(sequential execution)을 기본으로 합니다.
단일 스레드의 특징 JavaScript 엔진은 하나의 메인 스레드에서 동작하며, 모든 함수 호출은 동일한 콜 스택을 공유합니다. 이로 인해 다음과 같은 특성이 나타납니다:
- 실행 순서의 예측 가능성: 코드는 항상 작성된 순서대로 실행됨
- 동기적 실행: 이전 작업이 완료되어야 다음 작업 시작
- 경쟁 조건(race condition) 없음: 공유 자원에 대한 동시 접근 불가
function longTask() {
console.log('긴 작업 시작');
let sum = 0;
for (let i = 0; i < 1000000000; i++) {
sum += i;
}
console.log('긴 작업 완료');
return sum;
}
function quickTask() {
console.log('빠른 작업 실행');
}
longTask(); // 이 작업이 완료될 때까지
quickTask(); // 이 작업은 실행되지 않음
// 출력:
// 긴 작업 시작
// 긴 작업 완료 (수 초 후)
// 빠른 작업 실행
블로킹과 논블로킹
단일 스레드 환경에서 시간이 오래 걸리는 작업(블로킹 작업)은 전체 애플리케이션을 멈추게 합니다. 이를 해결하기 위해 JavaScript는 비동기 프로그래밍 모델을 제공합니다:
function longTaskAsync() {
console.log('비동기 작업 시작');
setTimeout(() => {
console.log('비동기 작업 완료');
}, 2000);
}
function quickTask() {
console.log('빠른 작업 실행');
}
longTaskAsync(); // 비동기 작업 예약
quickTask(); // 즉시 실행
// 출력:
// 비동기 작업 시작
// 빠른 작업 실행
// 비동기 작업 완료 (2초 후)
단일 스레드의 장단점
장점:
- 코드 실행 순서가 명확하고 예측 가능
- 메모리 동기화 문제 없음
- 디버깅이 상대적으로 쉬움
단점:
- CPU 집약적 작업 시 UI가 멈춤
- 멀티코어 활용 불가 (기본적으로)
- 한 작업의 지연이 전체에 영향
심화
JavaScript의 단일 스레드 모델은 ECMAScript 명세의 에이전트(Agent) 개념과 이벤트 루프(Event Loop) 메커니즘을 통해 정의되며, 브라우저와 Node.js 환경에서는 Web Workers와 Worker Threads를 통해 제한적인 멀티스레딩을 지원합니다.
ECMAScript 명세의 에이전트 모델 ECMAScript 2023, Section 9.7 (Agents)에서 에이전트는 JavaScript 코드를 실행하는 독립적인 실행 단위로 정의됩니다. 각 에이전트는 자체적인 실행 컨텍스트 스택과 실행 상태를 가지며, 메인 스레드는 단일 에이전트로 동작합니다.
에이전트의 구성 요소:
- Execution Context Stack: LIFO 구조의 실행 컨텍스트 스택
- Running Execution Context: 현재 실행 중인 컨텍스트
- Agent Record: 에이전트의 상태를 저장하는 레코드
- Job Queue: Promise와 같은 비동기 작업을 위한 큐
이벤트 루프와 태스크 큐의 상호작용 단일 콜 스택 환경에서 비동기 작업은 이벤트 루프를 통해 처리됩니다:
이벤트 루프의 동작 순서:
- 콜 스택이 비어있는지 확인
- 마이크로태스크 큐(Microtask Queue) 처리 (Promise, queueMicrotask)
- 매크로태스크 큐(Macrotask Queue) 처리 (setTimeout, setInterval, I/O)
- 렌더링 작업 (브라우저 환경)
- 1번으로 돌아가 반복
콜 스택이 비어있을 때만 태스크 큐의 작업이 실행되므로, 콜 스택을 오래 점유하는 동기 작업은 이벤트 루프를 블로킹합니다.
V8 엔진의 스레드 아키텍처 V8 엔진은 내부적으로 여러 스레드를 사용하지만, JavaScript 실행은 단일 메인 스레드에서만 이루어집니다:
주요 스레드 구성:
- Main Thread: JavaScript 코드 실행 및 콜 스택 관리
- Compiler Threads: TurboFan 최적화 컴파일 (백그라운드)
- Garbage Collector Threads: 병렬 및 동시 GC (백그라운드)
- Profiler Threads: 성능 프로파일링 (백그라운드)
백그라운드 스레드는 JavaScript 실행에 직접 영향을 주지 않으며, 메인 스레드의 콜 스택과는 독립적으로 동작합니다.
멀티스레딩 확장: Web Workers와 SharedArrayBuffer Web Workers (브라우저) 또는 Worker Threads (Node.js)를 사용하면 별도의 에이전트에서 JavaScript를 실행할 수 있습니다:
Worker의 특징:
- 독립적인 콜 스택과 실행 컨텍스트 스택
- 메인 스레드와 메시지 패싱(message passing)을 통해 통신
- DOM 접근 불가 (Web Workers의 경우)
- postMessage/onmessage로 데이터 전달
SharedArrayBuffer와 Atomics:
- 멀티 에이전트 간 메모리 공유 가능
- Atomics 객체를 통한 원자적 연산 제공
- 명시적 메모리 동기화 필요 (Spectre 취약점으로 인해 COOP/COEP 헤더 필수)
성능 영향 분석 단일 스레드 환경에서 CPU 집약적 작업의 영향:
블로킹 시간 측정 (Chrome DevTools Performance API):
// Long Task: 50ms 이상 메인 스레드를 블로킹하는 작업
// First Input Delay (FID): 사용자 입력부터 응답까지의 지연
// Time to Interactive (TTI): 페이지가 완전히 인터랙티브해지는 시간
브라우저는 60fps (프레임당 16.67ms)를 목표로 하므로, 16ms 이상 콜 스택을 점유하는 작업은 프레임 드롭을 발생시킵니다. 이를 방지하기 위해 작업 분할(task chunking) 기법을 사용합니다:
// requestIdleCallback을 사용한 작업 분할
function processLargeData(data) {
const chunkSize = 100;
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
// 데이터 처리
}
index = end;
if (index < data.length) {
requestIdleCallback(processChunk);
}
}
requestIdleCallback(processChunk);
}
스택 오버플로우와 재귀 호출
입문
콜 스택에 함수가 너무 많이 쌓이면 메모리가 부족해서 ‘스택 오버플로우’ 에러가 발생해요.
📚 책을 계속 쌓으면? 여러분이 책을 계속해서 쌓는다고 생각해보세요. 처음에는 잘 쌓이지만, 계속 쌓다 보면 천장에 닿게 돼요. 더 이상 쌓을 공간이 없으면 책탑이 무너지겠죠? 콜 스택도 마찬가지로 함수를 너무 많이 쌓으면 공간이 부족해져요.
🔁 거울 앞에 거울을 놓으면? 거울 두 개를 서로 마주보게 놓으면 무한히 반사되는 모습이 보여요. 함수가 자기 자신을 계속 부르는 것을 ‘재귀 호출’이라고 하는데, 멈추지 않고 계속 부르면 콜 스택이 끝없이 쌓여요. 결국 메모리가 다 차서 프로그램이 멈춰요.
🛑 언제 멈춰야 할까요? 계단을 올라갈 때 끝없이 올라가면 안 되죠? ‘5층까지만 올라가자’처럼 멈추는 조건이 필요해요. 재귀 함수도 마찬가지로 ‘언제 멈출지’를 정해줘야 해요. 그렇지 않으면 콜 스택이 꽉 차서 에러가 나요.
💡 스택 오버플로우를 피하는 방법 함수가 자기 자신을 부를 때는 반드시 ‘멈추는 조건’을 만들어야 해요. 예를 들어 숫자를 세다가 10이 되면 멈추는 식이에요. 이렇게 하면 콜 스택이 무한히 쌓이지 않고 안전하게 끝날 수 있어요.
중급
스택 오버플로우(Stack Overflow)는 콜 스택이 허용된 최대 크기를 초과할 때 발생하는 에러입니다. 주로 종료 조건이 없는 재귀 호출이나 깊은 중첩 호출로 인해 발생합니다.
재귀 호출과 스택 오버플로우 재귀 함수는 자기 자신을 호출하는 함수로, 각 호출마다 새로운 실행 컨텍스트가 스택에 추가됩니다. 종료 조건(base case)이 없거나 잘못 설정되면 무한 재귀가 발생하여 스택이 오버플로우됩니다.
function infiniteRecursion() {
return infiniteRecursion(); // 종료 조건 없음
}
infiniteRecursion();
// RangeError: Maximum call stack size exceeded
올바른 재귀 함수 작성
재귀 함수는 반드시 다음 요소를 포함해야 합니다:
- 베이스 케이스(Base Case): 재귀를 멈추는 조건
- 재귀 케이스(Recursive Case): 문제를 더 작은 단위로 분할
- 진행 방향: 각 재귀 호출이 베이스 케이스에 가까워져야 함
function factorial(n) {
// 베이스 케이스: 재귀 종료 조건
if (n <= 1) {
return 1;
}
// 재귀 케이스: 문제를 작은 단위로 분할
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120
// 스택 상태:
// factorial(5) → factorial(4) → factorial(3) → factorial(2) → factorial(1)
// 1 ← 2 ← 6 ← 24 ← 120
스택 깊이 제한
JavaScript 엔진마다 스택 크기 제한이 다릅니다:
- Chrome (V8): 약 10,000 ~ 15,000 프레임
- Firefox (SpiderMonkey): 약 50,000 프레임
- Safari (JavaScriptCore): 약 10,000 프레임
function measureStackDepth(depth = 0) {
try {
return measureStackDepth(depth + 1);
} catch (e) {
return depth;
}
}
console.log('최대 스택 깊이:', measureStackDepth());
// Chrome: 약 10000 ~ 15000
재귀 대신 반복문 사용
깊은 재귀가 필요한 경우 반복문으로 변환하여 스택 오버플로우를 방지할 수 있습니다:
// 재귀 버전
function factorialRecursive(n) {
if (n <= 1) return 1;
return n * factorialRecursive(n - 1);
}
// 반복문 버전
function factorialIterative(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
console.log(factorialIterative(10000)); // 스택 오버플로우 없음
심화
스택 오버플로우는 JavaScript 엔진의 스택 메모리 한계와 직접적으로 연관되며, 꼬리 재귀 최적화(Tail Call Optimization), 트램펄린(Trampoline) 기법, 그리고 명시적 스택 사용을 통해 해결할 수 있습니다.
ECMAScript 명세와 Tail Call Optimization ECMAScript 2015 (ES6), Section 14.6 (Tail Position Calls)에서 적절한 꼬리 호출(Proper Tail Calls, PTC)을 정의하고 있습니다. 꼬리 호출은 함수의 마지막 동작이 다른 함수 호출일 때를 의미하며, 엔진은 새로운 스택 프레임을 생성하지 않고 현재 프레임을 재사용할 수 있습니다.
꼬리 호출의 조건:
- Strict Mode에서만 적용
- 함수의 return 문이 다른 함수 호출만 포함
- 호출 후 추가 연산이 없음
꼬리 재귀 최적화 예시:
'use strict';
// 최적화 불가능 (꼬리 호출 아님)
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // n과 곱셈 연산이 남아있음
}
// 최적화 가능 (꼬리 재귀)
function factorialTailRecursive(n, acc = 1) {
if (n <= 1) return acc;
return factorialTailRecursive(n - 1, n * acc); // 마지막이 함수 호출만
}
주의: 현재 대부분의 JavaScript 엔진은 PTC를 완전히 구현하지 않았습니다:
- V8 (Chrome, Node.js): PTC 지원 안 함 (명세 준수 논란으로 제거됨)
- JavaScriptCore (Safari): PTC 지원
- SpiderMonkey (Firefox): PTC 지원 안 함
트램펄린 패턴으로 스택 오버플로우 방지 트램펄린은 재귀 함수를 반복문으로 변환하는 고차 함수 기법입니다. 함수가 다음 호출을 직접 실행하는 대신 thunk(지연 실행 함수)를 반환하여, 트램펄린이 이를 반복적으로 실행합니다:
function trampoline(fn) {
return function trampolined(...args) {
let result = fn(...args);
while (typeof result === 'function') {
result = result(); // thunk 실행
}
return result;
};
}
// 트램펄린 스타일 재귀
function factorialThunk(n, acc = 1) {
if (n <= 1) return acc;
return () => factorialThunk(n - 1, n * acc); // thunk 반환
}
const factorial = trampoline(factorialThunk);
console.log(factorial(100000)); // 스택 오버플로우 없음
명시적 스택을 사용한 반복 변환 복잡한 재귀 알고리즘은 명시적 스택 자료구조를 사용하여 반복문으로 변환할 수 있습니다:
// 재귀 버전 (이진 트리 순회)
function traverseRecursive(node) {
if (!node) return;
console.log(node.value);
traverseRecursive(node.left);
traverseRecursive(node.right);
}
// 명시적 스택 버전
function traverseIterative(root) {
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) continue;
console.log(node.value);
stack.push(node.right); // LIFO이므로 right 먼저 push
stack.push(node.left);
}
}
스택 메모리 할당과 한계 JavaScript 엔진의 스택 크기는 컴파일 타임에 결정되며, 플랫폼과 아키텍처에 따라 다릅니다:
V8 엔진의 스택 크기:
- Linux/Mac (64-bit): 984KB
- Windows (64-bit): 492KB
- 모바일 (iOS/Android): 약 256KB
각 스택 프레임의 크기는 함수의 특성에 따라 다릅니다:
- 간단한 함수: 64-128 bytes
- 클로저가 있는 함수: 256-512 bytes
- 많은 로컬 변수: 1KB 이상
최대 재귀 깊이 계산:
최대 깊이 ≈ 스택 크기 / 평균 프레임 크기
예: 984KB / 128 bytes ≈ 7,680 프레임
성능 비교: 재귀 vs 반복 벤치마크 결과 (피보나치 수열, n=30):
- 단순 재귀: 약 200ms (2^n 복잡도)
- 꼬리 재귀 (PTC 미지원): 약 0.5ms
- 메모이제이션 재귀: 약 0.1ms
- 반복문: 약 0.05ms
재귀는 코드 가독성이 높지만 성능과 스택 사용량 측면에서 불리하므로, 깊은 재귀가 예상되는 경우 반복문이나 트램펄린 사용을 권장합니다.