๐ Next.js 11์ฅ: Metadata API & SEO โ ๊ฒ์์์ง์ด ์ฌ๋ํ๋ ์ฑ ๋ง๋ค๊ธฐ
๐ ๊ฐ์
Metadata API๋ก OG ํ๊ทธ, ๊ตฌ์กฐํ ๋ฐ์ดํฐ, sitemap์ ์ค์ ํด ๊ฒ์์์ง์ ์ต์ ํํฉ๋๋ค.
๐ ๋ชฉ์ฐจ
- ๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
- ๐ค ์ ์์์ผ ํ๋๊ฐ
- ๐๏ธ ๋น์ ๋ก ๋จผ์ ์ดํดํ๊ธฐ
- ๐งฉ Static Metadata โ ๋ณํ์ง ์๋ ๋ช ํจ ๐ข
- ๐ฑ Dynamic Metadata โ ํ์ด์ง๋ง๋ค ๋ค๋ฅธ ์ผ๊ตด ๐ก
- ๐ผ๏ธ OG ์ด๋ฏธ์ง ์๋ ์์ฑ โ
opengraph-image.tsx๐ก - โก ๊ณ ๊ธ ๋ฉํ๋ฐ์ดํฐ: robots, sitemap, canonical ๐ด
- ๐ฅ ์๋ฌ ํด๊ฒฐ ์นดํ๋ก๊ทธ
- ๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
- ๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
- ๐ ๋ ์์๋ณด๊ธฐ
๐ ์ด ๋ฌธ์๋ฅผ ์ฝ๊ธฐ ์ ์
โฑ๏ธ ์์ ์ฝ๊ธฐ ์๊ฐ: 15๋ถ (์ ์ฒด) / ํต์ฌ ํํธ๋ง: 7๋ถ
๐บ๏ธ ์ด ๋ฌธ์์ ๋ฐฐ๊ฒฝ ์ธ๊ณ๊ด: '์์๋ค ์ปค๋ฎค๋ํฐ'
- ์์ฒ (์ ์ ): "์ด? ์ฐ๋ฆฌ ์๋น์ค ์นด์นด์คํก์ผ๋ก ๋งํฌ ๊ณต์ ํ๋๋ ์ธ๋ค์ผ์ด ์ ๋จ๊ณ URL๋ง ๋ฉ๊ทธ๋ฌ๋ ๋์ค๋ค์. ๊ทธ๋ฆฌ๊ณ ๊ตฌ๊ธ์ ๊ฒ์ํด๋ ์ฐ๋ฆฌ ์๋น์ค ์ด๋ฆ์ด ์ ๋์ค๊ณ 'Vercel App'์ด๋ผ๊ณ ๋ง ๋จ๋๋ฐ... ์ด๋ป๊ฒ ๊ณ ์ณ์?"
- ์ํธ(๋ฆฌ๋): "์์ฒ ๋,
<head>ํ๊ทธ ์ค์ ์ ์์ง ์ ํ์ จ๊ตฐ์. Next.js Metadata API ๋ฅผ ์ฐ๋ฉด ํ์ผ ํ๋ ๊ฑด๋๋ฆฌ์ง ์๊ณ ๋ ๊ฐ ํ์ด์ง๋ณ ํ์ดํ, ์ค๋ช , OG ์ด๋ฏธ์ง๋ฅผ ์ ํํ ์ธํ ํ ์ ์์ด์. SEO๋ ์ ํ์ด ์๋๋ผ ์๋น์ค ๊ฐ์์ฑ์ ๊ธฐ๋ณธ์ด์์."
๐บ๏ธ ์ด ๋ฌธ์์ ํ๋ฆ
์ ์ ๋ฉํ๋ฐ์ดํฐ โ ๋์ ๋ฉํ๋ฐ์ดํฐ(ํ์ด์ง๋ณ) โ OG ์ด๋ฏธ์ง ์๋ ์์ฑ โ robots/sitemap ๊ณ ๊ธ ์ค์
๐ฏ ์ด ๋ฌธ์๋ฅผ ๋ค ์ฝ์ผ๋ฉด ํ ์ ์๋ ๊ฒ
-
layout.tsx์์ ์ฌ์ดํธ ์ ์ฒด ๊ธฐ๋ณธ ๋ฉํ๋ฐ์ดํฐ๋ฅผ ์ ์ธํ ์ ์๋ค - ๊ฒ์๊ธ ์์ธ ํ์ด์ง์ฒ๋ผ URL๋ง๋ค ๋ค๋ฅธ
<title>,<description>์ ์๋์ผ๋ก ์์ฑํ ์ ์๋ค - ์นด์นด์คยท์ฌ๋ ๋งํฌ ๊ณต์ ์ ์์ ์ธ๋ค์ผ์ด ๋จ๋ OG ์ด๋ฏธ์ง๋ฅผ JSX ์ฝ๋๋ก ๋ง๋ค ์ ์๋ค
๐ค ์ ์์์ผ ํ๋๊ฐ
๋ฆฌ์กํธ SPA(Single Page Application)์ ๊ฐ์ฅ ํฐ ๋จ์ ์ด ๋ญ์ง ์์? ๋ฐ๋ก SEO(๊ฒ์์์ง ์ต์ ํ) ์์ด.
SPA๋ ์ฒ์ ์๋ฒ์์ ๋น HTML์ ๋ด๋ ค๋ณด๋ด๊ณ , JavaScript๊ฐ ๋ก๋๋ ๋ค์์ผ ํ๋ฉด์ ์ฑ์. ๊ทธ๋ฐ๋ฐ ๊ตฌ๊ธ ๋ด์ด ํ์ด์ง๋ฅผ ํฌ๋กค๋งํ๋ฌ ์์ ๋, JS๊ฐ ์คํ๋๊ธฐ ์ ์ด๋ผ๋ฉด ๋น ๊ป๋ฐ๊ธฐ๋ง ๋ณด๊ฒ ๋ผ.
๊ทธ๋์ Next.js๊ฐ ๋ฑ์ฅํ ๊ฑฐ์ผ. ์๋ฒ์์ ์์ฑ๋ HTML์ ๋ด๋ ค๋ณด๋ด๋๊น ๋ด๋ ๋ด์ฉ์ ๋ณผ ์ ์์ง. ํ์ง๋ง ๊ฑฐ๊ธฐ์ ๋ฉ์ถ๋ฉด ์ ๋ผ. ๋ด์ด "์ด ํ์ด์ง๊ฐ ๋ญ์ง"๋ฅผ ์ ํํ ์๋ ค๋ฉด <title>, <description>, OG ํ๊ทธ๋ค์ด ์ ๋๋ก ์ค์ ๋์ด ์์ด์ผ ํด.
๊ณผ๊ฑฐ Pages Router ์์ ์ next/head๋ก ๊ฐ ํ์ด์ง๋ง๋ค <Head> ์ปดํฌ๋ํธ๋ฅผ ์๋์ผ๋ก ๋ฌ์์คฌ์ด. ์ค๋ณต ์ฝ๋๊ฐ ๋์ณ๋ฌ๊ณ , ์์ ๊ฐ๋
๋ ์์ด์ ๊ด๋ฆฌ๊ฐ ์ง์ฅ์ด์์ง. App Router์ Metadata API๋ ์ด ๋ชจ๋ ๊ฑธ ํ์ผ ๊ธฐ๋ฐ ์์คํ
์ผ๋ก ํตํฉํด๋ฒ๋ ธ์ด.
๐๏ธ ๋น์ ๋ก ๋จผ์ ์ดํดํ๊ธฐ
๐ง 5์ด์๊ฒ ์ค๋ช ํ๋ค๋ฉด?
์์ ๋ค์ด ๋ชจ์ธ ์ผํ๋ชฐ์ ์์ํด๋ด. ๊ฐ ๊ฐ๊ฒ ์์๋ ๊ฐํ์ด ์์ด.
๊ตฌ๊ธ ๋ด์ ์ด ์ผํ๋ชฐ์ ์์ฐฐํ๋ ์๋ด์์ด์ผ. ๊ฐํ(๋ฉํ๋ฐ์ดํฐ)์ ์ฝ๊ณ "์ด ๊ฐ๊ฒ๋ ๋ญ ํ๋ ๊ณณ์ด์์"๋ผ๊ณ ์ง๋(๊ฒ์ ๊ฒฐ๊ณผ)์ ๊ธฐ๋กํด์ค.
๊ฐํ์ด ์๊ฑฐ๋ "Vercel App"์ด๋ผ๊ณ ๋ง ์ ํ ์์ผ๋ฉด, ์๋ด์๋ ๋ญ๋ผ๊ณ ๊ธฐ๋กํด์ผ ํ ์ง ๋ชฐ๋ผ์ ๊ทธ๋ฅ ์ฃผ์๋ง ์ ์ด๋๋ ๊ฑฐ์ผ.
๋ฉํ๋ฐ์ดํฐ๋ ํ์ด์ง์ "์ ์ฒด์ฑ ๋ช
ํจ"์ด์ผ. layout.tsx๋ ๊ฑด๋ฌผ ์ ์ฒด ์๋ดํ์ด๊ณ , page.tsx์ ๋ฉํ๋ฐ์ดํฐ๋ ๊ฐ ์ธต ๊ฐํ์ธ ๊ฑฐ์ง. ์์ ๊ตฌ์กฐ๊ฐ ์์ด์ ์๋์ธต ๊ฐํ์ด ์์ผ๋ฉด ์์ธต ๊ฑด๋ฌผ ์๋ดํ์ผ๋ก ๋์ฒด๋ผ.
๐งฉ Static Metadata โ ๋ณํ์ง ์๋ ๋ช ํจ ๐ข
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
layout.tsx์์metadata๊ฐ์ฒด๋ฅผ exportํด ์ฌ์ดํธ ์ ์ฒด ๊ธฐ๋ณธ๊ฐ์ ์ค์ ํ ์ ์๋ค- ๋ฉํ๋ฐ์ดํฐ์ ์์ ๊ตฌ์กฐ๊ฐ ์ด๋ป๊ฒ ์๋ํ๋์ง ์ค๋ช ํ ์ ์๋ค
๊ฐ์ฅ ๋จ์ํ ๋ฐฉ๋ฒ๋ถํฐ ์์ํด. ๊ทธ๋ฅ metadata ๊ฐ์ฒด๋ฅผ exportํ๋ฉด ๋์ด์ผ.
// app/layout.tsx โ ์ฌ์ดํธ ์ ์ฒด ๊ธฐ๋ณธ๊ฐ ์ค์
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
// template: ํ์ ํ์ด์ง์์ title์ ์ค์ ํ๋ฉด ์๋์ผ๋ก " | ์์ ์ปค๋ฎค๋ํฐ" ๊ฐ ๋ถ์
template: '%s | ์์ ์ปค๋ฎค๋ํฐ',
// default: ํ์ ํ์ด์ง์์ title์ ์ค์ ์ ํ๋ฉด ์ด๊ฒ ๊ธฐ๋ณธ๊ฐ
default: '์์ ์ปค๋ฎค๋ํฐ โ ๊ฐ๋ฐ์ ์คํฐ๋ ๋งค์นญ',
},
description: '๊ฐ๋ฐ์๋ค์ด ๋ชจ์ฌ ํจ๊ป ์ฑ์ฅํ๋ ์คํฐ๋ ๋งค์นญ ํ๋ซํผ',
// Open Graph: ์นด์นด์คํก, ์ฌ๋, ํ์ด์ค๋ถ ๋ฑ์์ ๋งํฌ ๊ณต์ ์ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์นด๋
openGraph: {
title: '์์ ์ปค๋ฎค๋ํฐ',
description: '๊ฐ๋ฐ์ ์คํฐ๋ ๋งค์นญ ํ๋ซํผ',
url: 'https://youngsu.community',
siteName: '์์ ์ปค๋ฎค๋ํฐ',
images: [
{
url: 'https://youngsu.community/og-image.png',
width: 1200,
height: 630,
},
],
locale: 'ko_KR',
type: 'website',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
)
}๋ฉํ๋ฐ์ดํฐ ์์(Merging) ๊ท์น:
Next.js๋ ๋ฃจํธ ๋ ์ด์์๋ถํฐ ๊ฐ์ฅ ๊น์ ํ์ด์ง๊น์ง ๋ฉํ๋ฐ์ดํฐ๋ฅผ ๊น์ ๋ณํฉ(deep merge) ํด์ค. ๊ฐ์ ํค๊ฐ ์์ผ๋ฉด ์๋ ๊ณ์ธต ๊ฐ์ด ์ด๊ฒจ.
app/layout.tsx โ { title: { default: "์์ ์ปค๋ฎค๋ํฐ", template: "%s | ์์ ์ปค๋ฎค๋ํฐ" } }
app/posts/layout.tsx โ { title: "๊ฒ์ํ" } โ ์ต์ข
: "๊ฒ์ํ | ์์ ์ปค๋ฎค๋ํฐ"
app/posts/[id]/page.tsx โ { title: "ํด๋ก์ ์คํฐ๋" } โ ์ต์ข
: "ํด๋ก์ ์คํฐ๋ | ์์ ์ปค๋ฎค๋ํฐ"
๐ก ํ ์ค๋ก ๊ธฐ์ตํ๊ธฐ
layout.tsx์metadata๋ ์ ์ ์์ผ. ์๋ ํ์ด์ง๋ ๋ฎ์ด์ธ ์ ์๊ณ , ์ค์ ์ ํ๋ฉด ๋ถ๋ชจ ๊ฒ์ ์ด์ด๋ฐ์.
๐ฑ Dynamic Metadata โ ํ์ด์ง๋ง๋ค ๋ค๋ฅธ ์ผ๊ตด ๐ก
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
generateMetadataํจ์๋ก URL ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์ ๋์ ์ผ๋ก<title>,<description>์ ์์ฑํ ์ ์๋ค- DB์์ ๋ฐ์ดํฐ๋ฅผ fetchํด ๋ฉํ๋ฐ์ดํฐ์ ์ฌ์ฉํ ์ ์๋ค
๐ค ์ ๊น, ๋จผ์ ์๊ฐํด๋ด
๊ฒ์๊ธ ์์ธ ํ์ด์ง/posts/123์/posts/456์ ๋น์ฐํ ํ์ดํ์ด ๋ฌ๋ผ์ผ ํด.
์ด๋ป๊ฒ URL๋ง๋ค ๋ค๋ฅธ<title>์ ์๋์ผ๋ก ๋ฃ์ ์ ์์๊น?
์ ์ metadata ๊ฐ์ฒด๋ก๋ ํ๊ณ๊ฐ ์์ด. ๊ฒ์๊ธ ID์ ๋ฐ๋ผ ์ ๋ชฉ์ด ๋ฌ๋ผ์ง๋ ๋์ ํ์ด์ง์์ generateMetadata ํจ์๋ฅผ ์จ์ผ ํด.
// app/posts/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
// Props ํ์
์ page.tsx์ params์ ๋์ผํ๊ฒ
type Props = {
params: Promise<{ id: string }>
}
// โ
๋น๋๊ธฐ ํจ์๋ก ์ ์ธ โ DB ์กฐํ๋ ๊ฐ๋ฅ
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata // ๋ถ๋ชจ ๋ ์ด์์ ๋ฉํ๋ฐ์ดํฐ์ ์ ๊ทผ ๊ฐ๋ฅ
): Promise<Metadata> {
const { id } = await params
// DB์์ ๊ฒ์๊ธ ๋ฐ์ดํฐ fetch (Next.js๊ฐ ์๋์ผ๋ก ์บ์ฑํด์ค โ 6์ฅ ์ฐธ๊ณ )
const post = await fetch(`https://api.youngsu.community/posts/${id}`, {
next: { revalidate: 3600 }, // 1์๊ฐ ์บ์ฑ
}).then((res) => res.json())
// ๋ถ๋ชจ ๋ ์ด์์์ OG ์ด๋ฏธ์ง ๋ฐฐ์ด์ ๋ด ์ด๋ฏธ์ง ์ถ๊ฐํ๋ ํจํด
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title, // โ "ํด๋ก์ ์คํฐ๋ | ์์ ์ปค๋ฎค๋ํฐ"
description: post.summary, // ๊ฒ์๊ธ ์์ฝ
openGraph: {
title: post.title,
description: post.summary,
images: [
`/posts/${id}/opengraph-image`, // ๋์ OG ์ด๋ฏธ์ง (๋ค์ ์น์
)
...previousImages, // ๋ถ๋ชจ ์ด๋ฏธ์ง๋ fallback์ผ๋ก ์ ์ง
],
},
}
}
export default async function PostPage({ params }: Props) {
const { id } = await params
// ...ํ์ด์ง ์ปดํฌ๋ํธ
}์์ฒ ์ด์ ์ค์ ํจํด:
// โ ์์ฒ ์ด์ ์์งํ ์ฝ๋ โ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์์ ๋ฉํ๋ฐ์ดํฐ ์ค์ ํ๋ ค๋ ์๋
'use client'
import { useEffect } from 'react'
export default function PostPage() {
useEffect(() => {
// ์ด๊ฑด ์ ๋ ์ ๋ผ! ๋ด์ JS ์คํ ์ ์ HTML์ ์ฝ์ด๊ฐ๊ฑฐ๋
document.title = 'ํด๋ก์ ์คํฐ๋'
}, [])
return <div>...</div>
}
// โ
์ํธ๊ฐ ๊ณ ์ณ์ค ์ฝ๋
// โ ์์ generateMetadata ํจ์ ์ฌ์ฉ (์๋ฒ์์ ์คํ๋๋ฏ๋ก ๋ด๋ ์ฝ์ ์ ์์)โก๏ธ ๋ค์ ์น์ ์์๋: ๋ฉํ๋ฐ์ดํฐ์ ๊ฝ, OG ์ด๋ฏธ์ง๋ฅผ ์ฝ๋๋ก ์๋ ์์ฑํ๋ ๋ฐฉ๋ฒ์ ์์๋ด.
๐ผ๏ธ OG ์ด๋ฏธ์ง ์๋ ์์ฑ โ opengraph-image.tsx ๐ก
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
opengraph-image.tsxํ์ผ ํ๋๋ก JSX๋ฅผ ์ด๋ฏธ์ง๋ก ๋ณํํ๋ Dynamic OG Image๋ฅผ ๋ง๋ค ์ ์๋ค- URL ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์ ๊ฒ์๊ธ๋ง๋ค ๋ค๋ฅธ OG ์ด๋ฏธ์ง๋ฅผ ์๋ ์์ฑํ ์ ์๋ค
์นด์นด์คํก์ด๋ ์ฌ๋์์ ๋งํฌ๋ฅผ ๊ณต์ ํ ๋ ์์ ์ธ๋ค์ผ์ด ๋จ๋ ๊ฑฐ ๋ดค์ง? ๊ทธ๊ฒ OG(Open Graph) ์ด๋ฏธ์ง์ผ.
Next.js๋ opengraph-image.tsx๋ผ๋ ํน์ ํ์ผ์ ํตํด JSX๋ก ์ด๋ฏธ์ง๋ฅผ ๋์ ์์ฑํ๋ ๊ธฐ๋ฅ์ ๋ด์ฅํ๊ณ ์์ด. ๋ด๋ถ์ ์ผ๋ก๋ @vercel/og ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ Satori ์์ง์ด JSX๋ฅผ SVG โ PNG๋ก ๋ณํํด์ค.
// app/posts/[id]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
// ์ด๋ฏธ์ง ํฌ๊ธฐ ์ค์ (๊ถ์ฅ: 1200x630)
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
// params๋ก URL ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ์ ๋์ ์ด๋ฏธ์ง ์์ฑ
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
// DB์์ ๊ฒ์๊ธ ์ ๋ชฉ ๊ฐ์ ธ์ค๊ธฐ (์๋ฒ์์ ์คํ)
const post = await fetch(`https://api.youngsu.community/posts/${id}`).then((r) => r.json())
return new ImageResponse(
// JSX๋ก ์ด๋ฏธ์ง ๋ ์ด์์ ๊ตฌ์ฑ
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#0F172A', // ๋คํฌ ๋ฐฐ๊ฒฝ
padding: '60px',
}}
>
{/* ์ปค๋ฎค๋ํฐ ๋ก๊ณ */}
<div style={{ color: '#60A5FA', fontSize: 28, marginBottom: 24 }}>
์์ ์ปค๋ฎค๋ํฐ
</div>
{/* ๊ฒ์๊ธ ์ ๋ชฉ โ DB์์ ๊ฐ์ ธ์จ ๋์ ๋ฐ์ดํฐ */}
<div
style={{
color: '#F1F5F9',
fontSize: 56,
fontWeight: 'bold',
textAlign: 'center',
lineHeight: 1.3,
}}
>
{post.title}
</div>
</div>,
{ ...size }
)
}๊ฒฐ๊ณผ: /posts/123/opengraph-image URL๋ก ์ ๊ทผํ๋ฉด ํด๋น ๊ฒ์๊ธ ์ ๋ชฉ์ด ๋ด๊ธด ์ด๋ฏธ์ง๊ฐ PNG๋ก ๋ฐํ๋ผ.
๐ ์ฉ์ด: Satori โ Vercel์ด ๋ง๋ JSX โ SVG ๋ณํ ๋ผ์ด๋ธ๋ฌ๋ฆฌ์ผ. ๋ธ๋ผ์ฐ์ ์์ด ์๋ฒ์์ HTML/CSS ๋ ์ด์์์ ์ด๋ฏธ์ง๋ก ๋ ๋๋งํด. ํ์๋ก '๊นจ๋ฌ์'์ด๋ผ๋ ๋ป.
๐ก ํ ์ค๋ก ๊ธฐ์ตํ๊ธฐ
opengraph-image.tsx๋ "์๋ ํฌ์คํฐ ์ ์๊ธฐ"์ผ. ๊ฒ์๊ธ ์ ๋ชฉ๋ง ๋ฃ์ผ๋ฉด ๋งํฌ ๊ณต์ ์ฉ ์ธ๋ค์ผ์ด ์๋์ผ๋ก ๋ง๋ค์ด์ ธ.
โก ๊ณ ๊ธ ๋ฉํ๋ฐ์ดํฐ: robots, sitemap, canonical ๐ด
๐ฏ ์ด ์น์ ์ ์ฝ๊ณ ๋๋ฉด:
- ๊ฒ์์์ง ํฌ๋กค๋ง ๋ฒ์๋ฅผ
robots.txt๋ก ์ ์ดํ ์ ์๋คsitemap.ts๋ก ๊ตฌ๊ธ์ ๋ชจ๋ ํ์ด์ง URL์ ์๋ ์ ์ถํ ์ ์๋ค
๐ค robots.txt โ ๋ด ์ถ์ ํต์
// app/robots.ts โ ์๋์ผ๋ก /robots.txt ์์ฑ
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*', // ๋ชจ๋ ๋ด
allow: '/', // ์ ์ฒด ํ์ฉ
disallow: [
'/api/', // API ๊ฒฝ๋ก๋ ๋ด ์ฐจ๋จ
'/admin/', // ๊ด๋ฆฌ์ ํ์ด์ง ์ฐจ๋จ
'/my/', // ๋ด ์ ๋ณด ํ์ด์ง ์ฐจ๋จ
],
},
],
sitemap: 'https://youngsu.community/sitemap.xml',
}
}๐บ๏ธ sitemap.xml โ ๊ตฌ๊ธ์ ํ์ด์ง ๋ชฉ๋ก ์ ์ถ
// app/sitemap.ts โ ์๋์ผ๋ก /sitemap.xml ์์ฑ
import type { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// ์ ์ ํ์ด์ง
const staticPages: MetadataRoute.Sitemap = [
{ url: 'https://youngsu.community', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://youngsu.community/posts', lastModified: new Date(), changeFrequency: 'hourly', priority: 0.9 },
]
// ๋์ ๊ฒ์๊ธ ํ์ด์ง โ DB์์ ์ ์ฒด ID ๋ชฉ๋ก ๊ฐ์ ธ์ค๊ธฐ
const posts = await fetch('https://api.youngsu.community/posts/ids').then((r) => r.json())
const postPages: MetadataRoute.Sitemap = posts.map((id: string) => ({
url: `https://youngsu.community/posts/${id}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [...staticPages, ...postPages]
}๐ canonical โ ์ค๋ณต URL ์ ๋ฆฌ
๊ฐ์ ๋ด์ฉ์ด ์ฌ๋ฌ URL์ ์กด์ฌํ ๋(์: ํ์ด์ง๋ค์ด์ , ํํฐ) ๊ตฌ๊ธ์ด ์ด๋ค ๊ฑธ ์๋ณธ์ผ๋ก ๋ด์ผ ํ๋์ง ์๋ ค์ค.
// app/posts/page.tsx
export const metadata: Metadata = {
alternates: {
canonical: 'https://youngsu.community/posts', // ์๋ณธ URL ๋ช
์
},
}๐ฅ ์๋ฌ ํด๊ฒฐ ์นดํ๋ก๊ทธ
์๋ฌ ๋ฉ์์ง๊ฐ ๋จ๋ฉด Ctrl+F๋ก ๊ฒ์ํด๋ด. ๋๋ถ๋ถ ์ฌ๊ธฐ ์์ด.
โ generateMetadata ์์ params๋ฅผ await ์ ํด์ ์๋ฌ
์ธ์ ๋์ค๋๊ฐ?
Error: params should be awaited before using its properties.
์์ธ: Next.js 15๋ถํฐ params๊ฐ Promise๋ก ๋ณ๊ฒฝ๋จ. await ์์ด ๋ฐ๋ก ์ฐ๋ฉด ์๋ฌ.
ํด๊ฒฐ์ฑ :
// โ ์ด์ ๋ฐฉ์ (Next.js 14 ์ดํ)
export async function generateMetadata({ params }: { params: { id: string } }) {
const id = params.id // ์๋ฌ!
}
// โ
Next.js 15+ ๋ฐฉ์
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params // await ํ์
}โ OG ์ด๋ฏธ์ง์์ ํฐํธ๊ฐ ๊นจ์ ธ์ ํ๊ธ์ด ๋ค๋ชจ๋ก ํ์๋จ
์์ธ: Satori ์์ง์ ๊ธฐ๋ณธ ํฐํธ๊ฐ ์๋ฌธ๋ง ์ง์ํด. ํ๊ธ์ ๋ณ๋ ํฐํธ ํ์ผ์ ์ ๊ณตํด์ผ ํด.
ํด๊ฒฐ์ฑ :
// app/posts/[id]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
// ํ๊ธ ํฐํธ ํ์ผ ๋ก๋ (public ํด๋์ ๋ฏธ๋ฆฌ ๋ฃ์ด๋ฌ์ผ ํจ)
const fontData = await fetch(
new URL('../../../../public/fonts/Noto_Sans_KR.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
return new ImageResponse(
<div>...</div>,
{
...size,
fonts: [
{ name: 'Noto Sans KR', data: fontData, style: 'normal' },
],
}
)โ ๋ฉํ๋ฐ์ดํฐ๊ฐ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์์ ์๋ ์ ํจ
์์ธ: metadata export์ generateMetadata๋ ์๋ฒ ์ปดํฌ๋ํธ์์๋ง ๋์ํด. 'use client' ํ์ผ์์ export ํ๋ฉด ๋ฌด์๋จ.
ํด๊ฒฐ์ฑ
: ๋ฉํ๋ฐ์ดํฐ๋ ํญ์ ์๋ฒ ์ปดํฌ๋ํธ ํ์ผ(layout.tsx, page.tsx)์์ exportํด์ผ ํด. ํด๋ผ์ด์ธํธ ๋ก์ง์ด ํ์ํ๋ฉด ์ปดํฌ๋ํธ๋ฅผ ๋ถ๋ฆฌํด.
๐ ์ด๋ฒ์ ๋ฐฐ์ด ๋ด์ฉ ์ด์ ๋ฆฌ
์ค๋ ๋ฐฐ์ด ํต์ฌ์ ํ๋์ ์ ๋ฆฌํด๋ณผ๊น? SEO ๋ฌธ์ ๊ฐ ์๊ธธ ๋ ์ด๊ฒ๋ง ๋ด๋ ๋ผ.
๐ ๋ฉํ๋ฐ์ดํฐ ์ค์ ํจํด
| ์ํฉ | ๋ฐฉ๋ฒ | ํ์ผ ์์น |
|---|---|---|
| ์ฌ์ดํธ ์ ์ฒด ๊ธฐ๋ณธ๊ฐ | export const metadata | app/layout.tsx |
| ํน์ ์น์ ๊ธฐ๋ณธ๊ฐ | export const metadata | app/[section]/layout.tsx |
| ๋์ ํ์ด์ง (DB ์กฐํ) | export async function generateMetadata | app/[section]/[id]/page.tsx |
| ๋งํฌ ๊ณต์ ์ธ๋ค์ผ | opengraph-image.tsx | app/[section]/[id]/opengraph-image.tsx |
| ๋ด ์ ๊ทผ ์ ์ด | export default function robots() | app/robots.ts |
| ๊ตฌ๊ธ ํ์ด์ง ๋ชฉ๋ก | export default async function sitemap() | app/sitemap.ts |
โ ๏ธ ์ ๋ ํ์ง ๋ง ๊ฒ
| ์ํฉ | โ ๋์ ์ | โ ์ข์ ์ |
|---|---|---|
| ๋์ ํ์ดํ | useEffect(() => { document.title = ... }) | generateMetadata ํจ์ |
| ํด๋ผ์ด์ธํธ์์ ๋ฉํ๋ฐ์ดํฐ | 'use client' ํ์ผ์ metadata export | ์๋ฒ ์ปดํฌ๋ํธ ํ์ผ์ export |
| ํ๊ธ OG ์ด๋ฏธ์ง ํฐํธ | ๊ธฐ๋ณธ ํฐํธ ๊ทธ๋๋ก ์ฌ์ฉ | ํ๊ธ TTF ํ์ผ ๋ช ์์ ์ผ๋ก ์ ๊ณต |
๐ ๋ง๋ฌด๋ฆฌ ํด์ฆ
Q1. ๋ค์ ์ค App Router์ Metadata API์ ๋ํ ์ค๋ช ์ผ๋ก ํ๋ฆฐ ๊ฒ์?
- A)
layout.tsx์์metadata๋ฅผ exportํ๋ฉด ํ์ ํ์ด์ง์ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ์ ์ฉ๋๋ค - B)
generateMetadata๋ ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ์์๋ ์ฌ์ฉํ ์ ์๋ค - C)
opengraph-image.tsx๋ JSX๋ฅผ PNG ์ด๋ฏธ์ง๋ก ๋ณํํด์ค๋ค - D) ๋์ ๋ผ์ฐํธ์์ DB ๋ฐ์ดํฐ๋ฅผ ๊ธฐ๋ฐ์ผ๋ก
<title>์ ์์ฑํ ์ ์๋ค
โ ์ ๋ต: B โ
generateMetadata์metadataexport๋ ์๋ฒ ์ปดํฌ๋ํธ ์ ์ฉ์ด์ผ.์ค๋ต ํด์ค:
- A โ ๋ง์.
layout.tsx์ ๋ฉํ๋ฐ์ดํฐ๋ ํ์๋ก ์์(๋ณํฉ)๋จ- C โ ๋ง์.
opengraph-image.tsx๋ Satori ์์ง์ผ๋ก JSX โ PNG ๋ณํ- D โ ๋ง์.
generateMetadata๋ async ํจ์๋ผ DB ์กฐํ ๊ฐ๋ฅ๐ ํต์ฌ ๊ธฐ์ต๋ฒ: metadata๋ ์๋ฒ์์๋ง! ํด๋ผ์ด์ธํธ ์ปดํฌ๋ํธ๋ ์๋์ง ๋ง.
Q2. ์๋ ๋น์นธ์ ์ฑ์๋ณด์.
๋ฃจํธ ๋ ์ด์์์์ title.template์ '%s | ์์ ์ปค๋ฎค๋ํฐ'๋ก ์ค์ ํ์ ๋,
๊ฒ์๊ธ ํ์ด์ง์์ title: 'ํด๋ก์ ์คํฐ๋'๋ฅผ ์ค์ ํ๋ฉด <title> ํ๊ทธ์ ์ต์ข
๊ฐ์?
โ ์ ๋ต:
ํด๋ก์ ์คํฐ๋ | ์์ ์ปค๋ฎค๋ํฐํด์ค:
%s์๋ฆฌ์ ํ์ ํ์ด์ง์ title ๊ฐ์ด ๋ค์ด๊ฐ๋ ํ ํ๋ฆฟ ํจํด์ด์ผ.๐ ํต์ฌ ๊ธฐ์ต๋ฒ:
%s๋ ์๋ฆฌํ์์(placeholder). "์ฌ๊ธฐ์ ํ์ด์ง ์ด๋ฆ ๋ค์ด์" ๋ผ๋ ๋ป.
Q3. ์น๊ตฌ์๊ฒ ์ค๋ช ํ๋ค๋ฉด?
OG ์ด๋ฏธ์ง๊ฐ ์ SEO์์ ์ค์ํ์ง, ๊ทธ๋ฆฌ๊ณ Next.js์์ ์ด๋ป๊ฒ ์๋ ์์ฑํ๋์ง ๋น์ ๋ฅผ ํ๋ ์จ์ ์ค๋ช ํด๋ด.
์์ ๋ต๋ณ:
"OG ์ด๋ฏธ์ง๋ ๋งํฌ ๊ณต์ ์ฉ ์ฑ ํ์ง์ผ. ์ ๋ชฉ๋ง ์๋ ์ฑ ์ด๋ ๋ฉ์ง ํ์ง๊ฐ ์๋ ์ฑ ์ค์ ์ด๋ค ๊ฑธ ๋ ํด๋ฆญํ๊ฒ ์ด? Next.js์
opengraph-image.tsx๋ ์ฑ ํ์ง ์ธ์๊ธฐ์ผ. ๊ฒ์๊ธ ์ ๋ชฉ๋ง ๋ฃ์ผ๋ฉด ํ์ง๋ฅผ ์๋์ผ๋ก ๋ฝ์์ค."
๐ฃ ์์ฒ ์ด์ ํด๊ทผ ์ผ๊ธฐ
์ค๋์ ์ฐ๋ฆฌ ์๋น์ค์ '๊ฐํ'์ ๋ค๋ ๋ ์ด์์ด. ๊ตฌ๊ธ์ ์ฐ๋ฆฌ ์๋น์ค ์ด๋ฆ์ ๊ฒ์ํด๋ ์ ๋์์ ์์ํ๋๋ฐ, ์ํธ ๋ฆฌ๋ ๋์ด ์๋ ค์ฃผ์ 'Metadata API' ๋ก ์ฌ์ดํธ์ ์ ์ฒด์ฑ์ ๋ช ํํ๊ฒ ์๋ ค์คฌ๊ฑฐ๋ !
๐ก ์ค๋์ ๊ตํ: "๋ฉํ๋ฐ์ดํฐ๋ ์๋น์ค์ ๋ช ํจ์ด๋ค. ๋ด๊ณผ ์ฌ๋์ด ์ฐ๋ฆฌ ์๋น์ค๋ฅผ ์ ์ฐพ์ ์ ์๊ฒ ์น์ ํ ๊ฐํ(SEO)์ ๋ฌ์์ฃผ์."
ํนํ ์นด์นด์คํก์ผ๋ก ๋งํฌ๋ฅผ ๋ณด๋์ ๋ ๊ฒ์๊ธ ์ ๋ชฉ์ด ๋ด๊ธด ์์ ์ธ๋ค์ผ(Dynamic OG Image)์ด ๋! ๋จ๋ ๊ฑธ ๋ณด๊ณ ์ ๋ง ๊ฐ๋ํ์ด. ๋จ์ํ ์ฝ๋๋ฅผ ์ง๋ ๊ฑธ ๋์ด, ์ธ์์ ์ฐ๋ฆฌ ์๋น์ค๋ฅผ ์ด๋ป๊ฒ ๋ณด์ฌ์ค์ง ์ค๊ณํ๋ ๊ฒ ์ผ๋ง๋ ์ค์ํ์ง ๊นจ๋ฌ์์ด. ์ค๋ ์ ๋ง ๊ณ ์ํ ๋ ์์ ์๊ฒ ๋ง์๋ ์์ด์คํฌ๋ฆผ ํ๋ ์ ๋ฌผํด์ผ์ง. ๋ด์ผ์ ๋ '๊ฒ์ ์นํ์ ์ธ' ๋ฉ์ง ํ์ด์ง๋ฅผ ๋ง๋ค์ด๋ณผ ๊ฑฐ์ผ! ๐ฃ