이벤트 루프 심화 — Call Stack, Task Queue, 그리고 실행 순서의 비밀
자바스크립트는 싱글 스레드인데,
setTimeout이나fetch는 어떻게 다른 작업을 기다리면서도 화면이 멈추지 않을까요?
비동기 코드가 "어떻게" 동작하는지를 이해하려면 이벤트 루프 라는 구조를 알아야 합니다. Call Stack, Web API, Task Queue, Microtask Queue — 이 네 가지가 어떻게 맞물려 돌아가는지 한 사이클 단위로 정리해 보겠습니다.
Promise 자체의 내부 동작(상태 머신, then 체이닝 등)은 비동기 심화 — Promise 내부 동작과 마이크로태스크 큐 글에서 다루고 있으니, 이 글에서는 이벤트 루프의 전체 그림에 집중하겠습니다.
자바스크립트는 싱글 스레드
자바스크립트 엔진(V8, SpiderMonkey 등)은 하나의 Call Stack 만 가지고 있습니다. 한 번에 하나의 작업만 실행할 수 있다는 뜻입니다.
그러면 네트워크 요청을 보내면서 동시에 버튼 클릭을 처리하는 건 어떻게 가능할까요?
핵심은 자바스크립트 엔진 혼자서 모든 걸 하는 게 아니라는 점 입니다. 브라우저(또는 Node.js)가 제공하는 Web API가 비동기 작업을 대신 처리하고, 완료되면 콜백을 큐에 넣어줍니다. 이벤트 루프는 이 큐에서 콜백을 꺼내 Call Stack에 올리는 역할을 합니다.
┌─────────────────────────────────────────────┐
│ 자바스크립트 엔진 │
│ ┌──────────┐ ┌──────────────┐ │
│ │Call Stack │ │ Memory Heap │ │
│ │ │ │ (객체 저장) │ │
│ └──────────┘ └──────────────┘ │
└─────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 브라우저 / Node.js 환경 │
│ ┌──────────────────────────────────────┐ │
│ │ Web API (setTimeout, fetch, DOM 등) │ │
│ └──────────────────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Microtask Queue │ │ Task Queue │ │
│ └─────────────────┘ └─────────────────┘ │
│ ┌──────────────┐ │
│ │ 이벤트 루프 │ │
│ └──────────────┘ │
└─────────────────────────────────────────────┘
Call Stack — 함수 호출의 LIFO 구조
Call Stack은 현재 실행 중인 함수들의 스택 입니다. 함수가 호출되면 스택에 쌓이고(push), 반환되면 빠집니다(pop).
function multiply(a, b) {
return a * b;
}
function square(n) {
return multiply(n, n); // multiply가 스택에 쌓임
}
function printSquare(n) {
const result = square(n); // square가 스택에 쌓임
console.log(result);
}
printSquare(4);
이 코드의 Call Stack 변화를 추적해 보면 이렇습니다.
1. printSquare(4) → [printSquare]
2. square(4) → [printSquare, square]
3. multiply(4, 4) → [printSquare, square, multiply]
4. multiply 반환 → [printSquare, square]
5. square 반환 → [printSquare]
6. console.log(16) → [printSquare, console.log]
7. console.log 반환 → [printSquare]
8. printSquare 반환 → [] (비어짐)
Call Stack이 비어야 이벤트 루프가 큐에서 다음 작업을 가져옵니다. 공부하다 보니 이 점이 정말 중요하더라고요 — Call Stack이 비어있지 않으면 어떤 비동기 콜백도 실행되지 않습니다.
Stack Overflow
Call Stack에도 크기 제한이 있습니다. 재귀 호출이 끝나지 않으면 스택이 넘쳐서 RangeError: Maximum call stack size exceeded가 발생합니다.
function infinite() {
infinite(); // 탈출 조건 없는 재귀
}
infinite(); // RangeError!
Web API — 브라우저가 처리하는 영역
setTimeout, fetch, DOM 이벤트 리스너 같은 것들은 ** 자바스크립트 엔진이 아닌 브라우저(혹은 Node.js)가 제공하는 API**입니다.
console.log('시작');
setTimeout(() => {
console.log('타이머 완료');
}, 1000);
console.log('끝');
이 코드의 동작 과정을 단계별로 보면 이렇습니다.
console.log('시작')— Call Stack에서 바로 실행, 출력setTimeout(cb, 1000)— Call Stack에서 실행되지만, ** 타이머 관리는 Web API에 위임**console.log('끝')— Call Stack에서 바로 실행, 출력- (1초 후) Web API가 타이머 완료를 감지하고, 콜백을 Task Queue에 넣음
- 이벤트 루프가 Call Stack이 비어있는 걸 확인하고, Task Queue에서 콜백을 꺼내 실행
setTimeout(fn, 0)이라고 해도 "0ms 후에 즉시 실행"이 아닌 이유가 여기에 있습니다. Web API → Task Queue → 이벤트 루프 → Call Stack이라는 경로를 반드시 거쳐야 하기 때문입니다.
Task Queue (Macrotask Queue)
** 매크로태스크(Macrotask)**라고도 부르는 Task Queue에 들어가는 것들입니다.
setTimeout/setInterval콜백- I/O 작업 완료 콜백
- UI 렌더링 이벤트
postMessageMessageChannel
중요한 규칙이 하나 있습니다.
이벤트 루프는 한 사이클에 Task를 ** 하나만** 꺼내서 실행합니다.
// 두 개의 setTimeout을 등록
setTimeout(() => console.log('Task 1'), 0);
setTimeout(() => console.log('Task 2'), 0);
Task 1이 실행되고, 이벤트 루프가 다시 한 바퀴 돈 다음에야 Task 2가 실행됩니다. 이 사이에 Microtask 처리, (필요하다면) 렌더링까지 일어날 수 있습니다.
Microtask Queue — 왜 우선순위가 높을까
Microtask Queue에 들어가는 것들입니다.
Promise.then/catch/finally콜백queueMicrotask()콜백MutationObserver콜백async/await의 await 이후 코드 (내부적으로 Promise.then)
Task Queue와의 결정적 차이를 정리하면 이렇습니다.
| 구분 | Task Queue | Microtask Queue |
|---|---|---|
| 한 사이클에 처리하는 양 | 1개 | ** 전부** (큐가 빌 때까지) |
| 대표 예시 | setTimeout, setInterval | Promise.then, queueMicrotask |
| 처리 시점 | Microtask 이후 | Call Stack 비자마자 즉시 |
Microtask가 우선순위가 높은 이유는 단순합니다 — ** 스펙에서 그렇게 정했기 때문 **입니다. HTML 스펙의 이벤트 루프 처리 모델을 보면, 매 Task가 끝날 때마다 Microtask checkpoint를 수행하도록 되어 있습니다.
Microtask가 Microtask를 만들면?
주의할 점이 있습니다. Microtask 처리 중에 새로운 Microtask가 추가되면, ** 그것도 같은 사이클에서 전부 처리합니다.**
queueMicrotask(() => {
console.log('Micro 1');
queueMicrotask(() => {
console.log('Micro 2'); // 이것도 같은 사이클에서 실행됨
});
});
setTimeout(() => console.log('Task'), 0);
출력 순서: Micro 1 → Micro 2 → Task
이론적으로 Microtask가 무한히 Microtask를 만들면, Task Queue는 영원히 실행되지 못합니다. ** 렌더링도 멈춥니다.** 실무에서 Promise 체이닝이 무한 루프에 빠지면 화면이 먹통이 되는 이유가 이것입니다.
이벤트 루프의 한 사이클
HTML 스펙에 정의된 이벤트 루프의 한 사이클을 정리하면 이렇습니다.
┌──────────────────────────────────────────┐
│ 1. Task Queue에서 가장 오래된 Task 하나 꺼냄 │
│ (Call Stack에서 실행) │
├──────────────────────────────────────────┤
│ 2. Call Stack이 비면 │
│ → Microtask Queue를 전부 비움 │
│ (처리 중 추가된 Microtask도 포함) │
├──────────────────────────────────────────┤
│ 3. 렌더링이 필요한 경우 │
│ a. requestAnimationFrame 콜백 실행 │
│ b. Style 계산, Layout, Paint │
├──────────────────────────────────────────┤
│ 4. 다시 1번으로 돌아감 │
└──────────────────────────────────────────┘
공부하면서 헷갈렸던 부분인데, 렌더링은 ** 매 사이클마다 일어나는 게 아닙니다.** 브라우저가 필요하다고 판단할 때만 수행합니다(보통 60fps 기준 약 16.6ms마다).
requestAnimationFrame의 위치
requestAnimationFrame(이하 rAF)은 Task도 Microtask도 아닙니다. ** 렌더링 단계 직전에 실행되는 별도의 콜백 **입니다.
console.log('동기');
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
requestAnimationFrame(() => console.log('rAF'));
console.log('동기 끝');
실행 순서를 예측해 보겠습니다.
동기
동기 끝
Promise ← Microtask
rAF ← 렌더링 직전 (다음 프레임)
setTimeout ← Task
다만 rAF와 setTimeout의 순서는 ** 환경에 따라 다를 수 있습니다.** rAF는 렌더링 사이클에 묶여 있고, setTimeout은 Task Queue에 있기 때문에, 브라우저가 렌더링을 언제 스케줄링하느냐에 따라 달라집니다.
rAF를 써야 하는 이유
DOM을 변경할 때 setTimeout(fn, 0) 대신 requestAnimationFrame을 쓰는 이유가 명확합니다.
// 나쁜 예: setTimeout으로 DOM 업데이트
element.style.transform = 'translateX(0px)';
setTimeout(() => {
element.style.transform = 'translateX(100px)'; // 언제 실행될지 불확실
}, 0);
// 좋은 예: rAF로 DOM 업데이트
element.style.transform = 'translateX(0px)';
requestAnimationFrame(() => {
element.style.transform = 'translateX(100px)'; // 다음 페인트 직전에 실행 보장
});
rAF는 ** 브라우저의 렌더링 사이클과 동기화 **되기 때문에, 애니메이션이나 DOM 변경 시 가장 적절한 타이밍에 실행됩니다.
Node.js의 이벤트 루프는 다릅니다
브라우저의 이벤트 루프와 Node.js의 이벤트 루프는 비슷하지만 같지 않습니다.
Node.js는 libuv 기반으로, 6개의 Phase를 순환합니다.
┌───────────────────────────┐
┌─▶│ timers │ ← setTimeout, setInterval
│ └──────────┬────────────────┘
│ ┌──────────▼────────────────┐
│ │ pending callbacks │ ← 시스템 콜백
│ └──────────┬────────────────┘
│ ┌──────────▼────────────────┐
│ │ idle, prepare │ ← 내부 전용
│ └──────────┬────────────────┘
│ ┌──────────▼────────────────┐
│ │ poll │ ← I/O 콜백
│ └──────────┬────────────────┘
│ ┌──────────▼────────────────┐
│ │ check │ ← setImmediate
│ └──────────┬────────────────┘
│ ┌──────────▼────────────────┐
│ │ close callbacks │ ← socket.on('close')
│ └──────────┬────────────────┘
│ │
└─────────────┘
Node.js에서 process.nextTick()은 Microtask보다도 먼저 실행됩니다. 우선순위는 이렇습니다.
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
nextTick ← process.nextTick (가장 높은 우선순위)
Promise ← Microtask
setTimeout 또는 setImmediate ← 상황에 따라 순서 변동
이 글에서는 브라우저 이벤트 루프에 집중하되, Node.js 환경에서 코드를 테스트할 때 순서가 다를 수 있다는 점만 기억해 두면 됩니다.
실행 순서 분석 — 종합 예제
지금까지 배운 걸 종합해서, 실제 코드의 실행 순서를 추적해 보겠습니다.
console.log('1'); // (1) 동기
setTimeout(() => {
console.log('2'); // (6) Task
Promise.resolve().then(() => {
console.log('3'); // (7) setTimeout 내부의 Microtask
});
}, 0);
Promise.resolve().then(() => {
console.log('4'); // (3) Microtask
queueMicrotask(() => {
console.log('5'); // (4) Microtask가 만든 Microtask
});
});
queueMicrotask(() => {
console.log('6'); // (5) Microtask
});
console.log('7'); // (2) 동기
단계별로 추적해 보겠습니다.
1단계: 동기 코드 실행 (현재 Task)
| 실행 | Call Stack | 출력 |
|---|---|---|
console.log('1') | 실행 후 pop | 1 |
setTimeout(cb, 0) | Web API에 위임 | - |
Promise.resolve().then(cb) | cb를 Microtask Queue에 등록 | - |
queueMicrotask(cb) | cb를 Microtask Queue에 등록 | - |
console.log('7') | 실행 후 pop | 7 |
현재 출력: 1, 7
2단계: Microtask Queue 전부 비우기
| 실행 | 설명 | 출력 |
|---|---|---|
Promise 콜백: console.log('4') | 첫 번째 Microtask | 4 |
↳ queueMicrotask(cb) | 새 Microtask 추가됨 | - |
queueMicrotask 콜백: console.log('6') | 두 번째 Microtask | 6 |
아까 추가된 콜백: console.log('5') | 처리 중 추가된 Microtask도 실행 | 5 |
현재 출력: 1, 7, 4, 6, 5
3단계: (렌더링 필요 시 렌더링)
4단계: Task Queue에서 다음 Task 실행
| 실행 | 설명 | 출력 |
|---|---|---|
setTimeout 콜백: console.log('2') | Task | 2 |
↳ Promise.resolve().then(cb) | Microtask 등록 | - |
5단계: 다시 Microtask Queue 비우기
| 실행 | 설명 | 출력 |
|---|---|---|
Promise 콜백: console.log('3') | Microtask | 3 |
** 최종 출력: 1, 7, 4, 6, 5, 2, 3**
실무에서 주의할 점
1. 무거운 동기 코드는 이벤트 루프를 막습니다
// 나쁜 예: 동기적으로 대량 데이터 처리
function processAllItems(items) {
items.forEach(item => {
// 무거운 연산...
heavyComputation(item);
});
}
// 개선: 청크 단위로 나눠서 이벤트 루프에 숨 쉴 틈을 줌
async function processInChunks(items, chunkSize = 100) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(item => heavyComputation(item));
// 다음 청크 전에 이벤트 루프가 한 바퀴 돌 수 있도록 양보
await new Promise(resolve => setTimeout(resolve, 0));
}
}
2. Microtask 무한 루프에 주의하세요
// 절대 하면 안 되는 코드
function infiniteMicrotask() {
queueMicrotask(() => {
infiniteMicrotask(); // Microtask가 끝나지 않음 → 렌더링 불가 → 브라우저 멈춤
});
}
Task Queue의 setTimeout으로 무한 루프를 만들면 그래도 Task 사이에 렌더링이 끼어들 수 있지만, Microtask 무한 루프는 ** 그 사이클에서 절대 벗어나지 못합니다.**
3. async/await은 결국 Microtask입니다
async function example() {
console.log('A'); // 동기 실행
await Promise.resolve();
console.log('B'); // await 이후 → Microtask로 실행
}
example();
console.log('C');
// 출력: A, C, B
await 이후의 코드는 Promise.then으로 감싸진 것과 같습니다. 그래서 Microtask Queue에 들어갑니다. 이걸 모르면 async/await을 쓰면서도 실행 순서를 잘못 예측하게 됩니다.
정리
이벤트 루프를 한 줄로 요약하면 이렇습니다.
Call Stack을 비우고 → Microtask를 전부 처리하고 → (필요하면 렌더링하고) → Task를 하나 꺼내서 다시 시작
기억해 두면 좋은 핵심 포인트들입니다.
- ** 자바스크립트 엔진은 싱글 스레드 **, 비동기를 가능하게 하는 건 브라우저/Node.js 환경
- Call Stack이 비어야 이벤트 루프가 큐에서 콜백을 가져옴
- **Microtask는 전부 , Task는 ** 하나씩 — 이 차이가 실행 순서를 결정
- Microtask > Task 우선순위:
Promise.then은 항상setTimeout보다 먼저 - requestAnimationFrame 은 렌더링 직전에 실행되는 별도의 타이밍
- 무거운 작업은 청크로 나누거나 Web Worker를 써서 이벤트 루프를 막지 말 것