자바스크립트 이벤트 루프 완전 정복: 마이크로태스크와 매크로태스크의 실행 순서
setTimeout(0)보다 Promise.then이 먼저 실행되는 이유는? 콜 스택, 태스크 큐, 마이크로태스크 큐가 어떤 순서로 돌아가는지 예제로 정리하고, 브라우저와 Node.js의 차이, 실무에서 UI 멈춤을 피하는 방법까지 알아보자.
왜 이 코드는 1 → 4 → 3 → 2 순서로 찍힐까?
먼저 퀴즈부터 하나.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
setTimeout의 지연 시간이 0이니 바로 실행될 것 같지만, 실제 출력은 이렇다.
1
4
3
2
Promise.then이 setTimeout(0)보다 먼저 실행된다. 이 순서를 이해하려면 이벤트 루프(Event Loop) 가 어떻게 돌아가는지 알아야 한다.
이벤트 루프란?
이벤트 루프는 싱글 스레드(Single Thread)인 자바스크립트가 비동기(Asynchronous) 작업을 처리할 수 있도록, "지금 실행할 코드"와 "나중에 실행할 콜백(Callback)"의 순서를 관리하는 장치다.
자바스크립트 엔진은 한 번에 하나의 코드만 실행한다. 그런데도 타이머, 네트워크 요청, 클릭 이벤트를 동시에 다루는 것처럼 보이는 건, 기다리는 일은 브라우저(또는 Node.js)에 맡기고 끝난 작업의 콜백만 큐에 줄 세워 하나씩 꺼내 실행하기 때문이다.
이 과정에 등장하는 구성 요소는 다음과 같다.
| 구성 요소 | 역할 | 예시 |
|---|---|---|
| 콜 스택 (Call Stack) | 지금 실행 중인 함수들이 쌓이는 곳 | 일반 함수 호출 |
| Web API / 런타임 API (Runtime API) | 시간이 걸리는 작업을 엔진 밖에서 처리 | 타이머, fetch, DOM 이벤트 |
| 태스크 큐 (Task Queue) = 매크로태스크 (Macrotask) | 완료된 비동기 작업의 콜백이 기다리는 곳 | setTimeout, setInterval, 이벤트 핸들러 |
| 마이크로태스크 큐 (Microtask Queue) | 우선순위가 더 높은 콜백이 기다리는 곳 | Promise.then/catch/finally, await 이후 코드, queueMicrotask |
실행 순서의 핵심 규칙
이벤트 루프는 다음 사이클을 계속 반복한다.
- 태스크 큐에서 태스크 하나를 꺼내 실행한다 (처음엔 스크립트 전체가 하나의 태스크).
- 콜 스택이 비면 마이크로태스크 큐를 완전히 비울 때까지 전부 실행한다.
- 필요하면 화면을 다시 그린다. 이를 렌더링(Rendering)이라 하고, requestAnimationFrame 콜백도 이 단계에서 실행된다.
- 1번으로 돌아간다.
핵심은 2번이다.
태스크는 한 번에 하나씩, 마이크로태스크는 큐가 빌 때까지 한꺼번에 실행된다.
이제 처음 퀴즈를 다시 보면 흐름이 이렇게 된다.
- 스크립트 실행(태스크) → 1 출력 → setTimeout 콜백은 태스크 큐로 → then 콜백은 마이크로태스크 큐로 → 4 출력
- 스크립트 종료, 콜 스택이 빔 → 마이크로태스크 큐 비우기 → 3 출력
- 다음 태스크 → 2 출력
예제로 연습하기
1. 태스크 안에서 생긴 마이크로태스크
setTimeout(() => {
console.log("timeout 1");
Promise.resolve().then(() => console.log("micro in timeout 1"));
}, 0);
setTimeout(() => console.log("timeout 2"), 0);
timeout 1
micro in timeout 1
timeout 2
두 타이머가 같은 시점에 큐에 들어가 있어도, 첫 번째 태스크가 끝난 직후 그 안에서 생긴 마이크로태스크가 먼저 처리된다. 그다음에야 두 번째 태스크로 넘어간다.
2. async / await
await 뒤의 코드는 then 콜백과 같다. 즉 마이크로태스크로 예약된다.
async function foo() {
console.log("foo 시작");
await null;
console.log("foo 끝");
}
console.log("스크립트 시작");
foo();
console.log("스크립트 끝");
스크립트 시작
foo 시작
스크립트 끝
foo 끝
async 함수는 첫 번째 await을 만나기 전까지는 동기적으로 실행된다. 그래서 foo 시작이 스크립트 끝보다 먼저 찍힌다. "async 함수는 통째로 비동기"라고 오해하기 쉬운 부분이다.
브라우저 vs Node.js
Node.js도 이벤트 루프를 쓰지만 libuv 기반이라 단계(Phase)가 더 세분화되어 있고, 브라우저에는 없는 API가 있다.
| 항목 | 브라우저 | Node.js |
|---|---|---|
| 렌더링 단계 | 있음 (requestAnimationFrame) | 없음 |
| 추가 큐 | - | process.nextTick 큐 (Promise보다 먼저) |
| 추가 API | - | setImmediate (check 단계에서 실행) |
// Node.js, CommonJS 모듈 기준
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
nextTick
promise
timeout / immediate (둘의 순서는 실행할 때마다 달라질 수 있음)
메인 모듈에서 setTimeout(0)과 setImmediate의 순서는 보장되지 않는다. 다만 I/O(입출력) 콜백(예: fs.readFile의 콜백) 안에서 호출하면 항상 setImmediate가 먼저 실행된다. 또 ES 모듈(.mjs)에서는 최상위 코드가 이미 마이크로태스크 흐름 안에서 실행되기 때문에 promise가 nextTick보다 먼저 찍힐 수 있다.
실무에서 자주 겪는 문제
1. 무거운 동기 작업이 화면을 멈춘다
렌더링은 태스크와 태스크 사이에서만 일어난다. 그래서 하나의 태스크가 오래 걸리면 그동안 클릭도, 스크롤도, 화면 갱신도 모두 멈춘다.
// ❌ 10만 개를 한 번에 처리하면 그동안 UI가 굳는다
items.forEach(processItem);
작업을 잘게 나누고, 중간중간 태스크 경계를 만들어 브라우저에 숨 쉴 틈을 준다. 이렇게 메인 스레드(Main Thread)를 잠깐 내어주는 것을 양보(Yield)라고 한다.
// ✅ 일정 개수마다 다음 태스크로 넘겨서 렌더링과 입력 처리가 끼어들 수 있게 한다
const yieldToMain = () => new Promise((resolve) => setTimeout(resolve, 0));
async function processAll(items) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 500 === 0) await yieldToMain();
}
}
최신 Chromium 계열 브라우저는 이 용도로 scheduler.yield()를 제공하므로, 지원 여부를 확인하고 쓸 수 있으면 쓰는 것이 좋다.
2. 마이크로태스크로는 양보가 안 된다
위 코드에서 setTimeout 대신 Promise.resolve()로 쉬어가면 효과가 없다. 마이크로태스크는 큐가 빌 때까지 이어서 실행되므로 렌더링이 끼어들 틈이 없기 때문이다. 마이크로태스크가 계속 새 마이크로태스크를 만들면 while (true)처럼 페이지가 완전히 멈춘다.
// ❌ 렌더링이 영원히 일어나지 않는다
function loop() {
Promise.resolve().then(loop);
}
loop();
3. "0ms"는 "즉시"가 아니다
setTimeout(fn, 0)은 "최소 0ms 뒤에 태스크 큐에 넣어달라"는 뜻이다. 앞에 쌓인 태스크와 마이크로태스크가 모두 끝나야 실행되고, 브라우저는 중첩된 타이머의 최소 지연을 4ms로 늘리기도 한다. 정확한 타이밍이 필요한 코드에 기대면 안 된다.
정리: 왜 알아두면 좋은가
| 상황 | 이벤트 루프 관점의 해석 |
|---|---|
| then 콜백이 setTimeout(0)보다 먼저 실행됨 | 마이크로태스크가 다음 태스크보다 우선 |
| async 함수 앞부분이 바로 실행됨 | 첫 await 전까지는 동기 실행 |
| 큰 반복문 중 화면이 멈춤 | 태스크 하나가 렌더링 기회를 막음 |
| Promise로 쪼갰는데도 멈춤 | 마이크로태스크는 렌더링 전에 전부 실행됨 |
이벤트 루프를 이해하면 "왜 이 로그가 이 순서로 찍히지?" 같은 디버깅 시간이 크게 줄고, 로딩 스피너가 안 돌거나 입력이 버벅이는 문제의 원인도 바로 짚을 수 있다. 비동기 코드가 예상과 다르게 움직일 때는 "이 콜백은 태스크인가, 마이크로태스크인가?" 부터 따져보자.