Next.js의 이미지, 폰트, 메타데이터 최적화를 다룹니다. next/image, next/font, generateMetadata, OG 이미지 생성, Core Web Vitals 전략을 살펴봅니다.
10장에서 미들웨어와 Proxy 패턴을 다루었습니다. 이번 장에서는 사용자가 실제로 체감하는 성능, 즉 이미지 로딩, 폰트 렌더링, 검색 엔진 최적화를 위한 Next.js의 내장 최적화 도구들을 살펴봅니다.
next/image는 HTML <img> 태그를 대체하는 컴포넌트로, 자동 최적화를 제공합니다.
import Image from 'next/image';
import profilePic from '@/assets/profile.jpg';
export default function Profile() {
return (
<Image
src={profilePic}
alt="프로필 사진"
width={300}
height={300}
placeholder="blur" // 빌드 시 자동 blur 이미지 생성
/>
);
}로컬 이미지를 import하면 width, height, blurDataURL이 자동으로 설정됩니다. 빌드 시점에 이미지를 분석하여 레이아웃 시프트를 방지합니다.
next/image는 브라우저가 지원하는 최적의 포맷으로 자동 변환합니다. WebP를 지원하면 WebP로, AVIF를 지원하면 AVIF로 변환하여 파일 크기를 최대 50-80% 줄입니다.
<Image
src="/hero.jpg"
alt="히어로 이미지"
fill // 부모 컨테이너를 채움
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
style={{ objectFit: 'cover' }}
/>sizes 속성은 브라우저에게 이미지의 표시 크기를 알려주어, 적절한 크기의 이미지를 요청하게 합니다. 모바일에서 2000px 원본을 다운로드하는 낭비를 방지합니다.
기본적으로 모든 이미지는 Lazy Loading됩니다. 뷰포트에 들어오기 전까지 이미지를 로드하지 않아 초기 페이지 로딩이 빨라집니다.
하지만 LCP(Largest Contentful Paint) 요소인 이미지는 priority 속성을 추가해야 합니다. 이 속성은 Lazy Loading을 비활성화하고, <link rel="preload">를 생성하여 이미지를 먼저 로드합니다.
export default function HeroSection() {
return (
<section>
{/* 히어로 이미지: LCP 요소이므로 priority 추가 */}
<Image
src="/hero-banner.jpg"
alt="메인 배너"
width={1200}
height={600}
priority // preload + eager loading
/>
{/* 하단 이미지: lazy loading (기본값) */}
<Image
src="/secondary.jpg"
alt="보조 이미지"
width={600}
height={400}
/>
</section>
);
}priority를 남용하면 오히려 성능이 저하됩니다. 페이지당 1-2개의 Above-the-fold 이미지에만 적용하세요. 모든 이미지에 priority를 추가하면 브라우저의 리소스 우선순위가 무의미해집니다.
| 버전 | 변경 내용 |
|---|---|
| 15.0 | sharp가 기본 이미지 처리 라이브러리로 변경 (성능 향상) |
| 15.0 | Content-Disposition: attachment가 기본값으로 변경 |
| 16.0 | 이미지 캐시 TTL이 4시간으로 변경 (이전: 무제한) |
| 16.0 | qualities 설정으로 이미지 품질 세밀 제어 가능 |
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
// 외부 이미지 호스트 허용
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**',
},
],
// Next.js 16: 이미지 품질 세밀 제어
qualities: [25, 50, 75, 90],
// 포맷 우선순위
formats: ['image/avif', 'image/webp'],
// 캐시 TTL (초 단위, 기본 4시간)
minimumCacheTTL: 60 * 60 * 24, // 24시간으로 변경
},
};
export default nextConfig;qualities 설정은 Next.js 16에서 추가된 기능으로, 이미지 요청 시 사용할 수 있는 품질 값을 제한합니다. 이를 통해 캐시 효율이 높아지고, 불필요한 품질 변형 생성을 방지합니다.
웹 폰트를 최적화하지 않으면 두 가지 문제가 발생합니다.
next/font는 빌드 시점에 폰트를 다운로드하여 정적 자산으로 셀프 호스팅함으로써, 런타임 네트워크 요청을 제거합니다.
import { Inter, JetBrains_Mono } from 'next/font/google';
export const inter = Inter({
subsets: ['latin'],
display: 'swap', // FOUT 허용 (FOIT 방지)
variable: '--font-inter',
});
export const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-code',
});한글 폰트인 Pretendard처럼 Google Fonts에 없는 폰트는 로컬에서 로드합니다. Variable Font를 사용하면 하나의 폰트 파일로 모든 굵기를 표현할 수 있어 파일 크기가 크게 줄어듭니다.
import localFont from 'next/font/local';
export const pretendard = localFont({
src: [
{
path: '../assets/fonts/PretendardVariable.woff2',
style: 'normal',
},
],
display: 'swap',
variable: '--font-pretendard',
weight: '100 900', // Variable Font 범위
fallback: [
'-apple-system',
'BlinkMacSystemFont',
'system-ui',
'Roboto',
'sans-serif',
],
});import { pretendard, jetbrainsMono } from '@/lib/fonts';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="ko"
className={`${pretendard.variable} ${jetbrainsMono.variable}`}
>
<body className="font-sans">
{children}
</body>
</html>
);
}@theme {
--font-sans: var(--font-pretendard), -apple-system, BlinkMacSystemFont,
system-ui, sans-serif;
--font-mono: var(--font-code), 'Courier New', monospace;
}font-display: swap은 폰트가 로드되기 전에 시스템 폰트를 표시하여 FOIT를 방지합니다. 텍스트가 잠깐 깜빡이는 것이 보이지 않는 것보다 낫다는 Web Vitals 철학에 기반합니다.
App Router에서 메타데이터는 generateMetadata 함수로 생성합니다. 각 페이지에서 동적으로 메타데이터를 설정할 수 있습니다.
import type { Metadata } from 'next';
import { getPost } from '@/lib/content';
interface PageProps {
params: Promise<{ slug: string }>;
}
export async function generateMetadata(
{ params }: PageProps
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
authors: [post.author],
images: [
{
url: `/og/${post.slug}`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
},
};
}Next.js 14부터 뷰포트 관련 메타데이터는 generateViewport로 분리되었습니다. generateMetadata에 뷰포트 설정을 넣으면 경고가 발생합니다.
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: {
template: '%s | My Blog',
default: 'My Blog',
},
description: '기술 블로그',
};
// viewport는 별도 export
export const viewport: Viewport = {
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
],
width: 'device-width',
initialScale: 1,
maximumScale: 5,
};검색 엔진이 콘텐츠를 더 잘 이해할 수 있도록 JSON-LD(JavaScript Object Notation for Linked Data)를 추가합니다.
import type { BlogPosting, WithContext } from 'schema-dts';
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
const jsonLd: WithContext<BlogPosting> = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.description,
datePublished: post.date,
dateModified: post.updated ?? post.date,
author: {
'@type': 'Person',
name: 'Kreath',
url: 'https://archive.kreathlab.com',
},
image: `https://archive.kreathlab.com/og/${post.slug}`,
publisher: {
'@type': 'Person',
name: 'Kreath',
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{/* 본문 */}
</article>
</>
);
}Next.js의 ImageResponse는 JSX를 기반으로 동적 OG 이미지를 서버에서 생성합니다. Next.js 16.2에서 성능이 2-20배 향상되었습니다.
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/content';
export const runtime = 'edge';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await getPost(slug);
// 폰트 로드
const fontData = await fetch(
new URL('../../../assets/fonts/Pretendard-Bold.otf', import.meta.url)
).then(res => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px 80px',
background: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)',
color: '#ffffff',
fontFamily: 'Pretendard',
}}
>
<div style={{ fontSize: 24, color: '#888', marginBottom: 16 }}>
{post.category.toUpperCase()}
</div>
<div style={{ fontSize: 52, fontWeight: 700, lineHeight: 1.3 }}>
{post.title}
</div>
<div
style={{
fontSize: 20,
color: '#aaa',
marginTop: 24,
lineHeight: 1.6,
}}
>
{post.description}
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Pretendard',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
);
}ImageResponse는 Satori 라이브러리를 기반으로 합니다. CSS Flexbox만 지원하며, CSS Grid는 사용할 수 없습니다. 복잡한 레이아웃이 필요하면 중첩 Flex 컨테이너로 구성합니다.
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/content';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const baseUrl = 'https://archive.kreathlab.com';
const postEntries = posts.map(post => ({
url: `${baseUrl}/tech/${post.slug}`,
lastModified: new Date(post.updated ?? post.date),
changeFrequency: 'monthly' as const,
priority: 0.7,
}));
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${baseUrl}/tech`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
...postEntries,
];
}import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
},
],
sitemap: 'https://archive.kreathlab.com/sitemap.xml',
};
}지금까지 다룬 최적화 도구들이 Core Web Vitals 각 지표에 어떻게 기여하는지 정리합니다.
LCP는 가장 큰 콘텐츠 요소가 표시되기까지의 시간입니다.
| 전략 | 효과 |
|---|---|
priority 속성 | LCP 이미지를 preload하여 로딩 우선순위 상승 |
next/font self-hosting | 폰트 네트워크 요청 제거로 텍스트 빠르게 표시 |
| AVIF/WebP 자동 변환 | 이미지 크기 감소로 다운로드 시간 단축 |
sizes 속성 | 적절한 크기의 이미지만 다운로드 |
CLS는 페이지 로딩 중 레이아웃이 얼마나 이동하는지를 측정합니다.
| 전략 | 효과 |
|---|---|
width/height 명시 | 이미지 공간을 미리 확보하여 레이아웃 시프트 방지 |
placeholder="blur" | 이미지 로딩 중 blur 이미지로 공간 유지 |
font-display: swap | 폰트 교체 시 레이아웃 시프트 최소화 |
| Variable Font | 하나의 파일로 모든 굵기 지원, 폰트 교체 횟수 감소 |
INP는 사용자 상호작용에 대한 응답 시간입니다. 이미지와 폰트 최적화가 직접적인 영향은 적지만, 전체적인 리소스 로딩 최적화가 메인 스레드 부하를 줄여 간접적으로 기여합니다.
대규모 사이트에서 메타데이터를 체계적으로 관리하는 패턴입니다.
import type { Metadata } from 'next';
const SITE_NAME = 'My Archive';
const SITE_URL = 'https://archive.kreathlab.com';
const SITE_DESCRIPTION = '기술 블로그, 포트폴리오, 독서 리뷰';
interface CreateMetadataParams {
title: string;
description?: string;
path?: string;
image?: string;
type?: 'website' | 'article';
publishedTime?: string;
}
export function createMetadata({
title,
description = SITE_DESCRIPTION,
path = '',
image,
type = 'website',
publishedTime,
}: CreateMetadataParams): Metadata {
const url = `${SITE_URL}${path}`;
const ogImage = image ?? `${SITE_URL}/og/default`;
return {
title,
description,
alternates: {
canonical: url,
},
openGraph: {
title,
description,
url,
siteName: SITE_NAME,
type,
...(publishedTime && { publishedTime }),
images: [{ url: ogImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
};
}import { createMetadata } from '@/lib/metadata';
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return createMetadata({
title: post.title,
description: post.description,
path: `/tech/${post.slug}`,
image: `/og/${post.slug}`,
type: 'article',
publishedTime: post.date,
});
}이 패턴은 메타데이터 생성 로직을 중앙화하여, 모든 페이지에서 일관된 메타데이터를 생성합니다.
next/image는 자동 WebP/AVIF 변환, Lazy Loading, blur placeholder를 제공합니다. LCP 이미지에는 priority 속성을, 반응형 이미지에는 sizes 속성을 반드시 적용합니다.next/font는 빌드 시점에 폰트를 셀프 호스팅하여 런타임 네트워크 요청을 제거합니다. Variable Font와 font-display: swap으로 최적의 로딩 경험을 제공합니다.generateMetadata와 generateViewport가 분리되어 각각 SEO 메타데이터와 뷰포트 설정을 담당합니다. JSON-LD 구조화 데이터로 검색 엔진 이해도를 높입니다.ImageResponse로 동적 OG 이미지를 JSX 기반으로 생성하며, Next.js 16.2에서 2-20배 성능이 향상되었습니다.sitemap.ts와 robots.ts로 검색 엔진 크롤링을 최적화합니다.다음 장에서는 모니터링, 보안, 프로덕션 배포를 다룹니다. instrumentation.ts, CSP 헤더, Docker 배포, Build Adapters 등 프로덕션 준비에 필요한 모든 것을 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js의 프로덕션 배포를 다룹니다. instrumentation.ts, OpenTelemetry, 보안 헤더, Docker 배포, Build Adapters, 셀프 호스팅 전략을 살펴봅니다.
Next.js의 미들웨어 진화를 다룹니다. middleware.ts에서 proxy.ts로의 전환, 인증, i18n, A/B 테스팅, 속도 제한 등 고급 패턴을 살펴봅니다.
Link Shortener 앱을 구축하며 Next.js App Router의 모든 기능을 실전에 적용합니다. 프로젝트 구조 설계부터 배포까지 전 과정을 다룹니다.