본문 바로가기
Frontend2026년 5월 13일6분 읽기

Web Vitals INP — FID 작별, 응답성 개선 실전 전략

YS
김영삼
조회 1169
Web Vitals INP — FID 작별, 응답성 개선 실전 전략

핵심 요약

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커머스 검색820ms180ms가상화 + transition
SaaS 대시보드620ms140msstore selector + worker
댓글 시스템520ms120ms광고 스크립트 async + defer
이미지 갤러리480ms110ms이미지 디코딩 async
차트 위주 페이지390ms160msrecharts → 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

아직 댓글이 없습니다.
Ctrl+Enter로 등록