로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS

스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊긴다. 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다. 어느 끝인지는 응답 시각을 붙잡고 보인 시간을 재서 가른다 — DOM에 붙어 있던 시간이 아니다.

요청 뒤 CSS 지연이 지나야 스피너가 뜬다. 앞쪽: 응답이 지연보다 먼저 오면 스피너는 한 프레임도 안 뜬다 — 지연이 통째로 건너뛰게 한다. 뒤쪽, CSS 만: 지연 뒤에 뜬 스피너가 곧바로 온 응답에 끊겨 짧게 번쩍하고 하드컷된다. 뒤쪽, useMinimumLoading: 응답이 와도 unmount 를 밀어 최소 노출 시간만큼은 보이게 한다. CSS 는 시작(지연)만 정하고, 끝은 응답 시각이 정하는데 CSS 는 그 값을 모른다.

흐름

Claude Code 의 node 스크립트가 Chrome(CDP)에 Fetch.enable 로 */api 를 가로채게 하고, 경계값을 볼 때는 Tracing.start 로 스크린샷도 켠다. hold 를 100·350·700 으로 바꿔 가며 반복한다: 프로브 evaluate 를 기다리지 않고 보낸 뒤 console.timeStamp 와 클릭을 보낸다. 페이지에 스피너가 붙고 fetch /api 가 나가면 Chrome 이 Fetch.requestPaused 를 보내고, 스크립트는 holdMs 를 기다린 뒤 Fetch.continueRequest 로 놓아준다. 요청이 서버에 가고 응답이 오면 스피너가 제거되고 프로브가 firstVisibleAt·visibleFrames·visibleMs 를 JSON 으로 돌려준다. 끝나면 Tracing.end 로 Screenshot 이벤트를 받는다 — 처음 물든 장부터 다음 흰 장까지가 실제로 보인 시간이고, 프로브는 1프레임쯤 더 센다.

  1. headless Chrome + Node 전역 WebSocket으로 raw CDP. MCP emulate는 프리셋 스로틀링뿐이라 요청 하나를 못 붙잡는다.
  2. 프로브 — 스피너가 사라질 때 보인 시간을 돌려준다.
  3. 경계값이면 disabled-by-default-devtools.screenshot trace로 확인한다. 스크린샷은 바뀐 프레임에만 찍히니 처음 물든 장 → 다음 흰 장이 보인 시간이다.
  4. 응답 < 지연인데 보였으면 앞쪽, 응답 > 지연인데 최소 노출보다 짧으면 뒤쪽.
지연 응답 최소 노출 프로브 trace 판정
0 100 — 9프레임 · 133ms 앞쪽
300 100 — 0 물든 장 없음 앞쪽 억제됨
300 350 — 4프레임 · 66ms 약 48ms, 불투명도 최대 약 0.8 뒤쪽
300 350 500 515ms 뒤쪽 잡힘
300 100 500 0 앞쪽 억제 유지

실측 조건은 각주에 있다.1 셋째 줄이 뒤쪽 깜빡임의 실제 모습이다 — 페이드가 끝나기 전에 끊겨 한 번도 제대로 진해지지 못하고 사라진다.

처방

  • 앞쪽 — animation-delay/transition-delay. 응답이 지연보다 빠르면 스피너를 통째로 건너뛴다. @starting-style 단독(지연 0)으로는 못 막는다.
  • 뒤쪽 — CSS는 시작만 통제하고 끝은 CSS가 모르는 응답 시각이 정하니, 사라지는 시각을 JS에서 민다(useMinimumLoading). 최소 노출은 처음 보인 시각부터 잰다: 사라지는 시각 = max(응답 시각, firstVisibleAt + 최소 노출). 요청 시점부터 재면 표 마지막 줄에서 안 떠도 될 스피너를 붙잡는다.

밟은 것

  • spawn한 프로세스만 죽이면 Chrome 자식이 남아 다음 실행이 옛 페이지에 붙는다 → detached: true + process.kill(-pid).
  • 옛 페이지에 붙은 채 Runtime.enable하면 이전 console 메시지가 다시 온다.
  • 포트가 뜨기 전 /json은 ECONNREFUSED → 폴링.
  • 첫 측정은 느리다(hold 100에 응답 149ms) → 첫 줄은 다시 잰다.

