목차
31 장

성능 · 번들 · Web Vitals

Core Web Vitals (LCP · INP · CLS) 측정과 개선. Lighthouse · bundle 분석 · code splitting · RSC streaming · next/image · next/font · INP 최적화까지.

30장에서 자동 테스트로 기능적 안전망을 입혔습니다. 다만 “동작은 한다"와 “빠르게 동작한다"는 다른 문제입니다. 본 챕터에서는 사용자가 실제로 체감하는 성능을 측정하고 개선하는 도구들을 살펴봅니다.

14장 (성능 최적화)이 리액트 내부의 재렌더링 비용을 다뤘다면, 본 챕터는 그보다 더 바깥쪽의 비용을 살핍니다. 번들 다운로드, 첫 페인트, 인터랙션 응답 - 사용자가 직접 느끼는 차원입니다. 그리고 26장 (Suspense와 use())에서 만든 streaming이 이 차원에서 어떻게 작동하는지도 한 번 더 살펴봅니다. 본 챕터의 실제 운영 RUM 데이터는 다음 33장 (배포와 관측성)에서 Sentry / PostHog와 만나게 됩니다.

측정 없이 최적화하지 않는다 #

성능 개선의 첫 규칙입니다. 인상이나 짐작으로 최적화하면 보통 잘못된 곳을 손대고 비용만 늡니다.

잘못된 흐름
"느린 것 같다" → useMemo 더 추가 → 실제로 더 느려짐
올바른 흐름
측정 → 가장 큰 비용 식별 → 그것 한 가지 개선 → 다시 측정 → 반복

본 챕터에서 등장하는 모든 도구는 위의 “측정” 단계에 들어갑니다. 도구를 알아 두는 게 중요하지만, 더 중요한 건 항상 측정을 먼저 한다는 자세입니다.

Core Web Vitals — 세 지표 #

Google이 검색 랭킹과 사용자 경험의 표준으로 정한 세 지표입니다.

LCP (Largest Contentful Paint) #

페이지에서 가장 큰 콘텐츠 (이미지, 큰 텍스트 블록)가 화면에 보일 때까지의 시간.

  • Good: 2.5초 이내
  • Needs improvement: 2.5~4초
  • Poor: 4초 이상

22장에서 본 CSR의 가장 큰 약점이 이 지표였습니다. RSC + SSR로 옮기면 LCP가 크게 개선됩니다.

INP (Interaction to Next Paint) #

사용자가 클릭 / 탭 / 키 입력을 했을 때 다음 페인트가 일어날 때까지의 시간. 2024년에 FID (First Input Delay)를 대체한 새 지표입니다. 페이지 전체 수명에서 가장 나쁜 인터랙션의 지연을 기준으로 합니다.

  • Good: 200ms 이내
  • Needs improvement: 200~500ms
  • Poor: 500ms 이상

JS의 long task가 INP를 망가뜨리는 주범입니다. 14장의 useMemo / React Compiler와 직접 연결됩니다.

CLS (Cumulative Layout Shift) #

페이지 수명 동안 발생한 누적 레이아웃 이동량. 이미지 / 폰트가 늦게 로드되면서 콘텐츠가 점프하는 현상을 잡습니다.

  • Good: 0.1 이하
  • Needs improvement: 0.1~0.25
  • Poor: 0.25 이상

이미지에 width/height를 안 주거나, 폰트가 fallback에서 swap되면서 텍스트 크기가 바뀌면 CLS가 나빠집니다.

측정 도구 #

Lighthouse — Lab data #

Chrome DevTools에 내장된 도구. **Lab 환경 (시뮬레이션된 네트워크와 CPU)**에서 한 번의 측정을 줍니다.

Lighthouse 실행
DevTools → Lighthouse 탭 → Analyze page load

장점: 즉시 결과, 개선 제안 함께 표시.

한계: lab 환경이라 실제 사용자의 다양한 환경 (저사양 디바이스, 느린 네트워크)을 완전히 반영하지 못합니다.

PageSpeed Insights (PSI) #

Google이 호스팅하는 웹 도구 — pagespeed.web.dev. Lighthouse + **실제 사용자 데이터 (CrUX)**를 함께 보여 줍니다.

CrUX (Chrome User Experience Report)는 Chrome 사용자들이 실제로 본 데이터의 28일 평균입니다. 실 사용자 환경의 분포를 반영합니다.

web-vitals 라이브러리 — Production RUM #

Lab 데이터는 한 번의 스냅샷이라 실제 사용자가 겪는 폭을 보지 못합니다. **Real User Monitoring (RUM)**이 그 공백을 채웁니다.

src/app/web-vitals.ts
'use client';

import { onCLS, onINP, onLCP } from 'web-vitals';

type Metric = {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
};

