Next.js 이론 4강

SEO 최적화와 메타데이터 관리

검색 엔진 최적화의 기본 원리부터 Next.js의 Metadata API, 구조화된 데이터까지 이커머스 SEO 전략을 학습합니다.

1. 검색 엔진 최적화 기본 원리

SEO(Search Engine Optimization)는 검색 엔진에서 웹사이트가 더 잘 노출되도록 최적화하는 과정입니다.

검색 엔진 동작 원리

1️⃣ 크롤링 (Crawling)

검색 엔진 봇이 웹페이지를 방문하여 콘텐츠를 수집합니다. 링크를 따라 새로운 페이지를 발견합니다.

2️⃣ 인덱싱 (Indexing)

수집한 콘텐츠를 분석하고 데이터베이스에 저장합니다. 페이지의 주제, 키워드, 구조를 파악합니다.

3️⃣ 랭킹 (Ranking)

검색어와 관련성, 페이지 품질 등을 기준으로 순위를 결정합니다.

SSR/SSG가 SEO에 유리한 이유

❌ CSR (클라이언트 렌더링)
<!-- 검색 엔진이 보는 것 -->
<html>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
</html>
<!-- 콘텐츠 없음! -->
✅ SSR/SSG
<!-- 검색 엔진이 보는 것 -->
<html>
  <head>
    <title>상품명 | MyShop</title>
    <meta name="description" content="..." />
  </head>
  <body>
    <h1>상품명</h1>
    <p>상품 설명...</p>
  </body>
</html>

핵심 SEO 요소

Title 태그: 검색 결과에 표시되는 제목
Meta Description: 검색 결과 설명문
Open Graph: SNS 공유 시 미리보기
구조화 데이터: 리치 스니펫 표시
Sitemap: 검색 엔진에 페이지 목록 제공

이커머스 SEO 중요성

상품 페이지가 검색 결과 상위에 노출되면 광고 비용 없이 자연 유입을 늘릴 수 있습니다.

2. Metadata API 완전 정복

Next.js의 Metadata API를 사용하면 페이지별로 메타데이터를 쉽게 관리할 수 있습니다.

정적 메타데이터

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

export const metadata: Metadata = {
  title: 'MyShop - 최고의 온라인 쇼핑몰',
  description: '다양한 상품을 합리적인 가격에 만나보세요',
  keywords: ['쇼핑몰', '온라인쇼핑', '할인'],
};

export default function HomePage() {
  return <div>홈페이지 콘텐츠</div>;
}

// 생성되는 HTML:
// <head>
//   <title>MyShop - 최고의 온라인 쇼핑몰</title>
//   <meta name="description" content="다양한 상품을..." />
//   <meta name="keywords" content="쇼핑몰,온라인쇼핑,할인" />
// </head>

레이아웃에서 기본 메타데이터

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: 'MyShop',
    template: '%s | MyShop',  // 하위 페이지 제목 템플릿
  },
  description: '최고의 온라인 쇼핑몰',
  metadataBase: new URL('https://myshop.com'),
};

// app/products/page.tsx
export const metadata: Metadata = {
  title: '전체 상품',  // → "전체 상품 | MyShop"
};

// app/about/page.tsx
export const metadata: Metadata = {
  title: '회사 소개',  // → "회사 소개 | MyShop"
};

주요 메타데이터 옵션

export const metadata: Metadata = {
  // 기본
  title: '페이지 제목',
  description: '페이지 설명',
  keywords: ['키워드1', '키워드2'],
  
  // 작성자
  authors: [{ name: 'MyShop' }],
  creator: 'MyShop Team',
  
  // 로봇 설정
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
    },
  },
  
  // 아이콘
  icons: {
    icon: '/favicon.ico',
    apple: '/apple-icon.png',
  },
  
  // 정규 URL
  alternates: {
    canonical: 'https://myshop.com/products',
  },
};

메타데이터 상속

레이아웃의 메타데이터는 하위 페이지로 상속됩니다. 하위 페이지에서 같은 필드를 정의하면 덮어씁니다.

3. generateMetadata로 동적 SEO

동적 라우트에서는 generateMetadata 함수를 사용하여 데이터 기반의 메타데이터를 생성합니다.

상품별 동적 메타데이터

// app/products/[id]/page.tsx
import type { Metadata } from 'next';

type Props = {
  params: { id: string };
};

// 동적 메타데이터 생성
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await fetch(
    `https://api.myshop.com/products/${params.id}`
  ).then(res => res.json());

  return {
    title: product.name,
    description: product.description.slice(0, 160),
    openGraph: {
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  };
}

