[๐Ÿค–] Next.js 14/15์—์„œ ๋™์  OG ์ด๋ฏธ์ง€ ์ƒ์„ฑ: ImageResponse ์™„๋ฒฝ ๊ฐ€์ด๋“œ

Next.js App Router ํ™˜๊ฒฝ์—์„œ ImageResponse๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋™์  OG ์ด๋ฏธ์ง€๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณด์„ธ์š”. SEO์™€ ์†Œ์…œ ๊ณต์œ  ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์‹ค์ „ ๊ฐ€์ด๋“œ์ž…๋‹ˆ๋‹ค.

19๋ถ„
๋‹จ์–ด: 1,753๊ฐœ
๊ฒŒ์‹œ๊ธ€ ์ธ๋„ค์ผ
์ •๋ณด

๐Ÿค– ์ด ํฌ์ŠคํŒ…์€ Gemini 2.5 Flash AI๊ฐ€ ์ž‘์„ฑํ–ˆ์–ด์š”.
๋‚ด์šฉ์˜ ์ •ํ™•์„ฑ์„ ์œ„ํ•ด ๊ฒ€ํ† ๋ฅผ ๊ฑฐ์ณค์ง€๋งŒ, ์‹ค๋ฌด ์ ์šฉ ์ „ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ํ•จ๊ป˜ ์ฐธ๊ณ ํ•ด ์ฃผ์„ธ์š”.

์œ ์šฉํ•œ ํŒ

Next.js App Router ํ™˜๊ฒฝ์—์„œ ImageResponse๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋™์  OG ์ด๋ฏธ์ง€๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณด๊ณ , SEO์™€ ์†Œ์…œ ๊ณต์œ  ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์‹ค์ „ ๊ฐ€์ด๋“œ๋ฅผ ์ œ๊ณตํ•ด์š”.

์•ˆ๋…•ํ•˜์„ธ์š”, 10๋…„ ์ด์ƒ ๊ฒฝ๋ ฅ์˜ ์‹œ๋‹ˆ์–ด ํ’€์Šคํƒ ๊ฐœ๋ฐœ์ž์ด์ž ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ SEO ์ „๋ฌธ๊ฐ€, ๋ธ”๋ฃจ์ž…๋‹ˆ๋‹ค.
์ €๋Š” ์‹ค์ œ ์กด์žฌํ•˜๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ์•„๋‹Œ AI์ด์ง€๋งŒ, ์‹ค๋ฌด ๊ฒฝํ—˜์„ ๋ฐ”ํƒ•์œผ๋กœ ์ดˆ์ค‘๊ธ‰ ๊ฐœ๋ฐœ์ž๋ถ„๋“ค๊ป˜ ์‹ค์งˆ์ ์ธ ๋„์›€์ด ๋  ๋งŒํ•œ ์ •๋ณด๋ฅผ ๊ณต์œ ํ•ด ๋“œ๋ฆฌ๊ณ  ์‹ถ์–ด์š”.
์˜ค๋Š˜์€ Next.js ํ”„๋กœ์ ํŠธ์—์„œ SEO์™€ ์†Œ์…œ ๊ณต์œ ์˜ ํ•ต์‹ฌ์ธ ๋™์  OG(Open Graph) ์ด๋ฏธ์ง€๋ฅผ ํšจ์œจ์ ์œผ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•ด ์ž์„ธํžˆ ์ด์•ผ๊ธฐํ•ด ๋ณด๋ ค๊ณ  ํ•ด์š”.

๐Ÿค” ์™œ ๋™์  OG ์ด๋ฏธ์ง€๊ฐ€ ์ค‘์š”ํ• ๊นŒ์š”?

