12.6 메타데이터 & SEO — metadata 객체, generateMetadata, OG 태그, sitemap
검색엔진 최적화(SEO)와 소셜 미디어 공유는 현대 웹 서비스의 필수 요소입니다.
Next.js App Router는 Metadata API를 통해 선언적으로 메타데이터를 관리하며, 빌드 시 자동으로 HTML <head> 태그를 생성합니다.
Metadata API 기본 개념
왜 Next.js Metadata API를 쓰는가?
기존 React에서는 react-helmet 같은 라이브러리로 <head> 태그를 조작했습니다.
Next.js App Router는 서버 컴포넌트에서 메타데이터를 정적/동적으로 선언할 수 있습니다.
| 방식 | 설명 | 사용 시점 |
|---|---|---|
export const metadata | 정적 메타데이터 객체 | 고정된 페이지 타이틀/설명 |
export async function generateMetadata | 동적 메타데이터 생성 함수 | DB/API에서 데이터를 가져와 메타데이터 설정 |
메타데이터 상속 구조
App Router의 메타데이터는 레이아웃 → 페이지 순으로 병합(merge)됩니다. 하위 레벨이 상위 레벨을 덮어씁니다.
app/layout.tsx ← 루트 메타데이터 (기본값)
app/blog/layout.tsx ← 블로그 섹션 메타데이터
app/blog/[id]/page.tsx ← 개별 게시글 메타데이터
기본 예제 — 정적 메타데이터
루트 레이아웃 메타데이터
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: '내 Next.js 앱', // 기본 타이틀
template: '%s | 내 Next.js 앱', // 하위 페이지 타이틀 템플릿
},
description: 'Next.js 15 App Router로 만든 현대적인 웹 앱',
keywords: ['Next.js', 'React', 'TypeScript', '웹 개발'],
authors: [{ name: '홍길동', url: 'https://example.com' }],
creator: '홍길동',
publisher: '홍길동',
// robots 설정
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
// 기본 Open Graph
openGraph: {
type: 'website',
locale: 'ko_KR',
url: 'https://example.com',
siteName: '내 Next.js 앱',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
개별 페이지 정적 메타데이터
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
// template에 의해 → "소개 | 내 Next.js 앱"으로 렌더링됨
title: '소개',
description: '우리 팀과 서비스를 소개합니다.',
openGraph: {
title: '소개 — 내 Next.js 앱',
description: '우리 팀과 서비스를 소개합니다.',
images: [
{
url: 'https://example.com/og/about.jpg',
width: 1200,
height: 630,
alt: '소개 페이지 이미지',
},
],
},
};
export default function AboutPage() {
return (
<main>
<h1>소개</h1>
<p>우리 팀을 소개합니다.</p>
</main>
);
}
Open Graph & 소셜 미디어 메타데이터
완전한 OG 태그 예제
// app/blog/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '블로그',
description: '개발, 기술, 일상에 관한 이야기를 공유합니다.',
// Open Graph — Facebook, LinkedIn, Kakao 등
openGraph: {
type: 'website',
url: 'https://example.com/blog',
title: '블로그 — 내 Next.js 앱',
description: '개발, 기술, 일상에 관한 이야기를 공유합니다.',
siteName: '내 Next.js 앱',
locale: 'ko_KR',
images: [
{
url: 'https://example.com/og/blog.png',
width: 1200,
height: 630,
alt: '블로그 메인 이미지',
type: 'image/png',
},
],
},
// Twitter Card — X(구 Twitter) 공유 시 표시
twitter: {
card: 'summary_large_image',
site: '@myaccount',
creator: '@myaccount',
title: '블로그 — 내 Next.js 앱',
description: '개발, 기술, 일상에 관한 이야기를 공유합니다.',
images: ['https://example.com/og/blog.png'],
},
};
export default function BlogPage() {
return <main><h1>블로그</h1></main>;
}
동적 메타데이터 — generateMetadata
DB나 API에서 데이터를 가져와 메타데이터를 동적으로 생성합니다.
기본 패턴
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
// 데이터 fetch 함수 (재사용)
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 },
});
if (!res.ok) return null;
return res.json();
}
// 동적 메타데이터 생성
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata // 상위 메타데이터에 접근 가능
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return {
title: '게시글을 찾을 수 없습니다',
};
}
// 상위 OG 이미지를 기본값으로 사용
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
description: post.excerpt,
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${slug}`,
publishedTime: post.createdAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: post.coverImage
? [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
...previousImages,
]
: previousImages,
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: post.coverImage ? [post.coverImage] : [],
},
};
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
return <p>게시글을 찾을 수 없습니다.</p>;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
DB에서 데이터를 가져오는 generateMetadata
// app/products/[id]/page.tsx
import type { Metadata } from 'next';
import { db } from '@/lib/db';
import { cache } from 'react';
// cache()로 감싸 generateMetadata와 page에서 중복 DB 조회 방지
const getProduct = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
if (!product) {
return {
title: '상품을 찾을 수 없습니다',
robots: { index: false }, // 404 페이지는 색인 제외
};
}
return {
title: product.name,
description: product.description.slice(0, 160), // 160자 제한
openGraph: {
type: 'website',
title: `${product.name} — ${product.category.name}`,
description: product.description.slice(0, 160),
images: product.images.map((img) => ({
url: img.url,
width: img.width,
height: img.height,
alt: img.alt || product.name,
})),
},
// 구조화된 데이터 (JSON-LD)
other: {
'script:ld+json': JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images[0]?.url,
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'KRW',
availability: product.stock > 0
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}),
},
};
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id); // 캐시에서 반환 — DB 재조회 없음
if (!product) return <p>상품을 찾을 수 없습니다.</p>;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>가격: {product.price.toLocaleString('ko-KR')}원</p>
</div>
);
}
실전 예제 — OG 이미지 동적 생성
ImageResponse로 동적 OG 이미지 생성
// app/og/route.tsx
import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
export const runtime = 'edge'; // Edge Runtime에서 실행
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const title = searchParams.get('title') || '기본 타이틀';
const description = searchParams.get('description') || '';
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1a1a2e',
padding: '40px',
}}
>
<div
style={{
fontSize: '60px',
fontWeight: 'bold',
color: '#e94560',
textAlign: 'center',
marginBottom: '20px',
}}
>
{title}
</div>
<div
style={{
fontSize: '30px',
color: '#a8a8b3',
textAlign: 'center',
}}
>
{description}
</div>
<div
style={{
position: 'absolute',
bottom: '40px',
right: '40px',
fontSize: '20px',
color: '#444',
}}
>
example.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
);
}
// 동적 OG 이미지를 generateMetadata에서 사용
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
const ogImageUrl = new URL('/og', 'https://example.com');
ogImageUrl.searchParams.set('title', post?.title || '게시글');
ogImageUrl.searchParams.set('description', post?.excerpt || '');
return {
title: post?.title,
openGraph: {
images: [
{
url: ogImageUrl.toString(),
width: 1200,
height: 630,
},
],
},
};
}
Sitemap 생성
정적 sitemap.xml
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: 'https://example.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
];
}
동적 sitemap — DB에서 URL 생성
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { db } from '@/lib/db';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';
// DB에서 모든 게시글 가져오기
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
});
const products = await db.product.findMany({
where: { published: true },
select: { id: true, updatedAt: true },
});
// 정적 페이지
const staticPages: MetadataRoute.Sitemap = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${baseUrl}/products`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
];
// 동적 게시글 페이지
const postPages: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.7,
}));
// 동적 상품 페이지
const productPages: MetadataRoute.Sitemap = products.map((product) => ({
url: `${baseUrl}/products/${product.id}`,
lastModified: product.updatedAt,
changeFrequency: 'weekly',
priority: 0.6,
}));
return [...staticPages, ...postPages, ...productPages];
}
robots.txt 생성
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/dashboard/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/private/'],
},
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
};
}
JSON-LD 구조화 데이터
검색엔진이 페이지 내용을 더 잘 이해하도록 구조화된 데이터를 추가합니다.
// components/JsonLd.tsx
interface ArticleJsonLdProps {
title: string;
description: string;
author: string;
publishedAt: string;
updatedAt: string;
imageUrl: string;
url: string;
}
export function ArticleJsonLd({
title,
description,
author,
publishedAt,
updatedAt,
imageUrl,
url,
}: ArticleJsonLdProps) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: title,
description,
image: imageUrl,
datePublished: publishedAt,
dateModified: updatedAt,
author: {
'@type': 'Person',
name: author,
},
publisher: {
'@type': 'Organization',
name: '내 사이트',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': url,
},
};
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
);
}
// app/blog/[slug]/page.tsx
import { ArticleJsonLd } from '@/components/JsonLd';
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
{/* JSON-LD는 <head>가 아닌 <body>에 삽입해도 Google이 인식 */}
<ArticleJsonLd
title={post.title}
description={post.excerpt}
author={post.author.name}
publishedAt={post.createdAt}
updatedAt={post.updatedAt}
imageUrl={post.coverImage}
url={`https://example.com/blog/${slug}`}
/>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
고수 팁
1. 메타데이터 타입 안전성
// 커스텀 메타데이터 헬퍼 함수
import type { Metadata } from 'next';
interface SeoProps {
title: string;
description: string;
image?: string;
noIndex?: boolean;
}
export function createMetadata({
title,
description,
image = 'https://example.com/og/default.png',
noIndex = false,
}: SeoProps): Metadata {
return {
title,
description,
robots: noIndex ? { index: false, follow: false } : undefined,
openGraph: {
title,
description,
images: [{ url: image, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
};
}
// 사용
export const metadata = createMetadata({
title: '상품 목록',
description: '최신 상품을 확인하세요.',
image: 'https://example.com/og/products.png',
});
2. 다국어(i18n) 메타데이터
// app/[lang]/layout.tsx
import type { Metadata } from 'next';
type Lang = 'ko' | 'en' | 'ja';
const siteMetadata: Record<Lang, Metadata> = {
ko: {
title: { default: '내 사이트', template: '%s | 내 사이트' },
description: '한국어 설명',
openGraph: { locale: 'ko_KR' },
},
en: {
title: { default: 'My Site', template: '%s | My Site' },
description: 'English description',
openGraph: { locale: 'en_US' },
},
ja: {
title: { default: '私のサイト', template: '%s | 私のサイト' },
description: '日本語の説明',
openGraph: { locale: 'ja_JP' },
},
};
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: Lang }>;
}): Promise<Metadata> {
const { lang } = await params;
return siteMetadata[lang] || siteMetadata.ko;
}
3. 캐노니컬 URL 설정
// app/products/[id]/page.tsx
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
return {
title: '상품 상세',
// 중복 콘텐츠 문제 방지 — 캐노니컬 URL 설정
alternates: {
canonical: `https://example.com/products/${id}`,
languages: {
'ko-KR': `https://example.com/ko/products/${id}`,
'en-US': `https://example.com/en/products/${id}`,
},
},
};
}
4. 메타데이터 디버깅
# 메타데이터 확인 방법
# 1. 브라우저 DevTools → Elements → <head> 태그 확인
# 2. curl로 서버 렌더링 결과 확인
curl -s https://example.com/blog/my-post | grep -i '<meta\|<title'
# 3. OG 디버거 도구 활용
# - Facebook: https://developers.facebook.com/tools/debug/
# - Twitter: https://cards-dev.twitter.com/validator
# - LinkedIn: https://www.linkedin.com/post-inspector/
5. 성능 최적화 — generateMetadata와 generateStaticParams 연동
// app/blog/[slug]/page.tsx
// 빌드 시 정적 경로 생성
export async function generateStaticParams() {
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map((post) => ({ slug: post.slug }));
}
// 각 정적 경로에 대해 메타데이터도 빌드 시 생성
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // 빌드 시 실행됨
return {
title: post?.title,
description: post?.excerpt,
};
}
6. 소셜 미디어 미리보기 테스트
// 개발 환경에서 OG 이미지 빠르게 확인
// app/og/route.tsx에 테스트 파라미터 추가
// 브라우저에서 직접 확인:
// http://localhost:3000/og?title=테스트 타이틀&description=테스트 설명
// 또는 ngrok으로 로컬 서버를 외부에 공개 후 OG 디버거 사용
// npx ngrok http 3000