곁가지 — @starting-style은 같은 뿌리의 반대편이다. transition은 두 상태 사이를 보간하는데, 진입에는 옮겨갈 이전 상태가 없고2 퇴장에는 요소 자체가 이미 사라진다. @starting-style은 앞쪽의 출발값을 대신 정해주고(진입 fade-in), useMinimumLoading은 뒤쪽의 unmount를 미룬다. 뿌리는 같아도 손대는 지점이 달라 별개 결정이다.

Footnotes

  1. Chrome 154 headless 에서 raw CDP 로 실측 (2026-09-30). /api 요청을 Fetch.requestPaused 로 100·350ms 붙잡고, 스피너(animation: fade 100ms <지연> both)의 opacity 를 매 requestAnimationFrame 마다 읽었다. 프로브를 먼저 걸고 버튼 클릭으로 스피너를 띄운 실행에서도 hold 100 → 0프레임, hold 350 → 2프레임(34ms)으로 같았다. 프로브 대 trace 비교는 스피너를 400px 빨간 상자로 두고 Tracing 의 Screenshot 이벤트를 받아, JPEG 를 sips 로 BMP 로 바꿔 상자 안 한 점의 픽셀을 읽었다 — hold 350 에서 (255,199,200) → (255,109,110) → (255,51,52) → 흰색. ↩

  2. CSS transition은 요소의 첫 스타일 적용이나 display: none → 표시 전환에서는 기본적으로 발동하지 않는다. @starting-style이 “무엇에서 출발할지”를 정의해 그걸 가능하게 한다. (@starting-style — MDN) ↩

#573

요청 취소의 경계

HTTP 요청 취소는 클라이언트에서만 일어난다.1 이미 보낸 바이트를 회수하지도, 서버가 하고 있는 일을 멈추지도 못한다. 취소한 순간에 요청 전송이 이미 끝나 있었나. 끝나 있었다면 서버는 그걸 받았고, 받았으면 처리한다. 취소는 그 처리를 못 막는다.

구간 어디 여기서 취소하면
send 클라이언트 요청이 다 전송되지 않는다 — 헤더·본문 일부는 도착할 수 있다
in-flight 네트워크 이미 다 보냈다. 서버는 받는다
processing 서버 서버가 처리 중이고 멈출 방법이 없다
resp 네트워크 → 클라이언트 응답이 오는 중이다. 클라이언트가 안 읽을 뿐

구간 사이 경계는 셋인데 결과를 가르는 건 첫 번째 하나다 — send와 in-flight 사이, 전송이 끝나는 순간. 그 뒤로는 어디서 취소하든 서버 입장에선 똑같다.

요청은 send(클라이언트) → in-flight(네트워크) → processing(서버) → resp(응답) 네 구간을 지난다. 결과를 가르는 경계는 send 가 끝나는 순간 하나다. 빠른 네트워크에서 전송이 끝난 뒤 abort 하면 promise 는 reject 되지만 서버는 요청을 받아 처리하고, 클라이언트는 응답을 안 읽을 뿐이다. 같은 시각에 abort 해도 네트워크가 느려 전송이 그 시각을 넘겨 끝나면 전송이 끊겨 서버는 요청을 다 받지 못한다(헤더·본문 일부는 도착할 수 있다). 서버가 처리 중일 때 abort 해도 서버 입장에선 전송 뒤 abort 와 같다 — reject 이면서 서버는 처리한다.

클라이언트에서는 네 경우가 똑같이 보인다 — promise는 AbortError, CDP는 Network.loadingFailed(canceled: true, net::ERR_ABORTED). 응답 전에 끊기면 responseReceived도 안 와서 timing.sendEnd조차 없다. “서버가 받았나”의 정답은 서버 로그에만 있고, CDP는 취소를 원하는 구간에 정확히 일으키는 데 쓴다.2