function report(metric: Metric) {
  // 33장에서 다룰 PostHog / Sentry로 전송
  navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
}

export function reportVitals() {
  onCLS(report);
  onINP(report);
  onLCP(report);
}

Next.js에는 별도의 useReportWebVitals 훅이 있습니다.

src/app/layout.tsx — Next.js의 useReportWebVitals
'use client';

import { useReportWebVitals } from 'next/web-vitals';

export function WebVitals() {
  useReportWebVitals(metric => {
    navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
  });
  return null;
}
src/app/layout.tsx — root layout에 끼우기
import { WebVitals } from './web-vitals';

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

navigator.sendBeacon은 페이지가 닫힐 때도 안정적으로 전송됩니다. 일반 fetch는 페이지 이동 중에 취소될 수 있습니다.

번들 분석 #

클라이언트로 가는 자바스크립트가 작을수록 페이지가 빨리 살아납니다. 무엇이 번들에 들어 있는지 보는 도구가 @next/bundle-analyzer입니다.

설치
pnpm add -D @next/bundle-analyzer
next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';

const analyze = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });

export default analyze({
  // ... 기타 Next.js 설정
});
분석 실행
ANALYZE=true pnpm build

빌드가 끝나면 브라우저에 트리맵이 열립니다. 큰 박스가 무거운 패키지입니다. 자주 발견되는 무거운 후보.

  • moment, date-fns의 전체 import (트리 쉐이킹이 안 되는 패턴)
  • 차트 라이브러리 (recharts, chart.js)
  • 마크다운 / MDX 파서가 클라이언트 번들에 들어간 경우
  • lodash 전체 import

각각의 대응.

  • momentdate-fns 또는 native Intl.DateTimeFormat
  • 차트 라이브러리 → 차트가 있는 페이지에서만 next/dynamic으로 lazy import
  • 마크다운 파서 → Server Component에서만 import (자동으로 클라이언트로 안 감)
  • lodashlodash-es의 named import만

Code Splitting과 Lazy Import #

번들 크기를 줄이는 가장 직관적인 방법이 필요할 때만 다운로드입니다.

next/dynamic — 컴포넌트 단위 lazy #

무거운 컴포넌트 lazy import
import dynamic from 'next/dynamic';

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  loading: () => <p>차트 불러오는 ...</p>,
  ssr: false,  // 브라우저에서만 렌더 (SSR 안 함)
});

export default function DashboardPage() {
  return (
    <main>
      <h1>대시보드</h1>
      <HeavyChart />
    </main>
  );
}

ssr: false는 SSR을 건너뛰는 옵션입니다. 브라우저 전용 라이브러리 (예: chart.js의 일부)가 SSR에서 에러를 내는 경우에 씁니다. 다만 SSR을 건너뛰면 LCP가 늦어질 수 있어 꼭 필요한 경우만 쓰세요.

React.lazy + Suspense — 일반 패턴 #

React.lazy 사용
import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export default function DashboardPage() {
  return (
    <Suspense fallback={<p>차트 불러오는 ...</p>}>
      <HeavyChart />
    </Suspense>
  );
}

Next.js에서는 next/dynamic이 더 일반적이지만, 일반 Vite 환경에서는 React.lazy를 씁니다.

Route-level vs Component-level #

  • Route-level: 라우트 단위로 코드를 가릅니다. Next.js의 App Router에서는 자동입니다. 다른 페이지의 코드는 그 페이지에 들어가야만 다운로드됩니다.
  • Component-level: 같은 페이지 안의 무거운 컴포넌트만 lazy. 위의 next/dynamic 패턴.

라우트 분리는 자동이라 의식할 일이 별로 없습니다. 컴포넌트 단위 lazy는 “이 컴포넌트가 늦게 나타나도 사용자 경험에 무리가 없을 때"에 효과가 큽니다.

RSC Streaming이 LCP에 주는 영향 #

26장의 Suspense + streaming은 체감 LCP에 직접 영향을 줍니다. 26장 본문에서 짧게 다뤘던 부분을 본 챕터에서 한 번 더 살펴봅니다.

Streaming 없는 페이지 #

🐢 모든 데이터를 페이지 함수 최상위에서 await
export default async function Page() {
  const profile = await getProfile();   // 100ms
  const posts = await getPosts();       // 2000ms
  const stats = await getStats();       // 3000ms
  return (
    <div>
      <Profile data={profile} />
      <Posts data={posts} />
      <Stats data={stats} />
    </div>
  );
}

이 페이지의 LCP는 3000ms 이후입니다. 가장 느린 데이터를 기다린 후에야 HTML이 클라이언트로 가기 때문입니다.

Streaming 적용 #

🚀 Suspense로 streaming
export default async function Page() {
  const profile = await getProfile();  // 100ms

  return (
    <div>
      <Profile data={profile} />
      <Suspense fallback={<Skeleton />}><PostsSection /></Suspense>
      <Suspense fallback={<Skeleton />}><StatsSection /></Suspense>
    </div>
  );
}

이 페이지는 100ms 시점에 첫 HTML chunk가 클라이언트로 갑니다. LCP의 후보가 Profile에 있다면 LCP가 100ms 근처입니다. 같은 데이터 양인데도 체감 속도가 30배 빨라집니다.

Suspense 경계 선택이 LCP에 직접 영향 #

LCP의 후보가 어디 있는지 의식하고 Suspense 경계를 둡니다.

  • LCP 후보를 Suspense 안에 두지 마세요. 후보가 fallback에 가려지면 의미가 없습니다.
  • 느린 자식들을 Suspense 안에 분리해 LCP 후보가 빨리 보이게 합니다.

이게 26장의 “경계를 어디에 둘지” 가이드라인의 성능 차원 해석입니다.

이미지 — next/image와 LCP #

LCP의 후보가 이미지인 경우가 많습니다. next/image는 LCP에 직접 영향을 주는 기능을 자동으로 처리합니다.

next/image 기본
import Image from 'next/image';

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      width={1200}
      height={600}
      alt="히어로 이미지"
      priority  // LCP 후보면 priority 지정
    />
  );
}

핵심.

  • priority: LCP 후보 이미지에 붙입니다. preload hint가 자동으로 들어가 가장 빨리 다운로드됩니다.
  • width / height: 픽셀이 아니라 종횡비 정보. layout shift를 막아 CLS를 보호합니다.
  • 자동 포맷 변환 (WebP / AVIF), 반응형 srcset 생성.

LCP가 안 잡힌다면 첫 번째로 의심해야 할 곳이 hero 이미지에 priority가 빠졌는지 여부입니다.

폰트 — next/font와 CLS #

웹폰트가 로드되기 전에는 fallback 폰트가 쓰이고, 도착하면 swap되면서 텍스트 크기가 미세하게 바뀝니다. 그 점프가 CLS를 망가뜨립니다.

next/font는 이 문제를 자동으로 처리합니다.

src/app/layout.tsx — next/font 사용
import { Pretendard } from 'next/font/google';

const pretendard = Pretendard({
  subsets: ['latin'],
  display: 'swap',
  preload: true,
});

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

자동으로 처리되는 것.

  • 폰트를 빌드 타임에 호스팅 (Google Fonts 등 외부 CDN 의존 제거)
  • 폰트 metadata 분석으로 fallback과의 metric 매칭 (size-adjust)
  • 자동 preload

next/font 없이 외부 폰트를 직접 <link>로 부르면 CLS 점수가 잘 떨어집니다. 새 프로젝트는 next/font가 표준입니다.

INP 개선 #

INP는 사용자의 인터랙션에 대한 응답 속도입니다. 가장 흔한 원인은 메인 스레드를 막는 긴 동기 작업입니다.

Long Task 식별 #

DevTools의 Performance 탭에서 녹화하면 long task (50ms 이상)가 빨간색으로 표시됩니다. 그 작업이 무엇인지 분석합니다.

자주 발견되는 long task.

  • 무거운 list rendering (가상화 안 한 1000+ 항목)
  • 큰 JSON parsing (몇 MB 응답)
  • 동기적인 무거운 계산 (정렬, 필터, 통계)

React 19의 React Compiler #

14장과 28장에서 본 React Compiler는 자동 memoization으로 불필요한 재렌더링을 줄여 INP에 간접적으로 도움을 줍니다. 다만 INP의 본질적인 해법은 계산 자체를 작게 만들거나 다른 스레드로 옮기는 것입니다.

useTransition — 우선순위 낮은 업데이트 #

우선순위 낮은 업데이트는 transition으로
'use client';

import { useState, useTransition } from 'react';

export default function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);  // 즉시 갱신 (high priority)
    startTransition(() => {
      setResults(computeExpensiveResults(e.target.value));  // 늦춰도 OK
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <p>검색 ...</p>}
      <ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
    </>
  );
}

startTransition 안의 업데이트는 React가 우선순위 낮게 처리합니다. 사용자 입력은 즉시 반영되고, 무거운 결과 갱신은 메인 스레드의 여유가 있을 때 처리됩니다.

scheduler.yield() (실험적) #

긴 동기 계산을 인터랙션 사이마다 양보하는 새 API.

scheduler.yield 사용 (실험)
async function heavyWork(items: Item[]) {
  for (const item of items) {
    processItem(item);
    if ('scheduler' in window && 'yield' in scheduler) {
      await scheduler.yield();  // 인터랙션이 있다면 양보
    }
  }
}

