검색 엔진 최적화의 기본 원리부터 Next.js의 Metadata API, 구조화된 데이터까지 이커머스 SEO 전략을 학습합니다.
SEO(Search Engine Optimization)는 검색 엔진에서 웹사이트가 더 잘 노출되도록 최적화하는 과정입니다.
검색 엔진 봇이 웹페이지를 방문하여 콘텐츠를 수집합니다. 링크를 따라 새로운 페이지를 발견합니다.
수집한 콘텐츠를 분석하고 데이터베이스에 저장합니다. 페이지의 주제, 키워드, 구조를 파악합니다.
검색어와 관련성, 페이지 품질 등을 기준으로 순위를 결정합니다.
<!-- 검색 엔진이 보는 것 -->
<html>
<body>
<div id="root"></div>
<script src="bundle.js"></script>
</body>
</html>
<!-- 콘텐츠 없음! --><!-- 검색 엔진이 보는 것 -->
<html>
<head>
<title>상품명 | MyShop</title>
<meta name="description" content="..." />
</head>
<body>
<h1>상품명</h1>
<p>상품 설명...</p>
</body>
</html>이커머스 SEO 중요성
상품 페이지가 검색 결과 상위에 노출되면 광고 비용 없이 자연 유입을 늘릴 수 있습니다.
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',
},
};메타데이터 상속
레이아웃의 메타데이터는 하위 페이지로 상속됩니다. 하위 페이지에서 같은 필드를 정의하면 덮어씁니다.
동적 라우트에서는 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],
},
};
}이커머스 팁
SNS에서 링크를 공유할 때 표시되는 미리보기를 설정합니다.
// 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" />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,
},
};
}이미지 권장 사이즈
구조화 데이터를 사용하면 검색 결과에 리치 스니펫(별점, 가격, 재고 등)이 표시됩니다.
JSON-LD(JavaScript Object Notation for Linked Data)는 검색 엔진이 페이지 콘텐츠를 더 잘 이해할 수 있도록 구조화된 데이터를 제공하는 형식입니다.
리치 스니펫 예시
⭐⭐⭐⭐⭐ 4.8 (1,234개 리뷰) · ₩99,000 · 재고 있음
// 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} />
</>
);
}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}`,
},
],
};테스트 도구
Next.js에서 sitemap.xml과 robots.txt를 자동으로 생성할 수 있습니다.
// 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,
},
];
}// 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,
];
}// 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 권장
지금까지 배운 모든 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 효과