로딩 스피너 깜빡임의 두 끝 — 앞쪽은 CSS, 뒤쪽은 JS
스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊긴다. 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다. 어느 끝인지는 응답 시각을 붙잡고 보인 시간을 재서 가른다 — DOM에 붙어 있던 시간이 아니다.
흐름
- headless Chrome + Node 전역
WebSocket으로 raw CDP. MCPemulate는 프리셋 스로틀링뿐이라 요청 하나를 못 붙잡는다. - 프로브 — 스피너가 사라질 때 보인 시간을 돌려준다.
- 경계값이면
disabled-by-default-devtools.screenshottrace로 확인한다. 스크린샷은 바뀐 프레임에만 찍히니 처음 물든 장 → 다음 흰 장이 보인 시간이다. - 응답 < 지연인데 보였으면 앞쪽, 응답 > 지연인데 최소 노출보다 짧으면 뒤쪽.
| 지연 | 응답 | 최소 노출 | 프로브 | 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
-
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)→ 흰색. ↩ -
CSS transition은 요소의 첫 스타일 적용이나
display: none→ 표시 전환에서는 기본적으로 발동하지 않는다.@starting-style이 “무엇에서 출발할지”를 정의해 그걸 가능하게 한다. (@starting-style — MDN) ↩
요청 취소의 경계
HTTP 요청 취소는 클라이언트에서만 일어난다.1 이미 보낸 바이트를 회수하지도, 서버가 하고 있는 일을 멈추지도 못한다. 취소한 순간에 요청 전송이 이미 끝나 있었나. 끝나 있었다면 서버는 그걸 받았고, 받았으면 처리한다. 취소는 그 처리를 못 막는다.
| 구간 | 어디 | 여기서 취소하면 |
|---|---|---|
send |
클라이언트 | 요청이 다 전송되지 않는다 — 헤더·본문 일부는 도착할 수 있다 |
in-flight |
네트워크 | 이미 다 보냈다. 서버는 받는다 |
processing |
서버 | 서버가 처리 중이고 멈출 방법이 없다 |
resp |
네트워크 → 클라이언트 | 응답이 오는 중이다. 클라이언트가 안 읽을 뿐 |
구간 사이 경계는 셋인데 결과를 가르는 건 첫 번째 하나다 — send와 in-flight 사이, 전송이 끝나는 순간. 그 뒤로는 어디서 취소하든 서버 입장에선 똑같다.
클라이언트에서는 네 경우가 똑같이 보인다 — 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
-
Fetch 표준의 abort 알고리즘은 controller 상태를
aborted로 바꾸고 이유를 기록할 뿐, 서버로 무엇을 보내라고 정하지 않는다. 연결을 닫거나 응답 읽기를 멈추는 건 구현 재량이다. (Fetch Standard — abort a fetch) ↩ -
Chrome 154 headless 에서 raw CDP 로 실측 (2026-10-01). 로컬 Node 서버가 요청 수신·본문 바이트·
end·응답 완료를 기록했고, 페이지는AbortController로 POST 를 취소했다. 네 경우 모두 promiseAbortError,Network.loadingFailedcanceled: truenet::ERR_ABORTED로 같았다.uploadThroughput스로틀링은 localhost 요청에도 걸렸다. ↩
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 덮어쓴 게 아니다.
3. 덤 — 버려진 이동도 서버에는 갔다
서버 기록은 ["/a","/b"]다. /a는 requestWillBeSent까지 갔다가 클라이언트에서 끊겼을 뿐, 요청은 서버에 도착했다. 이동 전에 서버 부수효과가 있는 URL(로그아웃 링크 같은)이라면 “결국 /b로 갔으니 /a는 없던 일”이 아니다 — 572의 “취소는 클라이언트에서만 일어난다”와 같은 이야기다.
Footnotes
목록 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
-
전부 모으는 기대 횟수는
n·H_n(H_n은 조화수). 그리고i번째 새 쿠폰까지의 기대 대기는n/(n-i+1)이므로 마지막 한 장은n/1 = n— 단계 중 가장 오래 걸린다. (Coupon collector’s problem — Wikipedia) ↩
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 가 먼저 칠해짐 — 없는 것과 같다 |
4. <body>가 열리기 전에 둔다
컴포넌트 마크업 안이면 이미 늦다. 스펙은 “head 안에 둬라”가 아니라 등록 창을 이렇게 닫는다.
A
Documentdocument allows adding render-blocking elements if document’s content type is “text/html” and the body element of document is null.
<body>가 생기는 순간 등록이 닫히고, 뒤에 붙인 속성은 실패 없이 무시된다. 속성을 모르는 브라우저가 옛 동작으로 떨어지는 것과 같은 모양이라 3처럼 재지 않으면 드러나지 않는다.
Footnotes
데스크톱 없이 안드로이드 Chrome 디버깅 — 로컬 프록시로 Origin 검사 우회
데스크톱 없이 안드로이드 기기 한 대로 그 기기의 Chrome을 디버깅한다. 순서를 기억하려고 적어둔 것이다 — 각 단계의 방법이 아니라 밟는 차례가 내용이다.
막히는 건 Origin 검사 하나다.1
Footnotes
-
Origin 검사(Chrome 111~)와 DNS rebinding을 막는 Host 헤더 검사(Chrome 66~)는 다른 방어다. 여기 걸리는 건 앞쪽 — WS 핸드셰이크에
--remote-allow-origins에 없는 Origin이 붙으면 거부된다. (Chromium issues) ↩
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()
- 제안이 개정 중이다 — 동시
next()동작이 미정이라 폴리필과 다를 수 있다. 순차로 당기는toArray엔 영향 없음. - TS 타입이 없다 — TS 5.8 lib·core-js 어디에도
AsyncIterator.from선언이 없어 직접 선언한다.
출처
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)
Fiber 가 렌더링을 쪼갤 수 있는 이유 — 콜 스택 대신 세 포인터
이벤트 루프 한 턴 — 마이크로태스크가 setTimeout(0) 보다 항상 먼저다
마이크로태스크 큐는 매 턴 끝까지 비워진다 — 그래서 then·queueMicrotask는 항상 setTimeout(0)보다 먼저다.
한 스텝씩 보려면: