식당 주방으로 이해하는 JavaScript 이벤트 루프
JavaScript 이벤트 루프의 동작 원리를 식당 주방 비유와 코드 예제로 쉽게 풀어봅니다. Call Stack, Web API, Task Queue, Microtask Queue의 관계와 실행 순서를 정리합니다.
들어가며
JavaScript를 사용하다 보면 setTimeout, Promise, async/await 같은 비동기 코드를 자연스럽게 작성하게 된다. 하지만 “왜 Promise가 setTimeout보다 먼저 실행되는 걸까?”, “비동기 코드의 실행 순서는 어떻게 정해지는 걸까?” 같은 질문에 명확하게 답하기는 쉽지 않다.
이 글에서는 JavaScript의 비동기 처리를 가능하게 해주는 이벤트 루프(Event Loop)의 동작 원리를 처음부터 끝까지 정리한다. 식당 주방이라는 비유를 함께 사용해서, 복잡한 개념을 직관적으로 이해할 수 있도록 구성했다.
왜 이벤트 루프가 필요한가
JavaScript는 싱글 스레드(Single Thread) 언어다. 코드를 실행하는 일꾼(스레드)이 단 1명뿐이라는 뜻이다.
만약 서버에서 데이터를 가져오는 데 3초가 걸린다면, 그 3초 동안 화면은 완전히 멈춰버린다. 버튼을 눌러도, 스크롤을 해도 아무 반응이 없다. 일꾼이 1명인데 그 1명이 데이터를 기다리느라 다른 일을 못 하기 때문이다.
이 문제를 해결하기 위해 이벤트 루프라는 메커니즘이 존재한다. 이벤트 루프 덕분에 JavaScript는 “기다려야 하는 작업”을 다른 곳에 맡기고, 그 사이에 다른 코드를 계속 실행할 수 있다.
식당에 비유하면, 셰프가 1명뿐인 주방에서 스테이크를 오븐에 넣고 기다리는 대신, 오븐이 알아서 굽게 두고 그 사이에 샐러드를 만드는 것과 같다.
이벤트 루프의 6가지 구성 요소
이벤트 루프를 이해하려면 6가지 구성 요소를 알아야 한다. 각 요소를 식당 주방에 비유하면 다음과 같다.
| 구성 요소 | 역할 | 식당 비유 |
|---|---|---|
| JS 엔진 (V8) | 코드를 실제로 읽고 실행 | 셰프 |
| Call Stack | 현재 실행 중인 함수가 쌓이는 곳 | 주문 접수대 |
| Web API | 타이머, I/O 등 비동기 작업 처리 | 오븐, 전자레인지 |
| Microtask Queue | V8이 직접 관리하는 우선 큐 | 급행 대기표 통 |
| Task Queue | Web API가 완료된 콜백을 넣는 큐 | 일반 대기표 통 |
| Event Loop | 큐에서 콜백을 꺼내 콜 스택에 전달 | 홀 매니저 |
JS 엔진 (V8)
JavaScript 코드를 실제로 읽고 실행하는 프로그램이다. Chrome과 Node.js에서는 Google이 만든 V8 엔진을 사용한다.
중요한 점은 V8 엔진 자체에는 setTimeout이나 DOM 조작 같은 기능이 없다는 것이다. 이런 기능은 브라우저나 Node.js가 별도로 제공한다. V8은 순수하게 JavaScript 코드를 파싱하고 실행하는 역할만 담당한다.
Call Stack (호출 스택)
현재 실행 중인 함수가 쌓이는 곳이다. LIFO(후입선출) 구조로, 가장 나중에 들어온 함수가 가장 먼저 실행된다.
const multiply = (a, b) => a * b;
const square = (n) => multiply(n, n);
const printSquare = (n) => {
const result = square(n);
console.log(result);
};
printSquare(4);
위 코드가 실행될 때 콜 스택의 변화를 보면 다음과 같다.
[printSquare] ← printSquare 호출
[printSquare, square] ← square 호출
[printSquare, square, multiply] ← multiply 호출
[printSquare, square] ← multiply 완료, 제거
[printSquare] ← square 완료, 제거
[] ← printSquare 완료 → 콜 스택이 비었다!
콜 스택이 비어야 이벤트 루프가 큐에서 다음 작업을 가져올 수 있다. 이 점이 매우 중요하다.
Web API (브라우저) / libuv (Node.js)
JS 엔진이 스스로 처리할 수 없는 비동기 작업을 대신 처리해주는 환경이다.
- 브라우저 환경: Web API가
setTimeout타이머, DOM 이벤트 리스닝,fetchHTTP 요청 등을 처리한다. - Node.js 환경: libuv가 파일 I/O, 네트워크, 타이머 등을 처리한다.
셰프가 직접 할 수 없는 “오븐으로 굽기”, “전자레인지로 데우기” 같은 작업을 기기에 맡기는 것과 같다. 기기(Web API)가 작업을 완료하면 결과(콜백)를 대기표 통(큐)에 넣어준다.
Microtask Queue
V8 엔진이 직접 관리하는 큐다. 외부(Web API)를 거치지 않고 엔진 내부에서 바로 이 큐에 콜백을 넣는다. 급행 대기표 통과 같아서, 항상 일반 대기표보다 먼저 처리된다.
이 큐에 들어가는 작업들은 다음과 같다.
Promise.then / catch / finally의 콜백queueMicrotask()의 콜백MutationObserver의 콜백process.nextTick의 콜백 (Node.js 전용)
Task Queue (= Macrotask Queue)
Web API가 완료된 콜백을 넣는 큐다. Web API에서 타이머가 끝나거나, DOM 이벤트가 발생하거나, HTTP 응답이 돌아오면 해당 콜백이 이 큐에 들어간다.
이 큐에 들어가는 작업들은 다음과 같다.
setTimeout / setInterval의 콜백- DOM 이벤트 (
click,scroll등)의 핸들러 - I/O 작업 완료 콜백
MessageChannel의 콜백
명칭에 대해: “Task Queue”가 HTML 스펙의 공식 이름이다. “Macrotask Queue”는 Microtask Queue와 구분하기 위해 커뮤니티에서 사용하는 비공식 이름이다. 스펙에서는 그냥 “Task”라고 부르지만, 학습할 때는 “Macrotask”라고 부르는 것이 이해하기 쉽다.
Event Loop (이벤트 루프)
콜 스택과 큐들 사이를 계속 순환하면서, “콜 스택이 비었으면 큐에서 작업을 꺼내 콜 스택에 올려주는” 조율자다. 홀 매니저가 셰프 손이 빈 걸 확인하고 대기표 통에서 다음 주문을 가져다주는 것과 같다.
큐에 들어가는 것은 “콜백 함수”
흔히 “setTimeout이 큐에 들어간다”고 표현하지만, 정확히는 setTimeout 자체가 아니라 그 안의 콜백 함수가 큐에 들어간다.
// 이 줄이 실행되면 일어나는 일:
// 1. setTimeout 호출 자체는 콜 스택에서 동기적으로 실행
// 2. Web API에 "1초 후에 이 콜백을 Task Queue에 넣어줘"라고 등록
// 3. setTimeout 호출 완료 → 콜 스택에서 제거 (기다리지 않음)
// 4. ... 1초 후 ...
// 5. Web API가 콜백 함수만 Task Queue에 넣음
setTimeout(() => {
console.log('1초 후 실행'); // ← 이 콜백이 Task Queue에 들어감
}, 1000);
// Promise도 마찬가지:
// 1. Promise.resolve()는 콜 스택에서 동기적으로 실행 → 즉시 resolve
// 2. .then의 콜백을 V8이 직접 Microtask Queue에 넣음
Promise.resolve().then(() => {
console.log('다음 microtask에 실행'); // ← 이 콜백이 Microtask Queue에 들어감
});
setTimeout(콜백, 1000) 호출 자체는 콜 스택에서 동기적으로 실행되고, Web API에 타이머를 등록한 뒤 바로 끝난다. 1초 후에 콜백만 Task Queue에 들어가는 것이다.
이벤트 루프 실행 순서
이벤트 루프의 한 사이클은 다음과 같다.
┌─ 콜 스택의 모든 동기 코드 실행
│
├─ 콜 스택이 비었다!
│
├─ [1단계] Microtask Queue 확인
│ └─ 있으면 → 전부 비울 때까지 실행
│ (실행 중 새 Microtask가 추가되면 그것도 이어서 실행)
│
├─ [2단계] Task Queue 확인
│ └─ 있으면 → 하나만 꺼내서 실행
│
├─ [3단계] 다시 Microtask Queue 확인
│ └─ 있으면 → 전부 비울 때까지 실행
│
└─ 다시 2단계로 (반복)
더 이상 아무 작업도 없으면 → 다음 이벤트 대기
여기서 핵심 두 가지를 기억하자.
- Microtask Queue는 전부 비울 때까지 실행한다. 10개가 쌓여있으면 10개를 다 실행한다.
- Task Queue는 하나만 실행한다. 10개가 쌓여있어도 1개만 실행하고, 다시 Microtask Queue를 확인한다.
기본적인 예제로 확인해보자.
console.log('1'); // 동기
setTimeout(() => {
console.log('2'); // Task Queue
}, 0);
Promise.resolve().then(() => {
console.log('3'); // Microtask Queue
});
console.log('4'); // 동기
출력 순서: 1 → 4 → 3 → 2
console.log('1')— 동기 코드, 즉시 실행setTimeout— Web API에 위임, 콜백이 Task Queue로Promise.then— 콜백이 Microtask Queue로console.log('4')— 동기 코드, 즉시 실행- 콜 스택이 비었으므로 → Microtask 먼저 →
'3'출력 - Task Queue 차례 →
'2'출력
Promise가 setTimeout보다 항상 빠른 이유
이유 1: 경로의 차이
setTimeout 콜백의 여정:
JS 엔진 → Web API(외부에서 타이머 처리) → Task Queue → 이벤트 루프 → 콜 스택
Promise 콜백의 여정:
JS 엔진 → Microtask Queue → 이벤트 루프 → 콜 스택
Promise는 Web API를 거치지 않는다. Promise는 ECMAScript 스펙에 정의된 JS 언어 자체의 기능이기 때문에 V8 엔진이 직접 처리하고, 직접 Microtask Queue에 넣는다. 반면 setTimeout은 JS 언어 스펙에 없는 기능이라 외부 환경(Web API)에 위임해야 한다.
이유 2: 실행 우선순위의 차이
이벤트 루프는 항상 Microtask Queue를 먼저 확인한다. Task Queue에 아무리 먼저 도착해 있어도, Microtask Queue에 작업이 있으면 그것부터 처리한다.
// setTimeout이 먼저 호출되었지만
setTimeout(() => console.log('Task'), 0);
// Promise 콜백이 먼저 실행됨
Promise.resolve().then(() => console.log('Microtask'));
// 출력:
// Microtask ← V8이 직접 큐에 넣고, 우선순위도 높음
// Task ← Web API를 거치고, 우선순위도 낮음
중첩 케이스 완전 분석
비동기 작업 안에 또 다른 비동기 작업이 있을 때 어떻게 되는지, 네 가지 경우를 모두 살펴보자.
Task 안에 Microtask가 있을 때
setTimeout(() => {
console.log('A'); // Task
Promise.resolve().then(() => console.log('B')); // Microtask 추가
}, 0);
setTimeout(() => {
console.log('C'); // Task
}, 0);
출력: A → B → C
1. Task Queue: [콜백1(A), 콜백2(C)]
2. 이벤트 루프 → Task 하나 실행 → 'A' 출력
3. 'A' 실행 중 Promise 콜백이 Microtask Queue에 들어감
4. Task 하나 완료 → Microtask Queue 확인 → 'B' 출력
5. Microtask Queue 비었음 → Task Queue에서 다음 하나 → 'C' 출력
Task 하나가 끝나면 항상 Microtask Queue를 먼저 확인하기 때문에, B가 C보다 먼저 실행된다.
Microtask 안에 Task가 있을 때
Promise.resolve().then(() => {
console.log('A'); // Microtask
setTimeout(() => console.log('B'), 0); // Task 추가
});
Promise.resolve().then(() => {
console.log('C'); // Microtask
});
출력: A → C → B
1. Microtask Queue: [콜백1(A), 콜백2(C)]
2. 이벤트 루프 → Microtask 전부 처리 시작
3. 콜백1 실행 → 'A' 출력, setTimeout 콜백이 Web API → Task Queue로
4. 콜백2 실행 → 'C' 출력
5. Microtask Queue 비었음 → Task Queue 확인 → 'B' 출력
Microtask Queue는 전부 비울 때까지 실행하기 때문에, A와 C가 모두 처리된 후에야 Task인 B가 실행된다.
Microtask 안에 Microtask가 있을 때
Promise.resolve().then(() => {
console.log('A');
Promise.resolve().then(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
});
});
출력: A → B → C
1. Microtask Queue: [콜백(A)]
2. 'A' 실행 → 새 Microtask(B)가 큐에 추가됨
3. 큐 아직 안 비었음 → 'B' 실행 → 새 Microtask(C)가 큐에 추가됨
4. 큐 아직 안 비었음 → 'C' 실행
5. 이제 큐가 비었음
Microtask Queue는 빌 때까지 실행하므로, 실행 중 새로 추가된 Microtask도 이어서 처리한다. 이 특성이 위험할 수도 있는데, Microtask가 계속 Microtask를 생성하면 무한루프에 빠져 Task Queue가 영원히 실행되지 않는다.
Task 안에 Task가 있을 때
setTimeout(() => {
console.log('A');
setTimeout(() => console.log('B'), 0); // 새 Task 추가
}, 0);
setTimeout(() => {
console.log('C');
}, 0);
출력: A → C → B
1. Task Queue: [콜백1(A), 콜백2(C)]
2. 이벤트 루프 → Task 하나 실행 → 'A' 출력
3. 'A' 실행 중 새 setTimeout 콜백이 Web API → Task Queue 끝에 추가
Task Queue: [콜백2(C), 콜백3(B)]
4. Microtask 확인 → 없음 → Task 하나 실행 → 'C' 출력
5. Microtask 확인 → 없음 → Task 하나 실행 → 'B' 출력
Task Queue는 FIFO(선입선출)이므로 먼저 들어간 C가 나중에 추가된 B보다 먼저 실행된다.
중첩 규칙 요약
| 상황 | 결과 |
|---|---|
| Task 안에서 Microtask 추가 | 해당 Task 완료 직후, 다음 Task 전에 실행 |
| Task 안에서 Task 추가 | Task Queue 끝에 들어가 나중에 실행 |
| Microtask 안에서 Microtask 추가 | 이어서 바로 실행 (큐를 전부 비워야 하므로) |
| Microtask 안에서 Task 추가 | 모든 Microtask 완료 후에 실행 |
setInterval은 어떻게 반복되는가
setInterval을 호출하면 Web API가 타이머 ID, 콜백 함수, 반복 주기 세 가지를 기억한다. JS 엔진이 아니라 Web API(브라우저) 또는 libuv(Node.js)가 주기를 관리하는 것이다.
const id = setInterval(() => console.log('tick'), 1000);
Web API 내부에 저장되는 정보:
┌─────────────────────────┐
│ 타이머 ID: 3 │
│ 콜백: () => log('tick') │
│ 주기: 1000ms │
│ 다음 실행: +1000ms │
└─────────────────────────┘
0초 → Web API가 타이머 시작
1초 → Web API가 콜백을 Task Queue에 넣음 → 실행 → 'tick'
다음 실행 시간을 +1000ms로 갱신
2초 → Web API가 콜백을 Task Queue에 넣음 → 실행 → 'tick'
다음 실행 시간을 +1000ms로 갱신
...계속 반복...
clearInterval(id) 호출
→ Web API가 타이머 정보를 삭제
→ 더 이상 콜백이 큐에 들어가지 않음
콜백 실행이 주기보다 오래 걸릴 때
setInterval(() => {
heavyWork(); // 2000ms 소요
}, 1000);
0초 → 타이머 시작
1초 → 콜백1 Task Queue에 넣음 → 실행 시작 (2초 걸림)
2초 → 콜백2 Task Queue에 넣음 (콜백1 아직 실행 중이라 대기)
3초 → 콜백1 완료 → 콜백2 대기 없이 바로 실행 (의도한 간격 무시됨)
→ 콜백3 Task Queue에 넣음
Web API는 콜 스택이 바쁜지 상관없이 주기마다 무조건 콜백을 큐에 넣기 때문에, 콜백이 쌓여서 간격 없이 연속 실행될 수 있다.
이 문제를 피하려면 재귀 setTimeout을 사용한다.
const loop = () => {
heavyWork(); // 2초 걸려도
setTimeout(loop, 1000); // 완료된 후 1초 대기 → 그 다음 실행
};
setTimeout(loop, 1000);
이렇게 하면 항상 “이전 작업 완료 → 1초 대기 → 다음 작업”이 보장된다.
Node.js에서의 이벤트 루프
브라우저에서는 Web API가 비동기를 처리하지만, Node.js에서는 libuv라는 C++ 라이브러리가 이 역할을 맡는다. Node.js의 이벤트 루프는 브라우저보다 더 세분화된 6개의 페이즈로 나뉜다.
┌───────────────────────────┐
│ timers │ ← setTimeout, setInterval 콜백
├───────────────────────────┤
│ pending callbacks │ ← 시스템 수준 I/O 콜백
├───────────────────────────┤
│ idle, prepare │ ← Node 내부 용도
├───────────────────────────┤
│ poll │ ← I/O 이벤트 대기 및 처리
├───────────────────────────┤
│ check │ ← setImmediate 콜백
├───────────────────────────┤
│ close callbacks │ ← socket.on('close') 등
└───────────────────────────┘
각 페이즈 사이마다 Microtask Queue가 처리되며, process.nextTick은 Promise.then보다도 먼저 실행된다.
페이즈 종료 → process.nextTick 전부 → Promise.then 전부 → 다음 페이즈
실무에서 주의할 점
긴 동기 작업은 이벤트 루프를 블로킹한다
// 이런 코드가 있으면 5초 동안 화면이 완전히 멈춤
const end = Date.now() + 5000;
while (Date.now() < end) {} // 콜 스택을 5초간 점유
// 이 동안 클릭 이벤트, setTimeout 콜백 등 모든 것이 큐에서 대기
콜 스택이 비어야 이벤트 루프가 큐를 확인할 수 있기 때문이다. 셰프가 한 요리에 5분간 매달리면, 나머지 대기표는 전부 밀리는 것과 같다.
setTimeout(fn, 0)은 “즉시”가 아니다
0ms라고 해도 “다음 Task 사이클”에 실행된다. 현재 콜 스택의 모든 동기 코드와 모든 Microtask가 끝난 후에야 실행된다.
setTimeout(() => console.log('나중'), 0);
console.log('먼저');
// 출력:
// 먼저
// 나중
Microtask starvation (기아 현상)
// 절대 하면 안 되는 코드
const loop = () => {
Promise.resolve().then(loop);
};
loop();
// Microtask가 Microtask를 무한 생성
// → Microtask Queue가 영원히 안 비움
// → Task Queue는 영원히 실행 기회를 못 얻음
// → setTimeout, 클릭 이벤트 등 모두 무응답
전체 흐름 한눈에 보기
코드 실행 시작
│
▼
┌──────────┐ 비동기 발견 ┌──────────────┐
│ Call Stack │ ──────────────────▶ │ Web API │
│ (동기 실행) │ │ (타이머, I/O) │
└────┬─────┘ └──────┬───────┘
│ │
│ Promise.then 발견 │ 작업 완료 시
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Microtask │ │ Task Queue │
│ Queue │ │ │
│ (V8이 직접 │ │ (Web API가 │
│ 관리) │ │ 콜백을 넣음) │
└──────┬───────┘ └──────┬───────┘
│ │
│ ┌──────────────┐ │
└────▶│ Event Loop │◀────────────┘
│ (홀 매니저) │
└──────┬───────┘
│
① Microtask 전부 처리
② Task 하나 처리
③ 다시 ①로 반복
│
▼
콜 스택에 올림
→ JS 엔진 실행
정리
JavaScript의 이벤트 루프는 결국 하나의 규칙으로 요약된다.
콜 스택이 비면 → Microtask를 전부 비우고 → Task를 하나 실행하고 → 다시 Microtask를 확인한다.
이 규칙만 기억하면 setTimeout, Promise, setInterval 등 어떤 비동기 코드의 실행 순서도 예측할 수 있다.
- Microtask Queue: V8 엔진이 직접 관리,
Promise.then등이 들어감, 항상 먼저 실행 - Task Queue: Web API가 콜백을 넣음,
setTimeout등이 들어감, Microtask 다음에 실행 - setInterval의 주기: Web API가 기억하고 관리, 매 주기마다 Task Queue에 새 콜백을 넣음
참고 자료
공식 문서
- HTML Living Standard — Event Loops — WHATWG 공식 스펙. 이벤트 루프, Task Queue, Microtask Queue의 정규 정의.
- MDN — The event loop — JavaScript 동시성 모델과 이벤트 루프를 시각적 다이어그램과 함께 설명하는 MDN 문서.
- Node.js — The Node.js Event Loop, Timers, and process.nextTick() — Node.js 이벤트 루프의 각 phase와
process.nextTick()을 설명하는 공식 문서.
블로그 및 아티클
- Jake Archibald — Tasks, microtasks, queues and schedules — Task와 Microtask의 실행 순서를 인터랙티브 시각화로 설명하는 글. 이벤트 루프 자료 중 가장 많이 인용된다.
- JavaScript.info — Event loop: microtasks and macrotasks — Macrotask와 Microtask 개념을 예제 코드와 함께 정리한 튜토리얼.
- Lydia Hallie — JavaScript Visualized: Event Loop — GIF 애니메이션으로 이벤트 루프 동작을 시각적으로 보여주는 글.
영상
- Philip Roberts — What the heck is the event loop anyway? (JSConf EU 2014) — 콜 스택, Web API, 콜백 큐, 이벤트 루프의 관계를 직관적인 애니메이션으로 설명하는 발표. 이벤트 루프 입문 영상 중 가장 유명하다.
도구
- Loupe — Philip Roberts가 만든 이벤트 루프 시각화 도구. 코드를 입력하면 콜 스택과 큐의 동작을 애니메이션으로 확인할 수 있다.