안동민 개발노트

안동민 개발노트

정적 메타데이터 설정동적 메타데이터 생성Open Graph 태그 활용robots.txt 및 sitemap.xml 설정
본문 시작
  1. 홈
  2. 문서
  3. Next.js
  4. 13장 : SEO 및 메타데이터
  5. 동적 메타데이터 생성
  1. Next.js
  2. 동적 메타데이터 생성

동적 메타데이터 생성

generateMetadata에서 경로·검색 파라미터와 콘텐츠 데이터를 읽어 게시물별 제목·설명·OG 이미지를 생성합니다.

웹 애플리케이션의 페이지 내용은 고정되어 있지 않고 요청이나 데이터베이스 상태에 따라 동적으로 변하는 경우가 많습니다.

예를 들어 게시물 페이지는 글마다 제목/설명/이미지가 다르고, 상품 페이지도 상품별 정보가 모두 다릅니다.

이런 동적 콘텐츠 메타데이터를 효율적으로 관리하는 것이 동적 메타데이터 생성의 핵심입니다.

Next.js App Router는 generateMetadata 함수를 통해 이러한 동적 메타데이터 요구 사항을 지원합니다.


동적 메타데이터의 필요성

정적 메타데이터는 웹사이트의 일반적인 정보나 특정 섹션의 고정된 정보를 설정하는 데 적합합니다.

하지만 다음과 같은 경우에는 동적 메타데이터가 필수적입니다.

  • 블로그 게시물/기사 페이지: 각 게시물마다 고유한 제목, 요약, 대표 이미지, 작성자 정보 등을 동적으로 생성하여 SEO 및 소셜 공유 최적화가 필요합니다.
  • 상품 상세 페이지: 각 상품의 이름, 가격, 설명, 이미지 등 상세 정보를 기반으로 메타데이터를 생성해야 합니다.
  • 사용자 프로필 페이지: 사용자 이름, 프로필 사진, 소개글 등을 반영한 메타데이터가 필요합니다.
  • 검색 결과 페이지: 검색어에 따라 동적으로 제목과 설명을 변경하여 검색 엔진이 페이지의 관련성을 정확히 파악하도록 돕습니다.

동적 메타데이터는 페이지의 콘텐츠와 밀접하게 연관되어 있으므로, 검색 엔진이 페이지의 주제를 더 정확하게 이해하고, 소셜 미디어 공유 시 풍부한 미리보기를 제공하여 클릭률을 높이는 데 기여합니다.


generateMetadata 함수 사용하기

Next.js App Router에서는 동적 라우트(Dynamic Routes)나 검색 파라미터(Search Parameters) 등을 기반으로 메타데이터를 생성하기 위해 page.tsx 또는 layout.tsx 파일에서 비동기 함수인 generateMetadata를 export 할 수 있습니다.

generateMetadata 함수는 서버 컴포넌트에서만 지원되며, 데이터 페칭을 포함한 비동기 로직을 사용하여 메타데이터를 동적으로 생성하고 반환할 수 있습니다.

generateMetadata 함수의 특징
  • 비동기 지원: async/await를 사용하여 데이터베이스, 외부 API 등에서 비동기적으로 데이터를 가져와 메타데이터를 구성할 수 있습니다.
  • 라우트 입력: params를 사용해 동적 경로를 읽습니다. searchParams는 page.tsx에서만 제공되며, 반복된 쿼리 이름의 값은 배열이 될 수 있습니다.
  • parent 메타데이터 상속: 상위 레이아웃에서 정의된 메타데이터를 parent 인수를 통해 가져와 병합하거나 덮어쓸 수 있습니다.
  • 요청 안의 조회 재사용: 같은 GET URL·옵션의 fetch는 렌더 과정에서 메모이제이션될 수 있습니다. 임의의 DB 함수나 POST 호출이 모두 자동 재사용되는 것은 아니며, 아래 모의 함수는 React cache로 감쌉니다.
  • 렌더링 시점: 요청 정보에 의존하지 않는 메타데이터는 미리 렌더될 수 있습니다. 동적 메타데이터는 초기 UI 뒤에 스트리밍할 수 있지만, HTML 제한 봇에는 생성을 기다린 뒤 head를 제공합니다. 처리 방식은 User-Agent와 설정에 따라 달라집니다.

