Published on

React/Next.js 프론트 최적화로 INP 200ms 달성

Authors

서버 응답이 빠른데도 “클릭이 먹히는 느낌”이 늦다면, 대개 네트워크가 아니라 메인 스레드(Main Thread) 가 바쁜 상태입니다. Core Web Vitals의 INP(Interaction to Next Paint) 는 사용자의 입력(클릭/탭/키 입력)부터 다음 페인트까지의 지연을 측정하며, SPA/리치 UI에서 체감 성능을 가장 잘 드러냅니다.

이 글은 React/Next.js 기반 서비스에서 INP 200ms(권장: good ≤ 200ms) 를 목표로, 원인 파악 → 병목 분리 → 렌더/이벤트 최적화 → 번들/로딩 최적화 → 운영 관측까지의 흐름으로 정리합니다. “무엇을 바꿔야 하는지”보다 먼저 “어디서 시간이 쓰이는지”를 재현 가능하게 계측하는 데 초점을 둡니다.

INP를 200ms로 만들기 위한 체크포인트

INP는 대략 아래 3개의 합으로 이해하면 최적화가 쉬워집니다.

  1. Input delay: 이벤트가 큐에 쌓여 핸들러가 늦게 실행되는 시간(메인 스레드 점유)
  2. Processing time: 이벤트 핸들러/상태 업데이트/동기 작업 실행 시간
  3. Presentation delay: React 렌더/레이아웃/페인트가 끝날 때까지의 시간

React/Next.js에서 INP를 망치는 대표 원인은 다음입니다.

  • 클릭 시 동기 계산/JSON 파싱/정렬/필터링을 UI 스레드에서 수행
  • 상태 업데이트가 광범위한 리렌더를 유발(부모 상태 변화 → 자식 대량 렌더)
  • Hydration 직후 상호작용이 몰려 메인 스레드가 바쁨
  • 큰 번들/서드파티 스크립트로 인해 Long Task(>50ms) 가 빈번
  • 입력 이벤트에서 setState가 연쇄적으로 발생하거나, useEffect가 과도하게 트리거

1) 먼저 “측정”부터: 로컬/실사용자(RUM) 기반으로 INP 쪼개기

Chrome DevTools로 Long Task와 상호작용 구간 찾기

  1. DevTools → Performance 기록
  2. 문제 상호작용(클릭/탭) 재현
  3. Main 트랙에서 Long Task(빨간 삼각형)와 이벤트 핸들러 실행 시간을 확인
  4. “Recalculate Style / Layout / Paint”가 길면 Presentation delay가 큼

여기서 중요한 건 “클릭 후 무엇이 실행되었는지”를 콜스택 단위로 보는 것입니다. React 렌더가 문제인지, 특정 유틸 함수/라이브러리가 문제인지가 갈립니다.

Next.js에서 web-vitals로 INP 수집(RUM)

실사용자에서 INP를 수집해야 “특정 페이지/브라우저/디바이스”에서 악화되는 패턴을 잡을 수 있습니다.

> Next.js(특히 pages router)는 pages/_app.tsxreportWebVitals를 통해 지표를 받을 수 있습니다. app router에서도 클라이언트에서 web-vitals를 직접 붙여 수집할 수 있습니다.

// pages/_app.tsx (Next.js pages router)
import type { AppProps, NextWebVitalsMetric } from 'next/app'

export function reportWebVitals(metric: NextWebVitalsMetric) {
  // metric.name: 'INP' | 'LCP' | 'CLS' ...
  // metric.value: ms
  // metric.id: unique
  // metric.label: 'web-vital' | 'custom'

  if (metric.name === 'INP') {
    navigator.sendBeacon(
      '/api/vitals',
      JSON.stringify({
        name: metric.name,
        value: metric.value,
        id: metric.id,
        route: location.pathname,
        ua: navigator.userAgent,
      })
    )
  }
}

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

서버에서는 route 기준 p75/p90을 집계해 “어느 화면이 INP를 망치는지”를 먼저 좁히세요. INP는 평균보다 상위 백분위(p75) 가 중요합니다.

2) 이벤트 핸들러에서 “동기 작업” 제거: 입력 처리 시간을 줄인다

클릭 핸들러에서 무거운 로직이 돌면 Processing time이 바로 치솟습니다. 가장 흔한 패턴은 아래입니다.

  • 클릭 → setState → 즉시 대량 데이터 가공 → 렌더
  • 클릭 → 라우팅 → 페이지 진입 시 대량 계산/파싱

(1) 무거운 계산은 Web Worker로 분리

예: 5만 건 리스트를 클릭 시 정렬/필터링한다면 메인 스레드에서 처리하지 마세요.

