iBetter Books
수정

Ch 07. SEO 최적화 — metadata API

좋은 글을 써도 검색 엔진이 찾지 못하면 아무도 읽지 않습니다. SEO(Search Engine Optimization)는 검색 결과에서 더 잘 보이도록 설정하는 작업입니다. Next.js는 metadata API로 이것을 쉽게 만들어줍니다.

정적 메타데이터

변하지 않는 메타데이터는 객체로 내보냅니다.

// 파일: app/layout.tsximport type { Metadata } from "next";export const metadata: Metadata = {  title: {    default: "나의 블로그", // 기본 제목    template: "%s | 나의 블로그", // 하위 페이지 제목 형식  },  description: "개발 이야기를 씁니다",  keywords: ["Next.js", "React", "TypeScript", "블로그"],  authors: [{ name: "홍길동", url: "https://myblog.com" }],  creator: "홍길동",  openGraph: {    type: "website",    locale: "ko_KR",    url: "https://myblog.com",    siteName: "나의 블로그",    title: "나의 블로그",    description: "개발 이야기를 씁니다",    images: [      {        url: "https://myblog.com/og-image.png",        width: 1200,        height: 630,        alt: "나의 블로그",      },    ],  },  twitter: {    card: "summary_large_image",    title: "나의 블로그",    description: "개발 이야기를 씁니다",    images: ["https://myblog.com/og-image.png"],  },};```text`title.template`의 `%s`는 하위 페이지의 제목으로 대체됩니다. 글 상세 페이지의 제목이 "Next.js 시작하기"라면, 최종 `<title>`은 "Next.js 시작하기 | 나의 블로그"가 됩니다.### generateMetadata — 동적 메타데이터글 상세 페이지처럼 내용에 따라 메타데이터가 달라지는 경우, `generateMetadata` 함수를 사용합니다.```typescript// 파일: app/posts/[slug]/page.tsximport { prisma } from "@/lib/prisma";import type { Metadata } from "next";type Props = { params: Promise<{ slug: string }> };export async function generateMetadata({ params }: Props): Promise<Metadata> {  const { slug } = await params;  const post = await prisma.post.findUnique({    where: { slug },    include: { author: { select: { name: true } } },  });  if (!post) {    return {      title: "글을 찾을 수 없습니다",    };  }  // 마크다운에서 첫 200자를 description으로 사용  const description = post.content.slice(0, 200).replace(/[#*`]/g, "");  return {    title: post.title,    description,    authors: [{ name: post.author.name }],    openGraph: {      title: post.title,      description,      type: "article",      publishedTime: post.createdAt.toISOString(),      authors: [post.author.name],    },    twitter: {      card: "summary_large_image",      title: post.title,      description,    },  };}```text`generateMetadata`와 페이지 컴포넌트 모두 같은 데이터(post)를 조회합니다. Next.js는 같은 인자로 호출되는 `fetch` 요청을 자동으로 중복 제거(deduplication)합니다. Prisma는 `fetch`를 사용하지 않으므로, 데이터 조회 함수를 분리하고 `cache()`로 감싸면 됩니다.```typescript// 파일: app/posts/[slug]/page.tsx (캐싱 최적화)import { cache } from "react";import { prisma } from "@/lib/prisma";// React cache로 같은 slug의 중복 DB 조회 방지const getPost = cache(async (slug: string) => {  return prisma.post.findUnique({    where: { slug },    include: { author: { select: { id: true, name: true } } },  });});```text### sitemap.ts — 사이트맵 자동 생성검색 엔진 크롤러에게 사이트 구조를 알려줍니다.```typescript// 파일: app/sitemap.tsimport { prisma } from "@/lib/prisma";import type { MetadataRoute } from "next";export default async function sitemap(): Promise<MetadataRoute.Sitemap> {  const posts = await prisma.post.findMany({    where: { published: true },    select: { slug: true, updatedAt: true },  });  const postEntries = posts.map((post) => ({    url: `https://myblog.com/posts/${post.slug}`,    lastModified: post.updatedAt,    changeFrequency: "weekly" as const,    priority: 0.8,  }));  return [    {      url: "https://myblog.com",      lastModified: new Date(),      changeFrequency: "daily",      priority: 1,    },    {      url: "https://myblog.com/posts",      lastModified: new Date(),      changeFrequency: "daily",      priority: 0.9,    },    ...postEntries,  ];}```text`https://myblog.com/sitemap.xml`로 접근하면 XML 형식의 사이트맵이 자동으로 반환됩니다.### robots.ts — 크롤링 규칙 설정어떤 페이지를 크롤링하고 어떤 페이지를 제외할지 설정합니다.```typescript// 파일: app/robots.tsimport type { MetadataRoute } from "next";export default function robots(): MetadataRoute.Robots {  return {    rules: [      {        userAgent: "*",        allow: "/",        disallow: ["/dashboard", "/admin", "/api/"],      },    ],    sitemap: "https://myblog.com/sitemap.xml",  };}```text`/dashboard``/admin`은 로그인한 사용자만 볼 수 있으므로 크롤링에서 제외합니다. `https://myblog.com/robots.txt`로 접근하면 확인할 수 있습니다.### OpenGraph 이미지 자동 생성`opengraph-image.tsx`를 만들면 OpenGraph 이미지를 코드로 생성할 수 있습니다.```typescript// 파일: app/posts/[slug]/opengraph-image.tsximport { ImageResponse } from "next/og";import { prisma } from "@/lib/prisma";export const size = { width: 1200, height: 630 };export const contentType = "image/png";export default async function OgImage({  params,}: {  params: Promise<{ slug: string }>;}) {  const { slug } = await params;  const post = await prisma.post.findUnique({    where: { slug },    include: { author: { select: { name: true } } },  });  return new ImageResponse(    (      <div        style={{          width: "100%",          height: "100%",          display: "flex",          flexDirection: "column",          justifyContent: "center",          padding: "80px",          background: "linear-gradient(135deg, #1e3a5f 0%, #0f2027 100%)",          color: "white",        }}      >        <div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.2 }}>          {post?.title ?? "블로그 글"}        </div>        <div style={{ fontSize: 28, marginTop: 32, opacity: 0.7 }}>          {post?.author.name} · 나의 블로그        </div>      </div>    ),    size  );}

소셜 미디어에 링크를 공유할 때 자동으로 생성된 이미지가 카드로 표시됩니다. 글마다 제목이 다른 OpenGraph 이미지를 동적으로 만들 수 있습니다.

다음 챕터에서는 개발 과정에서 자주 만나는 에러들과 해결 방법을 정리합니다.