setTimeout(fn, 0)이 Promise.then보다 늦게 실행된 이유
문제 발생
두 콜백의 실행 순서가 예상과 달랐습니다. 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가 들어갑니다. 이 큐는 현재 태스크가 끝난 직후, 다음 태스크가 시작되기 전에 비워집니다. 하나만 처리하는 게 아니라 큐가 빌 때까지 전부 실행합니다.
그래서 순서는 이렇게 됩니다. 스크립트 전체가 하나의 태스크이므로 1과 4가 먼저 나오고, 그 태스크가 끝나면 마이크로태스크가 전부 실행되어 3이 나오고, 그 다음 태스크 차례에 2가 나옵니다. setTimeout의 0ms는 "즉시"가 아니라 "가능한 한 빨리 태스크로 예약"입니다.
주의할 위험이 하나 있습니다. 마이크로태스크가 또 마이크로태스크를 큐에 넣을 수 있고, 이벤트 루프는 큐가 빌 때까지 계속 처리합니다. 재귀적으로 넣으면 다음 태스크로 영영 넘어가지 못해 페이지가 멈춥니다. MDN이 명시적으로 경고하는 부분입니다.
해결 방안
- 순서에 의존하는 코드를 만들지 않습니다. 위 규칙을 아는 것은 디버깅에 필요하지만, 실행 순서를 전제로 로직을 짜면 리팩터링 한 번에 깨집니다. 순서가 중요하면
await으로 명시합니다. - DOM 변경 직후 측정해야 할 때 차이가 드러납니다. 마이크로태스크는 브라우저가 화면을 다시 그리기 전에 실행되므로, 레이아웃을 읽어야 한다면 프레임 단위인
requestAnimationFrame이 맞습니다.
element.classList.add("open");
requestAnimationFrame(() => {
const height = element.getBoundingClientRect().height; // 그려진 뒤
});- 긴 작업을 쪼갤 때는 태스크로 넘깁니다. 마이크로태스크로 쪼개면 큐를 비우느라 렌더가 계속 막혀서 오히려 더 멈춘 것처럼 보입니다.
async function processAll(items) {
for (const chunk of chunks(items, 200)) {
process(chunk);
await new Promise((r) => setTimeout(r, 0)); // 브라우저에 차례를 넘긴다
}
}queueMicrotask는 "이번 태스크가 끝나자마자, 렌더 전에"가 필요할 때만 씁니다. 대부분은 프로미스로 충분합니다.- Node에도 비슷한 구분이 있습니다.
process.nextTick은 마이크로태스크보다도 먼저 처리되는 별도 큐라, 브라우저 모델을 그대로 옮기면 어긋납니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.