- Published on
React/Next.js 프론트 최적화로 INP 200ms 달성
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답이 빠른데도 “클릭이 먹히는 느낌”이 늦다면, 대개 네트워크가 아니라 메인 스레드(Main Thread) 가 바쁜 상태입니다. Core Web Vitals의 INP(Interaction to Next Paint) 는 사용자의 입력(클릭/탭/키 입력)부터 다음 페인트까지의 지연을 측정하며, SPA/리치 UI에서 체감 성능을 가장 잘 드러냅니다.
이 글은 React/Next.js 기반 서비스에서 INP 200ms(권장: good ≤ 200ms) 를 목표로, 원인 파악 → 병목 분리 → 렌더/이벤트 최적화 → 번들/로딩 최적화 → 운영 관측까지의 흐름으로 정리합니다. “무엇을 바꿔야 하는지”보다 먼저 “어디서 시간이 쓰이는지”를 재현 가능하게 계측하는 데 초점을 둡니다.
INP를 200ms로 만들기 위한 체크포인트
INP는 대략 아래 3개의 합으로 이해하면 최적화가 쉬워집니다.
- Input delay: 이벤트가 큐에 쌓여 핸들러가 늦게 실행되는 시간(메인 스레드 점유)
- Processing time: 이벤트 핸들러/상태 업데이트/동기 작업 실행 시간
- Presentation delay: React 렌더/레이아웃/페인트가 끝날 때까지의 시간
React/Next.js에서 INP를 망치는 대표 원인은 다음입니다.
- 클릭 시 동기 계산/JSON 파싱/정렬/필터링을 UI 스레드에서 수행
- 상태 업데이트가 광범위한 리렌더를 유발(부모 상태 변화 → 자식 대량 렌더)
- Hydration 직후 상호작용이 몰려 메인 스레드가 바쁨
- 큰 번들/서드파티 스크립트로 인해 Long Task(>50ms) 가 빈번
- 입력 이벤트에서
setState가 연쇄적으로 발생하거나,useEffect가 과도하게 트리거
1) 먼저 “측정”부터: 로컬/실사용자(RUM) 기반으로 INP 쪼개기
Chrome DevTools로 Long Task와 상호작용 구간 찾기
- DevTools → Performance 기록
- 문제 상호작용(클릭/탭) 재현
- Main 트랙에서 Long Task(빨간 삼각형)와 이벤트 핸들러 실행 시간을 확인
- “Recalculate Style / Layout / Paint”가 길면 Presentation delay가 큼
여기서 중요한 건 “클릭 후 무엇이 실행되었는지”를 콜스택 단위로 보는 것입니다. React 렌더가 문제인지, 특정 유틸 함수/라이브러리가 문제인지가 갈립니다.
Next.js에서 web-vitals로 INP 수집(RUM)
실사용자에서 INP를 수집해야 “특정 페이지/브라우저/디바이스”에서 악화되는 패턴을 잡을 수 있습니다.
> Next.js(특히 pages router)는 pages/_app.tsx의 reportWebVitals를 통해 지표를 받을 수 있습니다. 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는 배포 후에도 쉽게 무너집니다. 다음을 파이프라인/운영 지표로 고정하세요.
- RUM p75 INP를 라우트별로 수집
- 특정 릴리즈 이후 INP가 튀면, 해당 릴리즈의 번들/서드파티 변경 추적
- 성능 회귀 방지: Lighthouse만이 아니라 실사용자 지표를 알람 기준으로 삼기
- “클릭 → Long Task”가 늘어나는지 주기적으로 샘플링
프론트 최적화는 결국 “메인 스레드에 일을 덜 시키는 것”입니다. 다만 실서비스에서는 프론트만 고쳐도 한계가 있고, 백엔드/인프라 이슈가 사용자 상호작용을 간접적으로 악화시키기도 합니다(예: 에러 재시도 폭주, 폴링 증가, 실패로 인한 UI 스피너 루프 등). 운영 환경에서 503/네트워크 오류가 잦다면 먼저 안정화가 선행되어야 합니다. 필요하다면 아래 글들도 함께 참고해 장애/지연 원인을 빠르게 분리해보세요.
결론: INP 200ms는 “기술”보다 “구조”로 만든다
INP를 200ms로 만들기 위한 가장 재현성 높은 접근은 다음 순서입니다.
- RUM/Performance 패널로 문제 상호작용을 특정한다
- 이벤트 핸들러에서 무거운 작업을 제거하고(Worker/비동기/우선순위 분리)
- 상태 범위를 줄여 리렌더 면적을 최소화한다(memo, virtualization)
- hydration/서드파티/번들 로딩을 통제해 Long Task를 제거한다
이 과정을 한 번만 “프로젝트 규칙”으로 굳히면, 새 기능이 추가되어도 INP가 쉽게 회귀하지 않습니다. 다음 단계로는 라우트별 INP p75를 대시보드화하고, 릴리즈 단위로 성능 회귀를 자동 감지하는 체계를 권장합니다.