Published on

React/Next.js 최적화로 INP 200ms 줄이기

Authors

서버 렌더링이 빠르고 번들도 작아 보이는데, 막상 사용자가 클릭하거나 입력할 때 화면이 늦게 반응한다면 INP 개선이 가장 높은 우선순위가 됩니다. INP는 단순히 네트워크가 느린 문제가 아니라, 브라우저 메인 스레드가 바빠서 입력 이벤트를 제때 처리하지 못하는 상황을 정량화합니다.

React/Next.js 앱에서 INP를 200ms대로 낮추려면, 결국 입력 이벤트 -> 상태 업데이트 -> 렌더 -> 커밋 -> 페인트 경로에서 메인 스레드를 오래 잡아먹는 작업을 줄이고, 급하지 않은 작업을 뒤로 미루고, 불필요한 렌더링을 제거해야 합니다.

아래는 실전에서 효과가 큰 순서대로, 측정부터 코드 레벨 개선까지 한 번에 정리한 가이드입니다.

INP를 먼저 정확히 측정하기

INP 최적화는 체감으로 하면 실패합니다. 반드시 RUM과 로컬 프로파일링을 같이 가져가야 합니다.

1) Web Vitals로 RUM 수집

Next.js에서 web-vitals를 붙여 실제 사용자 환경의 INP를 수집합니다. App Router 기준 예시입니다.

// app/web-vitals.ts
import type { Metric } from 'web-vitals'

export function reportWebVitals(metric: Metric) {
  // metric.name: 'INP', 'LCP', ...
  // metric.value: ms
  // metric.attribution: 원인 추적에 도움
  if (metric.name === 'INP') {
    navigator.sendBeacon(
      '/api/vitals',
      JSON.stringify({
        name: metric.name,
        value: metric.value,
        id: metric.id,
        rating: metric.rating,
        attribution: metric.attribution,
        url: location.href,
      })
    )
  }
}
// app/layout.tsx
import { reportWebVitals } from './web-vitals'

export { reportWebVitals }

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>{children}</body>
    </html>
  )
}

핵심은 metric.attribution을 저장하는 것입니다. INP는 “어떤 상호작용이 느렸는지”를 알아야 고칠 수 있습니다.

2) Chrome DevTools로 원인 분류

로컬에서 DevTools Performance로 느린 클릭이나 입력을 재현하고, 다음을 확인합니다.

  • Main 스레드에 긴 Task가 있는지 (보통 50ms 이상이 반복되면 위험)
  • React commit이 긴지, JS 실행이 긴지, 스타일/레이아웃이 긴지
  • 이벤트 핸들러에서 동기적으로 무거운 작업을 하는지

INP는 “가장 느린 상호작용”이 지표에 크게 반영되므로, 평균이 아니라 최악 케이스를 없애는 전략이 중요합니다.

INP를 악화시키는 대표 패턴 5가지

React/Next.js에서 특히 자주 나오는 INP 악화 패턴은 다음과 같습니다.

  1. 입력 이벤트 핸들러에서 무거운 동기 작업 수행
  2. 입력마다 상태 업데이트로 대규모 리스트/트리 재렌더
  3. 불필요한 Context 변경으로 광범위 리렌더
  4. 초기 hydration 이후 상호작용 시점에 큰 JS chunk 로드
  5. 레이아웃 스래싱과 강제 동기 레이아웃 (getBoundingClientRect) 남발

이제부터는 위 패턴을 실제로 줄이는 방법을 코드 중심으로 설명합니다.

1) 입력 이벤트 경로에서 무거운 작업을 분리하기

가장 먼저 볼 것은 클릭/입력 핸들러입니다. 여기서 JSON.parse, 큰 배열 정렬, 복잡한 필터링, 마크다운 파싱 같은 작업을 동기적으로 하면 INP가 바로 튑니다.

해결: 메인 스레드 작업을 쪼개고, 급하지 않은 작업은 뒤로

  • 즉시 필요한 UI 반응만 먼저 처리
  • 나머지는 startTransition, requestIdleCallback, Web Worker 등으로 분리

startTransition으로 비긴급 렌더링을 뒤로 미루기

'use client'

import { useMemo, useState, useTransition } from 'react'

type Item = { id: string; name: string }

export default function SearchList({ items }: { items: Item[] }) {
  const [query, setQuery] = useState('')
  const [filtered, setFiltered] = useState<Item[]>(items)
  const [isPending, startTransition] = useTransition()

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const next = e.target.value
    setQuery(next) // 입력 반응은 즉시

    startTransition(() => {
      // 큰 리스트 필터링은 transition으로
      const q = next.toLowerCase()
      setFiltered(items.filter((x) => x.name.toLowerCase().includes(q)))
    })
  }

  return (
    <div>
      <input value={query} onChange={onChange} placeholder="검색" />
      {isPending ? <p>필터링 중...</p> : null}
      <ul>
        {filtered.map((x) => (
          <li key={x.id}>{x.name}</li>
        ))}
      </ul>
    </div>
  )
}