구간 CDP로 일으키기 서버 로그
send 전 Fetch.enable({ patterns: [{ requestStage: 'Request' }] })로 보내기 전에 붙잡고 abort 없음
send 중 Network.emulateNetworkConditions의 uploadThroughput을 낮추고(200KB 본문, 20KB/s) abort 헤더 + 본문 16KB, 미완료, 핸들러 안 돎
processing 서버가 오래 걸리는 엔드포인트에서 abort 받음, 처리 완료
resp requestStage: 'Response'로 응답을 붙잡고 abort 받음, 처리 완료

send 중 줄이 경계를 한 번 더 쪼갠다. 전송 도중 취소하면 서버는 아무것도 못 받는 게 아니라 헤더와 본문 일부를 받는다. 본문이 끝나야(end) 처리하는 서버라 핸들러가 안 돌았을 뿐이다. 헤더만 보고 일을 시작하는 서버라면 전송 완료 전 취소도 부수효과를 낸다 — “경계는 전송 완료”는 본문까지 받고 처리하는 서버에서의 이야기다.

Footnotes

  1. Fetch 표준의 abort 알고리즘은 controller 상태를 aborted로 바꾸고 이유를 기록할 뿐, 서버로 무엇을 보내라고 정하지 않는다. 연결을 닫거나 응답 읽기를 멈추는 건 구현 재량이다. (Fetch Standard — abort a fetch) ↩

  2. Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). 로컬 Node 서버가 요청 수신·본문 바이트·end·응답 완료를 기록했고, 페이지는 AbortController 로 POST 를 취소했다. 네 경우 모두 promise AbortError, Network.loadingFailed canceled: true net::ERR_ABORTED 로 같았다. uploadThroughput 스로틀링은 localhost 요청에도 걸렸다. ↩

#572

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

목록 90개에서 매번 무작위로 하나를 뽑아 보여준다고 하자 (작업 중에 문구를 바꿔 띄우는 스피너 같은 것). 90개를 다 보려면 90번쯤이면 될까.

아니다. 평균 457번이다. 5배다. 1초에 하나씩이면 7분 반.

목록을 한 번 섞어서 처음부터 끝까지 도는 게 아니라 매번 90개 전체에서 다시 뽑기 때문이다. 그러면 이미 본 것이 계속 다시 나온다. 섞어 돌았다면 90번에 정확히 끝났을 일이다.

꼬리가 어디서 생기는지가 핵심이다. 89개를 모았을 때, 남은 마지막 하나가 뽑힐 확률은 90분의 1이다. 그 한 개를 만나는 데만 평균 90번 — 섞어서 돌았다면 90개 전부를 봤을 횟수다.1

이게 쿠폰 수집가 문제(coupon collector)다. 전부 모으는 데 걸리는 평균 횟수는

n × (1 + 1/2 + 1/3 + … + 1/n)

Footnotes

  1. 전부 모으는 기대 횟수는 n·H_n(H_n은 조화수). 그리고 i번째 새 쿠폰까지의 기대 대기는 n/(n-i+1) 이므로 마지막 한 장은 n/1 = n — 단계 중 가장 오래 걸린다. (Coupon collector’s problem — Wikipedia) ↩

#568

blocking=render 는 <body> 전에만 등록된다 — 뒤에 붙으면 조용한 no-op

새로고침하면 한 프레임 동안 스타일이 붙기 전 default 상태가 보인다. 파서를 멈추는 것과 paint를 멈추는 것은 다르다 — 동기 <script>는 파서만 세우고, 그 위에 이미 파싱된 청크가 먼저 그려질지는 브라우저 재량이다. blocking="render"가 paint까지 막는 걸 보장하는데, <body> 뒤에 붙으면 조용히 아무 일도 안 한다.

1. 이 축의 문제인가

증상 원인 처방
스타일이 붙기 전 default 상태가 한 프레임 보인다 스타일 적용이 첫 페인트보다 늦다 blocking="render" — 여기서 계속
다크 테마인데 흰 화면이 번쩍인다 서버가 사용자 상태를 몰라 기본값으로 렌더했다 첫 페인트 전 인라인 스크립트 → 594

뒤쪽은 렌더를 막아도 안 고쳐진다 — 서버가 낸 HTML 자체가 이미 다른 값이다.

2. 붙였는데 먹었는지 무엇으로 아나

