본문으로 건너뛰기
Advertisement

13.4 성능 최적화 — next/image, next/font, next/script, Partial Prerendering

성능이 왜 중요한가?

웹 성능은 사용자 경험과 비즈니스 지표 모두에 직접적인 영향을 미칩니다. Google의 Core Web Vitals 기준으로 LCP(Largest Contentful Paint), INP(Interaction to Next Paint), CLS(Cumulative Layout Shift) 세 가지 지표가 SEO 순위에도 반영됩니다.

Next.js는 이런 성능 최적화를 위한 내장 컴포넌트와 기능을 제공하며, 별도 설정 없이도 최적화된 결과를 얻을 수 있습니다.

성능 지표 요약

지표의미좋음보통나쁨
LCP최대 콘텐츠 페인트 (주요 콘텐츠 로딩 시간)≤ 2.5s≤ 4s> 4s
INP상호작용 응답 시간≤ 200ms≤ 500ms> 500ms
CLS누적 레이아웃 이동≤ 0.1≤ 0.25> 0.25
FCP첫 번째 콘텐츠 페인트≤ 1.8s≤ 3s> 3s

next/image — 이미지 최적화

<img> 대신 <Image>를 쓰는가?

일반 HTML <img> 태그를 사용하면 원본 이미지를 그대로 불러옵니다. Next.js의 <Image> 컴포넌트는 다음을 자동으로 처리합니다:

  • WebP/AVIF 포맷 변환: 브라우저 지원에 따라 최적 포맷 자동 선택
  • 반응형 크기 조절: 디바이스 해상도에 맞는 크기로 리사이징
  • 지연 로딩(Lazy Loading): 뷰포트 밖 이미지는 로딩 연기
  • 레이아웃 이동 방지: width/height 속성으로 CLS 제거
  • 우선순위 제어: 히어로 이미지에 priority 설정으로 LCP 개선

기본 사용법

import Image from 'next/image';

// 로컬 이미지 (자동으로 크기 정보 추론)
import profilePic from '/public/profile.jpg';

export default function Avatar() {
return (
<Image
src={profilePic}
alt="프로필 사진"
// width/height 자동 추론 (로컬 이미지)
className="rounded-full"
/>
);
}

// 원격 이미지 (width, height 필수)
export function HeroImage() {
return (
<Image
src="https://images.unsplash.com/photo-example"
alt="히어로 이미지"
width={1200}
height={630}
priority // LCP 이미지에 사용 — 프리로드
className="w-full object-cover"
/>
);
}

원격 이미지 도메인 허용

외부 URL 이미지는 보안을 위해 next.config.js에 도메인을 등록해야 합니다.

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
port: '',
pathname: '/**',
},
{
protocol: 'https',
hostname: '**.amazonaws.com', // 와일드카드 지원
},
],
},
};

module.exports = nextConfig;

fill 모드 — 부모 요소를 채우는 이미지

import Image from 'next/image';

export function CoverImage() {
return (
// 부모 요소에 position: relative, 명시적 크기 필요
<div className="relative h-64 w-full overflow-hidden rounded-lg">
<Image
src="/cover.jpg"
alt="커버 이미지"
fill
sizes="100vw"
className="object-cover" // object-cover로 비율 유지하며 채우기
/>
</div>
);
}

sizes 속성 — 반응형 최적화

sizes 속성은 브라우저에게 이미지가 실제로 차지하는 크기를 알려줍니다. 올바른 sizes 값을 지정하면 불필요하게 큰 이미지를 다운로드하지 않습니다.

