성능 · 번들 · 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)**에서 한 번의 측정을 줍니다.
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)**이 그 공백을 채웁니다.
'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 훅이 있습니다.
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitals() {
useReportWebVitals(metric => {
navigator.sendBeacon('/api/vitals', JSON.stringify(metric));
});
return null;
}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-analyzerimport 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
각각의 대응.
moment→date-fns또는 nativeIntl.DateTimeFormat- 차트 라이브러리 → 차트가 있는 페이지에서만
next/dynamic으로 lazy import - 마크다운 파서 → Server Component에서만 import (자동으로 클라이언트로 안 감)
lodash→lodash-es의 named import만
Code Splitting과 Lazy Import #
번들 크기를 줄이는 가장 직관적인 방법이 필요할 때만 다운로드입니다.
next/dynamic — 컴포넌트 단위 lazy
#
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 — 일반 패턴
#
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 없는 페이지 #
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 적용 #
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에 직접 영향을 주는 기능을 자동으로 처리합니다.
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는 이 문제를 자동으로 처리합니다.
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 — 우선순위 낮은 업데이트
#
'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.
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장의 도구 | 영향 지표 | 적용 위치 |
|---|---|---|
memo | INP | 자주 재렌더링되는 자식 컴포넌트 |
useMemo | INP | 비싼 계산 결과 캐시 |
useCallback | INP | 자식의 memo를 살려 둘 때만 의미 |
| React Compiler | INP | 위 세 가지를 자동화 |
14장의 도구는 모두 렌더링 비용과 INP에 작용합니다. LCP / CLS는 14장이 아니라 본 챕터의 도구들(이미지, 폰트, streaming)이 개선하는 지표입니다. 셋의 역할이 다르다는 점을 분명히 두면 최적화의 방향을 헷갈리지 않습니다.
직접 해보기 — 작은 페이지의 Web Vitals 개선 #
이 책의 예제 사이트 중 하나를 골라 다음 사이클을 한 번 돌려 보세요.
- 측정: production 빌드 (
pnpm build && pnpm start)로 띄운 페이지에 Lighthouse를 돌립니다. LCP / INP / CLS 점수와 함께 “Opportunities” 섹션을 봅니다. - 번들 분석:
ANALYZE=true pnpm build로 트리맵을 열어 가장 큰 박스 세 개를 확인합니다. - 개선 — LCP: hero 이미지에
priority를 추가,next/image로 교체. 다시 측정해 차이를 봅니다. - 개선 — CLS: 폰트를
next/font로 옮기고, 이미지에 width/height를 모두 지정. 다시 측정. - 개선 — INP: 검색이나 필터 같은 인터랙션을
useTransition으로 감싸 보고, 큰 목록은 가상화 또는 페이지네이션을 적용. 다시 측정.
매 단계에서 점수의 변화를 기록해 두면 “어떤 도구가 어느 지표에 얼마나 영향을 주는지"가 분명해집니다.
연습문제 #
- 지표 분류. 다음 다섯 가지 증상이 LCP / INP / CLS 중 어느 지표를 망가뜨릴지 답하세요. (a) 히어로 이미지가 큰데 lazy로 로드, (b) 클릭 후 화면 반응에 1초가 걸림, (c) 페이지 로드 중 텍스트 크기가 살짝 점프, (d) JS 번들이 3MB, (e) 광고 슬롯에 width가 없어 콘텐츠가 밀려 내려감.
- streaming 가치 평가. 22장에서 본 CSR / SSR / RSC의 모델 중 streaming이 가장 큰 차이를 만드는 페이지는 어떤 모양인지 한 단락으로 설명해 보세요. “데이터 의존이 강한 페이지"와 “데이터가 거의 없는 페이지” 각각에서 streaming의 가치 차이를 함께 짚으면 답에 가까워집니다.
- 번들 다이어트 계획.
@next/bundle-analyzer로 본인의 (또는 이 책의 예제) 프로젝트를 분석하고, 가장 큰 패키지 두 개를 골라 다음 중 어느 전략으로 줄일지 적어 보세요 — (a) 가벼운 대안으로 교체, (b)next/dynamic으로 lazy, (c) Server Component로만 사용해 클라이언트에서 빼기. 각 전략의 트레이드오프를 한 줄씩 적습니다.
한 줄 요약: 성능 개선은 “측정 → 식별 → 한 가지 개선 → 다시 측정"의 사이클이다. Core Web Vitals (LCP · INP · CLS) 세 지표를 Lighthouse (lab)와 web-vitals (RUM)로 측정한다. LCP는
next/imagepriority와 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의 표준 셋업까지 한곳에 묶습니다.