본문으로 건너뛰기
Advertisement

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
Advertisement