동적 라우트 파라미터로 메타데이터 생성

블로그 게시물 상세 페이지(src/app/blog/[slug]/page.tsx)를 예로 들어, slug 값을 이용하여 특정 게시물의 데이터를 가져오고 메타데이터를 동적으로 설정해 보겠습니다.

src/app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'; // Metadata, ResolvingMetadata 타입 임포트
import { notFound } from 'next/navigation';
import { cache } from 'react';

// 가상의 데이터베이스 또는 API 호출 함수
const getPost = cache(async function getPost(slug: string) {
  // 실제로는 여기서 DB 또는 API를 호출하여 게시물 데이터를 가져옵니다.
  // 예시를 위해 더미 데이터를 반환합니다.
  const posts = [
    { slug: 'nextjs-seo-guide', title: 'Next.js SEO 가이드', content: 'Next.js에서 SEO를 위한 메타데이터 설정 방법...', description: 'Next.js에서 웹사이트 검색 엔진 최적화를 위한 필수 가이드입니다.', imageUrl: 'https://example.com/images/seo-guide.jpg' },
    {
      slug: 'dynamic-metadata-tutorial',
      title: '동적 메타데이터 튜토리얼',
      content: 'Next.js App Router에서 동적 메타데이터를 생성하는 방법...',
      description: 'Next.js generateMetadata 함수를 활용한 동적 SEO 설정 방법을 배웁니다.',
      imageUrl: 'https://example.com/images/dynamic-meta.jpg',
    },
  ];
  return new Promise<typeof posts[0] | undefined>(resolve => {
    setTimeout(() => {
      resolve(posts.find(post => post.slug === slug));
    }, 500); // 네트워크 지연 시뮬레이션
  });
});

// 1. generateMetadata 함수 정의
// params: 동적 라우트 파라미터 (예: { slug: 'nextjs-seo-guide' })
// parent: 상위 레이아웃에서 전달된 메타데이터 (Promise)
type Props = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata // 상위 메타데이터 타입
): Promise<Metadata> {
  // 2. 동적 라우트 파라미터로부터 데이터 가져오기
  const { slug } = await params;
  const post = await getPost(slug);

  // 게시물이 없으면 메타데이터 생성 단계에서도 not-found 처리로 전환
  if (!post) {
    notFound();
  }

  // 3. 상위 메타데이터 가져오기 (선택 사항)
  const previousImages = (await parent).openGraph?.images || [];

  // 4. 가져온 데이터를 기반으로 Metadata 객체 반환
  return {
    title: post.title, // 게시물 제목으로 페이지 제목 설정
    description: post.description, // 게시물 설명으로 페이지 설명 설정
    keywords: [`${post.title}`, '블로그', '튜토리얼', 'Next.js'], // 게시물 관련 키워드
    openGraph: {
      title: post.title,
      description: post.description,
      url: `https://blog-lab.dev/blog/${post.slug}`, // 게시물 고유 URL
      images: [
        {
          url: post.imageUrl, // 게시물 대표 이미지
          width: 800,
          height: 600,
          alt: post.title,
        },
        ...previousImages, // 상위 레이아웃의 OG 이미지를 함께 포함
      ],
      type: 'article', // 블로그 게시물은 article 타입으로 설정
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.description,
      images: [post.imageUrl],
    },
  };
}

// 5. 실제 페이지 컴포넌트 (데이터 페칭을 재사용할 수 있음)
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) {
    notFound();
  }

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #00BCD4', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ color: '#00BCD4', textAlign: 'center', marginBottom: '20px' }}>{post.title}</h1>
      {post.imageUrl && (
        // 본문 이미지 예시이며, OG 이미지 URL 선언과는 별개의 표시 요소
        // 실제 페이지 콘텐츠에서는 next/image를 사용하는 것이 좋습니다.
        <img src={post.imageUrl} alt={post.title} style={{ maxWidth: '100%', height: 'auto', display: 'block', margin: '0 auto 20px' }} />
      )}
      <p style={{ lineHeight: 1.6, fontSize: '1.1em', color: '#333' }}>{post.content}</p>
    </div>
  );
}