로그도 DOM도 답을 안 준다. <body> 뒤에 붙여도 console·Log·Audits 어디에도 아무것도 안 찍히고, el.blocking.contains('render')는 앞이든 뒤든 true다.1 갈리는 건 첫 페인트 시각 하나다 — 막는 리소스가 끝나기 전에 칠해졌으면 안 먹은 것이다.

3. CDP로 잰다

Page.navigate → Page.loadEventFired를 기다린 뒤, 페이지의 Performance 엔트리를 읽어 둘을 비교한다.

const fcp = performance.getEntriesByName('first-contentful-paint')[0].startTime
const done = performance.getEntriesByType('resource').find((e) => e.name.includes('slow.js')).responseEnd
fcp < done // true 면 막는 리소스가 오기 전에 이미 칠했다 — 안 먹었다

800ms 걸리는 <script async blocking="render" src="/slow.js"> 위치만 바꿔 잰 값이다.1

위치 FCP slow.js 끝 판정
blocking 없음 68ms 830ms default 가 먼저 칠해짐
<head> 836ms 816ms 스크립트 뒤에 첫 페인트 — 먹었다
<body> 안 16ms 807ms default 가 먼저 칠해짐 — 없는 것과 같다

파서는 head 를 읽고 body 를 연다. body 가 null 인 동안만 render-blocking 등록이 가능하고, body 가 열리는 순간 창이 닫힌다. head 안의 script blocking=render 는 등록되고, body 안의 같은 스크립트는 등록되지 않는데 경고도 없다. 실측: head 안이면 816ms 에 스크립트가 끝난 뒤 836ms 에 styled 상태로 첫 페인트한다. body 안이면 16ms 에 default 가 먼저 칠해지고 807ms 에 스크립트가 끝나서야 styled 로 바뀐다 — 그 사이 default 가 노출된다. 로그와 DOM(el.blocking)으로는 둘이 똑같고 첫 페인트 시각만 갈린다. 눈금은 비례가 아니다.

4. <body>가 열리기 전에 둔다

컴포넌트 마크업 안이면 이미 늦다. 스펙은 “head 안에 둬라”가 아니라 등록 창을 이렇게 닫는다.

A Document document allows adding render-blocking elements if document’s content type is “text/html” and the body element of document is null.

— HTML Standard

<body>가 생기는 순간 등록이 닫히고, 뒤에 붙인 속성은 실패 없이 무시된다. 속성을 모르는 브라우저가 옛 동작으로 떨어지는 것과 같은 모양이라 3처럼 재지 않으면 드러나지 않는다.

Footnotes

  1. Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). 로컬 Node 서버가 /slow.js 를 800ms 늦게 주고, 스크립트는 <p> 텍스트를 바꾼다. Log·Runtime·Audits 를 enable 한 채 <head> / <body> 두 경우를 봤고 둘 다 메시지 0건이었다. ↩ ↩2

#567

데스크톱 없이 안드로이드 Chrome 디버깅 — 로컬 프록시로 Origin 검사 우회

데스크톱 없이 안드로이드 기기 한 대로 그 기기의 Chrome을 디버깅한다. 순서를 기억하려고 적어둔 것이다 — 각 단계의 방법이 아니라 밟는 차례가 내용이다.

폰 한 대(Termux) 안에서 순서는 셋이다. ① adb forward 가 Chrome 의 chrome_devtools_remote unix socket 을 localhost:9222 로 연다 — 9222 는 /json 으로 탭 목록을, /devtools/page/{id} 로 페이지별 CDP WebSocket 을 준다. ② 파이썬 aiohttp 프록시를 9223 에 띄우고 /json 으로 탭을 고른다. ③ termux-open-url 로 폰 브라우저에서 appspot.com 의 DevTools 프론트엔드를 ?ws=localhost:9223/{id} 로 연다. 프론트엔드가 9222 에 직접 붙으면 Origin: appspot.com 이 붙어 Chrome 이 403 으로 거부한다(Origin 검사, Chrome 111~). 프록시는 중계가 아니라 프론트엔드 WS 를 종단하고, 파이썬이 Origin 없는 새 WS 로 9222 에 재접속해 통과한다. ws://localhost 는 localhost 가 secure context 라 HTTPS 페이지에서도 안 막힌다. 다른 길 둘 — Chrome 을 –remote-allow-origins=* 로 띄우거나 프론트엔드를 localhost 에 self-host — 은 해보지 않았다.

