icon
13장 : SEO 및 메타데이터

동적 메타데이터 생성


웹 애플리케이션에서 각 페이지의 내용은 고정되어 있지 않고, 사용자 요청이나 데이터베이스의 내용에 따라 동적으로 변하는 경우가 많습니다. 예를 들어, 블로그 게시물 페이지는 게시물마다 제목, 설명, 이미지가 다르고, 상품 상세 페이지는 상품마다 정보가 다릅니다. 이처럼 동적인 콘텐츠에 대한 메타데이터를 효율적으로 관리하는 것이 동적 메타데이터 생성의 핵심입니다.

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 등에서 비동기적으로 데이터를 가져와 메타데이터를 구성할 수 있습니다.
  • paramssearchParams 접근: 동적 라우트의 params (예: [slug], [id])와 URL 쿼리 파라미터인 searchParams에 접근하여 데이터를 기반으로 메타데이터를 생성할 수 있습니다.
  • parent 메타데이터 상속: 상위 레이아웃에서 정의된 메타데이터를 parent 인수를 통해 가져와 병합하거나 덮어쓸 수 있습니다.
  • 자동 캐싱: generateMetadata 내에서 이루어지는 fetch 요청은 자동으로 메모이제이션되어, 동일한 데이터가 다른 컴포넌트(예: page.tsx, layout.tsx)에서 다시 요청될 때 재사용됩니다.
  • 스트리밍 지원: 동적으로 렌더링되는 페이지의 경우, generateMetadata 함수가 메타데이터를 해결하는 동안 렌더링이 블록되지 않도록 Next.js는 해결된 메타데이터를 별도로 스트리밍하여 HTML에 주입합니다.

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

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

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

// 가상의 데이터베이스 또는 API 호출 함수
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: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
};

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

  // 게시물이 없는 경우 기본 메타데이터 또는 404 처리
  if (!post) {
    return {
      title: '페이지를 찾을 수 없습니다.',
      description: '요청하신 페이지를 찾을 수 없습니다.',
    };
  }

  // 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://yourwebsite.com/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: { slug: string } }) {
  const post = await getPost(params.slug);

  if (!post) {
    // metadata 함수에서 404를 처리했으므로, 여기서는 notFound()를 호출하여 Next.js의 404 페이지를 렌더링합니다.
    // 또는 여기에 커스텀 404 UI를 렌더링할 수 있습니다.
    return (
      <div style={{ padding: '20px', textAlign: 'center' }}>
        <h1>게시물을 찾을 수 없습니다.</h1>
        <p>요청하신 블로그 게시물이 존재하지 않습니다.</p>
      </div>
    );
  }

  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 && (
        // Next/Image 대신 일반 img 태그 사용 (메타데이터 이미지는 Next.js Image 컴포넌트로 자동 처리되지 않음)
        // 실제 페이지 콘텐츠에서는 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>
  );
}

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

검색 결과 페이지(app/search/page.tsx)처럼 URL 쿼리 스트링에 따라 내용이 달라지는 경우 searchParams를 사용하여 메타데이터를 동적으로 생성할 수 있습니다.

app/search/page.tsx
// 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: { q?: string }; // q는 쿼리 파라미터 (예: ?q=nextjs)
};

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const query = searchParams.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 query = searchParams.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 이미지파비콘(Favicon) 생성을 지원합니다. 이는 특히 블로그 게시물이나 상품 페이지처럼 각 콘텐츠마다 고유한 공유 이미지를 자동으로 생성하고 싶을 때 유용합니다.

app/api/og 또는 app/opengraph-image.tsx와 같은 특수 파일 컨벤션을 사용하며, @vercel/og 라이브러리와 React 컴포넌트를 사용하여 이미지를 렌더링할 수 있습니다.

예시: 동적 Open Graph 이미지 생성 (app/blog/[slug]/opengraph-image.tsx)

app/blog/[slug]/opengraph-image.tsx
// app/blog/[slug]/opengraph-image.tsx
// 이 파일은 Vercel Edge Runtime에서 실행됩니다.
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: { slug: string } }) {
  const postTitle = await getPostTitle(params.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,
      //   },
      // ],
    }
  );
}

opengraph-image.tsx 파일이 존재하면, generateMetadata에서 openGraph.images를 명시적으로 설정하지 않아도 Next.js가 자동으로 이 이미지를 사용하도록 구성됩니다.


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

  • 데이터 페칭 최적화: generateMetadata 함수는 각 요청마다 실행될 수 있으므로, 데이터 페칭 로직은 효율적이고 빠르게 응답할 수 있도록 최적화해야 합니다. Next.js는 fetch 요청을 자동으로 메모이제이션하므로, generateMetadata와 페이지 컴포넌트에서 동일한 데이터를 여러 번 fetch해도 실제 네트워크 요청은 한 번만 발생합니다.
  • 오류 처리: 데이터 페칭 실패 시를 대비하여 적절한 오류 처리(예: 기본값 반환, 404 페이지 렌더링)를 구현해야 합니다.
  • 캐싱 전략: ISR (Incremental Static Regeneration)이나 리밸리데이션(Revalidation)을 사용하여 동적 콘텐츠의 메타데이터도 효율적으로 캐싱하고 업데이트할 수 있습니다.
  • 라우트 세그먼트의 병합: 여러 layout.tsx 또는 page.tsx 파일에 generateMetadata 함수가 있을 경우, Next.js는 하위 레벨의 메타데이터가 상위 레벨을 덮어쓰는 방식으로 병합합니다. 배열 형태의 속성은 기본적으로 병합되지만, 특정 동작을 원한다면 parent 인자를 사용하여 직접 병합 로직을 구현해야 할 수 있습니다.
  • 성능 모니터링: 동적 메타데이터 생성이 애플리케이션의 성능에 미치는 영향을 주기적으로 모니터링해야 합니다.

Next.js의 generateMetadata 함수는 복잡한 동적 웹 애플리케이션에서 SEO 및 소셜 미디어 공유를 위한 메타데이터를 효과적으로 관리할 수 있는 강력하고 유연한 방법을 제공합니다. 이를 통해 검색 엔진 가시성을 높이고 사용자에게 더 풍부한 정보를 전달할 수 있습니다.