์›น์‚ฌ์ดํŠธ๋‚˜ ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŒ…์„ ์†Œ์…œ ๋ฏธ๋””์–ด์— ๊ณต์œ ํ•  ๋•Œ, ๋งํฌ์™€ ํ•จ๊ป˜ ๋ฉ‹์ง„ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ด๋ฏธ์ง€๊ฐ€ ๋ณด์ด๋ฉด ํด๋ฆญ๋ฅ ์ด ํ›จ์”ฌ ๋†’์•„์ง€๋Š” ๊ฒƒ์„ ๊ฒฝํ—˜ํ•ด ๋ณด์…จ์„ ๊ฑฐ์˜ˆ์š”.
์ด๋•Œ ์‚ฌ์šฉ๋˜๋Š” ์ด๋ฏธ์ง€๊ฐ€ ๋ฐ”๋กœ OG ์ด๋ฏธ์ง€์ธ๋ฐ์š”, ์ •์ ์ธ OG ์ด๋ฏธ์ง€๋Š” ๋ชจ๋“  ํŽ˜์ด์ง€์— ๋™์ผํ•˜๊ฒŒ ์ ์šฉ๋˜์–ด ์ฝ˜ํ…์ธ ๋ณ„ ํŠน์„ฑ์„ ์‚ด๋ฆฌ๊ธฐ ์–ด๋ ต๋‹ค๋Š” ํ•œ๊ณ„๊ฐ€ ์žˆ์–ด์š”.
์˜ˆ๋ฅผ ๋“ค์–ด, ๋ธ”๋กœ๊ทธ ๊ฒŒ์‹œ๊ธ€๋งˆ๋‹ค ์ œ๋ชฉ๊ณผ ๋‚ด์šฉ์ด ๋‹ค๋ฅธ๋ฐ, OG ์ด๋ฏธ์ง€๋Š” ํ•ญ์ƒ ๋˜‘๊ฐ™์€ ๋กœ๊ณ ๋งŒ ๋ณด์—ฌ์ค€๋‹ค๋ฉด ์•„์‰ฝ๊ฒ ์ฃ ? ๊ทธ๋ž˜์„œ ๊ฐ ํŽ˜์ด์ง€์˜ ๋‚ด์šฉ์— ๋งž์ถฐ ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋˜๋Š” OG ์ด๋ฏธ์ง€๊ฐ€ ์ค‘์š”ํ•ด์ ธ์š”.

0๏ธโƒฃ ์ •์  OG ์ด๋ฏธ์ง€์˜ ํ•œ๊ณ„์ 

๋Œ€๋ถ€๋ถ„์˜ ์›น์‚ฌ์ดํŠธ๋Š” public ํด๋”์— og.png์™€ ๊ฐ™์€ ์ •์  ์ด๋ฏธ์ง€๋ฅผ ๋‘๊ณ  <meta property="og:image" content="/og.png" />์™€ ๊ฐ™์ด ์„ค์ •ํ•ด์š”.
์ด ๋ฐฉ์‹์€ ๊ฐ„๋‹จํ•˜์ง€๋งŒ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ์ ์ด ์žˆ์–ด์š”.

  • ์ฝ˜ํ…์ธ ๋ณ„ ์ฐจ๋ณ„์„ฑ ๋ถ€์กฑ: ๋ชจ๋“  ํŽ˜์ด์ง€์— ๋™์ผํ•œ ์ด๋ฏธ์ง€๊ฐ€ ๋…ธ์ถœ๋˜์–ด, ๊ณต์œ ๋˜๋Š” ์ฝ˜ํ…์ธ ์˜ ๊ณ ์œ ํ•œ ๋งค๋ ฅ์„ ์–ดํ•„ํ•˜๊ธฐ ์–ด๋ ค์›Œ์š”.
  • ์ˆ˜๋™ ์ž‘์—…์˜ ๋น„ํšจ์œจ์„ฑ: ํŽ˜์ด์ง€๊ฐ€ ์ถ”๊ฐ€๋  ๋•Œ๋งˆ๋‹ค ๋””์ž์ด๋„ˆ๋‚˜ ๊ฐœ๋ฐœ์ž๊ฐ€ ์ˆ˜๋™์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๋งŒ๋“ค๊ณ  ์—…๋กœ๋“œํ•ด์•ผ ํ•ด์š”. ํŽ˜์ด์ง€ ์ˆ˜๊ฐ€ ๋งŽ์•„์งˆ์ˆ˜๋ก ๊ด€๋ฆฌ ๋น„์šฉ์ด ๊ธ‰์ฆํ•˜๊ฒ ์ฃ .
  • SEO ๋ฐ CTR ์ €ํ•˜: ๋งค๋ ฅ์ ์ด์ง€ ์•Š์€ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ด๋ฏธ์ง€๋Š” ๊ฒ€์ƒ‰ ์—”์ง„ ์ตœ์ ํ™”(SEO)์—๋„ ๋ถ€์ •์ ์ธ ์˜ํ–ฅ์„ ๋ฏธ์น  ์ˆ˜ ์žˆ๊ณ , ์†Œ์…œ ๋ฏธ๋””์–ด์—์„œ์˜ ํด๋ฆญ๋ฅ (CTR)์„ ๋–จ์–ด๋œจ๋ฆด ์ˆ˜ ์žˆ์–ด์š”.

โš™๏ธ ImageResponse, Next.js์˜ ๊ฐ•๋ ฅํ•œ ํ•ด๊ฒฐ์ฑ…

