[이제와서 시작하는 Next.js 마스터하기 #6] 이미지, 폰트, 메타데이터 최적화
“이미지 한 줄로 자동 최적화?” - Next.js의 Image 컴포넌트는 마법입니다!
🎯 이 글에서 배울 내용
- next/image로 이미지 최적화
- next/font로 폰트 최적화
- 메타데이터로 SEO 향상
- Open Graph와 Twitter 카드
- 최적화 후 실제로 확인할 체크리스트
예상 소요 시간: 40분
🖼️ 이미지 최적화 (next/image)
1. 기본 사용법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/page.js
import Image from 'next/image';
export default function Home() {
return (
<div>
<Image
src="/hero.jpg"
alt="히어로 이미지"
width={1200}
height={600}
priority // 우선 로딩
/>
</div>
);
}
자동 최적화:
- ✅ WebP/AVIF 형식으로 자동 변환
- ✅ 반응형 이미지 생성
- ✅ 지연 로딩 (lazy loading)
- ✅ 블러 placeholder
priority는 모든 이미지에 붙이는 옵션이 아닙니다. 첫 화면에서 가장 중요한 LCP 이미지, 예를 들면 hero 이미지나 상품 대표 이미지에만 사용하세요. 화면 아래쪽 이미지까지 우선 로딩하면 오히려 초기 로딩이 느려질 수 있습니다.
2. 외부 이미지
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
pathname: '/photo-*',
},
],
},
};
export default nextConfig;
remotePatterns는 최대한 좁게 잡는 편이 좋습니다. hostname: '**'처럼 모든 외부 이미지를 허용하면 의도하지 않은 URL까지 이미지 최적화 대상이 될 수 있습니다.
1
2
3
4
5
6
<Image
src="https://images.unsplash.com/photo-..."
alt="Unsplash 이미지"
width={800}
height={600}
/>
3. sizes를 꼭 지정해야 하는 경우
fill을 쓰거나 반응형 레이아웃에서 이미지가 차지하는 너비가 화면마다 달라진다면 sizes를 함께 적어야 브라우저가 적절한 이미지 크기를 고릅니다.
1
2
3
4
5
6
7
8
9
10
<div className="relative aspect-[16/9] w-full">
<Image
src="/hero.jpg"
alt="서비스 대시보드 미리보기"
fill
priority
sizes="(max-width: 768px) 100vw, 1200px"
style={{ objectFit: 'cover' }}
/>
</div>
카드 그리드라면 이렇게 더 좁게 잡을 수 있습니다.
1
2
3
4
5
6
7
<Image
src={post.cover}
alt={post.title}
width={640}
height={360}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
sizes가 없으면 실제 표시 크기보다 큰 이미지를 내려받는 일이 생깁니다. Lighthouse에서 LCP 이미지가 크다고 나오면 가장 먼저 이 값을 확인하세요.
4. 이미지 선택 기준
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 본문 안 고정 크기 이미지 | width/height | 레이아웃 이동을 줄이기 쉽다 |
| 카드 썸네일 | width/height + sizes | 그리드 너비에 맞는 파일을 받는다 |
| hero 배경형 이미지 | fill + sizes + priority | 첫 화면 LCP를 제어하기 좋다 |
| 아이콘, 로고 SVG | 일반 img 또는 inline SVG | 작은 벡터는 Image 최적화 이점이 적다 |
| 사용자 업로드 원본 | CDN 변환 또는 저장 시 리사이즈 | 원본이 너무 크면 런타임 비용이 커진다 |
이미지 최적화의 핵심은 “무조건 Image 컴포넌트”가 아니라, 브라우저가 필요한 크기의 이미지만 받도록 힌트를 주는 것입니다.
5. Fill 모드 (부모 크기 채우기)
1
2
3
4
5
6
7
8
<div className="relative w-full h-96">
<Image
src="/background.jpg"
alt="배경"
fill
style={{ objectFit: 'cover' }}
/>
</div>
6. 블러 Placeholder
1
2
3
4
5
6
7
8
<Image
src="/profile.jpg"
alt="프로필"
width={400}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..." // 작은 블러 이미지
/>
🔤 폰트 최적화 (next/font)
1. Google Fonts 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/layout.js
import { Inter, Roboto_Mono } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 폰트 로딩 전략
});
const robotoMono = Roboto_Mono({
subsets: ['latin'],
weight: ['400', '700'],
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={inter.className}>
<body>
{children}
</body>
</html>
);
}
장점:
- ✅ 자동으로 최적화된 폰트 로딩
- ✅ FOUT (Flash of Unstyled Text) 방지
- ✅ 자동 self-hosting (Google CDN 의존 없음)
Next.js의 next/font는 Google Fonts도 빌드 시점에 가져와 앱에서 직접 서빙합니다. 그래서 브라우저가 런타임에 Google 서버로 폰트를 요청하지 않아 개인정보와 성능 양쪽에서 이점이 있습니다.
2. 로컬 폰트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// app/layout.js
import localFont from 'next/font/local';
const myFont = localFont({
src: './fonts/MyFont.woff2',
display: 'swap',
weight: '400',
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={myFont.className}>
<body>{children}</body>
</html>
);
}
한글 사이트라면 폰트 파일 크기를 꼭 확인하세요. 전체 굵기와 전체 문자 집합을 한 번에 넣으면 JS를 줄여도 폰트가 병목이 될 수 있습니다. 가능하면 필요한 굵기만 고르고, 본문 폰트와 코드 폰트를 분리해 적용하세요.
3. 여러 폰트 조합
1
2
3
4
5
6
7
8
9
10
11
12
import { Inter, Noto_Sans_KR } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
const noto = Noto_Sans_KR({ subsets: ['korean'], variable: '--font-noto' });
export default function RootLayout({ children }) {
return (
<html lang="ko" className={`${inter.variable} ${noto.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}
1
2
3
4
5
6
7
8
9
10
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
font-family: var(--font-inter), var(--font-noto), sans-serif;
}
}
4. 폰트 적용 점검표
- Root Layout에서 전역 폰트를 한 번만 적용했는가?
- 제목/본문/코드 폰트가 너무 많이 섞이지 않는가?
- 한글 폰트 weight를 불필요하게 많이 불러오지 않는가?
- DevTools Network 탭에서 외부 폰트 요청이 남아 있지 않은가?
- Lighthouse에서 Cumulative Layout Shift가 증가하지 않는가?
📄 메타데이터와 SEO
1. 정적 메타데이터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/layout.js
export const metadata = {
title: 'My Blog',
description: 'Next.js로 만든 블로그',
keywords: ['Next.js', 'React', '블로그'],
};
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
);
}
metadata와 generateMetadata는 Server Component에서만 지원됩니다. Client Component 파일에 "use client"를 붙인 뒤 같은 파일에서 metadata를 export하려고 하면 구조를 나눠야 합니다.
2. 동적 메타데이터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// app/blog/[slug]/page.js
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
동적 메타데이터는 페이지 데이터와 같은 source를 쓰는 편이 안전합니다. 본문 제목은 “A”인데 OG 제목은 “B”인 상태가 오래 남으면 검색 결과와 공유 미리보기가 어긋납니다.
3. Open Graph와 Twitter 카드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
export const metadata = {
title: 'My Awesome Post',
description: 'This is an awesome post about Next.js',
// Open Graph (페이스북, 카카오톡 등)
openGraph: {
title: 'My Awesome Post',
description: 'This is an awesome post about Next.js',
url: 'https://mysite.com/blog/awesome-post',
siteName: 'My Blog',
images: [
{
url: 'https://mysite.com/og-image.jpg',
width: 1200,
height: 630,
alt: 'Post cover image',
},
],
locale: 'ko_KR',
type: 'article',
},
// Twitter 카드
twitter: {
card: 'summary_large_image',
title: 'My Awesome Post',
description: 'This is an awesome post about Next.js',
images: ['https://mysite.com/twitter-image.jpg'],
creator: '@myhandle',
},
// 추가 메타 태그
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
};
4. 파일 기반 메타데이터
Next.js는 app 폴더 안의 특별한 파일 이름을 메타데이터로 자동 인식합니다.
| 파일 | 역할 |
|---|---|
app/favicon.ico | 브라우저 탭과 북마크 아이콘 |
app/icon.png | 앱 아이콘 |
app/apple-icon.png | iOS 홈 화면 아이콘 |
app/opengraph-image.png | 기본 Open Graph 이미지 |
app/twitter-image.png | 기본 Twitter 카드 이미지 |
app/robots.txt | 크롤러 접근 정책 |
app/sitemap.xml | 검색엔진용 사이트맵 |
정적인 사이트라면 이미지 파일을 그대로 두는 방식이 가장 단순합니다. 블로그처럼 글마다 다른 OG 이미지가 필요하다면 route segment 안에 opengraph-image.tsx를 두고 동적으로 만들 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const size = {
width: 1200,
height: 630,
};
export const contentType = 'image/png';
export default async function Image({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#111827',
color: 'white',
fontSize: 72,
padding: 80,
}}
>
{post.title}
</div>
)
);
}
동적 OG 이미지는 예쁘게 만드는 것보다 먼저 실패하지 않게 만드는 것이 중요합니다. 제목이 길 때 줄바꿈이 되는지, 한글 폰트가 깨지지 않는지, 404 글에서 fallback이 있는지를 확인하세요.
🌍 다국어 메타데이터
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// app/[lang]/page.js
export async function generateMetadata({ params }) {
const { lang } = await params;
const translations = {
en: {
title: 'Welcome to My Site',
description: 'This is my awesome website',
},
ko: {
title: '제 사이트에 오신 것을 환영합니다',
description: '멋진 웹사이트입니다',
},
};
const t = translations[lang] || translations.en;
return {
title: t.title,
description: t.description,
alternates: {
canonical: `https://mysite.com/${lang}`,
languages: {
'en-US': 'https://mysite.com/en',
'ko-KR': 'https://mysite.com/ko',
},
},
};
}
✅ 최적화 후 확인 루틴
최적화 코드를 넣었다면 숫자로 확인해야 합니다.
1
2
npm run build
npm run start
브라우저에서 확인할 항목은 네 가지입니다.
- Lighthouse의 LCP 이미지가 의도한 hero 이미지인지 확인한다.
- Network 탭에서 이미지가 표시 크기보다 과하게 크지 않은지 본다.
- 폰트 요청이 자체 도메인에서 서빙되는지 확인한다.
- 공유 디버거 또는
<head>태그에서 title, description, OG 이미지가 맞는지 본다.
Vercel에 배포한다면 Speed Insights나 Web Analytics를 함께 보세요. Lighthouse 점수는 실험실 지표이고, 실제 사용자의 네트워크와 기기는 더 다양합니다.
🔍 자주 묻는 질문 (FAQ)
Q1: 일반 img 태그 대신 Image를 써야 하나요?
답: 네! Image 컴포넌트가 훨씬 좋습니다.
일반 img 태그:
1
<img src="/large-image.jpg" /> // 5MB 원본 그대로 로딩
next/image:
1
2
3
4
<Image src="/large-image.jpg" alt="제품 화면" width={800} height={600} />
// → 자동으로 WebP로 변환 (500KB)
// → 화면 크기에 맞게 여러 버전 생성
// → 지연 로딩
단, 실제 성능 차이는 원본 크기, CDN, sizes, 네트워크 상태에 따라 달라집니다. “무조건 몇 배 빠르다”보다 빌드 후 Lighthouse와 Network 탭으로 확인하는 습관이 더 중요합니다.
Q2: 폰트 깜빡임(FOUT)을 방지하려면?
답: next/font를 사용하세요!
1
2
3
4
5
6
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // ← 이게 핵심!
});
display 옵션:
swap: 폰트 로딩 전 시스템 폰트 사용optional: 네트워크 상태에 따라 결정block: 폰트 로딩까지 텍스트 숨김 (권장 안 함)
🎯 오늘 배운 내용 정리
- 이미지 최적화
- next/image 자동 최적화
- LCP 이미지만
priority fill과 반응형 그리드에는sizes지정
- 폰트 최적화
- next/font Google Fonts
- 로컬 폰트
- self-hosting과 레이아웃 이동 방지
- 메타데이터
- 정적/동적 메타데이터
- 파일 기반 favicon/OG 이미지
- 다국어 canonical과 alternates
- 검증
- Lighthouse, Network,
<head>태그 확인 - 실제 사용자 지표와 함께 판단
- Lighthouse, Network,
📚 시리즈 네비게이션
“최적화는 어렵지 않습니다. Next.js가 대부분 해줍니다!” ⚡
