마이크로태스크 큐와 실행 시점

Promise.then 콜백과 queueMicrotask 는 태스크 큐가 아니라 마이크로태스크 큐에 들어간다. 이벤트 루프는 태스크 하나가 끝날 때마다 마이크로태스크 큐를 완전히 비운 뒤에 렌더링과 다음 태스크로 넘어간다.

Seobway · · 8분

1. 문제 상황

세 콜백은 모두 「지금 말고 나중에」 실행된다. 그런데 순서가 등록 순서와 다르다.

setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => {
  console.log("then");
  queueMicrotask(() => console.log("nested"));
});
queueMicrotask(() => console.log("queueMicrotask"));
console.log("sync");

// sync → then → queueMicrotask → nested → timeout

setTimeout 을 가장 먼저 등록했고 지연도 0 인데 마지막에 실행된다. nested 는 then 콜백이 실행되는 도중에 등록했는데도 timeout 보다 먼저 실행된다. 태스크 큐만으로는 이 순서를 설명할 수 없다.


2. 정의

2.1 정의

HTML 표준에서 이벤트 루프는 태스크 큐와 별도로 마이크로태스크 큐를 하나 갖는다[2]. 마이크로태스크는 마이크로태스크 체크포인트에서 실행된다. 체크포인트는 큐가 빌 때까지 가장 오래된 항목을 하나씩 꺼내 실행하므로, 실행 중에 새로 추가된 마이크로태스크도 같은 체크포인트에서 실행된다.

마이크로태스크 큐에 들어가는 작업은 다음과 같다.

작업 큐
Promise.prototype.then·catch·finally 콜백 마이크로태스크
await 다음 코드 마이크로태스크
queueMicrotask(fn) 마이크로태스크
[[mutation-observer MutationObserver]] 콜백
setTimeout·setInterval 콜백, DOM 이벤트, 네트워크 응답 처리 태스크

2.2 사양에서의 위치

두 표준이 역할을 나눈다.

HTML 표준이 체크포인트를 수행하는 시점은 두 곳이다.

  1. 이벤트 루프가 태스크 하나를 실행한 직후.
  2. 스크립트나 콜백 실행이 끝나 자바스크립트 실행 컨텍스트 스택이 비었을 때(「clean up after running script」 단계).

3. 동작 원리

3.1 이벤트 루프 한 번의 반복

이벤트 루프는 한 번 반복할 때 태스크를 하나만 실행하고, 이어서 마이크로태스크 체크포인트를 수행한다. 렌더링은 그 뒤에 일어날 수 있다.

이벤트 루프 한 번의 반복
그림 1. 태스크는 반복마다 하나만 실행된다. 마이크로태스크는 체크포인트에서 큐가 빌 때까지 모두 실행되고, 렌더링은 그 뒤에 일어날 수 있다.

스크립트 파일을 처음 실행하는 것도 태스크 하나다. 그래서 1장의 순서는 「동기 코드(첫 태스크) → 마이크로태스크 전부 → 다음 태스크(timeout)」가 된다.

3.2 체크포인트는 큐가 빌 때까지 돈다

1장 코드에서 각 콜백이 어느 큐에 들어가고 언제 꺼내지는지 단계별로 본다.

인터랙티브 다이어그램: 1장 코드의 큐 상태 (단계별로 재생할 수 있다)

  1. 스크립트 실행(첫 태스크). setTimeout 의 콜백은 0ms 뒤 태스크 큐에 들어간다.
  2. Promise.resolve().then(…) 은 이미 이행된 Promise 라서 콜백이 바로 마이크로태스크 큐에 들어간다.
  3. queueMicrotask 도 마이크로태스크 큐에 추가한다.
  4. 동기 코드 console.log 가 실행된다.
  5. 스크립트가 끝나 콜 스택이 비었다. 체크포인트가 시작되어 가장 오래된 then 콜백을 실행한다.
  6. then 콜백이 queueMicrotask 로 nested 를 추가하고 끝난다. 큐에는 2개가 남았다.
  7. 큐가 비지 않았으므로 체크포인트가 계속된다. queueMicrotask 콜백을 실행한다.
  8. 방금 추가된 nested 도 같은 체크포인트에서 실행된다.
  9. 마이크로태스크 큐가 비었다. 다음 반복에서 태스크 큐의 timeout 을 실행한다.