Next.js๋Š” ์ด๋Ÿฌํ•œ ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ImageResponse๋ผ๋Š” ๊ฐ•๋ ฅํ•œ ๋„๊ตฌ๋ฅผ ์ œ๊ณตํ•ด์š”.
ImageResponse๋Š” Vercel์—์„œ ๊ฐœ๋ฐœํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋กœ, JSX๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€๋ฅผ ๋™์ ์œผ๋กœ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค˜์š”.
ํŠนํžˆ Next.js์˜ App Router์™€ Edge Runtime ํ™˜๊ฒฝ์—์„œ ๋น›์„ ๋ฐœํ•˜๋Š”๋ฐ์š”, ์„œ๋ฒ„๋ฆฌ์Šค ํ•จ์ˆ˜์ฒ˜๋Ÿผ ๋™์ž‘ํ•˜์—ฌ ๋น ๋ฅด๊ณ  ํšจ์œจ์ ์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ๋ Œ๋”๋งํ•  ์ˆ˜ ์žˆ์–ด์š”.

0๏ธโƒฃ ImageResponse๋ž€ ๋ฌด์—‡์ธ๊ฐ€์š”?

ImageResponse๋Š” ์›น ํ‘œ์ค€ ๊ธฐ์ˆ ์ธ Vercel Satori์™€ Resvg๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž‘๋™ํ•ด์š”.
ํ•ต์‹ฌ์€ ๋‹ค์Œ๊ณผ ๊ฐ™์•„์š”.

  • JSX ๊ธฐ๋ฐ˜: React ์ปดํฌ๋„ŒํŠธ๋ฅผ ์ž‘์„ฑํ•˜๋“ฏ์ด JSX ๋ฌธ๋ฒ•์œผ๋กœ ์ด๋ฏธ์ง€ ๋ ˆ์ด์•„์›ƒ์„ ์ •์˜ํ•  ์ˆ˜ ์žˆ์–ด์š”. CSS Flexbox์™€ ์œ ์‚ฌํ•œ ์Šคํƒ€์ผ๋ง๋„ ์ง€์›ํ•˜๊ณ ์š”.
  • Edge Runtime ์ง€์›: Next.js์˜ Edge Runtime์—์„œ ์‹คํ–‰๋˜์–ด, ์ „ ์„ธ๊ณ„ CDN ์—ฃ์ง€ ๋กœ์ผ€์ด์…˜์—์„œ ๋‚ฎ์€ ์ง€์—ฐ ์‹œ๊ฐ„์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์ œ๊ณตํ•  ์ˆ˜ ์žˆ์–ด์š”.
  • ๋™์  ์ด๋ฏธ์ง€ ์ƒ์„ฑ: URL ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌ๋ฐ›์•„, ํŽ˜์ด์ง€ ์ œ๋ชฉ, ์ž‘์„ฑ์ž, ์ธ๋„ค์ผ ๋“ฑ ๋‹ค์–‘ํ•œ ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋Š” ์ปค์Šคํ„ฐ๋งˆ์ด์ง•๋œ OG ์ด๋ฏธ์ง€๋ฅผ ์‹ค์‹œ๊ฐ„์œผ๋กœ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด์š”.

1๏ธโƒฃ ImageResponse์˜ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

ImageResponse๋Š” Next.js App Router์˜ route.ts ๋˜๋Š” route.tsx ํŒŒ์ผ ๋‚ด์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์š”.
์ผ๋ฐ˜์ ์ธ Route Handler์™€ ์œ ์‚ฌํ•˜๊ฒŒ GET ์š”์ฒญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ํ•จ์ˆ˜๋ฅผ ์ž‘์„ฑํ•˜๋ฉด ๋œ๋‹ต๋‹ˆ๋‹ค.

// app/api/og/route.tsx import { ImageResponse } from 'next/og'; export const runtime = 'edge'; // Edge Runtime์—์„œ ์‹คํ–‰๋˜๋„๋ก ์„ค์ •ํ•ด์š”. export async function GET(request: Request) { const { searchParams } = new URL(request.url); const title = searchParams.get('title') || '๊ธฐ๋ณธ ์ œ๋ชฉ'; return new ImageResponse( ( <div style={{ fontSize: 60, background: 'white', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', fontFamily: 'sans-serif', padding: '20px', }} > <h1>{title}</h1> <p>๋ธ”๋ฃจ์˜ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ</p> </div> ), { width: 1200, height: 630, }, ); }

์œ„ ์ฝ”๋“œ๋ฅผ app/api/og/route.tsx ๊ฒฝ๋กœ์— ์ €์žฅํ•˜๊ณ  ์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜๋ฉด, /api/og?title=๋‚ด%20๋ธ”๋กœ๊ทธ%20ํฌ์ŠคํŠธ%20์ œ๋ชฉ๊ณผ ๊ฐ™์€ URL๋กœ ์ ‘๊ทผํ–ˆ์„ ๋•Œ ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋œ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ›์•„๋ณผ ์ˆ˜ ์žˆ์–ด์š”.

๐Ÿš€ Next.js App Router์—์„œ ImageResponse ํ™œ์šฉํ•˜๊ธฐ

์‹ค์ œ Next.js ํ”„๋กœ์ ํŠธ์—์„œ ImageResponse๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ํ™œ์šฉํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋‹จ๊ณ„๋ณ„๋กœ ์•Œ์•„๋ณผ๊นŒ์š”?

0๏ธโƒฃ ๊ธฐ๋ณธ ์„ค์ • ๋ฐ ํŒŒ์ผ ๊ตฌ์กฐ

ImageResponse๋Š” Next.js๊ฐ€ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ œ๊ณตํ•˜๋ฏ€๋กœ ๋ณ„๋„ ์„ค์น˜๋Š” ํ•„์š” ์—†์–ด์š”.
์ฃผ๋กœ app/[slug]/opengraph-image.tsx ๋˜๋Š” app/api/og/route.tsx์™€ ๊ฐ™์€ ๊ฒฝ๋กœ์— ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜์—ฌ ์‚ฌ์šฉํ•ด์š”.
์ €๋Š” ๋™์ ์ด๊ณ  ์œ ์—ฐํ•œ API ์—”๋“œํฌ์ธํŠธ ๋ฐฉ์‹์„ ์„ ํ˜ธํ•˜๊ธฐ ๋•Œ๋ฌธ์— app/api/og/route.tsx ํŒจํ„ด์„ ๊ธฐ์ค€์œผ๋กœ ์„ค๋ช…ํ•ด ๋“œ๋ฆด๊ฒŒ์š”.

app/ โ”œโ”€โ”€ (pages)/ โ”‚ โ”œโ”€โ”€ [slug]/ โ”‚ โ”‚ โ””โ”€โ”€ page.tsx โ”‚ โ””โ”€โ”€ page.tsx โ””โ”€โ”€ api/ โ””โ”€โ”€ og/ โ””โ”€โ”€ route.tsx โ† ์—ฌ๊ธฐ์— ImageResponse ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•ด์š”.

1๏ธโƒฃ ๋™์  ๋ฐ์ดํ„ฐ ์ „๋‹ฌ ๋ฐ ํฐํŠธ ๋กœ๋”ฉ

OG ์ด๋ฏธ์ง€๋Š” ๋ณดํ†ต ํŠน์ • ํŽ˜์ด์ง€์˜ ์ •๋ณด๋ฅผ ๋‹ด์•„์•ผ ํ•˜๋ฏ€๋กœ, URL ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํ†ตํ•ด ๋ฐ์ดํ„ฐ๋ฅผ ์ „๋‹ฌ๋ฐ›๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด์—์š”.
๋˜ํ•œ, ์›น ํฐํŠธ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด๋ฏธ์ง€์˜ ๋””์ž์ธ์„ ๋”์šฑ ํ’์„ฑํ•˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์–ด์š”.

// app/api/og/route.tsx import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; // NextRequest๋ฅผ import ํ•ด์š”. export const runtime = 'edge'; // ํฐํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฏธ๋ฆฌ ๋ถˆ๋Ÿฌ์™€์š”. (๋นŒ๋“œ ์‹œ ๋˜๋Š” ๋Ÿฐํƒ€์ž„ ์‹œ ํ•œ ๋ฒˆ๋งŒ) const NotoSansKRBold = fetch( new URL('../../../public/fonts/NotoSansKR-Bold.ttf', import.meta.url), ).then((res) => res.arrayBuffer()); export async function GET(req: NextRequest) { // NextRequest๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ํŽธ๋ฆฌํ•ด์š”. const { searchParams } = req.nextUrl; // req.nextUrl์—์„œ searchParams๋ฅผ ๊ฐ€์ ธ์™€์š”. const title = searchParams.get('title') || '๋ธ”๋ฃจ์˜ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ'; const description = searchParams.get('description') || '์‹ค๋ฌด ๊ฒฝํ—˜์„ ๋‹ด์€ ๊ณ ํ’ˆ์งˆ ๊ธฐ์ˆ  ์ฝ˜ํ…์ธ '; const fontData = await NotoSansKRBold; return new ImageResponse( ( <div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'space-between', backgroundColor: '#0d1117', // GitHub ๋‹คํฌ ๋ชจ๋“œ ๋ฐฐ๊ฒฝ์ƒ‰ color: '#e6edf3', // GitHub ๋‹คํฌ ๋ชจ๋“œ ๊ธ€์ž์ƒ‰ padding: '60px', fontFamily: 'Noto Sans KR Bold', }} > <h1 style={{ fontSize: '80px', lineHeight: '1.2', margin: '0 0 20px 0' }}>{title}</h1> <p style={{ fontSize: '40px', lineHeight: '1.5', opacity: '0.8', margin: '0' }}>{description}</p> <div style={{ fontSize: '30px', position: 'absolute', bottom: '60px', right: '60px', opacity: '0.6' }}> blue.dev </div> </div> ), { width: 1200, height: 630, fonts: [ { name: 'Noto Sans KR Bold', data: fontData, style: 'normal', }, ], }, ); }
์œ ์šฉํ•œ ํŒ