이 패턴은 “입력은 즉시 반응”하고 “무거운 렌더는 나중에” 처리하게 만들어 INP가 크게 좋아집니다.

requestIdleCallback로 완전 비긴급 작업을 유휴 시간에

export function runWhenIdle(fn: () => void) {
  if (typeof window === 'undefined') return

  const ric = (window as any).requestIdleCallback
  if (ric) {
    ric(fn, { timeout: 1000 })
  } else {
    setTimeout(fn, 200)
  }
}

예를 들어, 클릭 직후 분석 이벤트 전송이나 로컬 캐시 정리 같은 작업은 유휴 시간으로 미루는 게 낫습니다.

2) 렌더 범위를 줄이는 것이 INP의 핵심이다

INP는 대체로 “클릭 한 번 했는데 화면 전체가 렌더링” 같은 구조에서 폭발합니다.

2-1) Context 남용 줄이기

큰 Context 하나에 모든 상태를 넣으면, 값이 조금만 바뀌어도 구독 중인 컴포넌트가 줄줄이 렌더됩니다.

  • Context를 도메인 단위로 쪼개기
  • 자주 바뀌는 값과 거의 안 바뀌는 값을 분리
  • selector 패턴(혹은 Zustand/Jotai 같은 store)을 고려

간단한 분리 예시:

const UserContext = React.createContext<{ id: string; name: string } | null>(null)
const ThemeContext = React.createContext<{ mode: 'light' | 'dark' } | null>(null)

2-2) memo, useMemo, useCallback은 “렌더 범위”에만 쓰기

무조건 감싸면 오히려 비용이 늘 수 있습니다. 다음 조건일 때만 우선 적용합니다.

  • 자식 컴포넌트가 크고 자주 렌더됨
  • props가 안정적일 때
  • 리스트 아이템처럼 개수가 많을 때
const Row = React.memo(function Row({ name }: { name: string }) {
  return <div className="row">{name}</div>
})

2-3) 대규모 리스트는 무조건 가상화

수백~수천 행을 DOM에 올리면, 어떤 상태 변화든 레이아웃/페인트 비용이 커져 INP가 악화됩니다. react-virtual 또는 react-window 계열로 가상화하세요.

'use client'

import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

export function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement | null>(null)

  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 36,
    overscan: 8,
  })

  return (
    <div ref={parentRef} style={{ height: 360, overflow: 'auto' }}>
      <div
        style={{
          height: rowVirtualizer.getTotalSize(),
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((v) => (
          <div
            key={v.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: v.size,
              transform: `translateY(${v.start}px)`,
            }}
          >
            {items[v.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

가상화는 INP뿐 아니라 스크롤/메모리까지 같이 좋아지는 경우가 많습니다.

3) Hydration 이후 “첫 상호작용”에 JS 로드가 터지는 문제

Next.js에서 특히 자주 보는 케이스는 이겁니다.

  • 초기 화면은 빨리 뜸
  • 사용자가 버튼을 처음 클릭하는 순간, 관련 chunk를 다운로드하고 파싱하느라 멈춤
  • 그 첫 클릭이 INP 최악값으로 찍힘

해결: 상호작용 경로에 있는 코드를 미리 준비하거나 더 가볍게

  • 무거운 컴포넌트는 next/dynamic으로 분리하되, 첫 상호작용 전에 프리로드
  • 라우트 전환이 예상되면 prefetch 적극 활용
  • 이벤트 핸들러 안에서 import() 하지 않기

next/dynamic으로 분리 + 조건부 프리로드

'use client'

import dynamic from 'next/dynamic'
import { useEffect, useState } from 'react'

const HeavyModal = dynamic(() => import('./HeavyModal'), {
  ssr: false,
  loading: () => null,
})

export default function Page() {
  const [open, setOpen] = useState(false)

  useEffect(() => {
    // 예: 페이지 진입 후 유휴 시간에 모달 코드를 미리 로드
    const t = setTimeout(() => {
      import('./HeavyModal')
    }, 800)
    return () => clearTimeout(t)
  }, [])

  return (
    <>
      <button onClick={() => setOpen(true)}>열기</button>
      {open ? <HeavyModal onClose={() => setOpen(false)} /> : null}
    </>
  )
}

포인트는 “첫 클릭에서 로드하지 않기”입니다. 첫 상호작용이 느려지면 INP는 쉽게 300ms를 넘어갑니다.

4) 서버/데이터 패턴도 INP에 간접 영향이 있다

INP 자체는 클라이언트 지표지만, 다음 상황에서는 서버/데이터 전략이 메인 스레드 부담으로 이어집니다.

  • 클라이언트에서 큰 JSON을 받아서 가공/정규화
  • 한 번의 입력에 여러 fetch가 연쇄적으로 발생
  • optimistic UI 없이 로딩 상태 전환이 과도하게 렌더를 유발

해결: 가공은 서버로, 클라이언트는 최소 렌더만

  • 가능한 한 Server Components에서 데이터 shape을 UI 친화적으로 만들어 내려주기
  • 클라이언트에서의 정렬/그룹핑/통계는 서버에서 계산
  • 캐시 키를 안정화해 불필요한 재요청 방지

또한 배포/인프라 이슈로 응답이 흔들리면 사용자는 클릭을 여러 번 하거나 입력을 반복하고, 그 과정에서 “최악 상호작용”이 늘어나 INP가 나빠지기도 합니다. 운영 환경에서 네트워크/게이트웨이 이슈를 겪고 있다면 Go net/http2 stream error 원인·해결 7가지 같은 글을 참고해 전송 계층 문제도 같이 점검하는 편이 좋습니다.

5) 레이아웃 스래싱과 강제 동기 레이아웃 피하기

입력 이벤트 처리 중 DOM 측정과 스타일 변경을 섞으면, 브라우저가 강제로 레이아웃을 계산하느라 메인 스레드를 오래 점유합니다.

나쁜 예: 측정과 변경이 섞임

function onDrag() {
  const rect = el.getBoundingClientRect()
  el.style.transform = `translateX(${rect.left + 10}px)`
}

개선: 읽기와 쓰기를 프레임 단위로 분리

let nextX = 0

function onDrag(delta: number) {
  nextX += delta
  requestAnimationFrame(() => {
    el.style.transform = `translateX(${nextX}px)`
  })
}

또한 애니메이션은 가능하면 transformopacity 중심으로 구성하고, top, left, width 변경을 줄이면 INP뿐 아니라 전반적인 프레임 안정성이 좋아집니다.

6) React 18 동시성 기능을 “입력 UX”에 맞게 쓰기

useDeferredValue로 입력과 결과 렌더링 분리

검색어 입력은 즉시 반응해야 하지만, 결과 렌더는 약간 늦어도 됩니다.

'use client'

import { useDeferredValue, useMemo, useState } from 'react'

export default function DeferredSearch({ items }: { items: string[] }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  const filtered = useMemo(() => {
    const q = deferredQuery.toLowerCase()
    return items.filter((x) => x.toLowerCase().includes(q))
  }, [items, deferredQuery])

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <div>결과: {filtered.length}</div>
    </div>
  )
}

이 방식은 입력 이벤트가 렌더 폭탄에 휘말리지 않게 해 INP 개선에 직접적으로 기여합니다.

7) 번들/서드파티 스크립트가 상호작용을 망치는 경우