같은 페이지 렌더에서 getPost(slug) 결과를 본문과 메타데이터가 재사용합니다. 이는 요청을 넘어 유지하는 데이터 캐시가 아닙니다. 500ms는 예제의 모의 지연이며 실제 조회시간을 측정한 값이 아닙니다.

notFound()는 렌더를 중단하고 not-found UI와 noindex 처리로 연결합니다. 이미 스트리밍을 시작한 응답은 HTTP 200일 수 있으므로, 항상 전송 상태가 404로 바뀐다고 해석하지 않습니다.

검색 파라미터를 이용한 메타데이터 생성

검색 결과 페이지(src/app/search/page.tsx)처럼 URL 쿼리 스트링에 따라 내용이 달라지는 경우 searchParams를 사용합니다. 이 예제는 같은 q가 반복되면 첫 값을 선택하며 메타데이터와 본문에 같은 정책을 적용합니다.

src/app/search/page.tsx
import type { Metadata } from 'next';

// 가상의 검색 결과 API 호출
async function searchProducts(query: string | undefined) {
  if (!query) return [];
  // 실제로는 여기서 검색 API를 호출합니다.
  const allProducts = [
    { id: 1, name: 'Next.js 책', description: 'Next.js 학습서', price: 30000 },
    { id: 2, name: 'React 티셔츠', description: 'React 개발자를 위한 티셔츠', price: 20000 },
    { id: 3, name: '웹 개발 도구', description: '생산성을 높여주는 웹 개발 도구', price: 50000 },
  ];
  return new Promise<typeof allProducts>(resolve => {
    setTimeout(() => {
      resolve(allProducts.filter(p => p.name.includes(query) || p.description.includes(query)));
    }, 300);
  });
}

type Props = {
  searchParams: Promise<{ q?: string | string[] }>; // q는 쿼리 파라미터 (예: ?q=nextjs)
};

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const { q } = await searchParams;
  const query = Array.isArray(q) ? q[0] : q;
  const title = query ? `${query} 검색 결과` : '검색 페이지';
  const description = query ? `'${query}'에 대한 검색 결과입니다.` : '웹사이트 내에서 원하는 정보를 검색하세요.';

  return {
    title: title,
    description: description,
    keywords: ['검색', '상품', '정보', query].filter(Boolean) as string[], // query가 있을 때만 포함
  };
}