// workers/filterSort.worker.ts
self.onmessage = (e) => {
  const { items, query } = e.data

  // 무거운 작업(정렬/필터)
  const filtered = items
    .filter((x: any) => x.name.includes(query))
    .sort((a: any, b: any) => a.score - b.score)

  // 결과 반환
  ;(self as any).postMessage({ filtered })
}
// components/Search.tsx
import { useEffect, useMemo, useState, useTransition } from 'react'

export function Search({ items }: { items: any[] }) {
  const [query, setQuery] = useState('')
  const [result, setResult] = useState<any[]>([])
  const [isPending, startTransition] = useTransition()

  const worker = useMemo(() => {
    if (typeof window === 'undefined') return null
    return new Worker(new URL('../workers/filterSort.worker.ts', import.meta.url))
  }, [])

  useEffect(() => {
    if (!worker) return
    worker.onmessage = (e) => {
      // React 업데이트는 transition으로 낮은 우선순위 처리
      startTransition(() => setResult(e.data.filtered))
    }
    return () => worker.terminate()
  }, [worker, startTransition])

  const onChange = (v: string) => {
    setQuery(v)
    worker?.postMessage({ items, query: v })
  }

  return (
    <div>
      <input value={query} onChange={(e) => onChange(e.target.value)} />
      {isPending ? <p>Updating…</p> : null}
      <ul>
        {result.map((x) => (
          <li key={x.id}>{x.name}</li>
        ))}
      </ul>
    </div>
  )
}

핵심은 “입력 이벤트 → 즉시 반응(가벼운 상태 업데이트) → 무거운 작업은 스레드 분리”입니다.

(2) useTransition / useDeferredValue로 렌더 우선순위 분리

검색 입력처럼 “즉시 타이핑 반응”이 중요한 UI는, 결과 렌더를 낮은 우선순위로 미루면 INP가 크게 개선됩니다.

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

export function SearchLite({ items }: { items: { id: string; name: string }[] }) {
  const [q, setQ] = useState('')
  const dq = useDeferredValue(q)

  const filtered = useMemo(() => {
    // dq 기준으로 필터 → 타이핑 자체는 덜 막힘
    return items.filter((x) => x.name.toLowerCase().includes(dq.toLowerCase()))
  }, [items, dq])

  return (
    <>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <div>results: {filtered.length}</div>
    </>
  )
}

3) 리렌더 폭발을 막아 Presentation delay 줄이기

INP가 나쁜 앱을 보면 “클릭 한 번에 너무 많은 컴포넌트가 다시 그려지는” 경우가 많습니다.

(1) 상태 범위를 좁히고, 파생 상태는 memo로 계산

  • 전역 상태(또는 상위 상태)에 “UI 토글/hover/selection” 같은 잦은 상태를 올리지 않기
  • 파생 데이터는 useMemo로 캐시하되, 의존성을 정확히 잡기
import React, { memo, useCallback, useMemo, useState } from 'react'

type Row = { id: string; name: string; score: number }

const ItemRow = memo(function ItemRow({ row, onSelect }: { row: Row; onSelect: (id: string) => void }) {
  return (
    <li>
      <button onClick={() => onSelect(row.id)}>{row.name} ({row.score})</button>
    </li>
  )
})

export function List({ rows }: { rows: Row[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null)

  const sorted = useMemo(() => {
    // rows가 바뀔 때만 정렬
    return [...rows].sort((a, b) => b.score - a.score)
  }, [rows])

  const onSelect = useCallback((id: string) => {
    setSelectedId(id)
  }, [])

  return (
    <div>
      <p>selected: {selectedId ?? 'none'}</p>
      <ul>
        {sorted.map((r) => (
          <ItemRow key={r.id} row={r} onSelect={onSelect} />
        ))}
      </ul>
    </div>
  )
}

여기서 포인트는 onSelect의 참조 안정성과, ItemRow의 memoization으로 “선택 상태가 바뀌어도 모든 Row가 불필요하게 다시 렌더되지 않게” 하는 것입니다.

(2) 대형 리스트는 가상화(virtualization)

수천 개 DOM을 유지하면 클릭 시 레이아웃/페인트 비용이 커져 INP가 나빠집니다. react-window 같은 가상화로 DOM 수를 제한하세요.

import { FixedSizeList as VList, ListChildComponentProps } from 'react-window'

export function VirtualList({ items }: { items: { id: string; name: string }[] }) {
  const Row = ({ index, style }: ListChildComponentProps) => (
    <div style={style}>{items[index].name}</div>
  )

  return (
    <VList height={400} width={'100%'} itemCount={items.length} itemSize={40}>
      {Row}
    </VList>
  )
}

4) Next.js에서 Hydration/라우팅 구간의 INP 악화를 줄이는 법

(1) 클라이언트 컴포넌트 남용 줄이기 (App Router)

