이벤트 루프 한 턴 — 마이크로태스크가 setTimeout(0) 보다 항상 먼저다

마이크로태스크 큐는 매 턴 끝까지 비워진다 — 그래서 then·queueMicrotask는 항상 setTimeout(0)보다 먼저다.1

한 턴은 ① task queue 에서 가장 오래된 매크로태스크 1개 실행 → ② microtask checkpoint, 큐가 빌 때까지 전부 드레인(드레인 중 추가된 것도 같은 체크포인트에서 처리) → ③ 필요하면 렌더링 → 다음 턴의 ① 로 돌아간다. 매크로는 드레인 도중 끼어들 수 없어 then·queueMicrotask 는 항상 setTimeout(0) 보다 먼저다. 예: setTimeout(timeout), then(then 안에서 queueMicrotask(nested)), 동기 log(sync) 를 실행하면 순서는 sync → then → nested → timeout 이다 — 스크립트 자체가 첫 매크로이고, then 과 그 안에서 추가된 nested 가 같은 드레인에서 돌고, timeout 은 다음 턴의 매크로다. 중첩이 끝없으면 렌더링과 다음 매크로가 굶는다.

한 스텝씩 보려면:


Footnotes

  1. In depth: Microtasks and the JavaScript runtime environment — MDN: “execution of microtasks continues until the queue is empty—even if new ones are scheduled in the interim. In other words, microtasks can enqueue new microtasks and those new microtasks will execute before the next task begins to run” ↩

#559

location.href 는 암묵적 await 가 아니다 — setter 는 동기, navigation 은 태스크

location.href에 값을 넣으면 그 줄에서 멈추고 이동할 것 같다 — 암묵적 await 처럼. 그럼 뒤의 코드는 안 돌까. 추측하지 말고 CDP로 깨 본다.

1. 실험을 건다

외부 도메인 대신 로컬 서버의 /a·/b로 이동시킨다(서버는 받은 요청을 기록한다). Runtime·Page·Network를 enable 하고, 마지막 식의 값까지 돌려받게 한 줄을 붙여 Runtime.evaluate로 실행한다.

location.href = '/a'
console.log('실행됨 1')
location.href = '/b'
console.log('실행됨 2')
// 'evaluate 끝까지 돌았다'

await였다면 첫 줄에서 멈춰 실행됨 1도 안 찍히고 /a로 가야 한다.

2. 이벤트 순서를 읽는다

CDP가 받은 순서 그대로다.1

ms 이벤트
514 Page.frameRequestedNavigation /a · scriptInitiated
516 Runtime.consoleAPICalled 실행됨 1
517 Page.frameRequestedNavigation /b
517 Runtime.consoleAPICalled 실행됨 2
517 Runtime.evaluate 응답 'evaluate 끝까지 돌았다'
518 Page.frameStartedNavigating /a
520 Network.requestWillBeSent /a
521 Network.loadingFailed net::ERR_ABORTED · canceled: true
523 Page.frameStartedNavigating · requestWillBeSent /b
528 Page.frameNavigated /b
  • setter는 동기로 끝나고 흐름을 안 끊는다. 이동 요청(frameRequestedNavigation)이 console 로그 사이사이에 찍히고, evaluate가 마지막 식의 값을 돌려준다 — 코드는 끝까지 돌았다.
  • 실제 이동은 그 뒤에 시작된다. frameStartedNavigating은 evaluate가 끝난 다음이다. 문서 언로드·페치는 태스크로 넘어가 지금 코드가 끝난 뒤에 돈다.
  • 마지막 값이 이기는 건 앞의 것이 취소되기 때문이다. /a가 시작됐다가 /b가 시작되며 ERR_ABORTED로 끊긴다.2 덮어쓴 게 아니다.