브라우저 지원이 아직 균일하지 않아 polyfill 또는 fallback이 필요합니다. 일반적으로 더 안전한 방법은 Web Worker로 옮기는 것이지만, 단순한 케이스에서는 scheduler.yield()가 가벼운 해결책입니다.

14장 (성능 최적화)와의 관계 #

14장의 useMemo / useCallback / memo와 React Compiler가 본 챕터의 어떤 지표에 영향을 주는지 정리해 두겠습니다.

14장의 도구영향 지표적용 위치
memoINP자주 재렌더링되는 자식 컴포넌트
useMemoINP비싼 계산 결과 캐시
useCallbackINP자식의 memo를 살려 둘 때만 의미
React CompilerINP위 세 가지를 자동화

14장의 도구는 모두 렌더링 비용INP에 작용합니다. LCP / CLS는 14장이 아니라 본 챕터의 도구들(이미지, 폰트, streaming)이 개선하는 지표입니다. 셋의 역할이 다르다는 점을 분명히 두면 최적화의 방향을 헷갈리지 않습니다.

직접 해보기 — 작은 페이지의 Web Vitals 개선 #

이 책의 예제 사이트 중 하나를 골라 다음 사이클을 한 번 돌려 보세요.

  1. 측정: production 빌드 (pnpm build && pnpm start)로 띄운 페이지에 Lighthouse를 돌립니다. LCP / INP / CLS 점수와 함께 “Opportunities” 섹션을 봅니다.
  2. 번들 분석: ANALYZE=true pnpm build로 트리맵을 열어 가장 큰 박스 세 개를 확인합니다.
  3. 개선 — LCP: hero 이미지에 priority를 추가, next/image로 교체. 다시 측정해 차이를 봅니다.
  4. 개선 — CLS: 폰트를 next/font로 옮기고, 이미지에 width/height를 모두 지정. 다시 측정.
  5. 개선 — INP: 검색이나 필터 같은 인터랙션을 useTransition으로 감싸 보고, 큰 목록은 가상화 또는 페이지네이션을 적용. 다시 측정.

매 단계에서 점수의 변화를 기록해 두면 “어떤 도구가 어느 지표에 얼마나 영향을 주는지"가 분명해집니다.

연습문제 #

  1. 지표 분류. 다음 다섯 가지 증상이 LCP / INP / CLS 중 어느 지표를 망가뜨릴지 답하세요. (a) 히어로 이미지가 큰데 lazy로 로드, (b) 클릭 후 화면 반응에 1초가 걸림, (c) 페이지 로드 중 텍스트 크기가 살짝 점프, (d) JS 번들이 3MB, (e) 광고 슬롯에 width가 없어 콘텐츠가 밀려 내려감.
  2. streaming 가치 평가. 22장에서 본 CSR / SSR / RSC의 모델 중 streaming이 가장 큰 차이를 만드는 페이지는 어떤 모양인지 한 단락으로 설명해 보세요. “데이터 의존이 강한 페이지"와 “데이터가 거의 없는 페이지” 각각에서 streaming의 가치 차이를 함께 짚으면 답에 가까워집니다.
  3. 번들 다이어트 계획. @next/bundle-analyzer로 본인의 (또는 이 책의 예제) 프로젝트를 분석하고, 가장 큰 패키지 두 개를 골라 다음 중 어느 전략으로 줄일지 적어 보세요 — (a) 가벼운 대안으로 교체, (b) next/dynamic으로 lazy, (c) Server Component로만 사용해 클라이언트에서 빼기. 각 전략의 트레이드오프를 한 줄씩 적습니다.

한 줄 요약: 성능 개선은 “측정 → 식별 → 한 가지 개선 → 다시 측정"의 사이클이다. Core Web Vitals (LCP · INP · CLS) 세 지표를 Lighthouse (lab)와 web-vitals (RUM)로 측정한다. LCP는 next/image priority와 RSC streaming, CLS는 next/font와 이미지 크기 지정, INP는 14장의 메모이제이션과 useTransition이 다룬다. 번들 분석으로 가장 큰 무게를 식별하고 next/dynamic 또는 Server Component로 클라이언트에서 빼낸다. production RUM 데이터는 다음 33장 (배포와 관측성)에서 PostHog / Sentry로 흘려 보낸다.

다음 챕터 #

다음 32장 인증과 세션 — Auth.js v5 / OAuth / JWT에서는 풀스택 앱의 첫 진입 장벽인 인증과 세션을 살펴봅니다. RSC / Client Component / Server Action / middleware 네 곳에서 세션을 어떻게 읽는지 정리하고, JWT vs DB session의 선택 기준과 Auth.js v5의 표준 셋업까지 한곳에 묶습니다.

X