동적 메타데이터 생성
generateMetadata에서 경로·검색 파라미터와 콘텐츠 데이터를 읽어 게시물별 제목·설명·OG 이미지를 생성합니다.
웹 애플리케이션의 페이지 내용은 고정되어 있지 않고, 요청이나 데이터베이스의 상태에 따라 바뀌는 경우가 많습니다. 예를 들어 게시물 페이지는 글마다 제목, 설명, 이미지가 다르고, 상품 페이지도 상품마다 정보가 다릅니다. 이런 콘텐츠의 메타데이터를 효율적으로 관리하는 것이 동적 메타데이터 생성의 핵심이며, App Router는 generateMetadata 함수로 이를 지원합니다. 이 글에서는 generateMetadata로 라우트 파라미터와 검색 파라미터, 콘텐츠 데이터에 따라 메타데이터를 만드는 방법을 알아봅니다.
동적 메타데이터의 필요성
정적 메타데이터는 웹사이트의 일반 정보나 특정 영역의 고정된 정보를 설정하는 데 알맞습니다. 하지만 블로그 게시물이나 기사 페이지는 게시물마다 고유한 제목, 요약, 대표 이미지, 작성자 정보를 만들어 SEO와 소셜 공유를 최적화해야 합니다. 상품 상세 페이지는 상품의 이름, 가격, 설명, 이미지를 바탕으로, 사용자 프로필 페이지는 사용자 이름, 프로필 사진, 소개글을 반영해 메타데이터를 만들어야 합니다. 검색 결과 페이지도 검색어에 따라 제목과 설명을 바꾸어, 검색 엔진이 페이지의 관련성을 정확히 파악하게 돕습니다.
동적 메타데이터는 페이지의 콘텐츠와 밀접하게 이어지므로, 검색 엔진이 페이지의 주제를 더 정확하게 이해하고 소셜 미디어에 공유될 때 풍부한 미리보기를 보여 주어 클릭률을 높이는 데 도움이 됩니다.
generateMetadata 함수 사용하기
App Router에서는 동적 라우트(dynamic routes)나 검색 파라미터(search parameters)를 바탕으로 메타데이터를 만들기 위해, page.tsx나 layout.tsx에서 비동기 함수인 generateMetadata를 export할 수 있습니다. generateMetadata는 서버 컴포넌트에서만 지원되며, 데이터 페칭을 포함한 비동기 로직으로 메타데이터를 만들어 돌려줄 수 있습니다.
이 함수는 async/await로 데이터베이스나 외부 API에서 데이터를 가져와 메타데이터를 구성할 수 있습니다. 동적 경로는 params로 읽으며, searchParams는 page.tsx에서만 제공되고 같은 이름의 쿼리가 반복되면 값이 배열이 될 수 있습니다. 상위 레이아웃에서 정의한 메타데이터는 parent 인수로 가져와 합치거나 덮어쓸 수 있습니다.
같은 요청 안의 조회는 다시 쓸 수 있습니다. 같은 GET URL과 옵션의 fetch는 렌더링 과정에서 메모이제이션될 수 있지만, 임의의 DB 함수나 POST 호출이 모두 자동으로 다시 쓰이지는 않으므로 아래 모의 함수는 React cache로 감쌉니다. 요청 정보에 의존하지 않는 메타데이터는 미리 렌더링될 수 있습니다. 동적 메타데이터는 초기 UI 뒤에 스트리밍할 수 있지만, HTML만 읽는 제한된 봇에게는 생성을 기다린 뒤 head를 제공하며, 처리 방식은 User-Agent와 설정에 따라 달라집니다.
동적 라우트 파라미터로 메타데이터 생성
블로그 게시물 상세 페이지 src/app/blog/[slug]/page.tsx를 예로 들어, slug 값으로 특정 게시물의 데이터를 가져와 메타데이터를 설정해 보겠습니다. 아래 파일에는 generateMetadata와 페이지 컴포넌트가 함께 있고, 둘 다 같은 getPost(slug)를 호출합니다. 이 교재의 Next.js 16에서 params는 Promise이므로 두 함수 모두 await params로 slug를 꺼냅니다. generateMetadata의 두 번째 인자 parent는 상위 세그먼트에서 해석한 메타데이터이며, 이 예제는 그 안의 openGraph.images를 게시물 이미지 뒤에 덧붙입니다.
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)의 결과를 함께 씁니다. 이것은 요청을 넘어 유지되는 Data Cache가 아닙니다. 500ms는 예제의 모의 지연이며 실제 조회 시간을 잰 값이 아닙니다.
notFound()는 렌더링을 멈추고 not-found UI와 noindex 처리로 이어집니다. 이미 스트리밍을 시작한 응답은 HTTP 200일 수 있으므로, 전송 상태가 언제나 404로 바뀐다고 생각하지 않습니다.
검색 파라미터를 이용한 메타데이터 생성
검색 결과 페이지 src/app/search/page.tsx처럼 URL 쿼리 문자열에 따라 내용이 달라지는 경우에는 searchParams를 씁니다. 이 예제는 같은 q가 반복되면 첫 값을 고르며, 메타데이터와 본문에 같은 정책을 적용합니다.
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>
);
}주소가 /search?q=React라면 query는 'React'가 되어 페이지 제목은 React 검색 결과가 되고, 본문에는 이름이나 설명에 React가 들어간 React 티셔츠 하나만 나옵니다. includes는 대소문자를 구분하므로 ?q=react로는 같은 상품을 찾지 못합니다. 폼에는 action이 없어서 검색 버튼을 누르면 브라우저가 같은 주소에 ?q=입력값을 붙여 GET 요청을 보내고, 서버가 새 searchParams로 메타데이터와 본문을 다시 만듭니다.
동적 OG 이미지 및 아이콘 생성 (고급)
Next.js는 동적으로 만들어지는 페이지를 위한 동적 Open Graph 이미지와 아이콘 생성을 지원합니다. 동적 아이콘은 icon.tsx 같은 파일 규칙을 쓰며, favicon.ico 자체는 정적 파일입니다. 이는 블로그 게시물이나 상품 페이지처럼 콘텐츠마다 고유한 공유 이미지를 자동으로 만들고 싶을 때 유용합니다.
opengraph-image.tsx 파일에서는 내장된 next/og의 ImageResponse를 돌려줄 수 있습니다. 참고로 /api/og는 특수한 파일 이름이 아니라, route.ts로 구현할 수 있는 일반 Route Handler 경로입니다. 다음은 src/app/blog/[slug]/opengraph-image.tsx에서 동적 Open Graph 이미지를 만드는 예시입니다.
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를 따로 맞춥니다.
NEXT · 13.2
본문과 메타데이터의 조회 재사용은 페이지 렌더 범위입니다
generateMetadata와 BlogPostPage는 같은 getPost(slug)를 재사용합니다. 이미지 URL 요청의 getPostTitle은 별도 모의 목록을 읽으며, 아래 화살표는 조회 결과의 전달 관계입니다.
메타데이터 노드는 generateMetadata, 페이지 본문 노드는 BlogPostPage입니다. 두 함수의 완료 순서를 정하는 그림이 아닙니다. React cache는 요청 간 데이터 캐시가 아니며 500ms·200ms는 서로 다른 예제의 모의 지연입니다. 파일 이미지 방식으로 전환할 때는 명시적 openGraph.images도 정리합니다.
위 그림은 본문 조회와 OG 이미지 요청이 다시 쓰는 범위를 보여 줍니다. 위쪽의 페이지 렌더링 영역에서는 generateMetadata와 BlogPostPage가 React cache로 감싼 같은 getPost(slug) 조회를 다시 써서, 각각 제목과 OG 필드, 제목과 콘텐츠를 만듭니다. 두 함수 사이에는 순서를 나타내는 연결이 없으며, 실행 완료 순서를 정하는 그림이 아닙니다. 아래쪽의 별도 이미지 URL 요청에서는 getPostTitle(slug)가 별도의 모의 목록을 읽어 ImageResponse로 PNG 응답을 만들며, 이는 페이지 조회 캐시와 별개입니다. 그림의 설명처럼 React cache는 요청 사이의 Data Cache가 아니고, 500ms와 200ms는 서로 다른 예제의 모의 지연입니다.
getPostTitle은 별도의 모의 목록을 읽습니다. 실제 서비스에서는 본문과 같은 저장소와 식별 기준을 쓰되, 이미지 URL 요청이 페이지 렌더링의 React 캐시까지 함께 쓴다고 가정하지 않습니다. 예제의 외부 이미지 주소와 글꼴 결과는 실제 배포 자산으로 확인해야 합니다.
더 깊이: ImageResponse가 그릴 수 있는 것과 이미지가 만들어지는 시점
ImageResponse는 브라우저로 화면을 찍는 방식이 아닙니다. Next.js 문서의 ImageResponse에 따르면 @vercel/og, Satori, Resvg를 써서 JSX와 CSS를 PNG로 바꾸므로, 쓸 수 있는 CSS는 플렉스박스(flexbox)와 일부 속성뿐이고 display: grid 같은 고급 레이아웃은 동작하지 않습니다. 기본 크기는 1200 × 630이고, JSX와 CSS, 글꼴, 이미지 같은 자산을 모두 합친 번들은 500KB를 넘을 수 없습니다. 글꼴은 ttf, otf, woff만 지원하며, 해석 속도 때문에 woff보다 ttf나 otf를 권합니다. 한글 글꼴은 파일이 커지기 쉬우므로 500KB 한도를 넘지 않는지부터 확인합니다.
opengraph-image.tsx로 만든 이미지는 요청할 때마다 새로 그려지는 것이 아닙니다. 이 파일은 특수한 Route Handler라서, 요청 시점 API나 캐시하지 않은 데이터를 쓰지 않는 한 빌드할 때 만들어 캐시하는 정적 최적화 대상입니다. Next.js 16에서는 이 이미지 함수가 받는 params와, generateImageMetadata로 여러 이미지를 만들 때 받는 id가 모두 Promise로 바뀌었습니다. 한편 이미지 파일을 직접 둘 때는 opengraph-image가 8MB, twitter-image가 5MB를 넘으면 빌드가 실패합니다.
동적 메타데이터 설정 시 고려사항
generateMetadata는 요청마다 실행될 수 있으므로, 데이터 페칭 로직은 효율적이고 빠르게 응답하도록 만들어야 합니다. 앞에서 구분한 요청 메모이제이션 조건을 확인하고, 요청 사이의 재사용이 필요하다면 별도의 캐시 수명과 갱신 정책을 정합니다. 데이터 페칭이 실패할 때를 대비해 기본값을 돌려주거나 404 페이지를 렌더링하는 등 알맞은 오류 처리를 구현합니다. ISR이나 재검증(revalidation)으로 동적 콘텐츠의 메타데이터도 효율적으로 캐싱하고 갱신할 수 있습니다.
라우트 세그먼트의 병합에서는 가까운 하위 세그먼트의 필드가 상위 값을 덮어쓰며, openGraph 같은 중첩 객체와 그 안의 배열도 자동으로 깊게 병합되지 않습니다. 상위의 이미지를 유지하려면 parent를 await하고 필요한 배열을 명시적으로 합칩니다. 동적 메타데이터 생성이 애플리케이션 성능에 주는 영향도 주기적으로 살펴봅니다.
generateMetadata는 동적인 웹 애플리케이션에서 SEO와 소셜 공유를 위한 메타데이터를 효과적으로 관리하는 유연한 방법이며, 이를 통해 검색 엔진에서의 노출을 높이고 사용자에게 더 풍부한 정보를 전달할 수 있습니다.
정리
generateMetadata는 서버 컴포넌트인page.tsx나layout.tsx에서 내보내는 비동기 함수로,params,searchParams,parent를 받아 메타데이터를 돌려줍니다.searchParams는 페이지에서만 제공되며, 같은 이름이 반복되면 배열이 되므로 값을 고르는 정책을 메타데이터와 본문에 똑같이 적용합니다.- 본문과 메타데이터가 같은 조회를 다시 쓰려면 React
cache로 감싸며, 이는 한 번의 렌더링 안에서만 유지됩니다. notFound()는 not-found UI로 이어지지만, 스트리밍을 시작한 응답의 상태가 언제나 404가 되지는 않습니다.opengraph-image.tsx는ImageResponse로 공유 이미지를 만들며, 같은 세그먼트의 명시적openGraph.images와 섞지 않습니다.
연습 문제
1. 두 요청을 구별해 보기
모의 목록에 없는 missing-post로 게시물 페이지를 요청하고, 같은 slug의 OG 이미지 URL도 따로 요청했다고 가정합니다. 둘 다 notFound()로 끝날까요? 또 검색 쿼리를 ?q=&q=React로 바꾸면 예제는 두 번째 값을 검색할까요?
해설 보기
게시물의 getPost는 undefined를 돌려주어 메타데이터와 본문에서 notFound()로 이어집니다. 반면 별도 이미지의 getPostTitle은 '알 수 없는 게시물'을 돌려주며, 이미지 함수는 그 문구로 ImageResponse를 만듭니다. 같은 slug라도 서로 다른 모의 함수의 실패 정책이 다르기 때문이며, 실제 전송 상태나 이미지 파일이 만들어지는지는 실행 환경에서 따로 확인합니다.
반복된 q의 첫 값은 빈 문자열이므로 두 번째의 React로 넘어가지 않습니다. 검색 함수는 빈 값에 빈 배열을 돌려주고, 제목은 "검색 페이지"가 됩니다. 비어 있지 않은 첫 값을 고르고 싶다면 그런 정책을 따로 설계해야 합니다.
2. 첫 값 선택과 대소문자
검색 URL이 ?q=React&q=Next.js이면 제목과 상품 검색에 어떤 값이 쓰일까요? 이어서 ?q=react로 바꾸면 모의 목록의 어떤 상품이 일치할까요?
해설 보기
첫 값인 React가 제목과 상품 검색에 함께 쓰입니다. includes는 대소문자를 구별하므로, react로 바꾸면 이 모의 목록의 React 티셔츠와 설명 React 개발자를 위한 티셔츠에 일치하지 않아 결과가 없습니다. 첫 값을 고르는 정책과 대소문자 정책은 서로 다른 조건입니다.
3. 상위 이미지를 함께 쓰기
게시물 페이지의 generateMetadata에서 ...previousImages를 지우면, 상위 레이아웃의 OG 이미지는 게시물 페이지에도 남을까요?
해설 보기
남지 않습니다. 게시물 페이지가 openGraph 객체를 선언하면 상위의 openGraph를 통째로 바꾸므로, 그 안의 images 배열도 페이지에서 지정한 이미지만 남습니다. 상위의 이미지까지 함께 쓰려면 이 예제처럼 await parent로 상위 메타데이터를 읽고, 필요한 배열을 명시적으로 합쳐야 합니다.
동적 메타데이터와 메타데이터 이미지 파일 규칙은 Next.js 공식 문서의 generateMetadata 레퍼런스와 opengraph-image 레퍼런스에서 확인할 수 있습니다.
다음 글 Open Graph 태그 활용에서는 소셜 공유 미리보기를 위한 Open Graph와 Twitter Card 설정을 더 자세히 알아봅니다.