블로그 목록

자바스크립트 이벤트 루프 — 마이크로태스크는 왜 항상 먼저 실행될까

2026-06-249 min read
JavaScriptEvent Loop비동기Promise마이크로태스크

Promise.then이 setTimeout(…, 0)보다 먼저 실행되는 이유를 한 문장으로 설명할 수 있는가? 이 글은 그 "왜"를 콜스택부터 차근차근 정리한다.

1. 왜 이벤트 루프가 필요한가

자바스크립트는 싱글 스레드다. 코드를 실행하는 콜스택이 하나뿐이라 한 번에 하나의 작업만 처리할 수 있다.

문제는 네트워크 요청·타이머·사용자 입력처럼 시간이 걸리는 작업이다. 이걸 콜스택에서 기다리면 그동안 다른 코드가 전부 멈추고, 브라우저는 화면조차 그리지 못한 채 얼어버린다.

그래서 자바스크립트 런타임은 다른 전략을 쓴다.

  • 시간이 걸리는 일은 콜스택 밖(브라우저/Node) 에 맡긴다.
  • 그 일이 끝나면 콜백을 큐(queue) 에 넣는다.
  • 콜스택이 비면, 큐에서 콜백을 하나씩 꺼내 실행한다.

이 마지막 "콜스택이 비면 큐에서 꺼내온다"를 끊임없이 반복하는 장치가 이벤트 루프(Event Loop) 다.

자주 하는 오해: setTimeout, fetch, DOM 이벤트는 자바스크립트 엔진의 기능이 아니다. 엔진(V8 등)은 콜스택·힙·마이크로태스크 큐 정도만 가진다. 타이머와 네트워크는 브라우저(Web API) 또는 Node 런타임이 제공한다. 그래서 "자바스크립트가 비동기를 지원한다"기보다 "런타임 환경이 비동기를 대신 처리해준다"가 더 정확하다.

2. 구성 요소

Call Stack현재 실행 중인 함수함수 C함수 B함수 AWeb APIs타이머 · fetch · DOM 이벤트(브라우저가 대신 처리)우선Microtask QueuePromise.then · awaitqueueMicrotaskMacrotask QueuesetTimeout · setInterval이벤트 콜백EventLoop① 비동기 작업 위임② 완료 콜백③ 큐 전부 비움④ 하나만⑤ 콜스택이 비면 실행핵심 — 마이크로태스크 큐를 「전부」 비운 뒤에야 매크로태스크를 「하나」 꺼낸다
구성 요소역할예
콜스택현재 실행 중인 함수가 쌓이는 곳모든 동기 코드
Web APIs비동기 작업을 대신 처리setTimeout, fetch, 이벤트 리스너
매크로태스크 큐일반 비동기 콜백 대기열setTimeout, setInterval, 이벤트 콜백
마이크로태스크 큐우선 처리되는 콜백 대기열Promise.then/catch/finally, await 이후, queueMicrotask

3. 핵심 규칙 — 이벤트 루프의 한 틱(tick)

이벤트 루프가 도는 순서가 곧 면접의 정답이다.

  1. 콜스택의 동기 코드를 전부 실행한다.
  2. 콜스택이 비면 → 마이크로태스크 큐를 "전부" 비운다. (비우는 도중 새로 추가된 마이크로태스크까지 모두)
  3. 그다음 매크로태스크 큐에서 "딱 하나"만 꺼내 실행한다.
  4. 그 매크로태스크가 끝나면 → 다시 마이크로태스크 큐를 전부 비운다.
  5. (브라우저면) 필요 시 렌더링 → 다시 2번부터 반복.

요약하면 두 가지다.

  • 마이크로태스크는 항상 매크로태스크보다 먼저, 그리고 큐가 빌 때까지 전부 실행된다.
  • 매크로태스크는 한 번에 하나씩만 처리하고, 그 사이사이에 마이크로태스크와 렌더링이 끼어든다.

4. 예제로 확인

기본

console.log("1"); // 동기

setTimeout(() => console.log("2"), 0); // 매크로태스크

Promise.resolve().then(() => console.log("3")); // 마이크로태스크

console.log("4"); // 동기
출력: 1 → 4 → 3 → 2
  • 동기 코드 1, 4 먼저
  • 콜스택이 비면 마이크로태스크 3
  • 마지막으로 매크로태스크 2

마이크로태스크 안에서 또 마이크로태스크

console.log("start");

setTimeout(() => console.log("timeout"), 0);

Promise.resolve().then(() => {
  console.log("promise1");
  Promise.resolve().then(() => console.log("promise2")); // then 안에서 또 then
});

console.log("end");
출력: start → end → promise1 → promise2 → timeout

promise1을 실행하는 도중에 추가된 promise2도 같은 틱의 마이크로태스크 단계에서 처리된다. 마이크로태스크 큐는 "지금 비우는 도중에 새로 들어온 것까지 전부" 비우기 때문이다. 그래서 매크로태스크인 timeout이 가장 마지막이다.

5. await는 사실 마이크로태스크다

await는 문법 설탕이라, await 이후의 코드는 .then 콜백과 같다. 즉 마이크로태스크로 밀린다.

async function f() {
  console.log("1");
  await Promise.resolve();
  console.log("2"); // ← 사실상 .then(() => console.log("2"))
}

console.log("start");
f();
console.log("end");
출력: start → 1 → end → 2

await를 만나는 순간 f는 일시정지하고 제어권을 호출부로 돌려준다. 그래서 end가 먼저 찍히고, 멈췄던 console.log("2")가 마이크로태스크로 실행된다.

이 원리를 알면 "왜 await 다음 줄이 즉시 실행되지 않지?" 같은 디버깅이 쉬워진다.

6. 실무에서 마주치는 함정

① 마이크로태스크 기아(starvation) — 화면 멈춤

마이크로태스크가 스스로를 계속 추가하면 큐가 영원히 안 비워진다. 그러면 매크로태스크도, 렌더링도 실행되지 못해 화면이 통째로 멈춘다.

function loop() {
  Promise.resolve().then(loop); // 🚨 화면 freeze
}
loop();

무한 반복이 필요하다면 매크로태스크(setTimeout)로 끊어 렌더링이 끼어들 틈을 줘야 한다.

② setTimeout(fn, 0)은 0ms가 아니다

최소 지연(중첩 시 약 4ms)과 큐 대기가 있어 "즉시"가 아니라 "다음 매크로태스크"일 뿐이다. 무거운 동기 작업을 잘게 쪼개 렌더링을 양보하고 싶을 때 쓰는 트릭에 가깝다.

③ 렌더링은 매크로태스크 사이에 일어난다

브라우저의 화면 갱신(reflow·paint)은 한 매크로태스크가 끝나고 마이크로태스크를 다 비운 뒤에 일어난다. 그래서 부드러운 애니메이션은 setTimeout이 아니라 requestAnimationFrame 으로 "다음 페인트 직전"에 그려야 한다.

④ 브라우저와 Node는 다르다

Node의 이벤트 루프는 timers·poll·check 등 여러 페이즈로 세분화돼 있고, process.nextTick이 일반 마이크로태스크보다도 먼저 실행된다. 실행 순서 문제를 다룰 땐 브라우저 기준인지 Node 기준인지 먼저 구분해야 한다.

7. 한 줄 정리

자바스크립트는 싱글 스레드라 콜스택이 하나다. 비동기 작업은 런타임(Web API)이 대신 처리하고, 끝나면 콜백을 큐에 넣는다. 이벤트 루프는 콜스택이 비면 마이크로태스크 큐를 전부 비운 뒤 매크로태스크를 하나 실행하는 사이클을 반복한다. 그래서 Promise.then은 setTimeout(…, 0)보다 항상 먼저 실행되고, await 이후 코드도 마이크로태스크라는 점이 핵심이다.