- Published on
Next.js INP 폭증? React 렌더 병목 7단계 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 응답도 빠르고(LCP/TTFB 양호), 번들도 크게 변한 게 없는데 어느 날부터 INP가 폭증한다면 대부분 “사용자 입력 → 메인 스레드 작업(이벤트 핸들러/렌더/레이아웃) → 다음 페인트” 구간에서 병목이 생긴 것입니다. 특히 Next.js(App Router)에서는 서버 컴포넌트/클라이언트 컴포넌트 경계, hydration, 상태 관리 방식, 리스트 렌더링, 서드파티 스크립트가 얽히면서 INP가 급격히 흔들릴 수 있습니다.
이 글은 “감으로 최적화”가 아니라, 측정 가능한 증거를 쌓아 원인을 좁히는 7단계 진단 루틴을 제공합니다. 각 단계는 무엇을 보고(증상) → 어떻게 재현/측정하고(도구) → *어떻게 고치는지(처방)*로 구성했습니다.
> 참고: 서버 액션/캐시 꼬임으로 인해 UI가 재렌더 폭탄을 맞는 케이스도 있습니다. 관련해서는 Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결도 함께 보면 원인 분리가 빨라집니다.
1단계: “진짜 INP 문제”인지 먼저 확정하기
INP는 단일 지표가 아니라, **페이지 생애주기 동안 가장 나쁜 입력 지연(Interaction Latency)**을 대표값으로 잡습니다. 즉, 특정 버튼/탭/입력에서 한 번 크게 튀면 전체가 나빠집니다.
체크 포인트
- 특정 페이지에서만 나쁜가? (예: 목록 페이지, 검색 결과 페이지)
- 특정 인터랙션에서만 나쁜가? (예: 필터 변경, 모달 오픈, 드래그)
- 로컬 Lighthouse는 괜찮은데 실제 사용자(RUM)만 나쁜가?
최소한의 RUM 계측 코드
web-vitals로 INP를 수집해 어떤 라우트/컴포넌트/액션에서 터지는지부터 분류합니다.
// app/vitals.ts (또는 lib/vitals.ts)
import { onINP, type Metric } from 'web-vitals';
function sendToAnalytics(metric: Metric) {
// 예: /api/vitals로 전송
navigator.sendBeacon(
'/api/vitals',
JSON.stringify({
name: metric.name,
value: metric.value,
id: metric.id,
rating: metric.rating,
navigationType: metric.navigationType,
// 커스텀 필드(라우트/유저/실험군 등)
path: location.pathname,
ts: Date.now(),
})
);
}
export function reportVitals() {
onINP(sendToAnalytics);
}
> 목표: “INP가 나쁘다”가 아니라 “/products에서 필터 토글 시 INP p95가 600ms로 튄다”처럼 재현 가능한 문장으로 바꾸는 것.
2단계: DevTools Performance로 ‘입력→페인트’ 구간을 쪼개기
INP가 나쁘다는 건 대부분 메인 스레드가 바쁨을 의미합니다. Chrome DevTools의 Performance 탭에서 해당 인터랙션(클릭/키입력)을 녹화해 아래를 확인합니다.
체크 포인트
- Long Task(> 50ms)가 입력 직후에 연달아 있는가?
- Scripting(자바스크립트 실행) 비중이 큰가, Rendering/Layout 비중이 큰가?
- 이벤트 핸들러가 오래 걸리는가?
빠른 판정 기준
- Scripting이 길다 → 렌더/상태 업데이트/계산/서드파티 JS 가능성
- Layout/Rendering이 길다 → DOM 변경 폭, 스타일 계산, 이미지/폰트, 큰 테이블/리스트 가능성
> 이 단계에서 “네트워크가 느리다”는 결론이 나오면 그건 INP보단 TTFB/LCP 쪽 이슈일 확률이 큽니다. INP는 대체로 클라이언트 메인 스레드 문제입니다.
3단계: React Profiler로 ‘무엇이 리렌더를 폭발시키는지’ 찾기
INP 폭증의 단골 원인은 불필요한 리렌더입니다. 특히 Next.js에서 클라이언트 컴포넌트를 크게 잡아두면, 작은 입력에도 거대한 트리가 다시 그려집니다.
체크 포인트
- 클릭 한 번에 몇 개 컴포넌트가 리렌더되는가?
- 리렌더되는 컴포넌트 중 “props가 바뀌지 않았는데도” 렌더되는 것이 있는가?
- Context Provider 하나가 페이지 최상단에 있고 값이 자주 바뀌는가?
흔한 패턴: Context로 전체 페이지가 리렌더
'use client';
import React, { createContext, useMemo, useState, useContext } from 'react';
type Ctx = { query: string; setQuery: (v: string) => void };
const SearchContext = createContext<Ctx | null>(null);
export function SearchProvider({ children }: { children: React.ReactNode }) {
const [query, setQuery] = useState('');
// ⚠️ query 변경 시 Provider value 객체가 매번 새로 만들어져
// 하위 트리 전체가 리렌더될 수 있음
const value = useMemo(() => ({ query, setQuery }), [query]);
return <SearchContext.Provider value={value}>{children}</SearchContext.Provider>;
}
export function useSearch() {
const ctx = useContext(SearchContext);
if (!ctx) throw new Error('SearchProvider missing');
return ctx;
}
처방
- Provider 범위를 최소화(필요한 섹션만 감싸기)
- Context를 “자주 바뀌는 값”과 “안 바뀌는 값”으로 분리
- 상태 라이브러리(Zustand/Jotai 등)로 구독 단위를 쪼개기
4단계: “입력 이벤트 핸들러” 안에서 무거운 일을 하고 있지 않은지 점검
INP는 입력 처리 자체가 느려도 바로 나빠집니다. 아래 같은 코드는 클릭/키 입력 순간에 메인 스레드를 점유합니다.
나쁜 예: onChange에서 필터링/정렬/가공을 즉시 수행
'use client';
import { useMemo, useState } from 'react';
export function SearchList({ items }: { items: string[] }) {
const [q, setQ] = useState('');
const filtered = useMemo(() => {
// q가 바뀔 때마다 큰 배열을 전수 검사
return items
.filter((x) => x.toLowerCase().includes(q.toLowerCase()))
.sort();
}, [items, q]);
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<ul>
{filtered.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</>
);
}
처방 1) useDeferredValue로 렌더 우선순위 낮추기
'use client';
import { useDeferredValue, useMemo, useState } from 'react';
export function SearchList({ items }: { items: string[] }) {
const [q, setQ] = useState('');
const dq = useDeferredValue(q); // 입력 반응성을 우선
const filtered = useMemo(() => {
return items.filter((x) => x.toLowerCase().includes(dq.toLowerCase()));
}, [items, dq]);
return (
<>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<ul>{filtered.map((x) => <li key={x}>{x}</li>)}</ul>
</>
);
}
처방 2) startTransition으로 상태 업데이트를 “덜 급한 일”로 분류
'use client';
import { startTransition, useMemo, useState } from 'react';
export function SearchList({ items }: { items: string[] }) {
const [q, setQ] = useState('');
const [query, setQuery] = useState('');
const filtered = useMemo(
() => items.filter((x) => x.toLowerCase().includes(query.toLowerCase())),
[items, query]
);
return (
<>
<input
value={q}
onChange={(e) => {
const v = e.target.value;
setQ(v);
startTransition(() => setQuery(v));
}}
/>
<ul>{filtered.map((x) => <li key={x}>{x}</li>)}</ul>
</>
);
}
5단계: 리스트/테이블 렌더링 병목(가장 흔함)을 구조적으로 해결
INP가 튀는 페이지를 보면 대개 **“한 번의 상태 변경 → 수천 개 DOM 업데이트”**가 발생합니다. 필터/정렬/선택/hover 같은 사소한 입력이 대량 렌더를 유발하면 INP는 쉽게 500ms를 넘습니다.
체크 포인트
- 화면에 보이는 아이템은 30개인데 실제로 2,000개를 렌더하고 있지 않은가?
- key가 안정적인가? (index key로 인해 재마운트가 발생하지 않는가?)
- 행 컴포넌트가 매번 새 props(새 객체/새 함수)를 받아 memo가 무력화되지 않는가?
처방: 가상화(virtualization)
react-window 같은 라이브러리로 DOM 수를 물리적으로 줄입니다.
'use client';
import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
export function VirtualizedList({ items }: { items: string[] }) {
const Row = ({ index, style }: ListChildComponentProps) => (
<div style={style}>{items[index]}</div>
);
return (
<List height={400} width={'100%'} itemCount={items.length} itemSize={36}>
{Row}
</List>
);
}
추가 처방
- 행(Row) 컴포넌트에
React.memo적용 + props 안정화(useCallback,useMemo) - 선택 상태를 “전체 배열”이 아니라 “선택된 id set”으로 관리해 변경 범위를 최소화
6단계: Hydration/클라이언트 경계가 커져서 초기 상호작용이 막히는지 확인
Next.js(App Router)에서는 서버 컴포넌트가 기본이지만, 무심코 use client를 상단에 붙이면 페이지 대부분이 클라이언트 번들 + hydration 대상이 됩니다. 이 경우 사용자가 첫 클릭을 하는 시점에 아직 hydration이 끝나지 않아 입력 처리가 늦어지고 INP가 악화될 수 있습니다.
체크 포인트
- 페이지 최상단 레이아웃/템플릿에
use client가 붙어 있지 않은가? - “그냥 UI 장식” 컴포넌트 때문에 상위가 클라이언트로 승격되지 않았는가?
- 서드파티 UI 라이브러리(모달/툴팁)가 루트에 걸려 hydration 비용이 커지지 않았는가?
처방: 클라이언트 컴포넌트를 리프(leaf)로 내리기
- 데이터 패칭/마크업은 서버 컴포넌트에서
- 상호작용이 필요한 작은 영역만 클라이언트 컴포넌트로 분리
// app/products/page.tsx (Server Component)
import Filters from './Filters.client';
export default async function Page() {
const products = await fetch('https://example.com/api/products', { cache: 'no-store' })
.then((r) => r.json());
return (
<div>
<h1>Products</h1>
{/* 상호작용 영역만 client */}
<Filters initialCount={products.length} />
{/* 나머지는 서버 렌더 */}
<ul>
{products.slice(0, 50).map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
);
}
// app/products/Filters.client.tsx
'use client';
import { useState } from 'react';
export default function Filters({ initialCount }: { initialCount: number }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>
Filters ({initialCount})
</button>
{open && <div>...</div>}
</div>
);
}
7단계: “서드파티 스크립트/관측 도구/광고”가 Long Task를 만드는지 격리
INP 폭증이 **특정 유저 세그먼트(특정 브라우저/국가/실험군)**에서만 발생한다면, 앱 코드보다 태그 매니저, A/B 스크립트, 분석 SDK, 챗 위젯이 범인인 경우가 많습니다.
체크 포인트
- Performance 녹화에서 Long Task의 call stack에 서드파티 도메인이 보이는가?
- 특정 페이지에서만 로드되는 스크립트가 있는가?
next/script로딩 전략이 적절한가?
처방: next/script 전략 조정 + 기능 플래그로 격리
import Script from 'next/script';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{/* 초기 상호작용 전에 필요 없다면 afterInteractive/idle로 */}
<Script
src="https://example.com/heavy-sdk.js"
strategy="afterInteractive"
/>
</body>
</html>
);
}
추가로, 문제가 되는 스크립트는 **샘플링(예: 5%)**으로만 켜서 INP 변화가 즉시 개선되는지 확인하면 원인 확정이 빨라집니다.
실전 체크리스트: 30분 안에 원인 좁히는 순서
- RUM으로 INP가 터지는 라우트/인터랙션을 특정한다.
- DevTools Performance로 **Long Task의 종류(Scripting vs Layout)**를 나눈다.
- React Profiler로 리렌더 폭발 지점을 찾는다.
- 이벤트 핸들러에서 무거운 계산/동기 작업을 제거(transition/deferred/worker)한다.
- 리스트/테이블은 가상화로 DOM 수를 줄인다.
use client범위를 줄여 hydration 비용을 낮춘다.- 서드파티 스크립트를 격리/지연 로드해 Long Task를 제거한다.
마무리: INP는 “렌더 비용을 지불하는 방식”의 문제다
INP 폭증은 대개 “코드가 느리다”가 아니라, 느린 일을 ‘입력 직후’에 몰아넣는 구조에서 발생합니다. Next.js에서는 서버/클라이언트 경계를 잘게 나누고, 입력 경로에는 가벼운 업데이트만 두며, 큰 렌더는 가상화/우선순위 조절로 뒤로 미루는 것이 핵심입니다.
만약 INP 폭증과 함께 서버 액션 호출 이후 UI가 과도하게 재검증되거나 캐시가 꼬여 리렌더가 반복된다면, Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결에서 재현/로그 포인트를 함께 점검해 보세요.