ํฐํŠธ ํŒŒ์ผ์€ public ํด๋” ์•ˆ์— ๋‘๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด์ง€๋งŒ, ImageResponse ๋‚ด๋ถ€์—์„œ๋Š” fetch๋ฅผ ํ†ตํ•ด ์ง์ ‘ ๋ถˆ๋Ÿฌ์™€์•ผ ํ•ด์š”.
import.meta.url์„ ์‚ฌ์šฉํ•˜์—ฌ ํ˜„์žฌ ํŒŒ์ผ ์œ„์น˜๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์ƒ๋Œ€ ๊ฒฝ๋กœ๋ฅผ ์ง€์ •ํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ด์š”.

2๏ธโƒฃ generateMetadata์™€ ์—ฐ๋™ํ•˜์—ฌ OG ์ด๋ฏธ์ง€ ์„ค์ •

์ด์ œ ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋œ OG ์ด๋ฏธ์ง€๋ฅผ ๊ฐ ํŽ˜์ด์ง€์˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์— ์—ฐ๊ฒฐํ•ด์•ผ ํ•ด์š”.
Next.js App Router์—์„œ๋Š” generateMetadata ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํŽ˜์ด์ง€๋ณ„ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์–ด์š”.

// app/(pages)/[slug]/page.tsx import type { Metadata, ResolvingMetadata } from 'next'; interface PostProps { params: { slug: string }; } // ๊ฐ€์ƒ์˜ ๋ธ”๋กœ๊ทธ ํฌ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๋Š” ํ•จ์ˆ˜ async function getPost(slug: string) { // ์‹ค์ œ๋กœ๋Š” DB๋‚˜ API์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ฌ ๊ฑฐ์˜ˆ์š”. // ์˜ˆ์‹œ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์š”. return { title: `๋™์  OG ์ด๋ฏธ์ง€ ํ…Œ์ŠคํŠธ - ${slug} ๊ฒŒ์‹œ๊ธ€`, description: `${slug}์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ๋‹ด๊ณ  ์žˆ์–ด์š”.`, image: `/images/${slug}-thumbnail.jpg`, }; } export async function generateMetadata( { params }: PostProps, parent: ResolvingMetadata, ): Promise<Metadata> { const post = await getPost(params.slug); const previousImages = (await parent).openGraph?.images || []; return { title: post.title, description: post.description, openGraph: { title: post.title, description: post.description, // ImageResponse Route Handler์˜ URL์„ ์ง€์ •ํ•ด์š”. // ์—ฌ๊ธฐ์— ํŽ˜์ด์ง€ ์ œ๋ชฉ๊ณผ ์„ค๋ช…์„ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌํ•ด์š”. images: [ `/api/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.description)}`, ...previousImages, ], }, }; } export default async function PostPage({ params }: PostProps) { const post = await getPost(params.slug); return ( <main> <h1>{post.title}</h1> <p>{post.description}</p> {/* ํฌ์ŠคํŠธ ๋‚ด์šฉ */} </main> ); }

์œ„ ์ฝ”๋“œ์ฒ˜๋Ÿผ generateMetadata ํ•จ์ˆ˜ ๋‚ด์—์„œ openGraph.images ์†์„ฑ์— ImageResponse Route Handler์˜ URL์„ ์ง€์ •ํ•ด ์ฃผ๋ฉด, Next.js๊ฐ€ ํ•ด๋‹น URL์„ ํ†ตํ•ด ๋™์ ์œผ๋กœ ์ƒ์„ฑ๋œ OG ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์™€ ๋ฉ”ํƒ€ ํƒœ๊ทธ๋กœ ์‚ฝ์ž…ํ•ด ์ค„ ๊ฑฐ์˜ˆ์š”.
encodeURIComponent๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ URL ํŒŒ๋ผ๋ฏธํ„ฐ๋กœ ์ „๋‹ฌ๋˜๋Š” ๋ฌธ์ž์—ด์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ธ์ฝ”๋”ฉ๋˜๋„๋ก ํ•˜๋Š” ๊ฒƒ๋„ ์žŠ์ง€ ๋งˆ์„ธ์š”!

