본문으로 건너뛰기
개발 머꼬
개발 노트JavaScript
hohyeon.dev14

setTimeout(fn, 0)이 Promise.then보다 늦게 실행된 이유

  • #Async
  • #Engineering Note
  • #JavaScript

문제 발생

두 콜백의 실행 순서가 예상과 달랐습니다. setTimeout(..., 0)이 먼저일 거라고 생각했는데 아니었습니다.

console.log("1");
setTimeout(() => console.log("2 (timeout)"), 0);
Promise.resolve().then(() => console.log("3 (promise)"));
console.log("4");

// 1, 4, 3 (promise), 2 (timeout)

원인 분석

이벤트 루프에는 성격이 다른 두 개의 큐가 있습니다.

태스크 큐setTimeout, setInterval, 이벤트 리스너, 최초 스크립트 실행이 들어갑니다. 이벤트 루프는 한 번에 하나의 태스크를 처리합니다.

마이크로태스크 큐 — 프로미스 콜백(.then/.catch), queueMicrotask(), MutationObserver가 들어갑니다. 이 큐는 현재 태스크가 끝난 직후, 다음 태스크가 시작되기 전에 비워집니다. 하나만 처리하는 게 아니라 큐가 빌 때까지 전부 실행합니다.

그래서 순서는 이렇게 됩니다. 스크립트 전체가 하나의 태스크이므로 14가 먼저 나오고, 그 태스크가 끝나면 마이크로태스크가 전부 실행되어 3이 나오고, 그 다음 태스크 차례에 2가 나옵니다. setTimeout의 0ms는 "즉시"가 아니라 "가능한 한 빨리 태스크로 예약"입니다.

주의할 위험이 하나 있습니다. 마이크로태스크가 또 마이크로태스크를 큐에 넣을 수 있고, 이벤트 루프는 큐가 빌 때까지 계속 처리합니다. 재귀적으로 넣으면 다음 태스크로 영영 넘어가지 못해 페이지가 멈춥니다. MDN이 명시적으로 경고하는 부분입니다.

해결 방안

  1. 순서에 의존하는 코드를 만들지 않습니다. 위 규칙을 아는 것은 디버깅에 필요하지만, 실행 순서를 전제로 로직을 짜면 리팩터링 한 번에 깨집니다. 순서가 중요하면 await으로 명시합니다.
  2. DOM 변경 직후 측정해야 할 때 차이가 드러납니다. 마이크로태스크는 브라우저가 화면을 다시 그리기 전에 실행되므로, 레이아웃을 읽어야 한다면 프레임 단위인 requestAnimationFrame이 맞습니다.
element.classList.add("open");
requestAnimationFrame(() => {
  const height = element.getBoundingClientRect().height;   // 그려진 뒤
});
  1. 긴 작업을 쪼갤 때는 태스크로 넘깁니다. 마이크로태스크로 쪼개면 큐를 비우느라 렌더가 계속 막혀서 오히려 더 멈춘 것처럼 보입니다.
async function processAll(items) {
  for (const chunk of chunks(items, 200)) {
    process(chunk);
    await new Promise((r) => setTimeout(r, 0));   // 브라우저에 차례를 넘긴다
  }
}
  1. queueMicrotask는 "이번 태스크가 끝나자마자, 렌더 전에"가 필요할 때만 씁니다. 대부분은 프로미스로 충분합니다.
  2. Node에도 비슷한 구분이 있습니다. process.nextTick은 마이크로태스크보다도 먼저 처리되는 별도 큐라, 브라우저 모델을 그대로 옮기면 어긋납니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.