<Image
src="/product.jpg"
alt="상품 이미지"
width={800}
height={600}
sizes="
(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw
"
/>

위 설정의 의미:

  • 모바일(768px 이하): 화면 전체 너비
  • 태블릿(1200px 이하): 화면 절반
  • 데스크탑: 화면의 1/3

placeholder — 로딩 중 블러 효과

import Image from 'next/image';
import productImg from '/public/product.jpg';

export function ProductCard() {
return (
<Image
src={productImg}
alt="상품"
width={400}
height={300}
placeholder="blur" // 로딩 중 블러 처리 (로컬 이미지 자동 생성)
// placeholder="blur"
// blurDataURL="data:image/png;base64,..." // 원격 이미지는 직접 제공
/>
);
}

next/font — 폰트 최적화

폰트 로딩의 문제점

외부 폰트 서비스(Google Fonts 등)를 직접 사용하면:

  1. 추가 DNS 조회 및 네트워크 요청 발생
  2. FOUT(Flash of Unstyled Text): 폰트 로딩 전 기본 폰트가 잠깐 표시됨
  3. CLS 발생: 폰트 교체 시 레이아웃 이동

next/font는 빌드 타임에 폰트를 다운로드해 자체 서버에서 제공하므로 이 문제를 모두 해결합니다.

Google Fonts 사용

// app/layout.tsx
import { Inter, Noto_Sans_KR, Fira_Code } from 'next/font/google';

// 라틴 폰트
const inter = Inter({
subsets: ['latin'],
display: 'swap', // FOUT 방지를 위한 font-display: swap
variable: '--font-inter',
});

// 한국어 폰트
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700'], // 필요한 굵기만 로드
display: 'swap',
variable: '--font-noto-kr',
});

// 코드 폰트
const firaCode = Fira_Code({
subsets: ['latin'],
display: 'swap',
variable: '--font-fira-code',
});

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="ko"
className={`${inter.variable} ${notoSansKr.variable} ${firaCode.variable}`}
>
<body className="font-sans">{children}</body>
</html>
);
}

Tailwind CSS와 연동:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./app/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['var(--font-inter)', 'var(--font-noto-kr)', 'sans-serif'],
mono: ['var(--font-fira-code)', 'monospace'],
},
},
},
};

로컬 폰트 사용

import localFont from 'next/font/local';

const pretendard = localFont({
src: [
{
path: '../public/fonts/Pretendard-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../public/fonts/Pretendard-Bold.woff2',
weight: '700',
style: 'normal',
},
],
display: 'swap',
variable: '--font-pretendard',
});

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

next/script — 서드파티 스크립트 최적화

스크립트 로딩 전략

외부 스크립트(Google Analytics, 채팅봇, 광고 등)를 잘못 로드하면 페이지 렌더링을 차단해 성능이 크게 저하됩니다. next/script는 로딩 시점을 제어할 수 있습니다.

전략설명사용 예
beforeInteractiveHTML 파싱 전에 로드 — 렌더링 차단폴리필, 크리티컬 스크립트
afterInteractive페이지 하이드레이션 후 로드 (기본값)Google Analytics, 태그 매니저
lazyOnload브라우저 유휴 시간에 로드채팅봇, 소셜 위젯
workerWeb Worker에서 로드성능 집약적 연산

기본 사용 예

// app/layout.tsx
import Script from 'next/script';

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

