이벤트 루프에서 마이크로태스크가 매크로태스크보다 먼저 실행된다는 건 알겠는데, 왜 그렇게 설계된 건지 궁금해서 파봤다.
MacroTask Queue vs MicroTask Queue
이벤트 루프에는 큐가 두 개 있다.
| MacroTask Queue | MicroTask Queue |
|---|---|
setTimeout |
Promise.then() |
setInterval |
queueMicrotask() |
| DOM 이벤트 핸들러 | MutationObserver |
간단한 예시로 확인해보면:
console.log("start");
setTimeout(() => {
console.log("a: setTimeout");
});
Promise.resolve().then(() => {
console.log("i: promise.then");
});
console.log("end");
출력 순서:
start
end
i: promise.then
a: setTimeout
Promise가 마이크로태스크 큐에 들어가기 때문에 setTimeout보다 먼저 실행된다. 이벤트 루프가 콜 스택이 비었을 때 마이크로태스크 큐를 먼저 확인하고, 전부 비워진 다음에야 매크로태스크 큐로 넘어간다.
근데 왜 이렇게 설계된 걸까?
Promise 체이닝을 생각해보면 이해가 된다.
Promise.resolve()
.then(() => "A")
.then((val) => {
console.log(val);
return "B";
})
.then(console.log);
.then()으로 체이닝한 콜백들은 연속된 작업이다. A를 만들고, A를 출력하고, B를 만들고, B를 출력하는 일련의 흐름.
근데 여기서 중간에 setTimeout이 끼어든다면? 작업의 연속성이 무너지는 상황이 된다. A 출력하고 갑자기 전혀 관계없는 타이머 콜백이 실행되고, 그 다음에야 B가 출력되는 거다.
그래서 마이크로태스크 큐를 우선적으로 전부 비운 다음에 매크로태스크를 처리하는 것.
핵심: 마이크로태스크 우선 = 작업의 연속성 보장
async/await + setTimeout 복합 예제
좀 더 복잡한 상황을 보자.
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function run() {
Promise.resolve()
.then(async () => {
console.log("then 1");
await delay(2000);
})
.then(() => {
console.log("then 2");
});
setTimeout(() => {
console.log("setTimeout 1");
}, 1000);
}
이거 실행하면 어떻게 될까?
출력 순서: then 1 → setTimeout 1 → then 2
흐름을 따라가보면:
- 첫 번째
.then()은 마이크로태스크로 즉시 실행됨 →"then 1"출력 await delay(2000)안에서setTimeout(resolve, 2000)이 Web API에 등록됨- 1초 뒤, 1000ms짜리 setTimeout이 매크로태스크 큐를 통해 실행 →
"setTimeout 1"출력 - 2초 뒤, delay의 Promise가 resolve됨 → await 이후 코드가 실행되고, 다음
.then()이 마이크로태스크로 등록 →"then 2"출력
여기서 포인트는 await이 내부적으로 .then()으로 변환된다는 것. await 이후의 코드는 Promise가 resolve된 시점에 마이크로태스크로 등록된다. 그래서 then 2가 마이크로태스크임에도 불구하고 setTimeout 1보다 늦게 실행되는 거다 — delay(2000)이 아직 resolve 안 됐으니까.
마이크로태스크가 무조건 먼저가 아니라, 큐에 들어간 시점이 중요한 것.
MutationObserver도 마이크로태스크다
DOM에 변경이 발생했을 때 이를 감시하는 MutationObserver API도 마이크로태스크 큐에서 실행된다.
const observer = new MutationObserver((mutations) => {
console.log("DOM changed: ", mutations);
});
observer.observe(targetNode, { childList: true });
btn.addEventListener("click", () => {
const newEl = document.createElement("p");
newEl.textContent = "New element added";
targetNode.appendChild(newEl);
setTimeout(() => {
console.log("setTimeout");
}, 0);
});
버튼을 누르면:
DOM changed: [MutationRecord]
setTimeout
MutationObserver 콜백이 마이크로태스크이기 때문에 setTimeout(fn, 0)보다 먼저 실행된다. 코드에서 setTimeout을 appendChild 앞에 놓든 뒤에 놓든 결과는 동일하다. 코드 순서가 아니라 큐의 우선순위가 실행 순서를 결정하니까.
정리
- 마이크로태스크가 매크로태스크보다 먼저인 이유는 작업의 연속성 보장 때문
- Promise 체이닝 사이에 setTimeout이 끼어들면 흐름이 깨지니까, 마이크로태스크를 전부 비운 다음에 매크로태스크를 처리하는 것
await은 내부적으로.then()이라 resolve 시점에 마이크로태스크로 등록됨 — 마이크로태스크라고 무조건 먼저가 아니라 큐에 들어간 시점이 중요MutationObserver도 마이크로태스크 큐에서 실행됨
'코딩딩 > Javascript' 카테고리의 다른 글
| 날것 - 제어문 (Control Flow Statements) (0) | 2026.03.25 |
|---|---|
| 날것 - 연산자 (0) | 2026.03.18 |
| 날것 - 데이터 타입 (0) | 2026.03.11 |
| 날것 - 표현식과 문 (0) | 2026.03.11 |
| JavaScript 변수의 동작 원리: 메모리, 호이스팅, var/let/const (0) | 2026.03.08 |