generateMetadata — 동적 메타데이터 심화
Next.js의 generateMetadata 함수로 DB 데이터를 기반으로 메타데이터를 동적 생성하는 방법을 설명합니다. parent 파라미터로 상위 메타데이터 상속, fetch dedup 최적화, notFound 처리, 정적 생성과의 조합까지 다룹니다.
지난 글에서 metadata 객체로 정적 메타데이터를 설정하고, generateMetadata 함수로 동적 데이터를 다루는 방법의 기초를 살펴봤다. 이번에는 generateMetadata를 더 깊이 파고든다. 상위 메타데이터 상속, fetch 중복 제거, generateStaticParams와의 조합, 그리고 404 처리까지 실전에서 자주 마주치는 패턴을 다룬다.
generateMetadata 기본 시그니처
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
// ...
}
두 번째 파라미터 parent는 상위 레이아웃의 메타데이터를 ResolvingMetadata 타입으로 제공한다. await parent로 실제 값을 가져올 수 있다.
fetch 중복 제거 (Dedup)
generateMetadata에서 fetch로 데이터를 가져오면, 동일 페이지의 Server Component에서 동일한 URL로 fetch할 때 자동으로 중복 제거된다. React의 cache() 기반 deduplication 덕분에 실제 네트워크 요청은 한 번만 일어난다.
// app/posts/[slug]/page.tsx
async function getPost(slug: string) {
const res = await fetch(`${process.env.API_URL}/posts/${slug}`)
if (!res.ok) return null
return res.json()
}
// generateMetadata와 Page 컴포넌트가 같은 함수를 호출해도 fetch는 1회
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) return { title: '포스트 없음' }
return { title: post.title, description: post.summary }
}
export default async function PostPage({ params }: Props) {
const post = await getPost(params.slug) // 캐시에서 반환 — 실제 fetch 없음
if (!post) notFound()
return <article>...</article>
}
단, dedup은 동일한 요청 생명주기(같은 렌더링 패스) 안에서만 작동한다. fetch 대신 ORM을 직접 사용한다면 React.cache()로 동일한 효과를 낼 수 있다.
import { cache } from 'react'
import { db } from '@/lib/db'
const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } })
})
parent — 상위 메타데이터 상속
상위 레이아웃의 이미지나 키워드를 하위 페이지의 메타데이터에 병합할 때 parent를 사용한다.
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.slug)
// 상위 OG 이미지를 가져와 하위 이미지와 병합
const parentImages = (await parent).openGraph?.images ?? []
const postOgImage = `/api/og?slug=${params.slug}`
return {
title: post?.title ?? 'Not Found',
openGraph: {
images: [postOgImage, ...parentImages],
},
}
}
parent를 await하기 전에는 렌더링이 블로킹되지 않는다. 실제로 필요한 값만 꺼내 쓰는 것이 권장 패턴이다.
notFound와 연동
포스트가 없을 때 notFound()를 호출하면 Next.js가 자동으로 not-found.tsx를 렌더링한다. generateMetadata에서도 이를 일관되게 처리해야 한다.
import { notFound } from 'next/navigation'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) {
// 별도 메타데이터를 반환할 수 있음 — not-found.tsx가 렌더링됨
return { title: '페이지를 찾을 수 없습니다' }
// 또는 그냥 notFound()를 호출해도 됨
}
return {
title: post.title,
description: post.summary,
}
}
generateStaticParams와 조합
정적 생성 페이지에서 generateMetadata를 함께 쓰면 빌드 타임에 모든 정적 경로의 메타데이터가 생성된다.
// app/posts/[slug]/page.tsx
// 1. 정적 생성할 슬러그 목록 제공
export async function generateStaticParams() {
const posts = await db.post.findMany({
select: { slug: true },
where: { published: true },
})
return posts.map((p) => ({ slug: p.slug }))
}
// 2. 슬러그별 메타데이터 생성 (빌드 타임)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
images: [`/api/og/${post.slug}`],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
},
alternates: {
canonical: `/posts/${post.slug}`,
},
}
}
빌드 타임에 수백 개의 페이지 메타데이터가 생성되므로, DB 쿼리 최적화에 주의해야 한다. select로 필요한 컬럼만 가져오는 것이 중요하다.
동적 라우트에서 타입 처리
Next.js 15에서 params가 비동기 객체로 변경됐다. 타입 정의 시 주의가 필요하다.
// Next.js 15 방식 — params가 Promise
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return { title: post?.title ?? 'Not Found' }
}
Next.js 14 이하에서는 params가 일반 객체였다. 버전에 따라 타입이 다르므로 package.json의 Next.js 버전을 확인하고 맞는 방식을 써야 한다.
메타데이터 병합 규칙
상위(layout)와 하위(page) 메타데이터는 **얕은 병합(shallow merge)**된다. 최상위 필드(title, description)는 하위가 상위를 덮어쓴다. 그러나 중첩 객체(openGraph, twitter)는 하위가 해당 객체 전체를 교체한다.
// 루트 레이아웃
export const metadata = {
openGraph: {
siteName: 'PALDYN',
locale: 'ko_KR',
type: 'website',
},
}
// 포스트 페이지 — openGraph 전체 교체됨
export async function generateMetadata(): Promise<Metadata> {
return {
openGraph: {
title: '포스트 제목', // siteName, locale은 상실됨!
},
}
}
siteName을 유지하려면 포스트 페이지에서도 명시해야 한다. 또는 parent로 상위 값을 읽어 스프레드하는 방법을 쓴다.
지난 글: Metadata API — SEO를 위한 메타데이터 설정
다음 글: 동적 OG 이미지 — ImageResponse로 SNS 카드 생성
읽어주셔서 감사합니다. 😊