자바스크립트 이벤트 루프 완전 정복: 마이크로태스크와 매크로태스크의 실행 순서

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

실행 순서의 핵심 규칙

이벤트 루프는 다음 사이클을 계속 반복한다.

  1. 태스크 큐에서 태스크 하나를 꺼내 실행한다 (처음엔 스크립트 전체가 하나의 태스크).
  2. 콜 스택이 비면 마이크로태스크 큐를 완전히 비울 때까지 전부 실행한다.
  3. 필요하면 화면을 다시 그린다. 이를 렌더링(Rendering)이라 하고, requestAnimationFrame 콜백도 이 단계에서 실행된다.
  4. 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로 쪼갰는데도 멈춤마이크로태스크는 렌더링 전에 전부 실행됨

이벤트 루프를 이해하면 "왜 이 로그가 이 순서로 찍히지?" 같은 디버깅 시간이 크게 줄고, 로딩 스피너가 안 돌거나 입력이 버벅이는 문제의 원인도 바로 짚을 수 있다. 비동기 코드가 예상과 다르게 움직일 때는 "이 콜백은 태스크인가, 마이크로태스크인가?" 부터 따져보자.