Promise.then은 항상 setTimeout보다 먼저 실행된다
큐는 하나가 아니라 최소 두 개다
지금까지는 "태스크 큐"라는 단어를 하나만 썼지만, 실제로는 우선순위가 다른 큐가 최소 두 개 있습니다.
- 태스크 큐(매크로태스크 큐):
setTimeout,setInterval, DOM 이벤트,fetch의 네트워크 완료 알림 등 - 마이크로태스크 큐:
Promise.then/catch/finally,queueMicrotask,MutationObserver
두 큐의 우선순위 규칙은 이렇습니다. 콜 스택이 비면, 이벤트 루프는 매크로태스크를 하나 꺼내기 전에 마이크로태스크 큐를 완전히 비운다. "완전히"라는 표현이 핵심입니다. 마이크로태스크 하나를 실행하는 도중에 새로운 마이크로태스크가 또 큐에 들어오면, 그것도 이번 라운드 안에 처리합니다. 매크로태스크는 한 번에 딱 하나만 꺼내는 것과 대조적입니다.
순서를 직접 추적해 보기
이론만으로는 잘 와닿지 않으니 코드로 확인합니다.
console.log("1: 동기 코드");
setTimeout(() => console.log("2: 매크로태스크 (setTimeout)"), 0);
Promise.resolve().then(() => console.log("3: 마이크로태스크 (Promise)"));
queueMicrotask(() => console.log("4: 마이크로태스크 (queueMicrotask)"));
console.log("5: 동기 코드");
// 실제 출력 순서: 1, 5, 3, 4, 2동기 코드(1, 5)가 가장 먼저 끝나는 건 당연합니다. 그다음이 중요합니다 — setTimeout 이 코드 순서상 더 먼저 등록됐는데도 Promise.then 과 queueMicrotask 보다 나중에 실행됩니다. 콜 스택이 비자마자 이벤트 루프는 "매크로태스크 큐에 뭐가 있지?"를 보기 전에 "마이크로태스크 큐부터 싹 비우자"를 먼저 합니다. 이 순서를 헷갈리면 "분명 setTimeout 을 먼저 걸었는데 Promise 콜백이 먼저 찍힌다"는 디버깅 미스터리에 빠지게 됩니다. 실제로는 미스터리가 아니라 스펙대로 동작한 겁니다.
마이크로태스크는 서로를 계속 낳을 수 있다
더 헷갈리는 경우는 마이크로태스크 안에서 또 마이크로태스크를 만드는 상황입니다.
Promise.resolve()
.then(() => {
console.log("A");
return Promise.resolve();
})
.then(() => console.log("B"));
Promise.resolve().then(() => console.log("C"));
// 출력: A, C, B"A"가 실행되면서 새로운 .then 체인이 마이크로태스크 큐 맨 뒤에 추가됩니다. 이 시점에 큐에는 이미 "C"가 대기하고 있었으므로, "B"보다 "C"가 먼저 나갑니다. 마이크로태스크 큐는 FIFO(선입선출)이고, 실행 중에 추가된 항목도 같은 라운드의 큐 끝에 붙어서 처리됩니다 — 다음 매크로태스크로 넘어가기 전까지는요.
이 흐름을 그림으로 정리하면 이렇습니다.
flowchart TD A["콜 스택이 비었는가?"] -->|아니오| A A -->|예| B["마이크로태스크 큐에 항목이 있는가?"] B -->|예| C["마이크로태스크 하나 실행 (실행 중 추가된 것도 이번 라운드에 포함)"] C --> B B -->|아니오, 큐가 비었다| D["매크로태스크 큐에서 딱 하나만 꺼내 실행"] D --> A
여기서 위험 신호 하나를 미리 짚어 둡니다. 마이크로태스크가 자기 자신을 계속 다시 예약하면(.then 안에서 다시 Promise.resolve().then(...) 을 무한히 반복하는 식), 이 그림의 B → C 루프에서 절대 빠져나오지 못합니다. 매크로태스크 큐에 쌓인 setTimeout, 클릭 이벤트, 렌더링 요청 전부가 무한히 뒤로 밀립니다. 화면이 완전히 멈춘 것처럼 보이는데 콜 스택 자체는 비어 있다 채워졌다를 반복하고 있어서 "무한 루프"라기보다는 "무한 마이크로태스크"라고 부르는 게 더 정확합니다. 이 사고는 8장에서 실제 재현 코드와 함께 다시 다룹니다.
왜 마이크로태스크에 더 높은 우선순위를 줬을까
이건 설계 의도를 짐작해 볼 만한 대목입니다. Promise 는 "이 값이 준비되면 바로 다음 계산을 이어간다"는 체이닝을 표현하기 위한 도구입니다. 만약 .then 콜백이 다른 매크로태스크(타이머, 이벤트)들과 동등한 우선순위로 줄을 선다면, Promise.resolve(a).then(b).then(c) 처럼 짧게 이어지는 체인 사이에 사용자 클릭이나 타이머가 끼어들 수 있습니다. 그러면 Promise 체인이 "하나의 논리적 흐름"이라는 보장이 깨집니다. 마이크로태스크를 매크로태스크보다 먼저, 그리고 몰아서 처리하게 만든 건 Promise 체인의 각 단계가 최대한 끊기지 않고 이어지도록 하기 위한 설계로 보입니다.