JS(콜 스택): href = '/a' → 실행됨 1 → href = '/b' → 실행됨 2 → evaluate 반환까지 끊기지 않고 돈다. href 를 넣을 때는 이동 요청만 등록된다. await 였다면 첫 href 에서 멈췄어야 한다. 브라우저 이동은 evaluate 가 끝난 517ms 뒤에 시작된다: /a 가 시작되어 요청까지 나갔다가 /b 가 시작되며 ERR_ABORTED 로 취소되고, /b 가 요청되어 528ms 에 frameNavigated 로 도착한다. 서버는 /a 와 /b 를 둘 다 받았다. 마지막 값이 이기는 건 덮어써서가 아니라 앞의 이동이 시작됐다가 취소되기 때문이다. 눈금은 비례가 아니다.

3. 덤 — 버려진 이동도 서버에는 갔다

서버 기록은 ["/a","/b"]다. /a는 requestWillBeSent까지 갔다가 클라이언트에서 끊겼을 뿐, 요청은 서버에 도착했다. 이동 전에 서버 부수효과가 있는 URL(로그아웃 링크 같은)이라면 “결국 /b로 갔으니 /a는 없던 일”이 아니다 — 572의 “취소는 클라이언트에서만 일어난다”와 같은 이야기다.

Footnotes

  1. Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). ms 는 스크립트 시작 기준이고, evaluate 를 보낸 시각은 512ms 다. 로컬 Node 서버가 문서 요청 경로를 기록했다. ↩

  2. “Set the ongoing navigation for navigable to navigationId. This will have the effect of aborting other ongoing navigations of navigable, since at certain points during navigation changes to the ongoing navigation will cause further work to be abandoned.” — 그리고 문서 언로드·페치는 in parallel 로 넘어간다. (HTML Standard — navigate) ↩

#569

Node 에서 재귀 크롤을 이벤트 루프에 맡기기

종료 감지를 큐의 onIdle() 에1, 중복 제거의 원자성을 단일 스레드에2 맡기면 in-flight 카운터와 락이 사라진다.

Node 로 링크를 따라가며 페이지를 재귀 크롤할 때 꺼낸다. 처리 결과(새 링크)가 다시 입력으로 돌아가서 일이 언제 바닥날지 미리 모르는 경우다.

import PQueue from 'p-queue'

const queue = new PQueue({ concurrency: 10 }) // 동시 요청 수만 제한한다. add 는 막히지 않는다(무제한 대기열)
const seen = new Set()

function enqueue(url) {
  if (seen.has(url)) return
  seen.add(url) // has 와 add 사이에 await 가 없으니 다른 코드가 끼어들 수 없다 — 락이 필요 없다
  queue.add(() => visit(url)) // 요청보다 먼저 표시한다. 요청 뒤에 표시하면 그 사이 같은 URL 이 또 들어온다
}

async function visit(url) {
  const res = await fetch(url) // HEAD 가 아니라 GET — 링크를 더 찾으려면 본문이 있어야 한다
  const html = await res.text()
  for (const link of extractLinks(html, url)) enqueue(link) // 이 태스크가 끝나기 전에 자식을 넣는다
}

enqueue(startUrl)
await queue.onIdle() // 대기열이 비고 실행 중인 태스크도 0 일 때 resolve

enqueue 는 has → add → queue.add 를 await 없이 한 턴에 끝내 락이 필요 없다. 큐는 무제한 대기열과 동시성 상한이 걸린 실행 중으로 나뉜다. visit 은 GET 으로 본문을 받아 링크를 뽑고 태스크가 끝나기 전에 enqueue 로 돌려보낸다. onIdle 은 대기열과 실행 중이 둘 다 0 일 때 resolve 된다. 자식을 .then·setTimeout 으로 태스크 밖에서 넣으면 부모가 먼저 끝나 onIdle 이 일이 남았는데 resolve 된다.

extractLinks 는 HTML 에서 같은 사이트 링크를 뽑는 함수(자리표시자). 서로 가리키는 페이지가 있어도 onIdle() 이 resolve 되고 seen.size 가 고유 URL 수와 같으면 된 것이다.


Footnotes

  1. p-queue README: “Returns a promise that resolves when the queue is empty and all tasks have completed” — queue.size === 0 && queue.pending === 0. ↩

  2. JavaScript execution model — MDN: “whenever a function runs, it cannot be preempted and will run entirely before any other code runs” ↩

#597