App Router에서 use client가 늘어날수록 초기 JS 실행량이 커지고, hydration 이후 첫 상호작용이 지연됩니다.

  • 가능한 한 서버 컴포넌트로 유지
  • 상호작용이 필요한 작은 부분만 클라이언트로 격리
  • 클라이언트 컴포넌트에 큰 데이터/큰 props를 넘기지 않기(직렬화 비용)

(2) 동적 import로 “상호작용 뒤” 로딩

초기 상호작용에 필요 없는 컴포넌트(차트, 에디터, 맵)는 동적 로딩으로 분리합니다.

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <p>Loading chart…</p>,
})

export function Dashboard() {
  return (
    <section>
      <h2>Dashboard</h2>
      <HeavyChart />
    </section>
  )
}

ssr: false는 만능이 아닙니다. SEO/초기 렌더가 중요한 영역엔 SSR을 유지하고, 정말 무거운 위젯에만 제한적으로 사용하세요.

(3) 라우팅 시 프리패치/코드 스플리팅 확인

Next.js의 링크 프리패치가 오히려 메인 스레드를 바쁘게 만들거나 네트워크를 점유하는 경우도 있습니다. 화면 특성에 따라 prefetch={false}를 고려하세요.

import Link from 'next/link'

<Link href="/heavy" prefetch={false}>Go heavy</Link>

5) 서드파티 스크립트가 INP를 망친다: 실행 시점 통제

광고/태그매니저/AB테스트/채팅 위젯은 메인 스레드를 점유해 Input delay를 증가시킵니다.

  • 꼭 필요한 것만 남기기
  • Next.js next/script로 로딩 전략을 분리
import Script from 'next/script'

export function ThirdParty() {
  return (
    <>
      {/* 초기 렌더에 꼭 필요 없다면 afterInteractive / lazyOnload 고려 */}
      <Script
        src="https://example.com/sdk.js"
        strategy="lazyOnload"
        onLoad={() => {
          // 초기 상호작용 이후에 초기화
          ;(window as any).SDK?.init?.()
        }}
      />
    </>
  )
}

또 하나의 실전 팁은 “기능 플래그”로 실험적으로 제거해 INP 변화를 보는 것입니다. INP는 원인이 복합적이라, 제거 실험이 가장 빠른 진단 도구가 됩니다.

6) CSS/레이아웃이 느리면 클릭도 느리다

Presentation delay가 큰 경우는 다음을 의심하세요.

  • 클릭 시 클래스 토글로 인해 레이아웃 스래싱(layout thrashing) 발생
  • 큰 DOM 트리에서 offsetHeight 같은 측정이 반복
  • 애니메이션을 top/left/width/height로 수행(레이아웃 유발)

대응:

  • 애니메이션은 transform, opacity 중심
  • DOM 측정은 한 번에 모으고, 쓰기(write)와 읽기(read)를 섞지 않기
  • 큰 페이지는 섹션 단위로 DOM을 줄이기(가상화/접기)

7) “200ms 달성”을 위한 운영 체크리스트

INP는 배포 후에도 쉽게 무너집니다. 다음을 파이프라인/운영 지표로 고정하세요.

  1. RUM p75 INP를 라우트별로 수집
  2. 특정 릴리즈 이후 INP가 튀면, 해당 릴리즈의 번들/서드파티 변경 추적
  3. 성능 회귀 방지: Lighthouse만이 아니라 실사용자 지표를 알람 기준으로 삼기
  4. “클릭 → Long Task”가 늘어나는지 주기적으로 샘플링

프론트 최적화는 결국 “메인 스레드에 일을 덜 시키는 것”입니다. 다만 실서비스에서는 프론트만 고쳐도 한계가 있고, 백엔드/인프라 이슈가 사용자 상호작용을 간접적으로 악화시키기도 합니다(예: 에러 재시도 폭주, 폴링 증가, 실패로 인한 UI 스피너 루프 등). 운영 환경에서 503/네트워크 오류가 잦다면 먼저 안정화가 선행되어야 합니다. 필요하다면 아래 글들도 함께 참고해 장애/지연 원인을 빠르게 분리해보세요.

결론: INP 200ms는 “기술”보다 “구조”로 만든다

INP를 200ms로 만들기 위한 가장 재현성 높은 접근은 다음 순서입니다.

  1. RUM/Performance 패널로 문제 상호작용을 특정한다
  2. 이벤트 핸들러에서 무거운 작업을 제거하고(Worker/비동기/우선순위 분리)
  3. 상태 범위를 줄여 리렌더 면적을 최소화한다(memo, virtualization)
  4. hydration/서드파티/번들 로딩을 통제해 Long Task를 제거한다

이 과정을 한 번만 “프로젝트 규칙”으로 굳히면, 새 기능이 추가되어도 INP가 쉽게 회귀하지 않습니다. 다음 단계로는 라우트별 INP p75를 대시보드화하고, 릴리즈 단위로 성능 회귀를 자동 감지하는 체계를 권장합니다.