막히는 건 Origin 검사 하나다.1

Footnotes

  1. Origin 검사(Chrome 111~)와 DNS rebinding을 막는 Host 헤더 검사(Chrome 66~)는 다른 방어다. 여기 걸리는 건 앞쪽 — WS 핸드셰이크에 --remote-allow-origins에 없는 Origin이 붙으면 거부된다. (Chromium issues) ↩

#565

Iterator Helpers의 지연 평가

조기 종료를 take에 위임하면 필요한 만큼만 fetch 한다. 페이지네이션 API에서 “조건에 맞는 N개”만 필요할 때. sync 는 네이티브(모던 브라우저·Node 22+), async 는 제안(Stage 2)이라 core-js 폴리필.

import 'core-js/proposals/async-iterator-helpers.js'

type Page<T> = { items: T[]; next: string | null }

/** 커서 페이지네이션 API 를 순회한다 — 현재 페이지를 다 당긴 뒤에야 다음 페이지를 받는다. */
async function* fetchPages<T>(url: string | null): AsyncGenerator<T> {
  while (url) {
    const { items, next }: Page<T> = await fetch(url).then((r) => r.json())
    yield* items
    url = next
  }
}

await AsyncIterator.from(fetchPages<Item>('/api/items')).filter(isValid).take(10).toArray()

toArray 가 next() 로 당길 때만 take → filter → fetchPages 순으로 계산이 거슬러 올라가고, 아이템은 fetchPages → filter(isValid) → take(10) → toArray 로 흐른다. fetchPages 는 현재 페이지를 다 당긴 뒤에야 다음 페이지를 요청한다. 예를 들어 10개가 2페이지에서 차면 take 가 끝나며 return() 이 filter 를 거쳐 fetchPages 까지 거슬러 올라가 소스를 닫는다 — finally 가 돌아 연결·커서를 정리하고, 3페이지는 요청되지 않는다. 요청 수를 세면 확인된다. for await 에서 break 해도 같은 return() 이 일어난다.

  • 제안이 개정 중이다 — 동시 next() 동작이 미정이라 폴리필과 다를 수 있다. 순차로 당기는 toArray엔 영향 없음.
  • TS 타입이 없다 — TS 5.8 lib·core-js 어디에도 AsyncIterator.from 선언이 없어 직접 선언한다.

출처

#564

React 밖 상태를 useSyncExternalStore로 구독하기

class ToastManager extends EventTarget {
  get toasts() {
    return this._toasts // 새로 만들지 않는다 — 같은 참조면 안 바뀐 것
  }
  set toasts(value) {
    this._toasts = [...value] // 새 참조 = "바뀌었다"는 신호
    this.dispatchEvent(new Event('changed'))
  }
  // add·remove … 는 전부 이 setter 를 거친다 — 변경과 발행이 한 곳이라 발행 누락이 없다
}
const subscribe = (cb) => {
  toastManager.addEventListener('changed', cb)
  return () => toastManager.removeEventListener('changed', cb)
}

// useEffect + useState 로 손으로 복사하던 동기화를 React 가 맡는다.
// changed 마다 getSnapshot 을 불러 참조를 비교하고, 다를 때만 리렌더한다.
const toasts = useSyncExternalStore(subscribe, () => toastManager.toasts)

어디서든 add 를 부르면 setter 가 새 배열을 만들고 changed 를 발행한다. React 는 getSnapshot 으로 다시 읽어 이전 값과 Object.is 로 비교해 다르면 리렌더, 같으면 건너뛴다. getter 는 내부 배열을 그대로 돌려주므로 안 바뀌면 같은 참조다. getSnapshot 이 매번 새 배열을 만들면 항상 달라 무한 루프가 된다.


#563

Fiber 가 렌더링을 쪼갤 수 있는 이유 — 콜 스택 대신 세 포인터

#560

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

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

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

한 스텝씩 보려면:

#559
34 중 2페이지