dev.syw

번들 분석과 코드 분할로 Core Web Vitals를 끌어올린다.

성능 최적화: 번들·이미지·Core Web Vitals

입문편의 10강에서는 next/imagenext.config.js 수준의 기본 최적화를 다뤘습니다. 하지만 실무에서는 그 이상이 필요합니다. "뭔가 느리다"는 감이 있더라도 측정 없이 손을 대면 잘못된 곳을 고치기 쉽습니다. 이 레슨은 번들 분석 → 코드 분할 → 이미지·폰트 최적화 → Core Web Vitals 계측이라는 측정 주도(data-driven) 사이클을 체계적으로 익히는 것을 목표로 합니다.

Partial Prerendering(PPR)과 서버 컴포넌트 기반 병렬 패칭 전략도 함께 다루므로, 렌더링 내부 구조(1강)와 캐싱 아키텍처(2강)를 먼저 학습한 뒤 이 레슨을 보는 것을 권장합니다.

학습 목표

  • @next/bundle-analyzer 로 번들을 시각화하고 무거운 의존성·중복 청크를 찾을 수 있다.
  • dynamic import와 Suspense 경계를 조합해 클라이언트 JS 전송량을 줄일 수 있다.
  • next/imagenext/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);
JavaScript
# 분석 실행 — 브라우저에 트리맵이 자동으로 열린다
ANALYZE=true npm run build

분석 결과 해석

트리맵에서 주목해야 할 패턴은 두 가지입니다.

패턴의미해결 방향
거대한 단일 블록무거운 의존성이 메인 번들에 포함됨dynamic import로 분리
동일 패키지가 여러 청크에 중복코드 분할 경계가 잘못 설정됨splitChunks 설정 조정 또는 공용 레이아웃으로 hoisting

💡 TIP moment.jslodash 전체가 번들에 포함되는 경우가 많습니다. momentdate-fns로, lodashlodash-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']);
JavaScript

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/scriptstrategy 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;
JavaScript
// 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 reactcache()는 서버 컴포넌트 전용입니다. 같은 요청 내에서만 dedup이 적용되며, 요청이 끝나면 캐시가 초기화됩니다. 장기 캐싱은 fetchnext.revalidate 또는 unstable_cache를 사용하세요.

요약

  • 번들 분석ANALYZE=true npm run build로 실행하며, 트리맵에서 거대한 블록·중복 청크를 찾아 dynamic import 또는 tree-shaking으로 해결한다.
  • next/dynamicssr: false와 조건부 렌더링 조합으로 초기 클라이언트 JS 전송량을 줄이고, next/scriptstrategy로 서드파티 스크립트 영향을 통제한다.
  • next/imagepriority + sizes로 LCP를 개선하고, next/font 의 CSS 변수 방식으로 FOUT을 제거해 CLS를 낮춘다.
  • PPR은 Suspense 경계를 기준으로 정적 셸을 CDN에서 즉시 제공하고 동적 홀을 스트리밍으로 채우는 전략이다.
  • useReportWebVitals 로 LCP·INP·CLS를 수집해 분석 백엔드로 전송하면 실 사용자 데이터 기반의 지속적 개선이 가능하다.
  • Promise.all 병렬 패칭과 preload 패턴으로 서버 컴포넌트의 데이터 워터폴을 제거한다.

연습문제

  1. 현재 프로젝트에 @next/bundle-analyzer를 설치하고 분석을 실행해 보세요. 트리맵에서 크기가 가장 큰 의존성 세 개를 찾고, 각각을 dynamic import 또는 tree-shaking으로 줄일 수 있는지 검토하세요.

힌트 node_modules/.pnpm 또는 node_modules 아래 패키지 이름을 트리맵의 블록과 대조해 보세요.

  1. 다음 컴포넌트는 <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()은 컴포넌트 바깥에서 한 번만 호출하세요.

  1. useReportWebVitals를 사용해 LCP 값이 2500ms를 초과할 때만 콘솔에 경고를 출력하는 <PerformanceWatcher> 컴포넌트를 작성하세요.

힌트 metric.name === 'LCP'metric.value를 조합하세요.

  1. 아래 서버 컴포넌트는 데이터 워터폴이 있습니다. 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>;
}

힌트 reactcache()fetchProduct를 감싸면 preloadProductProductDetailPage 양쪽에서 호출해도 실제 네트워크 요청은 한 번만 발생합니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Next.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.