BestOW's Blog

이벤트 루프 다시 보기 — 태스크와 마이크로태스크

setTimeout과 Promise의 실행 순서는 왜 다를까. 마이크로태스크 큐의 동작을 정확히 이해하면 비동기 버그의 8할이 보입니다.

3분 읽기

“자바스크립트는 싱글 스레드”라는 말은 6년차라면 수백 번 들었을 겁니다. 하지만 마이크로태스크 큐를 정확히 설명할 수 있는 사람은 의외로 적습니다. 이걸 알면 await 순서, React 상태 배칭, 이벤트 핸들러 타이밍이 전부 한 그림으로 보입니다.

두 개의 큐

이벤트 루프는 한 번의 “틱”마다 이렇게 움직입니다.

  1. 태스크(매크로태스크) 하나를 꺼내 실행 (콜 스택이 빌 때까지)
  2. 그 후 마이크로태스크 큐를 전부 비운다 (비우는 도중 새로 추가된 마이크로태스크도 포함)
  3. 필요하면 렌더링
  4. 다음 태스크로
  • 태스크: setTimeout/setInterval, I/O, 사용자 이벤트, MessageChannel
  • 마이크로태스크: Promise.then/catch/finally, await 이후, queueMicrotask, MutationObserver

핵심은 **“한 태스크가 끝나면 다음 태스크로 넘어가기 전에 마이크로태스크를 몽땅 비운다”**는 것입니다.

순서를 맞혀 보기

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 출력: 1 → 4 → 3 → 2

setTimeout(…, 0)이 0ms인데도 3보다 늦는 이유 — 2다음 태스크로 예약되고, 3현재 태스크가 끝난 직후 마이크로태스크로 먼저 처리되기 때문입니다.

async/await는 마이크로태스크다

await는 문법 설탕일 뿐, 뒤따르는 코드는 .then 콜백과 동일하게 마이크로태스크로 스케줄됩니다.

async function run() {
  console.log('a');
  await null; // 여기서 함수가 '일시정지' → 나머지는 마이크로태스크로
  console.log('b');
}
run();
console.log('c');
// 출력: a → c → b

실무에서 물리는 지점

  • 마이크로태스크 기아(starvation): .then 안에서 계속 새 Promise를 만들며 재귀하면 마이크로태스크 큐가 안 비워져 렌더링·이벤트가 멈춥니다. 무한 루프인데 브라우저가 “먹통”이 되는 전형적 패턴.
  • 렌더링 타이밍: DOM을 바꾼 직후 크기를 읽어야 한다면 마이크로태스크(await)로는 부족할 수 있습니다. 다음 프레임이 필요하면 requestAnimationFrame.
  • Node.js 차이: Node엔 process.nextTick이 있고, 이건 Promise 마이크로태스크보다도 먼저 처리됩니다. 브라우저엔 없는 개념이라 크로스 런타임 코드에서 주의.

규칙 하나만 기억하세요. “동기 코드 전부 → 마이크로태스크 전부 → 태스크 하나 → 다시 마이크로태스크 전부…” 이 리듬이 비동기 순서의 전부입니다.

댓글

← 목록으로