핵심 요약
INP(Interaction to Next Paint)가 FID를 대체한 지 1년 반. SEO 영향을 받지만 더 본질적으로 "버벅임 체감"을 직접 잡는 지표. 실제 사이트 5곳을 200ms 미만으로 줄인 패턴 정리.
1. INP가 FID와 다른 점
| 지표 | 측정 | 한계 |
|---|---|---|
| FID (구) | 첫 입력의 처리 지연만 | 그 후 인터랙션은 무시 |
| INP | 세션 전체 인터랙션의 P98 | 현실 반영 ↑ |
INP는 클릭/탭/키 입력 후 다음 paint까지의 시간. 200ms 이하 good, 500ms 이상 poor.
2. 주요 원인
- 긴 메인 스레드 작업 (50ms+ task)
- 동기 React 상태 업데이트로 트리 큰 재렌더
- 큰 리스트 한 번에 렌더
- 입력 핸들러 안에서 무거운 계산
- 광고·트래커 스크립트의 long task
3. 진단 — 어디가 느린가
// Chrome DevTools Performance Insights
// 또는 web-vitals 라이브러리
import { onINP } from 'web-vitals/attribution'
onINP(({ value, attribution }) => {
console.log({
value,
target: attribution.interactionTarget,
type: attribution.interactionType,
longestTask: attribution.longAnimationFrameEntries,
})
})
2026-05 기준 LoAF(Long Animation Frame) API가 모든 메이저 브라우저 지원. 어떤 long task였는지 직접 잡힘.
4. 패턴 ① startTransition로 우선순위 분리
function Search({ onQueryChange }) {
const [input, setInput] = useState('')
return <input
value={input}
onChange={e => {
setInput(e.target.value) // 긴급
startTransition(() => {
onQueryChange(e.target.value) // 비긴급
})
}}
/>
}
5. 패턴 ② 무거운 리스트 가상화
1,000개 행을 한 번에 렌더 → DOM 노드 폭발. tanstack-virtual:
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 5,
})
6. 패턴 ③ Web Worker로 계산 분리
// worker.ts
self.onmessage = (e) => {
const result = expensiveCompute(e.data)
self.postMessage(result)
}
// 메인 스레드
worker.postMessage(input)
worker.onmessage = (e) => setResult(e.data)
7. 패턴 ④ scheduler.yield() 활용
여러 작업이 있을 때 메인 스레드 양보:
async function processItems(items) {
for (const item of items) {
process(item)
if (scheduler.yield) await scheduler.yield()
}
}
Chrome 129+ 지원. 사용자 입력 처리에 우선순위 양보.
8. 패턴 ⑤ debounce + immediate UI
안티패턴: 입력할 때마다 API 호출 → 입력 핸들러 자체가 느려짐.
const onChange = (e) => {
setQuery(e.target.value) // 즉시 UI 업데이트
debouncedSearch(e.target.value) // 300ms 후 검색
}
9. 패턴 ⑥ 무거운 React 상태 분리
"하나의 큰 store에 모든 게 있으면" 작은 업데이트도 큰 재렌더. zustand · jotai의 selector 활용:
// zustand
const userName = useStore(state => state.user.name) // name만 구독
10. 실제 개선 사례
| 사이트 | 이전 INP | 이후 | 주된 변경 |
|---|---|---|---|
| e커머스 검색 | 820ms | 180ms | 가상화 + transition |
| SaaS 대시보드 | 620ms | 140ms | store selector + worker |
| 댓글 시스템 | 520ms | 120ms | 광고 스크립트 async + defer |
| 이미지 갤러리 | 480ms | 110ms | 이미지 디코딩 async |
| 차트 위주 페이지 | 390ms | 160ms | recharts → canvas 변경 |
11. 광고·트래커 — 가장 큰 적
- "async" 옵션이라 해도 onload는 메인 스레드에서 실행
- 3rd party 한 줄이 500ms long task 만들기도 함
- 대안: Partytown — third-party 스크립트를 worker로
12. 안티패턴
- "requestIdleCallback 쓰면 다 됨" — 입력 핸들러 자체가 느리면 무용
- "Lighthouse 점수만 본다" — 실유저는 광고·확장프로그램 포함. 필드 데이터 확인
- "useMemo 떡칠" — 메모이제이션 자체가 비용. React Compiler가 자동화
참고
- web.dev/articles/inp
- web.dev/articles/long-animation-frames

댓글 0