prefetch는 내려받기만 하고, prerender는 내려받은 뒤 렌더링까지 시작한다.
Tachyon은 <a>에 커서가 50ms 이상 머물면 <link rel="prerender">를 넣어주는 라이브러리.
대용량 리스트에서 selected 아이템 조회 최적화
O(n×m) → O(n+m)로 개선: Map으로 인덱싱
// 기존: 매번 find (느림)
selected.map((key) => items.find((item) => item.key === key))
// 개선: Map 인덱싱 (빠름)
const itemsMap = new Map(items.map((item) => [item.key, item]))
selected.map((key) => itemsMap.get(key)).filter(Boolean)
React에서 백그라운드 인덱싱:
function useItemsIndex(items: Item[]) {
const [map, setMap] = useState(new Map())
const [ready, setReady] = useState(false)
useEffect(() => {
// 청크 단위로 처리하여 UI 블로킹 방지
const newMap = new Map(items.map((item) => [item.key, item]))
setMap(newMap)
setReady(true)
}, [items])
return { map, ready }
}
10만개 이상이면 Web Worker 고려.
WeakMap의 키를 “입력 배열의 참조 자체”로 쓰면 캐시 무효화 로직이 0줄이 된다 — 무효화를 참조 동등성에 위임.
같은 배열을 이름 같은 키로 반복 조회해서 O(n) 스캔이 계속 도는데, 그 배열이 언제 바뀌는지 추적하기는 싫을 때 쓴다.
// 키가 weak — items 가 어디서도 안 잡히면 entry 도 GC. 일반 Map 이면 영구히 붙들려 누수
const indexCache = new WeakMap<Item[], Map<string, string>>()
// name → id 역인덱스를 1회 빌드 (O(n))
const buildIndex = (items: Item[]) =>
new Map(items.map((it) => [it.name, it.id]))
function lookup(items: Item[], name: string): string | null {
// 키는 내용이 아니라 참조(===)로 비교된다. 불변 업데이트면 "데이터가 바뀜" = "참조가 바뀜"이라
// 이 조회가 곧 무효화 검사다 — 그래서 delete·clear 가 어디에도 없다
let index = indexCache.get(items)
if (!index) {
// 새 참조(refetch 등) → 자동 miss. 옛 index 는 옛 items 와 함께 GC
index = buildIndex(items)
indexCache.set(items, index)
}
return index.get(name) ?? null // 여기부터 O(1) — 호출마다 돌던 find 의 O(n) 스캔이 사라진 자리
}
같은 배열 참조로 두 번 불러 buildIndex가 한 번만 돌면 된 것이다.
단 호출 측 items 참조가 stable해야 작동한다. 매번 [...data]·data.filter()로 새 배열을 넘기면 항상 miss라 무의미하다. (TanStack Query의 data/select 결과는 참조 안정성을 보장하므로 그대로 넘기면 OK) 반대로 push·splice 로 제자리에서 바꾸면 참조가 같아 옛 인덱스가 그대로 나온다 — 이쪽은 느린 게 아니라 틀린 값이다.
참고
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처럼 재지 않으면 드러나지 않는다.