export default async function SearchPage({ searchParams }: Props) {
  const { q } = await searchParams;
  const query = Array.isArray(q) ? q[0] : q;
  const results = await searchProducts(query);

  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #FFC107', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}>
      <h1 style={{ color: '#FFC107', textAlign: 'center', marginBottom: '20px' }}>
        {query ? `'${query}' 검색 결과` : '검색 페이지'}
      </h1>
      <form style={{ display: 'flex', justifyContent: 'center', marginBottom: '30px' }}>
        <input
          type="text"
          name="q"
          defaultValue={query}
          placeholder="검색어를 입력하세요..."
          style={{ padding: '10px', width: '70%', marginRight: '10px', border: '1px solid #ddd', borderRadius: '5px' }}
        />
        <button type="submit" style={{ padding: '10px 20px', backgroundColor: '#FFC107', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>
          검색
        </button>
      </form>

      {query && results.length > 0 ? (
        <div>
          <h2 style={{ fontSize: '1.2em', color: '#555' }}>총 {results.length}개의 결과가 발견되었습니다.</h2>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {results.map(product => (
              <li key={product.id} style={{ border: '1px solid #eee', padding: '15px', marginBottom: '10px', borderRadius: '8px', backgroundColor: '#fff' }}>
                <h3>{product.name}</h3>
                <p>{product.description}</p>
                <p>가격: {product.price.toLocaleString()}원</p>
              </li>
            ))}
          </ul>
        </div>
      ) : query && results.length === 0 ? (
        <p style={{ textAlign: 'center', color: '#888' }}>'{query}'에 대한 검색 결과가 없습니다.</p>
      ) : (
        <p style={{ textAlign: 'center', color: '#888' }}>검색어를 입력하여 정보를 찾아보세요.</p>
      )}
    </div>
  );
}

동적 OG 이미지 및 아이콘 생성 (고급)

Next.js는 동적으로 생성되는 페이지를 위한 동적 Open Graph 이미지와 아이콘 생성을 지원합니다. 동적 아이콘은 icon.tsx 같은 컨벤션을 사용하며, favicon.ico 자체는 정적 파일입니다.

이는 특히 블로그 게시물이나 상품 페이지처럼 각 콘텐츠마다 고유한 공유 이미지를 자동으로 생성하고 싶을 때 유용합니다.

opengraph-image.tsx 파일에서 내장된 next/og의 ImageResponse를 반환할 수 있습니다. /api/og는 특수 파일명이 아니라 route.ts로 구현할 수 있는 일반 Route Handler 경로입니다.

예시: 동적 Open Graph 이미지 생성 (src/app/blog/[slug]/opengraph-image.tsx)
src/app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';

// 이미지 메타데이터
export const alt = '게시물 이미지';
export const size = {
  width: 1200,
  height: 630,
};
export const contentType = 'image/png';

async function getPostTitle(slug: string): Promise<string> {
  // 실제로는 여기서 DB 또는 API를 호출하여 게시물 제목을 가져옵니다.
  const titles: { [key: string]: string } = {
    'nextjs-seo-guide': 'Next.js SEO 가이드',
    'dynamic-metadata-tutorial': '동적 메타데이터 튜토리얼',
  };
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(titles[slug] || '알 수 없는 게시물');
    }, 200);
  });
}

// 1. ImageResponse를 반환하는 함수 정의 (export default)
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const postTitle = await getPostTitle(slug);

  return new ImageResponse(
    (
      // 2. React JSX를 사용하여 이미지 콘텐츠 정의
      <div
        style={{
          fontSize: 60,
          background: 'white',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          fontFamily: 'sans-serif',
          padding: '50px',
          textAlign: 'center',
          boxSizing: 'border-box',
        }}
      >
        <p style={{ fontSize: 30, color: '#666' }}>Next.js 블로그</p>
        <h1 style={{ fontSize: 70, color: '#0070f3', lineHeight: 1.2 }}>
          {postTitle}
        </h1>
        <p style={{ fontSize: 40, color: '#333', marginTop: '20px' }}>
          SEO & 메타데이터 최적화
        </p>
      </div>
    ),
    {
      ...size,
      // 폰트 로드 (선택 사항, 필요 시)
      // fonts: [
      //   {
      //     name: 'Noto Sans KR',
      //     data: await fetch(new URL('./path-to-your-font.woff', import.meta.url)).then(
      //       (res) => res.arrayBuffer()
      //     ),
      //     style: 'normal',
      //     weight: 400,
      //   },
      // ],
    }
  );
}

파일 방식으로 전환할 때는 앞 generateMetadata 예제의 명시적 openGraph.images를 빼고 opengraph-image.tsx가 제공하는 이미지를 사용합니다. 같은 세그먼트의 명시적 이미지와 파일 방식을 섞어 자동 병합을 기대하지 않습니다. Twitter 이미지를 전환하려면 twitter.images도 별도로 맞춥니다.

본문 조회와 OG 이미지 요청의 범위

페이지 렌더에서 generateMetadata와 BlogPostPage는 같은 cache(getPost)의 slug 조회를 재사용합니다. 두 함수의 실행 완료 순서를 뜻하지 않습니다. OG 이미지 URL은 별도 요청 경로이며 원문의 getPostTitle은 별도 모의 목록을 읽습니다. 아래 연결은 데이터 흐름이며 요청 사이의 단일 캐시를 뜻하지 않습니다.

