C
Next.js/SEO/Lesson 14

Metadata API — generateMetadata 로 동적 SEO + OG 카드

30분·theory
이 챕터
1/2
TypeScript

Metadata API — generateMetadata 로 동적 SEO + OG 카드

💡 왜 배워야 할까요? — SEO 와 소셜 공유의 차이를 만드는 부분

🎯 검색 결과의 제목·설명, 카톡·트위터 공유 카드의 이미지·문구 — 전부 `` 의 meta 태그가 결정.
💼 Pages Router 의 `` 컴포넌트 → App Router 의 `metadata` export 로 표준화됨.
동적 페이지(블로그 글 등) 는 `generateMetadata({ params })` 로 페이지마다 다른 메타 자동 생성.
🔗 파일 컨벤션 — `app/icon.tsx`·`app/opengraph-image.tsx`·`app/twitter-image.tsx` 만 두면 자동으로 메타에 연결.
📈 이걸 모르면 모든 페이지 공유 카드가 똑같이 나옴 = '아마추어' 인상.
🏢 실무에서는
블로그 글 100개 — 각 글마다 제목·요약·대표 이미지가 다르게 공유 카드에 떠야 함. generateMetadata 한 함수로 100개 페이지 자동. 또 검색 엔진이 글 제목을 정확히 가져가 SEO 순위에 직결.

metadata export · generateMetadata · 파일 컨벤션

1. 정적 metadata export

ts
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '코드마스터 — 무료 코딩 학습',
  description: '170개 학습 콘텐츠, AI 코드 리뷰, 모의면접',
  keywords: ['코딩', '학습', 'AI', '면접'],
  openGraph: {
    title: '코드마스터',
    description: '무료 코딩 학습 플랫폼',
    images: ['/og-image.png'],
  },
  twitter: {
    card: 'summary_large_image',
    images: ['/og-image.png'],
  },
};

각 페이지·레이아웃의 최상단에 export. 자동으로 <head> 에 주입됨.

2. generateMetadata — 동적 페이지용

ts
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());

  return {
    title: `${post.title} | 코드마스터 블로그`,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
    },
  };
}

같은 fetch 가 페이지 본문에서도 호출되면 자동 dedupe — 추가 네트워크 비용 없음.

3. layout.tsx 의 title.template — 페이지마다 자동 접미사

ts
// app/layout.tsx — 루트
export const metadata: Metadata = {
  title: {
    default: '코드마스터',
    template: '%s | 코드마스터',
  },
};

// app/posts/[slug]/page.tsx
export const metadata: Metadata = {
  title: 'TypeScript 완전 정복',  // 실제 렌더: 'TypeScript 완전 정복 | 코드마스터'
};

4. 파일 컨벤션 — 자동 메타 연결

code
app/
├── icon.ico          ← 자동으로 <link rel="icon">
├── apple-icon.png    ← Apple Touch Icon
├── opengraph-image.tsx  ← 동적 OG 이미지 (Edge Runtime)
├── twitter-image.png    ← Twitter 카드 이미지
└── robots.txt        ← /robots.txt 로 자동 노출

opengraph-image.tsx 는 ImageResponse 로 동적 PNG 생성 가능. codemaster40 도 이미 [src/app/opengraph-image.tsx](src/app/opengraph-image.tsx) 로 동적 생성.

5. 자주 사용하는 필드 정리

ts
export const metadata: Metadata = {
  // 검색 엔진
  title: '...',
  description: '...',
  keywords: ['...'],
  authors: [{ name: '홍길동', url: 'https://...' }],
  robots: { index: true, follow: true },

  // 표준 링크
  alternates: {
    canonical: 'https://example.com/canonical-url',
    languages: { 'ko-KR': '/ko', 'en-US': '/en' },
  },

  // Open Graph (페북·카톡)
  openGraph: {
    type: 'article',
    url: 'https://...',
    title: '...',
    description: '...',
    images: [{ url: '...', width: 1200, height: 630 }],
    siteName: '코드마스터',
  },

  // Twitter
  twitter: {
    card: 'summary_large_image',
    title: '...',
    description: '...',
    images: ['...'],
    creator: '@handle',
  },
};
💻 🅰️ Pages Router — next/head 컴포넌트
// ❌ Pages Router — next/head 컴포넌트 안에서

// 📁 pages/blog/[slug].tsx
import Head from 'next/head';
import type { GetServerSideProps } from 'next';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

export const getServerSideProps: GetServerSideProps<{ post: Post }> = async (ctx) => {
  const post = await fetch(`https://api/posts/${ctx.params!.slug}`).then(r => r.json());
  return { props: { post } };
};