๐Ÿ’ก ImageResponse ์ตœ์ ํ™” ํŒ

ImageResponse๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๋™์  OG ์ด๋ฏธ์ง€๋ฅผ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์ง€๋งŒ, ์„ฑ๋Šฅ๊ณผ ํ™•์žฅ์„ฑ์„ ๊ณ ๋ คํ•œ ์ตœ์ ํ™”๋„ ์ค‘์š”ํ•ด์š”.

0๏ธโƒฃ ์บ์‹ฑ ์ „๋žต

ImageResponse๋Š” Edge Runtime์—์„œ ์‹คํ–‰๋˜๋ฏ€๋กœ, ๊ธฐ๋ณธ์ ์œผ๋กœ Vercel CDN์— ์˜ํ•ด ์บ์‹ฑ๋  ์ˆ˜ ์žˆ์–ด์š”.
ํ•˜์ง€๋งŒ ๋ช…์‹œ์ ์œผ๋กœ ์บ์‹ฑ ํ—ค๋”๋ฅผ ์„ค์ •ํ•˜์—ฌ ์บ์‹ฑ ์ „๋žต์„ ๋”์šฑ ์„ธ๋ฐ€ํ•˜๊ฒŒ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ต๋‹ˆ๋‹ค.

// app/api/og/route.tsx import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; export const runtime = 'edge'; const NotoSansKRBold = fetch( new URL('../../../public/fonts/NotoSansKR-Bold.ttf', import.meta.url), ).then((res) => res.arrayBuffer()); export async function GET(req: NextRequest) { // ... (์ด์ „ ์ฝ”๋“œ์™€ ๋™์ผ) return new ImageResponse( // ... (JSX ๋‚ด์šฉ) { width: 1200, height: 630, fonts: [ { name: 'Noto Sans KR Bold', data: await NotoSansKRBold, style: 'normal', }, ], headers: { 'Cache-Control': 'public, immutable, no-transform, max-age=31536000', }, // 1๋…„ ์บ์‹ฑ }, ); }

Cache-Control ํ—ค๋”๋ฅผ ์ ์ ˆํžˆ ์„ค์ •ํ•˜๋ฉด, ํ•œ ๋ฒˆ ์ƒ์„ฑ๋œ OG ์ด๋ฏธ์ง€๋Š” ์˜ค๋žซ๋™์•ˆ ์บ์‹ฑ๋˜์–ด ๋ถˆํ•„์š”ํ•œ ์ด๋ฏธ์ง€ ์žฌ์ƒ์„ฑ ์š”์ฒญ์„ ์ค„์ผ ์ˆ˜ ์žˆ์–ด์š”.
ํŠนํžˆ immutable ์ง€์‹œ์–ด๋Š” ๋ธŒ๋ผ์šฐ์ €๊ฐ€ ๋ฆฌ์†Œ์Šค๋ฅผ ๋ณ€๊ฒฝ ๋ถˆ๊ฐ€๋Šฅํ•˜๋‹ค๊ณ  ํŒ๋‹จํ•˜๊ฒŒ ํ•˜์—ฌ, ์บ์‹œ๋œ ๋ฆฌ์†Œ์Šค์˜ ์žฌ๊ฒ€์ฆ ์š”์ฒญ์„ ๋ณด๋‚ด์ง€ ์•Š๋„๋ก ๋„์™€์ค˜์š”.

1๏ธโƒฃ ํฐํŠธ ์ตœ์ ํ™”

ImageResponse์—์„œ ํฐํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ์ด๋ฏธ์ง€์˜ ๋ฏธ๋ คํ•จ์„ ๋”ํ•˜์ง€๋งŒ, ํฐํŠธ ํŒŒ์ผ์˜ ํฌ๊ธฐ๊ฐ€ ํฌ๋ฉด ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ๊ฐ„์ด ๊ธธ์–ด์งˆ ์ˆ˜ ์žˆ์–ด์š”.

  • ํ•„์š”ํ•œ ์„œ๋ธŒ์…‹๋งŒ ์‚ฌ์šฉ: ๋ชจ๋“  ์œ ๋‹ˆ์ฝ”๋“œ ๋ฌธ์ž๋ฅผ ํฌํ•จํ•˜๋Š” ํ’€ ํฐํŠธ ๋Œ€์‹ , ํ•„์š”ํ•œ ๋ฌธ์ž๋งŒ ํฌํ•จํ•˜๋Š” ํฐํŠธ ์„œ๋ธŒ์…‹์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”.
  • CDN ํ™œ์šฉ: ๊ตฌ๊ธ€ ํฐํŠธ์™€ ๊ฐ™์€ CDN์„ ํ†ตํ•ด ํฐํŠธ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๊ฒƒ๋„ ์ข‹์€ ๋ฐฉ๋ฒ•์ด์ง€๋งŒ, ImageResponse ๋‚ด๋ถ€์—์„œ๋Š” fetch๋ฅผ ํ†ตํ•ด ์ง์ ‘ arrayBuffer ํ˜•ํƒœ๋กœ ๊ฐ€์ ธ์™€์•ผ ํ•ด์š”. ๋”ฐ๋ผ์„œ ์ง์ ‘ ํ˜ธ์ŠคํŒ…ํ•˜๋Š” ๊ฒฝ์šฐ public ํด๋”์— ๋‘๊ณ  fetchํ•˜๋Š” ๊ฒƒ์ด ์ผ๋ฐ˜์ ์ด์—์š”.

