Suspense는 비동기를 동기처럼 보이게 만든다. 데이터가 아직 없으면 promise를 던지고, React가 그걸 받아 기다렸다가 다시 그린다. 그래서 상태를 조율하는 대신 입력을 미루고 → 질의하고 → 그리는 순서로 끝난다.

언제: 비동기 데이터를 그리는데 로딩·에러가 늘면서 화면이 튈 때. 또는 새로 만들면서 어느 쪽으로 갈지 고를 때.

절차

  1. 검색어·결과·로딩 중인지·에러인지를 각각 들고 있는지 본다. 넷이 따로 켜지고 꺼지니 실제로는 있을 수 없는 조합까지 코드가 표현할 수 있다 — “로딩 중이면서 동시에 에러” 같은 것. 그 조합이 한 프레임이라도 그려지면 화면이 튄다.
  2. 분기를 if/else에서 트리로 옮긴다 — 로딩은 <Suspense>, 에러는 <ErrorBoundary>, 성공은 본문.
  3. 본문에 loading·error를 인자로 넘기지 않는다. 안 넘겨도 가장 가까운 경계가 대신 잡기 때문이다 — render는 결과 하나만 받는다.

확인: 있을 수 없는 조합을 적을 수 있는가. 적을 수 없으면 된 것이다 — 상태가 로딩이거나, 에러거나, 성공이거나 셋 중 하나가 된다.

함정: 스피너가 너무 빨리 뜨고 사라지는 건 이 축으로 안 고쳐진다 — 다른 축이다 → 573


곁가지 — 인자를 위로 들어올려 바깥이 처리하게 하는 이 모양은 대수적 효과(algebraic effects)의 handler와 같다.1

stale-while-revalidate(두 시계·isStale) 부분은 570으로.

Footnotes

  1. try/catch와 비슷하다 — 중간 함수들은 몰라도 되고 가장 가까운 handler가 받는다. 다른 점은 처리하고 끝나지 않는다는 것이다. handler가 값을 돌려주면 효과를 일으킨 자리로 되돌아가 실행이 이어진다. (Algebraic Effects for the Rest of Us)

#575

useMemo 와 useState 의 갈림길 — 틀린 동작인가 느린 동작인가

테마에서 뽑은 강조색처럼 한 번 정해지면 유지돼야 하는 값은 useMemo에 두면 안 된다.

언제갈림길은 하나다 — "이 값이 재계산되면 틀린 동작인가, 아니면 그냥 느린 동작인가."

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

로딩 스피너의 깜빡임은 양 끝에서 따로 생기는데 CSS는 한쪽만 잡는다.

언제스피너가 한 프레임 번쩍하고 사라지거나, 떴다가 하드컷으로 끊길 때. 앞쪽인지 뒤쪽인지부터 판별한다.

#573

요청 취소의 경계 — 전송이 끝났으면 서버는 받는다

HTTP 요청 취소는 클라이언트에서만 일어난다.1 이미 보낸 바이트를 회수하지도, 서버가 하고 있는 일을 멈추지도 못한다.

갈리는 건 딱 하나 — 취소한 순간에 요청 전송이 이미 끝나 있었나. 끝나 있었다면 서버는 그걸 받았고, 받았으면 처리한다. 취소는 그 처리를 못 막는다.

  • reject된 promise와 “서버가 다 처리했다”는 동시에 참일 수 있다. 전송이 끝난 뒤부터 응답이 오기 전까지가 그 구간이다. 취소했다고 서버에서 아무 일도 없었던 게 아니다.
  • 절약되는 건 클라이언트 쪽뿐 — 응답을 받아 처리하는 비용. 도달했다면 서버 작업은 그대로 수행된다.
  • 네트워크 속도가 결과를 뒤집는다. abort() 를 부르는 시각이 같아도, 네트워크가 느려 전송이 그 시각을 넘겨 끝나면 “서버에 도달함”이 참에서 거짓으로 바뀐다.

요청은 네 구간을 지난다. 취소가 어느 구간에서 걸리느냐로 결과가 달라진다.

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

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

곁가지 — 라이브러리가 내부에서 만들고 돌려주지 않는 AbortController 는 밖에서 부를 수 없다. 노출된 signal 을 받는 요청만 취소가 닿는다.

Footnotes

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

출처

#572

useDeferredValue 의 보장 범위 — 값은 보장, 반영 시점은 미보장