export default function BlogPost({ post }: { post: Post }) {
  return (
    <>
      <Head>
        <title>{post.title} | 코드마스터 블로그</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        <meta property="og:image" content={post.coverImage} />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:image" content={post.coverImage} />
      </Head>
      <article>{/* ... */}</article>
    </>
  );
}

// 단점:
// - 페이지마다 <Head> 직접 작성 — 보일러플레이트
// - OG 와 Twitter 가 같은 정보 중복 (이중 작성)
// - 컴포넌트 트리 안에 있어 부모/자식 우선순위 헷갈림
💻 🅱️ App Router — metadata export + generateMetadata
// ✅ App Router — metadata + generateMetadata

import type { Metadata } from 'next';
import { notFound } from 'next/navigation';

interface Post { slug: string; title: string; excerpt: string; coverImage: string; }

// 📁 app/layout.tsx — 루트 (모든 페이지에 상속)
export const metadata: Metadata = {
  title: {
    default: '코드마스터 — 무료 코딩 학습',
    template: '%s | 코드마스터',  // 자식 페이지가 'X' 이면 'X | 코드마스터'
  },
  description: '170개 학습 콘텐츠, AI 코드 리뷰, 모의면접',
  metadataBase: new URL('https://codemaster40.com'),
  openGraph: {
    siteName: '코드마스터',
    locale: 'ko_KR',
  },
  twitter: {
    card: 'summary_large_image',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html lang="ko"><body>{children}</body></html>;
}

// 📁 app/blog/[slug]/page.tsx — 동적 메타
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post: Post | null = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) return { title: '글을 찾을 수 없음' };

  return {
    title: post.title,                   // 자동으로 'TypeScript 완전 정복 | 코드마스터'
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
      type: 'article',
      publishedTime: '2026-05-26T00:00:00.000Z',
    },
    twitter: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://codemaster40.com/blog/${post.slug}`,
    },
  };
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  // 같은 fetch — Next.js 가 자동 dedupe (generateMetadata 와 한 번만 호출)
  const post = await fetch(`https://api/posts/${params.slug}`).then(r => r.json());
  if (!post) notFound();
  return <article>{post.title}</article>;
}

// 📁 app/opengraph-image.tsx — 동적 OG 이미지 (Edge Runtime)
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function OGImage() {
  return new ImageResponse(
    <div style={{ background: '#000', color: '#fff', fontSize: 80 }}>
      코드마스터
    </div>,
    { ...size },
  );
}

💡 💡 Metadata 실전 5

1. metadataBase 를 루트 레이아웃에 두기

ts
metadataBase: new URL('https://codemaster40.com')

OG 이미지 등 상대 경로가 절대 경로로 자동 변환됨.

2. title.template 으로 일관 브랜딩
루트에 template: '%s | 코드마스터' → 자식 페이지는 그냥 'TypeScript 기초' 만 적음.

3. generateMetadata 의 fetch 는 페이지 fetch 와 자동 dedupe
같은 URL 이면 한 번만 호출. 추가 네트워크 비용 없음.

4. 파일 컨벤션 4종 활용 — 코드 0줄
app/icon.ico·apple-icon.png·opengraph-image.png·twitter-image.png 만 두면 자동 메타 연결.

5. OG 이미지는 1200×630, Twitter card 는 summary_large_image
표준 사이즈. 검증: https://www.opengraph.xyz/, https://cards-dev.twitter.com/validator

⚡ 직접 실행해보기 — generateMetadata 결과 시뮬레이션

각 페이지의 metadata 가 어떻게 머지되어 최종 head 가 되는지 시뮬레이션.
✏️ JS 코드
📟 콘솔 출력
▶ 실행 버튼을 눌러보세요
⚠️ 브라우저 샌드박스에서 실행 — console.log()만 지원, alert/fetch 불가

확인 퀴즈

App Router 의 `title.template = '%s | 코드마스터'` 가 루트 레이아웃에 있고, 자식 페이지가 `title: 'Promise<T>'` 이면 실제 렌더되는 `<title>` 은?
💡 `title.template` 의 `%s` 가 자식의 title 로 치환됩니다. 그래서 'Promise<T>' + ' | 코드마스터' = **'Promise<T> | 코드마스터'**. 자식이 template 영향 안 받기를 원하면 `title: { absolute: 'Plain Title' }` 으로 명시. 일관된 브랜드 접미사를 자동 적용하는 패턴이라 모든 페이지에서 자식은 핵심 단어만 적으면 됩니다.