본문 조회와 OG 이미지 요청의 범위페이지 렌더에서 generateMetadata와 BlogPostPage는 같은 cache(getPost)의 slug 조회를 재사용합니다. 두 함수의 실행 완료 순서를 뜻하지 않습니다. OG 이미지 URL은 별도 요청 경로이며 원문의 getPostTitle은 별도 모의 목록을 읽습니다. 아래 연결은 데이터 흐름이며 요청 사이의 단일 캐시를 뜻하지 않습니다.페이지 렌더 · 같은 조회 재사용getPost(slug)React cachegenerateMetadatatitle · OG 필드BlogPostPage제목 · 콘텐츠별도 이미지 URL 요청getPostTitle(slug)별도의 모의 목록ImageResponsePNG 응답페이지 조회 캐시와 별개
본문 조회와 OG 이미지 요청의 범위페이지 렌더에서 generateMetadata와 BlogPostPage는 같은 cache(getPost)의 slug 조회를 재사용합니다. 두 함수의 실행 완료 순서를 뜻하지 않습니다. OG 이미지 URL은 별도 요청 경로이며 원문의 getPostTitle은 별도 모의 목록을 읽습니다. 아래 연결은 데이터 흐름이며 요청 사이의 단일 캐시를 뜻하지 않습니다.페이지 렌더 · 같은 조회 재사용getPost(slug)React cache메타데이터title · OG 필드페이지 본문제목 · 콘텐츠별도 이미지 URL 요청getPostTitle(slug)별도의 모의 목록ImageResponsePNG 응답페이지 조회 캐시와 별개

getPostTitle은 별도의 모의 목록을 읽습니다. 실제 서비스에서는 본문과 같은 저장소·식별 기준을 쓰되, 이미지 URL 요청이 페이지 렌더의 React 캐시까지 공유한다고 가정하지 않습니다. 예제의 외부 이미지 주소와 글꼴 결과는 실제 배포 자산으로 확인해야 합니다.


동적 메타데이터 설정 시 고려사항

  • 데이터 페칭 최적화: generateMetadata 함수는 각 요청마다 실행될 수 있으므로, 데이터 페칭 로직은 효율적이고 빠르게 응답할 수 있도록 최적화해야 합니다. 앞에서 구분한 요청 메모이제이션 조건을 확인하고, 요청 간 재사용이 필요하면 별도의 캐시 수명과 갱신 정책을 정합니다.
  • 오류 처리: 데이터 페칭 실패 시를 대비하여 적절한 오류 처리(예: 기본값 반환, 404 페이지 렌더링)를 구현해야 합니다.
  • 캐싱 전략: ISR (Incremental Static Regeneration)이나 리밸리데이션(Revalidation)을 사용하여 동적 콘텐츠의 메타데이터도 효율적으로 캐싱하고 업데이트할 수 있습니다.
  • 라우트 세그먼트의 병합: 가까운 하위 세그먼트의 필드가 상위 값을 덮어쓰며, openGraph 같은 중첩 객체와 그 안의 배열도 자동으로 깊게 병합되지 않습니다. 상위 이미지를 유지하려면 parent를 await하고 필요한 배열을 명시적으로 합칩니다.
  • 성능 모니터링: 동적 메타데이터 생성이 애플리케이션의 성능에 미치는 영향을 주기적으로 모니터링해야 합니다.

Next.js의 generateMetadata 함수는 복잡한 동적 웹 애플리케이션에서 SEO 및 소셜 미디어 공유를 위한 메타데이터를 효과적으로 관리할 수 있는 강력하고 유연한 방법을 제공합니다.

이를 통해 검색 엔진 가시성을 높이고 사용자에게 더 풍부한 정보를 전달할 수 있습니다.

정적 메타데이터 설정

이전 페이지

Open Graph 태그 활용

다음 페이지

이 페이지의 목차

동적 메타데이터의 필요성generateMetadata 함수 사용하기동적 라우트 파라미터로 메타데이터 생성검색 파라미터를 이용한 메타데이터 생성동적 OG 이미지 및 아이콘 생성 (고급)동적 메타데이터 설정 시 고려사항