Published on

Next.js INP 폭증? React 렌더 병목 7단계 진단

Authors

서버 응답도 빠르고(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분 안에 원인 좁히는 순서

  1. RUM으로 INP가 터지는 라우트/인터랙션을 특정한다.
  2. DevTools Performance로 **Long Task의 종류(Scripting vs Layout)**를 나눈다.
  3. React Profiler로 리렌더 폭발 지점을 찾는다.
  4. 이벤트 핸들러에서 무거운 계산/동기 작업을 제거(transition/deferred/worker)한다.
  5. 리스트/테이블은 가상화로 DOM 수를 줄인다.
  6. use client 범위를 줄여 hydration 비용을 낮춘다.
  7. 서드파티 스크립트를 격리/지연 로드해 Long Task를 제거한다.

마무리: INP는 “렌더 비용을 지불하는 방식”의 문제다

INP 폭증은 대개 “코드가 느리다”가 아니라, 느린 일을 ‘입력 직후’에 몰아넣는 구조에서 발생합니다. Next.js에서는 서버/클라이언트 경계를 잘게 나누고, 입력 경로에는 가벼운 업데이트만 두며, 큰 렌더는 가상화/우선순위 조절로 뒤로 미루는 것이 핵심입니다.

만약 INP 폭증과 함께 서버 액션 호출 이후 UI가 과도하게 재검증되거나 캐시가 꼬여 리렌더가 반복된다면, Next.js 14 서버액션 500·CSRF·캐시 꼬임 해결에서 재현/로그 포인트를 함께 점검해 보세요.