{/* Google Analytics */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'GA_MEASUREMENT_ID');
`}
</Script>

{/* 채팅 위젯 — 유휴 시간에 로드 */}
<Script
src="https://cdn.example-chat.com/widget.js"
strategy="lazyOnload"
/>
</body>
</html>
);
}

이벤트 핸들러

'use client';

import Script from 'next/script';

export default function MapPage() {
return (
<>
<div id="map" className="h-96 w-full" />
<Script
src="https://maps.googleapis.com/maps/api/js?key=API_KEY"
strategy="afterInteractive"
onLoad={() => {
// 스크립트 로드 완료 후 지도 초기화
const map = new window.google.maps.Map(document.getElementById('map')!, {
center: { lat: 37.5665, lng: 126.978 },
zoom: 13,
});
}}
onError={(e) => {
console.error('Google Maps 로드 실패:', e);
}}
/>
</>
);
}

Partial Prerendering (PPR) — 혼합 렌더링

PPR이란?

Partial Prerendering은 Next.js 15에서 도입된 혁신적인 렌더링 방식입니다. 하나의 페이지에서 정적 콘텐츠와 동적 콘텐츠를 동시에 최적화합니다.

기존 렌더링 방식의 한계:

  • SSG: 전체 페이지가 정적 → 개인화 불가
  • SSR: 전체 페이지가 동적 → 첫 바이트(TTFB) 느림

PPR 방식:

  • 정적 쉘(Static Shell): 레이아웃, 네비게이션, 히어로 이미지 → 즉시 전송(캐시)
  • 동적 콘텐츠: 장바구니, 추천 상품, 사용자 정보 → Suspense로 스트리밍
요청 → 정적 쉘 즉시 응답 → 동적 콘텐츠 스트리밍 완료
(캐시 HIT) (서버에서 렌더링 중...)

PPR 활성화

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: 'incremental', // 점진적 적용 (특정 페이지만 PPR 활성화)
},
};

module.exports = nextConfig;

PPR 적용 예제

// app/shop/page.tsx
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';

// 이 페이지에만 PPR 활성화
export const experimental_ppr = true;

// 정적 컴포넌트 — 빌드 타임에 생성, 즉시 전송
function StaticHero() {
return (
<section className="bg-gradient-to-r from-blue-600 to-purple-600 py-20 text-center text-white">
<h1 className="text-4xl font-bold">쇼핑몰에 오신 것을 환영합니다</h1>
<p className="mt-4 text-xl">최고의 상품을 최저가로</p>
</section>
);
}

// 동적 컴포넌트 — 요청마다 새로 렌더링
async function PersonalizedRecommendations() {
noStore(); // 동적 렌더링 강제
const userId = await getCurrentUserId(); // 쿠키에서 사용자 ID 읽기
const recommendations = await fetchRecommendations(userId);

return (
<div className="grid grid-cols-4 gap-4">
{recommendations.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

async function CartSummary() {
noStore();
const cart = await getCartItems();
return <div>장바구니: {cart.length}개 상품</div>;
}

export default function ShopPage() {
return (
<main>
{/* 정적 — 즉시 표시 */}
<StaticHero />

{/* 동적 — Suspense로 스트리밍 */}
<section className="container mx-auto py-12">
<h2 className="mb-6 text-2xl font-bold">추천 상품</h2>
<Suspense
fallback={
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-48 animate-pulse rounded-lg bg-gray-200" />
))}
</div>
}
>
<PersonalizedRecommendations />
</Suspense>
</section>

<Suspense fallback={<div>장바구니 로딩 중...</div>}>
<CartSummary />
</Suspense>
</main>
);
}

실전 예제 — 종합 성능 최적화 쇼핑몰 페이지

최적화된 상품 목록 페이지

// app/products/page.tsx
import Image from 'next/image';
import { Suspense } from 'react';

// 상품 타입
interface Product {
id: number;
name: string;
price: number;
image: string;
rating: number;
}

// 상품 카드 컴포넌트 (이미지 최적화 포함)
function ProductCard({ product, priority = false }: {
product: Product;
priority?: boolean;
}) {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-md transition hover:-translate-y-1 hover:shadow-xl">
<div className="relative h-56">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover"
priority={priority} // 첫 4개 상품은 priority
placeholder="blur"
blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900">{product.name}</h3>
<div className="mt-1 flex items-center justify-between">
<span className="text-lg font-bold text-blue-600">
{product.price.toLocaleString()}
</span>
<span className="text-sm text-yellow-500">
{product.rating}
</span>
</div>
<button className="mt-3 w-full rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700">
장바구니 담기
</button>
</div>
</div>
);
}

// 스켈레톤 로딩 UI
function ProductSkeleton() {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-md">
<div className="h-56 animate-pulse bg-gray-200" />
<div className="p-4 space-y-2">
<div className="h-4 animate-pulse rounded bg-gray-200" />
<div className="h-4 w-2/3 animate-pulse rounded bg-gray-200" />
<div className="h-8 animate-pulse rounded bg-gray-200" />
</div>
</div>
);
}

// 데이터 페칭 컴포넌트
async function ProductList() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
const products: Product[] = await res.json();

return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product, index) => (
<ProductCard
key={product.id}
product={product}
priority={index < 4} // 첫 4개 이미지 우선 로드
/>
))}
</div>
);
}

export default function ProductsPage() {
return (
<main className="container mx-auto px-4 py-8">
<h1 className="mb-8 text-3xl font-bold text-gray-900">전체 상품</h1>
<Suspense
fallback={
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<ProductSkeleton key={i} />
))}
</div>
}
>
<ProductList />
</Suspense>
</main>
);
}

캐싱 전략 비교

// 다양한 캐싱 전략 예시

// 1. 정적 캐시 (빌드 타임 고정)
const staticData = await fetch('https://api.example.com/config');

// 2. 시간 기반 재검증 (ISR)
const revalidatedData = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // 1시간
});

// 3. 태그 기반 재검증 (On-demand ISR)
const taggedData = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // revalidateTag('posts')로 수동 갱신
});

// 4. 동적 (캐시 없음)
const dynamicData = await fetch('https://api.example.com/user', {
cache: 'no-store',
});

On-demand 재검증 Route Handler:

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const { tag, secret } = await req.json();

// 비밀 키 검증
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

revalidateTag(tag);
return NextResponse.json({ revalidated: true, tag });
}

고수 팁

1. Bundle Analyzer로 번들 크기 분석

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
// 기존 next.config
});
ANALYZE=true npm run build

분석 후 큰 패키지는 동적 임포트로 코드 스플리팅:

import dynamic from 'next/dynamic';

// 무거운 차트 라이브러리를 필요할 때만 로드
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div className="h-64 animate-pulse bg-gray-200" />,
ssr: false, // 서버 렌더링 비활성화 (브라우저 전용 API 사용 시)
});

2. 이미지 preload로 LCP 개선

히어로 이미지처럼 뷰포트 최상단에 위치하는 이미지는 priority 속성을 설정합니다.

// 절대 하면 안 되는 것
<Image src="/hero.jpg" alt="히어로" width={1200} height={630} />
// ↑ priority 빠짐 → LCP 점수 저하

// 올바른 방법
<Image src="/hero.jpg" alt="히어로" width={1200} height={630} priority />

3. React Server Components로 클라이언트 번들 최소화

데이터 페칭은 Server Component에서, 인터랙션만 Client Component에서 처리합니다.

// app/dashboard/page.tsx — Server Component (기본값)
async function Dashboard() {
const data = await fetchData(); // 서버에서만 실행, 번들에 포함 안 됨

return (
<div>
<StaticContent data={data} /> {/* Server Component */}
<InteractiveButton /> {/* Client Component만 번들에 포함 */}
</div>
);
}

4. 폰트 서브셋 지정으로 로드 크기 최소화

// 나쁜 예: 전체 폰트 로드
const font = Inter({ subsets: ['latin', 'latin-ext', 'cyrillic'] });

// 좋은 예: 실제 사용하는 서브셋만 로드
const font = Inter({ subsets: ['latin'] }); // 영문만 사용한다면
const krFont = Noto_Sans_KR({ subsets: ['latin'] }); // 한글은 별도 폰트로

5. next/scriptstrategy="worker" 실험적 기능

무거운 스크립트를 Web Worker로 분리해 메인 스레드 차단을 방지합니다.

// next.config.js
module.exports = {
experimental: {
nextScriptWorkers: true,
},
};
// 사용 예
<Script src="/heavy-analytics.js" strategy="worker" />

6. generateMetadata로 동적 OG 이미지 최적화

// app/products/[id]/page.tsx
import { Metadata } from 'next';

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());

return {
title: product.name,
description: product.description,
openGraph: {
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
],
},
};
}

7. Lighthouse CI로 성능 회귀 방지

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [push]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci && npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v10
with:
urls: |
http://localhost:3000/
http://localhost:3000/products
uploadArtifacts: true
temporaryPublicStorage: true
Advertisement