번들 분석과 코드 분할로 Core Web Vitals를 끌어올린다.
성능 최적화: 번들·이미지·Core Web Vitals
입문편의 10강에서는 next/image나 next.config.js 수준의 기본 최적화를 다뤘습니다. 하지만 실무에서는 그 이상이 필요합니다. "뭔가 느리다"는 감이 있더라도 측정 없이 손을 대면 잘못된 곳을 고치기 쉽습니다. 이 레슨은 번들 분석 → 코드 분할 → 이미지·폰트 최적화 → Core Web Vitals 계측이라는 측정 주도(data-driven) 사이클을 체계적으로 익히는 것을 목표로 합니다.
Partial Prerendering(PPR)과 서버 컴포넌트 기반 병렬 패칭 전략도 함께 다루므로, 렌더링 내부 구조(1강)와 캐싱 아키텍처(2강)를 먼저 학습한 뒤 이 레슨을 보는 것을 권장합니다.
학습 목표
@next/bundle-analyzer로 번들을 시각화하고 무거운 의존성·중복 청크를 찾을 수 있다.- dynamic import와 Suspense 경계를 조합해 클라이언트 JS 전송량을 줄일 수 있다.
next/image와next/font의 고급 옵션으로 LCP·CLS를 정량적으로 개선할 수 있다.- Partial Prerendering(PPR) 의 정적 셸 + 동적 홀 전략을 이해하고 적용할 수 있다.
useReportWebVitals로 LCP·INP·CLS를 수집해 분석 도구로 전송할 수 있다.
번들 분석: 무거운 의존성과 중복 청크 찾기
최적화는 항상 측정에서 시작해야 합니다. @next/bundle-analyzer는 webpack 번들을 트리맵으로 시각화해 어떤 패키지가 얼마나 많은 공간을 차지하는지 한눈에 보여줍니다.
설치 및 설정
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
// 기존 설정
};
module.exports = withBundleAnalyzer(nextConfig);
# 분석 실행 — 브라우저에 트리맵이 자동으로 열린다
ANALYZE=true npm run build
분석 결과 해석
트리맵에서 주목해야 할 패턴은 두 가지입니다.
| 패턴 | 의미 | 해결 방향 |
|---|---|---|
| 거대한 단일 블록 | 무거운 의존성이 메인 번들에 포함됨 | dynamic import로 분리 |
| 동일 패키지가 여러 청크에 중복 | 코드 분할 경계가 잘못 설정됨 | splitChunks 설정 조정 또는 공용 레이아웃으로 hoisting |
💡 TIP
moment.js나lodash전체가 번들에 포함되는 경우가 많습니다.moment는date-fns로,lodash는lodash-es의 named import로 교체하면 tree-shaking이 적용되어 번들 크기가 크게 줄어듭니다.
// ❌ lodash 전체를 임포트하면 tree-shaking이 되지 않는다
import _ from 'lodash';
const result = _.pick(obj, ['a', 'b']);
// ✅ named import + lodash-es 조합으로 필요한 함수만 번들에 포함된다
import { pick } from 'lodash-es';
const result = pick(obj, ['a', 'b']);
dynamic import와 클라이언트 JS 전송량 줄이기
번들에서 문제를 발견했다면 다음 단계는 코드 분할입니다. Next.js에서는 next/dynamic(서버 컴포넌트 환경) 또는 React의 lazy + Suspense(클라이언트 컴포넌트 환경)를 사용합니다.
next/dynamic 활용
// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
// ✅ ssr: false — 무거운 차트 라이브러리를 서버에서 렌더하지 않고
// 클라이언트에서 필요할 때 로드한다
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
ssr: false,
loading: () => <div className="h-64 animate-pulse bg-gray-100 rounded" />,
});
export default function DashboardPage() {
return (
<main>
<h1>대시보드</h1>
{/* HeavyChart의 JS는 이 페이지를 방문할 때 처음 로드된다 */}
<HeavyChart />
</main>
);
}
조건부 렌더링과 dynamic import 조합
사용자 인터랙션이 있을 때만 필요한 컴포넌트는 이벤트 핸들러 안에서 동적으로 임포트하면 초기 JS 전송량을 더 줄일 수 있습니다.
'use client';
import { useState } from 'react';
import dynamic from 'next/dynamic';
// 컴포넌트 선언은 모듈 최상단에서 — 렌더 안에서 dynamic()을 호출하면
// 매 렌더마다 새 참조가 생겨 리마운트가 발생한다
const MarkdownEditor = dynamic(() => import('@/components/MarkdownEditor'), {
loading: () => <p>에디터 로딩 중...</p>,
});
export default function PostForm() {
const [showEditor, setShowEditor] = useState(false);
return (
<div>
<button onClick={() => setShowEditor(true)}>에디터 열기</button>
{/* ✅ 버튼을 클릭하기 전까지 MarkdownEditor 번들을 내려받지 않는다 */}
{showEditor && <MarkdownEditor />}
</div>
);
}
서드파티 스크립트 지연 로딩
next/script의 strategy prop으로 서드파티 스크립트가 Core Web Vitals에 미치는 영향을 통제할 수 있습니다.
// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
{children}
{/* ✅ afterInteractive — 페이지 인터랙티브 이후 로드. 애널리틱스에 적합 */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive"
/>
{/* ✅ lazyOnload — 브라우저 유휴 시간에 로드. 채팅 위젯 등에 적합 */}
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
</body>
</html>
);
}
next/image와 next/font로 LCP·CLS 개선
LCP(Largest Contentful Paint)와 CLS(Cumulative Layout Shift)는 각각 이미지와 폰트가 직접적인 원인인 경우가 많습니다.
next/image 고급 옵션
import Image from 'next/image';
import heroImage from '@/public/hero.jpg';
export default function HeroSection() {
return (
<section>
{/*
priority: LCP 후보 이미지에 반드시 추가. preload 링크가 <head>에 삽입된다.
sizes: 뷰포트 폭에 따른 이미지 크기 힌트. 브라우저가 srcset에서
올바른 크기를 선택하도록 도와 불필요한 대용량 이미지 다운로드를 막는다.
*/}
<Image
src={heroImage}
alt="히어로 이미지"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority
className="object-cover"
/>
</section>
);
}
⚠️ 주의
priority를 남발하면 효과가 사라집니다. Above-the-fold에 있는 LCP 후보 이미지 하나에만 사용하세요. 모든 이미지에priority를 붙이면 preload 경쟁이 발생해 오히려 LCP가 나빠질 수 있습니다.
sizes 속성을 잘못 설정하면 모바일에서 데스크톱 크기의 이미지를 내려받는 일이 발생합니다. 아래 표는 레이아웃 유형별 권장 sizes 값입니다.
| 레이아웃 | 권장 sizes |
|---|---|
| 전체 너비 히어로 | 100vw |
| 2열 그리드 | (max-width: 768px) 100vw, 50vw |
| 3열 카드 | (max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw |
| 사이드바 썸네일 | (max-width: 768px) 33vw, 10vw |
next/font로 CLS 제거
외부 폰트를 <link>로 직접 불러오면 폰트가 로드되기 전까지 시스템 폰트가 보이다가 레이아웃이 밀리는 FOUT(Flash of Unstyled Text) 현상이 CLS를 높입니다. next/font는 폰트를 빌드 타임에 다운로드하고 font-display: swap 및 크기 조정 메트릭을 자동으로 적용합니다.
// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google';
// ✅ subsets와 display를 명시적으로 지정한다
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
});
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
// preload: false — 한국어 폰트는 용량이 크므로 필요한 경우 preload를 끌 수 있다
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
// ✅ className에 variable을 넣으면 CSS 변수로 폰트를 전역 사용할 수 있다
<html lang="ko" className={`${inter.variable} ${notoSansKr.variable}`}>
<body>{children}</body>
</html>
);
}
/* globals.css */
body {
font-family: var(--font-noto-sans-kr), var(--font-inter), sans-serif;
}
💡 TIP 로컬 폰트 파일을 사용하는 경우에는
next/font/local을 씁니다.adjustFontFallback옵션을true로 설정하면 폴백 폰트의 크기를 자동 조정해 CLS를 더욱 줄일 수 있습니다.
Partial Prerendering(PPR): 정적 셸 + 동적 홀
PPR은 Next.js 14에서 실험적으로 도입된 렌더링 전략으로, 하나의 라우트 안에서 정적 셸(static shell) 과 동적 홀(dynamic holes) 을 함께 사용할 수 있게 합니다. 기존에는 페이지 전체가 정적이거나 동적이었지만, PPR을 사용하면 레이아웃·헤더 등 정적인 부분은 즉시 제공하고, 개인화·실시간 데이터가 필요한 부분만 스트리밍으로 채웁니다.
PPR 활성화 및 사용법
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Next.js 15부터는 'incremental' 옵션도 지원
},
};
module.exports = nextConfig;
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import ProductInfo from '@/components/ProductInfo'; // 정적 — 빌드 시 렌더
import PersonalizedOffers from '@/components/PersonalizedOffers'; // 동적 — 요청 시 스트리밍
import StockStatus from '@/components/StockStatus'; // 동적 — 요청 시 스트리밍
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<main>
{/*
✅ ProductInfo는 정적 셸의 일부. 빌드 타임에 렌더링되어
CDN에서 즉시 서빙된다.
*/}
<ProductInfo id={params.id} />
{/*
✅ Suspense로 감싼 동적 컴포넌트는 홀(hole)이 된다.
fallback이 정적 셸에 포함되고, 실제 콘텐츠는 스트리밍된다.
*/}
<Suspense fallback={<div className="h-20 animate-pulse bg-gray-100" />}>
<StockStatus productId={params.id} />
</Suspense>
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100" />}>
<PersonalizedOffers userId="current" productId={params.id} />
</Suspense>
</main>
);
}
⚠️ 주의 PPR이 동작하려면 동적 컴포넌트가
cookies(),headers(),noStore()등 동적 API를 사용하거나cache: 'no-store'로 데이터를 패칭해야 합니다. 그렇지 않으면 해당 컴포넌트도 정적으로 취급됩니다.
정적 셸의 범위 설계 원칙
PPR에서 가장 중요한 결정은 "어디까지를 정적 셸로 볼 것인가"입니다.
- 정적 셸에 넣을 것: 페이지 구조(레이아웃, 네비게이션, 제품 설명), 메타데이터, SEO에 중요한 콘텐츠
- 동적 홀로 만들 것: 사용자 인증 상태 의존 UI, 실시간 재고·가격, 개인화 추천
Core Web Vitals 계측: useReportWebVitals
구글의 Core Web Vitals 세 지표는 실제 사용자 경험을 가장 잘 반영합니다.
| 지표 | 의미 | 좋음 기준 |
|---|---|---|
| LCP (Largest Contentful Paint) | 가장 큰 콘텐츠 요소가 렌더되기까지의 시간 | ≤ 2.5초 |
| INP (Interaction to Next Paint) | 사용자 입력 후 다음 페인트까지의 지연 | ≤ 200ms |
| CLS (Cumulative Layout Shift) | 예상치 못한 레이아웃 이동 누적 점수 | ≤ 0.1 |
useReportWebVitals로 수집하기
useReportWebVitals는 클라이언트에서 실행되는 훅으로, 브라우저가 Web Vitals를 측정하면 콜백을 호출합니다.
// app/_components/WebVitalsReporter.tsx
'use client';
import { useReportWebVitals } from 'next/web-vitals';
export function WebVitalsReporter() {
useReportWebVitals((metric) => {
// metric.name: 'LCP' | 'INP' | 'CLS' | 'FCP' | 'TTFB'
// metric.value: 수치 (LCP·INP는 ms, CLS는 점수)
// metric.rating: 'good' | 'needs-improvement' | 'poor'
// ✅ 분석 엔드포인트로 전송
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
navigationType: metric.navigationType,
});
// sendBeacon을 사용하면 페이지 언로드 시에도 데이터가 유실되지 않는다
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
});
return null; // 렌더링 결과 없음
}
// app/layout.tsx
import { WebVitalsReporter } from './_components/WebVitalsReporter';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
{children}
{/* ✅ 레이아웃에 한 번만 추가 */}
<WebVitalsReporter />
</body>
</html>
);
}
// app/api/vitals/route.ts
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const metric = await request.json();
// 여기서 DataDog, Grafana, BigQuery 등으로 전송한다
console.log('[Web Vitals]', metric.name, metric.value, metric.rating);
return new Response(null, { status: 204 });
}
서버 컴포넌트로 워터폴 제거: 병렬 패칭과 preload 패턴
데이터 패칭 워터폴은 성능 문제의 흔한 원인입니다. 서버 컴포넌트 환경에서도 await를 순차적으로 쓰면 워터폴이 생깁니다.
워터폴 문제와 해결
// ❌ 워터폴 — user를 기다린 후 posts를 패칭한다. 총 시간 = T(user) + T(posts)
async function BadProfile({ userId }: { userId: string }) {
const user = await fetchUser(userId);
const posts = await fetchPosts(userId); // user와 posts는 서로 독립적인데 순차 실행됨
return <div>...</div>;
}
// ✅ 병렬 패칭 — Promise.all로 동시에 실행. 총 시간 = max(T(user), T(posts))
async function GoodProfile({ userId }: { userId: string }) {
const [user, posts] = await Promise.all([
fetchUser(userId),
fetchPosts(userId),
]);
return <div>...</div>;
}
preload 패턴으로 더 일찍 시작하기
부모 컴포넌트에서 자식이 필요로 할 데이터를 미리 워밍업하면, 자식 컴포넌트의 렌더가 시작되는 순간 이미 데이터가 캐시에 있어 더 빠르게 응답할 수 있습니다.
// lib/data.ts
import { cache } from 'react';
// cache()로 감싸면 같은 렌더 트리 내에서 중복 요청이 dedup된다
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`, { next: { revalidate: 60 } });
return res.json();
});
// preload 함수: 데이터를 반환하지 않고 React 캐시를 워밍업만 한다
export function preloadUser(id: string) {
void getUser(id); // ✅ 결과를 기다리지 않고 요청만 시작
}
// app/user/[id]/page.tsx
import { preloadUser, getUser } from '@/lib/data';
import UserProfile from '@/components/UserProfile';
import UserPosts from '@/components/UserPosts';
export default async function UserPage({ params }: { params: { id: string } }) {
// ✅ 렌더 트리를 탐색하기 전에 미리 패칭을 시작한다
preloadUser(params.id);
return (
<div>
{/*
UserProfile 내부에서 getUser(params.id)를 호출하더라도
이미 캐시에 데이터가 있으므로 즉시 반환된다
*/}
<UserProfile userId={params.id} />
<UserPosts userId={params.id} />
</div>
);
}
💡 TIP
react의cache()는 서버 컴포넌트 전용입니다. 같은 요청 내에서만 dedup이 적용되며, 요청이 끝나면 캐시가 초기화됩니다. 장기 캐싱은fetch의next.revalidate또는unstable_cache를 사용하세요.
요약
- 번들 분석은
ANALYZE=true npm run build로 실행하며, 트리맵에서 거대한 블록·중복 청크를 찾아 dynamic import 또는 tree-shaking으로 해결한다. next/dynamic의ssr: false와 조건부 렌더링 조합으로 초기 클라이언트 JS 전송량을 줄이고,next/script의strategy로 서드파티 스크립트 영향을 통제한다.next/image의priority+sizes로 LCP를 개선하고,next/font의 CSS 변수 방식으로 FOUT을 제거해 CLS를 낮춘다.- PPR은 Suspense 경계를 기준으로 정적 셸을 CDN에서 즉시 제공하고 동적 홀을 스트리밍으로 채우는 전략이다.
useReportWebVitals로 LCP·INP·CLS를 수집해 분석 백엔드로 전송하면 실 사용자 데이터 기반의 지속적 개선이 가능하다.Promise.all병렬 패칭과 preload 패턴으로 서버 컴포넌트의 데이터 워터폴을 제거한다.
연습문제
- 현재 프로젝트에
@next/bundle-analyzer를 설치하고 분석을 실행해 보세요. 트리맵에서 크기가 가장 큰 의존성 세 개를 찾고, 각각을 dynamic import 또는 tree-shaking으로 줄일 수 있는지 검토하세요.
힌트
node_modules/.pnpm또는node_modules아래 패키지 이름을 트리맵의 블록과 대조해 보세요.
- 다음 컴포넌트는
<RichTextViewer>라는 무거운 라이브러리를 사용합니다. 사용자가 "본문 보기" 버튼을 클릭할 때만 해당 라이브러리를 로드하도록 리팩터링하세요.
'use client';
import RichTextViewer from 'some-heavy-rich-text-lib';
export default function ArticlePage({ content }: { content: string }) {
return (
<article>
<h1>기사 제목</h1>
<RichTextViewer content={content} />
</article>
);
}
힌트
useState로 표시 여부를 관리하고,dynamic()은 컴포넌트 바깥에서 한 번만 호출하세요.
useReportWebVitals를 사용해 LCP 값이 2500ms를 초과할 때만 콘솔에 경고를 출력하는<PerformanceWatcher>컴포넌트를 작성하세요.
힌트
metric.name === 'LCP'와metric.value를 조합하세요.
- 아래 서버 컴포넌트는 데이터 워터폴이 있습니다.
Promise.all을 사용해 병렬 패칭으로 수정하고,preloadProduct함수를 만들어 페이지 컴포넌트에서 사전에 호출하도록 구조를 바꾸세요.
// 수정 전
async function ProductDetailPage({ id }: { id: string }) {
const product = await fetchProduct(id);
const reviews = await fetchReviews(id);
const related = await fetchRelatedProducts(id);
return <div>...</div>;
}
힌트
react의cache()로fetchProduct를 감싸면preloadProduct와ProductDetailPage양쪽에서 호출해도 실제 네트워크 요청은 한 번만 발생합니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Next.js 심화” 강좌에 대한 댓글입니다.