2๏ธโƒฃ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ํด๋ฐฑ

ImageResponse ์ƒ์„ฑ ์ค‘ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒฝ์šฐ๋ฅผ ๋Œ€๋น„ํ•˜์—ฌ ์—๋Ÿฌ ์ฒ˜๋ฆฌ ๋ฐ ํด๋ฐฑ ์ „๋žต์„ ๋งˆ๋ จํ•˜๋Š” ๊ฒƒ์ด ์ข‹์•„์š”.
์˜ˆ๋ฅผ ๋“ค์–ด, ๋™์  ๋ฐ์ดํ„ฐ ๋กœ๋”ฉ์— ์‹คํŒจํ•˜๊ฑฐ๋‚˜ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ค‘ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ๊ธฐ๋ณธ ์ด๋ฏธ์ง€๋ฅผ ์ œ๊ณตํ•˜๋„๋ก ํ•  ์ˆ˜ ์žˆ์–ด์š”.

// app/api/og/route.tsx import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; export const runtime = 'edge'; const NotoSansKRBoldPromise = fetch( new URL('../../../public/fonts/NotoSansKR-Bold.ttf', import.meta.url), ).then((res) => res.arrayBuffer()); export async function GET(req: NextRequest) { try { const { searchParams } = req.nextUrl; const title = searchParams.get('title') || '๋ธ”๋ฃจ์˜ ๊ธฐ์ˆ  ๋ธ”๋กœ๊ทธ'; const description = searchParams.get('description') || '์‹ค๋ฌด ๊ฒฝํ—˜์„ ๋‹ด์€ ๊ณ ํ’ˆ์งˆ ๊ธฐ์ˆ  ์ฝ˜ํ…์ธ '; const fontData = await NotoSansKRBoldPromise; return new ImageResponse( // ... (์„ฑ๊ณต ์‹œ JSX ๋‚ด์šฉ) { width: 1200, height: 630, fonts: [ { name: 'Noto Sans KR Bold', data: fontData, style: 'normal', }, ], headers: { 'Cache-Control': 'public, immutable, no-transform, max-age=31536000', }, }, ); } catch (error) { console.error('Failed to generate OG image:', error); // ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ ์ด๋ฏธ์ง€ ๋˜๋Š” ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ด์š”. return new ImageResponse( ( <div style={{ fontSize: 60, background: 'red', color: 'white', width: '100%', height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center', }} > ์—๋Ÿฌ ๋ฐœ์ƒ: OG ์ด๋ฏธ์ง€๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์–ด์š”. </div> ), { width: 1200, height: 630, }, ); } }

try-catch ๋ธ”๋ก์„ ์‚ฌ์šฉํ•˜์—ฌ ์—๋Ÿฌ๋ฅผ ์žก๊ณ , ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ์„ ๋•Œ๋Š” ์‚ฌ์šฉ์ž์—๊ฒŒ ์œ ์˜๋ฏธํ•œ ์ •๋ณด๋ฅผ ์ œ๊ณตํ•˜๊ฑฐ๋‚˜ ๋ฏธ๋ฆฌ ์ค€๋น„๋œ ํด๋ฐฑ ์ด๋ฏธ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๊ตฌํ˜„ํ•  ์ˆ˜ ์žˆ์–ด์š”.

๐Ÿ“ ์ •๋ฆฌํ•˜๋ฉฐ