광고/태그매니저/분석/채팅 위젯 등은 메인 스레드에 긴 Task를 만들기 쉽습니다. 특히 “사용자가 처음 클릭할 때” 함께 실행되면 INP 최악값을 만들 수 있습니다.

대응 전략:

  • 꼭 필요한 스크립트만 남기기
  • next/script에서 strategyafterInteractive 또는 lazyOnload로 조정
  • 위젯은 실제로 열 때 로드
import Script from 'next/script'

export default function Analytics() {
  return (
    <Script
      src="https://example.com/analytics.js"
      strategy="lazyOnload"
    />
  )
}

8) 실전 체크리스트: INP 200ms를 만드는 우선순위

아래 순서로 진행하면 재현 가능하게 내려갑니다.

  1. RUM으로 느린 상호작용 페이지/컴포넌트 식별
  2. DevTools Performance에서 긴 Task의 원인 함수 확인
  3. 이벤트 핸들러에서 동기 무거운 작업 제거
  4. 렌더 범위 축소: Context 분리, 리스트 가상화, memo 적용
  5. 첫 상호작용 시점 chunk 로드 제거: 프리로드 또는 사전 로드
  6. 레이아웃 스래싱 제거: DOM 측정/변경 분리
  7. 서드파티 스크립트 로딩 전략 조정

운영 중에 장애나 리소스 압박이 함께 있다면, 프론트 지표만 보지 말고 런타임 리소스도 같이 보세요. 예를 들어 노드/컨테이너 메모리 압박이 잦다면 GC/스케줄링 지연이 체감 성능에 영향을 줄 수 있어 K8s OOMKilled 반복? cgroup v2 메모리 진단 같은 방식으로 원인부터 제거하는 편이 좋습니다.

마무리: INP는 “입력 경로의 단순화” 게임이다

INP를 200ms대로 낮추는 핵심은 화려한 트릭이 아니라, 사용자의 입력 이벤트가 지나가는 길목에서 메인 스레드를 오래 점유하는 일을 없애는 것입니다. React/Next.js에서는 특히 “대규모 렌더를 유발하는 상태 구조”와 “첫 상호작용 시점의 지연 로딩”이 가장 흔한 범인입니다.

오늘 바로 적용할 수 있는 최소 조합은 다음입니다.

  • 입력 UI는 즉시 업데이트, 결과 렌더는 startTransition 또는 useDeferredValue
  • 큰 리스트는 가상화
  • 모달/에디터/차트 같은 무거운 UI는 분리하고 첫 클릭 전에 준비

이 3가지만 제대로 해도, 많은 서비스에서 INP가 눈에 띄게 내려가는 것을 확인할 수 있습니다.