export default async function ProductPage({ params }: Props) {
  const product = await fetch(
    `https://api.myshop.com/products/${params.id}`
  ).then(res => res.json());

  return <ProductDetail product={product} />;
}

데이터 중복 요청 방지

// React cache로 요청 중복 제거
import { cache } from 'react';

const getProduct = cache(async (id: string) => {
  const res = await fetch(`https://api.myshop.com/products/${id}`);
  return res.json();
});

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await getProduct(params.id);  // 첫 번째 요청
  return { title: product.name };
}

export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);  // 캐시된 결과 사용
  return <ProductDetail product={product} />;
}

부모 메타데이터 접근

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata  // 부모 메타데이터
): Promise<Metadata> {
  const product = await getProduct(params.id);
  
  // 부모의 openGraph 이미지 가져오기
  const previousImages = (await parent).openGraph?.images || [];

  return {
    title: product.name,
    openGraph: {
      images: [product.image, ...previousImages],
    },
  };
}

이커머스 팁

  • • 상품명을 title에 포함
  • • 가격, 재고 상태를 description에 포함
  • • 상품 이미지를 OG 이미지로 설정

4. Open Graph와 Twitter Card

SNS에서 링크를 공유할 때 표시되는 미리보기를 설정합니다.

Open Graph 설정

// app/products/[id]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await getProduct(params.id);

  return {
    openGraph: {
      title: product.name,
      description: product.description,
      url: `https://myshop.com/products/${params.id}`,
      siteName: 'MyShop',
      images: [
        {
          url: product.image,
          width: 1200,
          height: 630,
          alt: product.name,
        },
      ],
      locale: 'ko_KR',
      type: 'website',
    },
  };
}

// 생성되는 HTML:
// <meta property="og:title" content="상품명" />
// <meta property="og:description" content="상품 설명" />
// <meta property="og:image" content="https://..." />
// <meta property="og:url" content="https://myshop.com/products/123" />

Twitter Card 설정

export const metadata: Metadata = {
  twitter: {
    card: 'summary_large_image',  // 큰 이미지 카드
    title: '상품명',
    description: '상품 설명',
    images: ['https://myshop.com/product-image.jpg'],
    creator: '@myshop',
    site: '@myshop',
  },
};

// 카드 타입:
// - summary: 작은 이미지
// - summary_large_image: 큰 이미지
// - app: 앱 다운로드
// - player: 비디오/오디오

상품 페이지 전체 예제

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await getProduct(params.id);
  const url = `https://myshop.com/products/${params.id}`;

  return {
    title: product.name,
    description: `${product.name} - ${product.price.toLocaleString()}원`,
    
    openGraph: {
      title: `${product.name} | MyShop`,
      description: product.description,
      url,
      siteName: 'MyShop',
      images: [{
        url: product.image,
        width: 1200,
        height: 630,
      }],
      type: 'website',
    },
    
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.image],
    },
    
    alternates: {
      canonical: url,
    },
  };
}

이미지 권장 사이즈

  • • Open Graph: 1200 x 630px
  • • Twitter: 1200 x 600px
  • • 최소: 600 x 315px

5. JSON-LD 구조화 데이터

구조화 데이터를 사용하면 검색 결과에 리치 스니펫(별점, 가격, 재고 등)이 표시됩니다.

JSON-LD란?

JSON-LD(JavaScript Object Notation for Linked Data)는 검색 엔진이 페이지 콘텐츠를 더 잘 이해할 수 있도록 구조화된 데이터를 제공하는 형식입니다.

리치 스니펫 예시

⭐⭐⭐⭐⭐ 4.8 (1,234개 리뷰) · ₩99,000 · 재고 있음

상품 스키마 (Product)

// app/products/[id]/page.tsx
export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.image,
    sku: product.sku,
    brand: {
      '@type': 'Brand',
      name: product.brand,
    },
    offers: {
      '@type': 'Offer',
      url: `https://myshop.com/products/${params.id}`,
      priceCurrency: 'KRW',
      price: product.price,
      availability: product.inStock 
        ? 'https://schema.org/InStock' 
        : 'https://schema.org/OutOfStock',
      seller: {
        '@type': 'Organization',
        name: 'MyShop',
      },
    },
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: product.rating,
      reviewCount: product.reviewCount,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <ProductDetail product={product} />
    </>
  );
}

빵 부스러기 (BreadcrumbList)

const breadcrumbJsonLd = {
  '@context': 'https://schema.org',
  '@type': 'BreadcrumbList',
  itemListElement: [
    {
      '@type': 'ListItem',
      position: 1,
      name: '홈',
      item: 'https://myshop.com',
    },
    {
      '@type': 'ListItem',
      position: 2,
      name: '전자기기',
      item: 'https://myshop.com/categories/electronics',
    },
    {
      '@type': 'ListItem',
      position: 3,
      name: product.name,
      item: `https://myshop.com/products/${product.id}`,
    },
  ],
};

테스트 도구

  • • Google Rich Results Test
  • • Schema.org Validator
  • • Google Search Console

6. Sitemap과 Robots.txt

Next.js에서 sitemap.xml과 robots.txt를 자동으로 생성할 수 있습니다.

정적 Sitemap

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://myshop.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://myshop.com/products',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.8,
    },
    {
      url: 'https://myshop.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
  ];
}

동적 Sitemap (상품 페이지)

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // 모든 상품 가져오기
  const products = await fetch('https://api.myshop.com/products')
    .then(res => res.json());

  const productUrls = products.map((product) => ({
    url: `https://myshop.com/products/${product.id}`,
    lastModified: new Date(product.updatedAt),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }));

  return [
    {
      url: 'https://myshop.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    ...productUrls,
  ];
}

Robots.txt

// app/robots.ts
import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/', '/cart/', '/checkout/'],
      },
    ],
    sitemap: 'https://myshop.com/sitemap.xml',
  };
}

// 생성되는 robots.txt:
// User-Agent: *
// Allow: /
// Disallow: /api/
// Disallow: /admin/
// Disallow: /cart/
// Disallow: /checkout/
// Sitemap: https://myshop.com/sitemap.xml

이커머스 Disallow 권장

  • • /cart/ - 장바구니
  • • /checkout/ - 결제 페이지
  • • /api/ - API 엔드포인트
  • • /admin/ - 관리자 페이지
  • • /search?* - 검색 결과 (중복 방지)

7. 이커머스 예제: 상품 페이지 SEO 완벽 구현

지금까지 배운 모든 SEO 기법을 적용한 완전한 상품 페이지 예제입니다.

완전한 상품 페이지

// app/products/[id]/page.tsx
import type { Metadata } from 'next';
import { cache } from 'react';

type Props = { params: { id: string } };

const getProduct = cache(async (id: string) => {
  const res = await fetch(`https://api.myshop.com/products/${id}`, {
    next: { tags: [`product-${id}`] }
  });
  return res.json();
});

// 동적 메타데이터
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const product = await getProduct(params.id);
  const url = `https://myshop.com/products/${params.id}`;

  return {
    title: product.name,
    description: `${product.name} - ${product.price.toLocaleString()}원. ${product.description.slice(0, 100)}`,
    
    openGraph: {
      title: `${product.name} | MyShop`,
      description: product.description,
      url,
      siteName: 'MyShop',
      images: [{ url: product.image, width: 1200, height: 630 }],
      type: 'website',
      locale: 'ko_KR',
    },
    
    twitter: {
      card: 'summary_large_image',
      title: product.name,
      description: product.description,
      images: [product.image],
    },
    
    alternates: { canonical: url },
  };
}

// 정적 경로 생성
export async function generateStaticParams() {
  const products = await fetch('https://api.myshop.com/products')
    .then(res => res.json());
  return products.map((p) => ({ id: p.id }));
}

export default async function ProductPage({ params }: Props) {
  const product = await getProduct(params.id);

  // JSON-LD 구조화 데이터
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Product',
    name: product.name,
    description: product.description,
    image: product.image,
    sku: product.sku,
    brand: { '@type': 'Brand', name: product.brand },
    offers: {
      '@type': 'Offer',
      url: `https://myshop.com/products/${params.id}`,
      priceCurrency: 'KRW',
      price: product.price,
      availability: product.inStock 
        ? 'https://schema.org/InStock' 
        : 'https://schema.org/OutOfStock',
    },
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: product.rating,
      reviewCount: product.reviewCount,
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>
        <h1>{product.name}</h1>
        <img src={product.image} alt={product.name} />
        <p>{product.description}</p>
        <p>{product.price.toLocaleString()}원</p>
      </article>
    </>
  );
}

SEO 체크리스트

동적 title, description
Open Graph 메타태그
Twitter Card 메타태그
Canonical URL
JSON-LD Product 스키마
SSG + ISR (빠른 로딩)
시맨틱 HTML (h1, article)

SEO 효과

  • • 검색 결과에 리치 스니펫 표시
  • • SNS 공유 시 매력적인 미리보기
  • • 빠른 페이지 로딩으로 순위 상승
  • • 검색 엔진의 정확한 콘텐츠 이해