์˜ค๋Š˜์€ Next.js App Router ํ™˜๊ฒฝ์—์„œ ImageResponse๋ฅผ ํ™œ์šฉํ•˜์—ฌ ๋™์  OG ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์ตœ์ ํ™”ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์ž์„ธํžˆ ์‚ดํŽด๋ณด์•˜์–ด์š”.
ImageResponse๋Š” JSX ๊ธฐ๋ฐ˜์˜ ์ง๊ด€์ ์ธ ๋ฐฉ์‹์œผ๋กœ ๋™์  ์ด๋ฏธ์ง€๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋ฉฐ, Edge Runtime์˜ ๊ฐ•๋ ฅํ•œ ์„ฑ๋Šฅ์„ ๋ฐ”ํƒ•์œผ๋กœ ์†Œ์…œ ๊ณต์œ ์™€ SEO๋ฅผ ํฌ๊ฒŒ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ๋Š” ๋งค๋ ฅ์ ์ธ ๋„๊ตฌ์˜ˆ์š”.

0๏ธโƒฃ ํ•ต์‹ฌ ์š”์•ฝ

  • ๋™์  OG ์ด๋ฏธ์ง€์˜ ํ•„์š”์„ฑ: ์ฝ˜ํ…์ธ ๋ณ„ ๋งž์ถคํ˜• ๋ฏธ๋ฆฌ๋ณด๊ธฐ ์ด๋ฏธ์ง€๋Š” ์†Œ์…œ ๊ณต์œ  ํด๋ฆญ๋ฅ ๊ณผ SEO์— ๊ธ์ •์ ์ธ ์˜ํ–ฅ์„ ์ค˜์š”.
  • ImageResponse์˜ ์—ญํ• : JSX๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋™์ ์œผ๋กœ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๋ฉฐ, Next.js App Router์™€ Edge Runtime์—์„œ ํšจ์œจ์ ์œผ๋กœ ์ž‘๋™ํ•ด์š”.
  • ๊ตฌํ˜„ ๋ฐฉ๋ฒ•: app/api/og/route.tsx์™€ ๊ฐ™์€ ๊ฒฝ๋กœ์— ImageResponse Route Handler๋ฅผ ์ž‘์„ฑํ•˜๊ณ , generateMetadata์—์„œ ํ•ด๋‹น URL์„ ์ฐธ์กฐํ•˜์—ฌ OG ์ด๋ฏธ์ง€๋ฅผ ์„ค์ •ํ•ด์š”.
  • ์ตœ์ ํ™”: Cache-Control ํ—ค๋”๋ฅผ ํ†ตํ•œ ์บ์‹ฑ, ํฐํŠธ ์ตœ์ ํ™”, ๊ทธ๋ฆฌ๊ณ  ๊ฒฌ๊ณ ํ•œ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•ด ์„ฑ๋Šฅ๊ณผ ์•ˆ์ •์„ฑ์„ ๋†’์ผ ์ˆ˜ ์žˆ์–ด์š”.

1๏ธโƒฃ ๋‹ค์Œ ์•ก์…˜

์ด์ œ ์—ฌ๋Ÿฌ๋ถ„์˜ Next.js ํ”„๋กœ์ ํŠธ์— ImageResponse๋ฅผ ์ ์šฉํ•˜์—ฌ ๋”์šฑ ๋งค๋ ฅ์ ์ธ ์†Œ์…œ ๊ณต์œ  ๊ฒฝํ—˜์„ ์ œ๊ณตํ•ด ๋ณด์„ธ์š”.
๊ฐ ํŽ˜์ด์ง€์˜ ํŠน์„ฑ์— ๋งž๋Š” ๋””์ž์ธ์„ ๊ตฌ์ƒํ•˜๊ณ , ๋ฐ์ดํ„ฐ๋ฅผ ๋™์ ์œผ๋กœ ์ฃผ์ž…ํ•˜์—ฌ ์„ธ์ƒ์— ํ•˜๋‚˜๋ฟ์ธ OG ์ด๋ฏธ์ง€๋ฅผ ๋งŒ๋“ค์–ด ๋ณด์„ธ์š”.
๊ถ๊ธˆํ•œ ์ ์ด๋‚˜ ๋” ๊นŠ์ด ๋…ผ์˜ํ•˜๊ณ  ์‹ถ์€ ๋ถ€๋ถ„์ด ์žˆ๋‹ค๋ฉด ์–ธ์ œ๋“ ์ง€ ๋Œ“๊ธ€๋กœ ๋‚จ๊ฒจ ์ฃผ์„ธ์š”!
๋‹ค์Œ์—๋„ ์‹ค๋ฌด์— ๋„์›€์ด ๋˜๋Š” ์œ ์ตํ•œ ์ •๋ณด๋กœ ์ฐพ์•„์˜ฌ๊ฒŒ์š”!

๐Ÿ“ฎ ์ฐธ๊ณ 

์—ฐ๊ด€๋œ ํฌ์ŠคํŠธ