검색어를 치면 입력창은 바로 바뀌어야 하는데, 아래 무거운 목록까지 매번 다시 그리면 입력이 밀린다. useDeferredValue(value)는 같은 값을 한 박자 늦게 따라오는 짝으로 하나 더 만들어준다.

언제같은 값을 쓰는 무거운 목록이 입력마다 다시 그려져 입력이 밀릴 때.

#570

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

location.href에 값을 넣으면 그 줄에서 바로 페이지가 이동할 것 같다. 마치 **암묵적 await**처럼 거기서 멈춘다고 생각하기 쉽다. 그럼 뒤에 오는 코드는 실행되지 않을까.

location.href = 'https://google.com'
console.log('실행됨 1')
location.href = 'https://google2.com'
console.log('실행됨 2')

둘 다 출력된다. 그리고 이동은 google2.com으로 간다.

await였다면 첫 줄에서 멈춰 실행됨 1도 출력되지 않고 google.com으로 갔어야 한다. 두 결과가 모두 어긋나니 가설은 틀렸다.

실제로는 이렇다. setter는 동기적으로 정상 실행되고 흐름을 끊지 않는다. 문서를 언로드하고 네트워크 요청을 보내는 실제 작업은 태스크로 넘어가 지금 실행 중인 코드가 끝난 뒤에 시작된다. 그리고 두 번째 navigation이 시작되면 진행 중이던 첫 번째가 버려진다1 — 마지막 값이 이기는 건 덮어써서가 아니라 앞의 것이 취소되기 때문이다.

location.href setter가 실행되는 시점과 실제 navigation이 처리되는 시점이 분리되는 이벤트 루프 타임라인

Footnotes

  1. “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

파서를 멈추는 것과 paint를 멈추는 것은 다르다. 동기 <script>는 파서만 세운다. 그 위에 이미 파싱된 청크가 먼저 그려질지는 브라우저 재량이라, default 상태가 한 프레임 노출되지 않는다는 보장이 없다. blocking="render"가 그 보장을 명시적으로 만든다.

언제: 새로고침하면 한 프레임 동안 다른 화면이 보일 때. 먼저 원인을 판별한다.

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

뒤쪽은 이 축으로 안 고쳐진다 — 서버가 낸 HTML 자체가 이미 다른 값이라 렌더를 막아도 그 값이 그대로 보인다.

절차

  1. blocking="render"를 붙인다.
  2. <body>가 열리기 전에 둔다. 컴포넌트 마크업 안이면 이미 늦다.

확인: 마크업에서 위치를 본다 — <body>보다 앞인가. 화면으로는 구분이 안 된다(아래).

함정: <body>가 생기는 순간 등록 자체가 닫힌다. 뒤에 붙인 blocking="render"는 실패하지 않고 조용한 no-op이 된다 — attribute를 모르는 브라우저에서 옛 동작으로 떨어지는 것과 같은 모양이라, 안 먹는다는 사실이 드러나지 않는다.

<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

#567

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

데스크톱 없이 안드로이드 기기 한 대로 그 기기의 Chrome을 디버깅한다. Termux에서 파이썬 스크립트를 돌리고, DevTools는 폰 브라우저로 연다.

#565

Iterator Helpers 의 지연 평가

iterator는 이미 가진 데이터가 아니라 아직 일어나지 않은 작업(work)이다. 소비하기 전엔 아무 일도 안 일어나고, 필요한 만큼만 하고, 한 번 하면 끝이다.

naturals().filter(isPrime).take(10).toArray()
// [2, 3, 5, … 29] · checked === 28 — 29에서 스스로 멈춘다

정작 가장 쓰고 싶은 async 소스엔 아직 못 쓴다. 페이지네이션 fetch를 fetchPages().filter(isValid).take(10)으로 감는 모양은 AsyncIterator Helpers가 필요한데 별개 제안이고 Stage 2.7이다 — Chrome 151·Node 24에서 AsyncIterator 전역 자체가 없고 체인은 TypeError로 떨어진다. Stage 4로 shipped된 건 sync 쪽(모던 브라우저·Node 22+)뿐이다.1

곁가지 — iterator로 변환: .values() / .keys() / .entries() 또는 generator. slice(0,n)take(n), slice(n)drop(n).

Footnotes

  1. 원문은 *“Async iterables have their own iterator helpers, which makes them a great fit for paginated APIs and streams”*라며 async function* fetchPages() 예제를 싣는데, 그 체인은 아직 어디서도 돌지 않는다. (Matt Smith · tc39/proposal-async-iterator-helpers)

#564
31 중 3페이지