3.3 콜백이 끝날 때마다 체크포인트가 온다

체크포인트는 태스크 단위만이 아니라 콜백이 끝나 실행 컨텍스트 스택이 빌 때도 수행된다. 버튼에 리스너 두 개가 있고, 각 리스너가 마이크로태스크를 하나씩 추가하는 경우다.

button.addEventListener("click", () => { log("L1"); queueMicrotask(() => log("M1")); });
button.addEventListener("click", () => { log("L2"); queueMicrotask(() => log("M2")); });
클릭 방식 출력 이유
사용자가 직접 클릭 L1 → M1 → L2 → M2 리스너 하나가 끝날 때마다 스택이 비어 체크포인트가 온다
코드에서 button.click() L1 → L2 → M1 → M2 click() 을 호출한 코드가 스택에 남아 있어 리스너 사이에 체크포인트가 오지 않는다

4.1절의 두 번째 예제에서 두 경우를 직접 비교할 수 있다.


4. 예제

4.1 기본

첫 번째 예제는 1장 코드에 async 함수를 더한 것이다. async 함수 본문은 첫 await 까지 동기로 실행되고, await 다음 코드는 마이크로태스크로 이어진다. 실행 전에 출력 순서를 예측해 본다.

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

Promise.resolve().then(() => {
  console.log("then");
  queueMicrotask(() => console.log("nested"));
});

queueMicrotask(() => console.log("queueMicrotask"));

(async () => {
  console.log("async 본문");
  await null;
  console.log("await 다음");
})();

console.log("sync");

Node 22 에서 실행한 결과는 async 본문 → sync → then → queueMicrotask → await 다음 → nested → timeout 이다. await 다음 은 then 과 queueMicrotask 보다 늦게 큐에 들어갔지만 nested 보다는 먼저 들어갔다.

두 번째 예제는 3.3절의 표를 브라우저에서 확인한다. 「직접 클릭」 버튼을 마우스로 누른 결과와 「click() 호출」 버튼의 결과를 비교한다.

<button id="target">직접 클릭</button>
<button id="programmatic">click() 호출</button>
<pre id="out"></pre>
<script>
  const out = document.getElementById("out");
  const target = document.getElementById("target");
  const lines = [];
  const log = (s) => { lines.push(s); out.textContent = lines.join(" → "); };

  target.addEventListener("click", () => { log("L1"); queueMicrotask(() => log("M1")); });
  target.addEventListener("click", () => { log("L2"); queueMicrotask(() => log("M2")); });

  document.getElementById("programmatic").addEventListener("click", (e) => {
    e.stopPropagation();
    lines.length = 0;
    target.click();
  });
  target.addEventListener("pointerdown", () => { lines.length = 0; });
</script>

4.2 실무 패턴

같은 태스크 안에서 여러 번 일어난 변경을 모아 한 번만 처리할 때 마이크로태스크를 쓴다. 현재 코드가 끝난 직후, 렌더링 전에 실행되므로 중간 상태가 화면에 그려지지 않는다.

let pending = false;
const changed = new Set();

function markChanged(key) {
  changed.add(key);
  if (pending) return;
  pending = true;
  queueMicrotask(() => {
    pending = false;
    flush([...changed]);   // 이번 태스크에서 바뀐 키를 한 번에 처리
    changed.clear();
  });
}

markChanged("a");
markChanged("b");   // flush 는 한 번만, ["a", "b"] 로 호출된다

UI 프레임워크가 상태 변경을 모아 한 번에 렌더링하는 것도 이 방식이다.


5. 자주 하는 실수


6. 정리

마이크로태스크는 콜 스택이 비는 즉시, 다음 태스크와 렌더링보다 먼저 실행된다. 이벤트 루프는 태스크 하나마다 체크포인트를 수행하고, 체크포인트는 실행 중 추가된 것까지 큐가 빌 때까지 실행한다.

참고

  1. [1] JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기 — MDN Web Docs
  2. [2] Perform a microtask checkpoint — HTML Standard (WHATWG)
  3. [3] Event loop processing model — HTML Standard (WHATWG)
  4. [4] HostEnqueuePromiseJob — ECMAScript 사양

관련 글