V VibeCoding 365
목록으로 동적 OG 이미지

바이브코딩

동적 OG 이미지 | metadataBase와 Satori 한글 폰트

절대 URL, 500KB 번들, woff2 거부, 버전 쿼리

동적 OG 이미지와 링크 미리보기는 배포 URL HTML의 Open Graph 메타 태그가 크롤러 User-Agent로 절대 URL 이미지와 200을 주는지에 달려 있는 공유 카드 규약이다. 구현 기준은 Next.js App Router의 generateMetadata와 ImageResponse(내부 Satori, Resvg)다. 플랫폼이 읽는 것은 디자인 시안이 아니라 <head>에 박힌 태그다.

og:image는 https://로 시작하는 절대 URL이어야 하고, 그 URL이 facebookexternalhit나 KakaoTalk-Scrap 같은 크롤러로도 실제 이미지를 줘야 카드가 나온다. 로컬 브라우저에서 되는 것처럼 보여도 크롤러가 받는 HTML은 다른 경우가 많다. 상대 경로가 프리뷰 도메인으로 붙거나, 한글이 렌더링되지 않은 빈 PNG가 나가거나, 이미지 URL이 401을 주는 식이다. 시안 손질은 그다음이다.

한국어 사이트에서 자주 실패하는 설정은 metadataBase 누락, Satori 기본 라틴 폰트, opengraph-image.tsx 파일 해시가 DB 제목 변경을 따라가지 못하는 캐시, Next 15.2 스트리밍 메타데이터, Vercel Deployment Protection의 401이다. X Card Validator(cards-dev.twitter.com/validator)는 프리뷰 기능이 제거된 상태라, 지금 링크를 걸면 빈 화면이다.

페이지 캐시 무효화와 DB 발행 루프는 DB 런타임 발행과 같이 본다. Astro나 Cloudflare Worker, 정적 HTML이어도 크롤러가 받는 <head>에 절대 URL og:image가 있으면 카드는 만들어진다. Next.js는 그 태그를 만드는 한 가지 방법일 뿐이다. 크롤러는 JavaScript를 실행하지 않는 경우가 많다. 클라이언트에서 document.title을 바꿔도 카드 제목은 안 바뀐다.

og:image

제목, 설명, 이미지, URL만으로는 부족하다. 플랫폼이 읽는 최소 집합은 여섯이다.

태그역할빠지거나 틀리면
og:title카드 제목브라우저 <title>로 대체되거나 비어 보임
og:description요약카드에 본문 일부가 잘려 들어감
og:image썸네일상대 경로면 대부분 실패. 절대 URL 필수
og:url정규 URLcanonical과 어긋나면 다른 페이지가 캐시됨
og:image:width / height크기 힌트첫 크롤 때 카드가 작게 뜨거나 레이아웃이 튐
twitter:card카드 형태summary_large_image 없으면 작은 정사각 카드

og:image:width와 height를 명시하는 이유는 성능이 아니라 첫 크롤 타이밍이다. 플랫폼이 이미지를 아직 다운로드하지 못한 상태에서 카드를 그릴 때, 크기 힌트가 있으면 큰 카드 레이아웃을 미리 잡는다. 없으면 작은 카드로 그렸다가 나중에 바뀐다. Open Graph 이미지 상한은 8MB, Twitter 이미지 상한은 5MB다. ImageResponse PNG는 보통 50~200KB라 문제없지만, 4K PNG를 그대로 올리면 걸린다. 권장 크기는 1200×630이다. Meta 공유 이미지 문서가 이 구간의 1차 출처다.

시안 PNG를 CDN에 올려도 HTML의 og:image가 그 주소를 가리키지 않으면 플랫폼은 그 파일을 보지 않는다. 이미지 URL이 쿠키나 로그인 뒤에 있으면 facebookexternalhit는 빈 응답을 받는다. og:image가 301/302를 거치면 일부 크롤러가 포기한다. 최종 URL을 직접 쓴다. twitter:card를 summary_large_image로 두지 않으면 큰 이미지가 있어도 작은 정사각으로 줄어든다. 크롤러는 JavaScript를 실행하지 않는 경우가 많다. 클라이언트에서 document.title을 바꿔도 카드 제목은 안 바뀐다.

metadataBase

openGraph.images에 /og/post.png 같은 상대 경로를 쓰면 Next.js는 metadataBase를 기준으로 절대 URL을 만든다. metadataBase를 설정하지 않으면 빌드 에러가 나거나, Vercel 환경변수에서 유추한 프리뷰 도메인이 박힌다. 프로덕션 HTML에 og:image로 https://myblog-git-main-xxx.vercel.app/...이 들어가 있는 사고가 여기서 난다.

루트 레이아웃에 한 번만 두면 하위 전 라우트가 상속한다. 프로덕션에 NEXT_PUBLIC_SITE_URL을 넣는다. 폴백에만 의존하면 도메인을 바꾼 날 조용히 틀린다. 루트 metadataBase 예시는 다음 블록이다.

import type { Metadata } from 'next'

const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://vibecoding365days.com'

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: {
    default: 'VIBE 코딩 365',
    template: '%s | VIBE 코딩 365',
  },
  openGraph: {
    siteName: 'VIBE 코딩 365',
    locale: 'ko_KR',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
  },
}

new URL(siteUrl)은 문자열이 아니라 URL 객체다. 스킴이 빠지면 생성자가 던진다. template의 %s는 페이지 title이 들어가는 자리다. twitter.card를 summary_large_image로 두면 큰 이미지 카드가 기본이 된다.

메타데이터는 얕은 병합(shallow merge) 이다. 하위 페이지에서 openGraph를 정의하는 순간 레이아웃의 openGraph 필드가 전부 덮어써진다. siteName이나 locale을 유지하려면 각 페이지에서 다시 쓰거나, 공통 객체를 스프레드한다. 상대 경로 og:image를 프로덕션에서 눈으로만 확인하면 브라우저가 현재 호스트로 해석해 되는 것처럼 보일 수 있다. 크롤러는 HTML에 적힌 절대 URL만 본다.

핵심 포인트: 배포 HTML의 og:image가 https:// 절대 URL인지가 시안보다 앞선다. metadataBase가 비면 프리뷰 도메인이 프로덕션 카드에 박힌다.
metadataBase가 비면 상대 경로
그림 1. metadataBase와 NEXT_PUBLIC_SITE_URL. 브라우저 해석과 크롤러 HTML은 다른 물건이다.

generateMetadata

페이지 메타는 DB row와 실제로 연결해야 한다. updatedAt을 쿼리에 붙이면 제목을 고치는 순간 이미지 URL이 달라진다. alternates.canonical과 openGraph.url은 같은 경로다. 둘이 어긋나면 플랫폼이 어느 URL을 캐시할지 제멋대로 정한다. 글 상세 generateMetadata 예시는 다음 블록이다.

export async function generateMetadata(
  { params }: { params: Promise<{ category: string; slug: string }> }
): Promise<Metadata> {
  const { category, slug } = await params
  const post = await getPost(category, slug)
  if (!post) return {}

  const path = `/${category}/${slug}`
  const ogUrl = `/api/og?slug=${encodeURIComponent(slug)}&v=${post.updatedAtEpoch}`

  return {
    title: post.title,
    description: post.summary,
    alternates: { canonical: path },
    openGraph: {
      type: 'article',
      title: post.title,
      description: post.summary,
      url: path,
      siteName: 'VIBE 코딩 365',
      locale: 'ko_KR',
      publishedTime: post.publishedAt,
      modifiedTime: post.updatedAt,
      images: [{ url: ogUrl, width: 1200, height: 630, alt: post.title }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.summary,
      images: [ogUrl],
    },
  }
}

params가 Promise인 것은 App Router의 비동기 params 규약이다. await 없이 쓰면 slug가 비어 메타가 빈 객체가 된다. ogUrl은 상대 경로로 두되, 루트 metadataBase가 절대 URL로 만든다. encodeURIComponent는 slug의 하이픈 밖 문자가 쿼리를 깨지 않게 한다. width와 height는 첫 크롤 레이아웃 힌트다.

DB에서 글 제목을 수정하면 세 군데가 따라가야 한다. 페이지 HTML의 og:title, OG 이미지 안에 그려진 텍스트, 플랫폼에 이미 캐시된 카드. ?v=updatedAt 패턴을 쓰면 앞의 둘은 자동이다. 세 번째는 이미 공유된 링크라 어쩔 수 없지만, 새로 공유되는 링크는 새 URL을 물고 나간다. revalidatePath와 함께 처리하는 발행 루프에 넣는다.

버전 쿼리와 파일 컨벤션

Next.js는 동적 OG 이미지 생성 방법을 두 가지 준다. 운영에서는 전혀 다른 물건이다.

opengraph-image.tsx 파일 컨벤션은 메타 태그를 Next가 알아서 넣어 주고, 빌드 타임에 정적 최적화된다. 문제는 Next가 이미지 URL에 붙이는 캐시버스팅 쿼리가 라우트 파일 기준 해시라는 점이다. 코드를 안 고치고 DB에서 제목만 바꾸면 URL이 그대로다. 플랫폼 캐시를 비워도 Next가 옛 이미지를 다시 내주는 상황이 생긴다. revalidatePath로 세그먼트를 무효화해도 OG 이미지 라우트는 안 따라오는 케이스가 GitHub discussion 62742에 보고되어 있다.

콘텐츠가 런타임에 바뀌는 사이트라면 라우트 핸들러를 쓴다. /api/og?slug=...&v=<updatedAt>처럼 URL 자체에 버전을 넣으면, 제목을 고치는 순간 URL이 달라지고 Next 캐시, CDN, 플랫폼 캐시가 갈린다. 파일 컨벤션은 searchParams를 못 받는다. 쿼리로 변형을 주고 싶으면 처음부터 라우트 핸들러다. 글이 빌드 타임에 고정(MDX 커밋 후 배포)되면 파일 컨벤션이 낫다. 런타임 비용이 없다.

상황선택
글이 빌드 타임에 고정 (MDX 커밋 후 배포)opengraph-image.tsx 파일 컨벤션
글이 DB에 있고 런타임에 수정됨라우트 핸들러와 ?v=updatedAt
프로필, 상품처럼 사용자마다 다름라우트 핸들러 (searchParams 필요)

runtime은 nodejs를 둔다. 로컬 파일에서 폰트를 읽거나 DB에 직접 붙을 수 있다. Edge는 폰트를 fetch로만 가져올 수 있어 제약이 더 크다. GitHub discussion 62742가 가리키는 지점은 페이지 HTML 무효화와 이미지 라우트 무효화가 같은 사건이 아니라는 점이다. 제목만 바꾼 뒤 페이지는 새데 카드 이미지가 옛 제목이면, 파일 해시가 안 바뀐 쪽을 의심한다.

Slack과 Discord는 수동 무효화 수단이 없다. 그래서 ?v=updatedAt 패턴이 선택이 아니라 필수에 가깝다. ?v= 덕분에 이미지 자체도 CDN에서 오래 살아 있어 실제 생성 횟수는 글 하나당 몇 번 수준이다. 같은 URL은 영원히 같은 이미지, 내용이 바뀌면 새 URL이다. Cache-Control: public, max-age=31536000, immutable을 걸어도 안전하다.

ImageResponse

ImageResponse는 내부적으로 Satori로 JSX를 SVG로, Resvg로 PNG로 바꾼다. 캔버스는 1200×630, display: flex 세로 배치가 기본 골격이다. 포맷은 PNG다. ImageResponse의 기본 출력이고, WebP는 플랫폼별 지원이 고르지 않다.

Satori에서 자주 터지는 것은 display: flex 누락이다. 자식 노드가 둘 이상인 <div>에 display가 없으면 Expected <div> to have explicit "display: flex" or "display: none" 에러가 난다. 문자열과 변수를 섞어 쓴 곳(<div>{a}건</div>)도 자식이 둘로 세어져서 걸린다. display: grid는 아예 미지원이고, position: absolute와 flexbox 조합으로 푼다. 더 나쁜 것은 이 에러가 로컬 개발에서만 콘솔에 뜨고 프로덕션에서는 조용히 빈 이미지로 나가는 경우다. curl이 200을 줘도 한글이 두부면 content-length가 비정상적으로 작을 때가 많다. 배포 후 실제 이미지 URL을 브라우저 새 탭으로 연다.

카드는 모바일에서 폭 300~350px로 줄어든다. 1200px 캔버스의 68px 글자가 모바일에서 20px 정도로 보인다고 생각하고 크기를 잡는다. 제목은 두 줄이 한계다. 세 줄 넘어가면 플랫폼이 카드 높이를 잘라서 아래가 사라진다. 데이터에서 오는 제목 길이를 신뢰하지 말고 코드에서 자른다. 가장자리 60~80px는 여백으로 비운다. 일부 플랫폼이 카드를 살짝 크롭한다. 배경과 글자 대비는 최소 4.5:1이다.

라우트 핸들러는 app/api/og/route.tsx에 두고 export const runtime = 'nodejs'를 명시한다. 렌더링에 쓰는 모든 글자(제목, 카테고리, 사이트명)를 서브셋 요청에 포함시킨다.

에이전트에 레이아웃만 맡기면 display: grid를 쓴 코드가 나온다. 제약으로 적을 것은 1200×630, ImageResponse, runtime = 'nodejs', grid 미지원, 자식 2개 이상인 div에 flex 명시, 폰트 ttf/otf/woff와 번들 500KB, Google Fonts text=와 구형 UA, generateMetadata의 ?v=updatedAt, canonical과 openGraph.url 동일, 루트 metadataBase다.

Satori 한글

Satori에 폰트를 명시하지 않으면 기본 라틴 폰트만 쓴다. 한글은 빈 칸이나 두부로 나온다. 로컬에서 안 보이고 배포 후 카톡에 공유했을 때 발견되는 것이 보통이다.

한글 폰트 TTF는 보통 3~8MB다. ImageResponse 번들 상한은 500KB(JSX, CSS, 폰트, 이미지 전부 포함)라 통째로 넣으면 실패한다. 해법은 필요한 글자만 서브셋하는 것이다. Google Fonts CSS2 API의 text= 파라미터가 요청한 글자만 담은 폰트를 돌려준다. 자주 쓰는 글자를 미리 서브셋한 TTF를 assets에 두고 readFile로 읽으면 보통 300~400KB로 맞출 수 있다. 매 요청 fetch는 next: { revalidate: 86400 }으로 캐시된다.

woff2 거부

지원 포맷은 ttf, otf, woff뿐이다. woff2는 안 된다. 구형 UA를 보내는 이유가 핵심이다. 최신 브라우저 UA로 요청하면 Google이 woff2를 주고, Satori가 파싱에 실패한다. 한글 서브셋 로더는 다음 블록이다.

export async function loadKoreanFont(text: string): Promise<ArrayBuffer> {
  const family = 'Noto+Sans+KR:wght@700'
  const url = `https://fonts.googleapis.com/css2?family=${family}&text=${encodeURIComponent(text)}`

  const css = await fetch(url, {
    headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 6.1)' },
    next: { revalidate: 60 * 60 * 24 },
  }).then((r) => r.text())

  const match = css.match(/src:\s*url\(([^)]+)\)\s*format\('(?:opentype|truetype)'\)/)
  if (!match) throw new Error('폰트 서브셋 URL 파싱 실패')

  const res = await fetch(match[1])
  if (!res.ok) throw new Error(`폰트 다운로드 실패: ${res.status}`)
  return res.arrayBuffer()
}

User-Agent의 Windows NT 6.1은 구형 UA다. 이 헤더가 있어야 CSS에 opentype 또는 truetype URL이 나온다. 정규식은 woff2 format 줄을 고르지 않는다. text=에 제목과 카테고리, 사이트명을 빼먹으면 그 글자가 두부가 된다. revalidate: 86400은 초 단위 하루다. 폰트 바이트를 매 카드마다 다시 받지 않게 한다.

한글 OG 폰트와 서브셋
그림 2. ttf otf woff만 허용. 구형 UA로 CSS2 text= 서브셋을 받는다.

크롤러 UA

facebookexternalhit, KakaoTalk-Scrap 같은 크롤러는 HTML만 읽는다. 클라이언트에서 document.title이나 meta 태그를 주입하는 SPA는 카드가 안 나온다.

Next 15.2부터 generateMetadata가 느리면 초기 UI를 먼저 보내고 메타 태그를 나중에 <body>에 붙이는 스트리밍 메타데이터가 있다. Googlebot처럼 JS를 실행하는 봇은 괜찮지만, HTML만 읽는 봇에게는 문제가 되므로 Next가 User-Agent로 이런 봇들을 감지해서 그 경우에만 렌더링을 블로킹하고 <head>에 메타를 넣는다. 기본값은 안전하다. htmlLimitedBots 설정을 직접 건드렸거나, 리스트에 없는 크롤러(사내 메신저, 신생 플랫폼)를 상대하면 카드가 빈다. 커스텀 봇을 지원해야 하면 next.config에서 리스트를 확장하거나 스트리밍을 끈다. 기본 리스트는 Next 저장소의 html-bots.ts에 있다.

Vercel Deployment Protection은 프리뷰 배포나 보호가 켜진 프로덕션에서 크롤러에게 401을 준다. 로컬에선 되는데 배포하면 빈 카드의 가장 흔한 원인이다. OG 검증은 공개 URL로 한다. S3 presigned URL을 og:image에 넣으면 며칠 뒤 만료되면서 그때부터 공유되는 링크가 전부 빈 카드가 된다. 이미 캐시된 카드는 살아 있어서 발견이 늦다. 인증 미들웨어가 /api/og를 가로채면 크롤러가 로그인 페이지 HTML을 받는다. matcher에서 OG 경로를 제외했는지 본다.

X Card Validator는 프리뷰 기능이 제거됐다. 2026년 8월 기준 실제 상황이다.

플랫폼도구상태
Facebook / InstagramSharing Debugger정상. Scrape Again으로 강제 재수집
카카오톡공유 디버거정상. 로그인 후 캐시 초기화
X공식 검증기프리뷰 제거됨. 작성창에 URL을 붙여 미리보기로 확인
Slack없음30분 내외 자동 만료. 같은 대화방에 1시간 내 재공유하면 언퍼링 안 됨
Discord없음자체 프록시 캐시. URL에 ?v=가 사실상 필수
LinkedInPost Inspector정상

디버거로 비울 수 있는 플랫폼은 절반뿐이다.

브라우저로 열어보는 것은 검증이 아니다. 크롤러 User-Agent로 받아야 한다. 통과 기준은 셋이다. og:image가 https://로 시작하는 절대 URL일 것, 그 URL이 크롤러 UA로 200을 줄 것, content-type이 image/png 계열이고 content-length가 0이 아닐 것. content-length가 2~3KB면 한글이 렌더링 안 된 빈 이미지일 가능성이 높다. 크롤러 UA로 메타와 이미지 헤더를 보는 스크립트는 다음 블록이다.

#!/usr/bin/env bash
set -euo pipefail
URL="$1"
UA="facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
html=$(curl -sL -A "$UA" --max-time 15 "$URL")
echo "$html" | grep -oE '<meta (property|name)="(og|twitter):[^"]*" content="[^"]*"'
img=$(echo "$html" | grep -oE 'property="og:image" content="[^"]*"' | head -n1 | sed 's/.*content="//; s/"$//')
case "$img" in
  https://*) ;;
  *) echo "실패: 절대 URL이 아닙니다"; exit 1 ;;
esac
curl -sI -A "$UA" --max-time 15 "$img" | grep -iE '^(HTTP/|content-type|content-length|cache-control)'

-A는 User-Agent다. -sL은 리다이렉트를 따라가되 진행 표시는 끈다. --max-time 15는 15초를 넘기면 실패다. case는 https://가 아니면 그 자리에서 끝낸다. 이미지 HEAD에서 401이면 배포 보호, 403이면 미들웨어나 WAF, 404면 경로, 200인데 실패면 용량이나 content-type이다.

카드가 여전히 옛날 이미지이면 배포 HTML의 og:image URL이 실제로 바뀌었는지를 먼저 본다. 안 바뀌었으면 ?v= 로직 문제다. URL은 새데 열어보면 옛 이미지면 Next나 CDN 캐시다. 둘 다 새데 카드만 옛것이면 플랫폼 캐시이니 Facebook과 카카오 디버거로 재수집한다.

로컬 dev 서버, 브라우저 개발자 도구의 Elements 탭(JS 실행 후 DOM), 프리뷰 배포는 전부 다른 것을 보여줄 수 있다. 기준은 배포 URL을 크롤러 UA로 curl해서 나온 HTML이다.

크롤러로 OG 확인
그림 3. facebookexternalhit curl. 브라우저 Elements 탭은 크롤러 HTML이 아니다.

빈 카드의 나머지 원인

로컬 dev 서버는 브라우저가 현재 호스트로 상대 경로를 해석한다. 개발자 도구 Elements 탭은 JS 실행 후 DOM이다. 프리뷰 배포는 Vercel이 유추한 도메인이 og:image에 박힐 수 있다. 셋은 전부 다른 화면을 보여줄 수 있다. 기준은 배포 URL을 크롤러 UA로 curl해서 나온 HTML이다. 제목, 요약, 카테고리를 바꿨다면 그 스크립트를 배포 URL에서 돈다.

401이면 배포 보호다. Vercel Deployment Protection은 프리뷰나 보호가 켜진 프로덕션에서 크롤러에게 401을 준다. 로컬에선 되는데 배포하면 빈 카드의 가장 흔한 원인이다. 403이면 미들웨어나 WAF다. matcher가 /api/og를 가로채면 크롤러가 로그인 페이지 HTML을 받는다. 404면 경로다. 200인데 content-length가 2~3KB면 한글이 렌더링 안 된 빈 이미지일 가능성이 높다. Satori에 폰트를 안 넣었거나, woff2를 받아 파싱에 실패한 경우가 여기 해당한다.

S3 presigned URL을 og:image에 넣으면 며칠 뒤 만료되면서 그때부터 공유되는 링크가 전부 빈 카드가 된다. 이미 캐시된 카드는 살아 있어서 발견이 늦다. og:image가 301/302를 거치면 일부 크롤러가 포기한다. 최종 URL을 직접 쓴다. Next 15.2 스트리밍 메타데이터는 htmlLimitedBots 기본값에 기대며, 리스트 밖 봇은 <head>가 비어 카드가 빈다. 기본 리스트는 Next 저장소의 html-bots.ts에 있다. 커스텀 봇을 지원해야 하면 next.config에서 리스트를 확장하거나 스트리밍을 끈다.

파일 컨벤션 opengraph-image.tsx의 캐시버스팅 쿼리는 라우트 파일 기준 해시다. 코드를 안 고치고 DB에서 제목만 바꾸면 URL이 그대로다. GitHub discussion 62742는 revalidatePath로 세그먼트를 무효화해도 OG 이미지 라우트가 안 따라오는 케이스를 보고한다. 런타임에 글이 바뀌는 사이트라면 라우트 핸들러와 ?v=updatedAt이 그 갈림을 만든다. Slack은 30분 내외 자동 만료이고, 같은 대화방에 1시간 내 재공유하면 언퍼링이 안 된다. Discord는 자체 프록시 캐시라 URL에 ?v=가 사실상 필수다. X 공식 검증기 프리뷰는 제거됐다. 작성창에 URL을 붙여 미리보기로 확인한다.

얕은 병합 때문에 하위 페이지에서 openGraph만 정의하면 레이아웃의 siteName과 locale이 사라진다. 각 페이지에서 다시 쓰거나 공통 객체를 스프레드한다. alternates.canonical과 openGraph.url이 어긋나면 플랫폼이 어느 URL을 캐시할지 제멋대로 정한다. metadataBase가 비면 https://myblog-git-main-xxx.vercel.app/...이 프로덕션 카드에 박힌다. NEXT_PUBLIC_SITE_URL 폴백에만 의존하면 도메인을 바꾼 날 조용히 틀린다.

번들 상한 500KB는 JSX, CSS, 폰트, 이미지를 전부 포함한다. 한글 TTF 3~8MB를 통째로 넣으면 실패한다. Google Fonts text= 서브셋과 구형 UA Mozilla/5.0 (Windows NT 6.1)이 그 상한을 넘긴다. 자주 쓰는 글자를 미리 서브셋한 TTF를 assets에 두고 readFile로 읽으면 보통 300~400KB다. 매 요청 fetch는 next: { revalidate: 86400 }으로 캐시된다. ?v= 덕분에 이미지 자체도 CDN에서 오래 살아 실제 생성 횟수는 글 하나당 몇 번 수준이다. Cache-Control: public, max-age=31536000, immutable은 같은 URL이 영원히 같은 이미지일 때만 안전하다.

카드는 모바일에서 폭 300~350px로 줄어든다. 1200px 캔버스의 68px 글자가 모바일에서 20px 정도로 보인다고 생각하고 크기를 잡는다. 제목은 두 줄이 한계다. 세 줄 넘어가면 플랫폼이 카드 높이를 잘라서 아래가 사라진다. 가장자리 60~80px는 여백으로 비운다. 배경과 글자 대비는 최소 4.5:1이다. Open Graph 이미지 상한은 8MB, Twitter는 5MB다. ImageResponse PNG는 보통 50~200KB라 문제없지만 4K PNG를 그대로 올리면 걸린다. 권장 크기는 1200×630이다.

마무리

앞에서 다룬 동적 OG 이미지와 링크 미리보기의 핵심만 짧게 정리한다.

  • 카드는 시안이 아니라 배포 HTML의 절대 URL og:image와 크롤러 200이다.
  • metadataBase와 NEXT_PUBLIC_SITE_URL이 비면 프리뷰 도메인이 프로덕션에 박힌다. openGraph는 얕은 병합이다.
  • DB 글은 파일 컨벤션 해시가 아니라 라우트 핸들러와 ?v=updatedAt이 캐시를 가른다.
  • Satori는 한글 폰트와 500KB, woff2 거부가 있고, 구형 UA 서브셋이 그 상한을 넘긴다.
  • Next 15.2 스트리밍 메타데이터는 htmlLimitedBots 기본값에 기대며, 리스트 밖 봇은 카드가 빈다.
  • X 공식 검증기 프리뷰는 제거됐고, Slack과 Discord는 수동 퍼지가 없어 버전 쿼리가 사실상 필수다.
  • 검증은 크롤러 UA curl이다. 401, 만료 토큰, 리다이렉트, 미들웨어가 빈 카드의 나머지 원인이다.

「크롤러가 받은 HTML이 유일한 기준이다」 시안과 로컬 탭과 프리뷰 배포는 다른 화면을 보여 줄 수 있다. 공개 URL의 og:image가 절대 경로로 200과 한글 PNG를 주기 전에는 공유 카드가 준비된 것이 아니다.

출처와 링크

조사 기준: 2026년 8월. X 공식 검증기의 프리뷰 기능은 제거된 상태이며, 플랫폼 캐시 정책은 변경될 수 있다.

FAQ

자주 묻는 질문

opengraph-image.tsx 파일 컨벤션은 언제 맞는가?

콘텐츠가 빌드 타임에 고정되면 파일 컨벤션이 낫다. 메타 태그를 Next가 넣어 주고 런타임 비용이 없다. DB에서 제목만 바뀌는 구조에서는 라우트 파일 해시가 안 바뀌어 옛 이미지가 다시 나간다.

OG 라우트에 Edge runtime이 필요한가?

nodejs 런타임이 보통이다. 로컬 파일에서 폰트를 읽거나 DB에 직접 붙을 수 있다. Edge는 폰트를 fetch로만 가져올 수 있어 제약이 더 크다. 콜드스타트 차이만으로 Edge를 고르지는 않는다.

폰트 서브셋을 요청마다 fetch하면 지연이 커지는가?

next revalidate 86400으로 CSS와 폰트가 캐시되고, 이미지 URL의 v 쿼리 덕분에 PNG 자체도 오래 산다. 실제 생성 횟수는 글 하나당 몇 번 수준이다. 자주 쓰는 글자를 미리 서브셋한 TTF를 300~400KB로 두면 fetch를 줄일 수 있다.

카드가 옛날 이미지이면 어디부터 보는가?

배포 HTML의 og:image URL이 실제로 바뀌었는지를 먼저 본다. 안 바뀌었으면 v 쿼리 로직이다. URL은 새데 파일이 옛것이면 Next나 CDN 캐시다. 둘 다 새데 카드만 옛것이면 플랫폼 캐시이니 디버거로 재수집한다.

검증 도구가 이미지를 가져올 수 없다고 하면 상태 코드는 무엇을 가리키는가?

크롤러 UA로 curl한 상태 코드가 갈림길이다. 401은 배포 보호, 403은 미들웨어나 WAF, 404는 경로, 200인데 실패면 용량이나 content-type이다. 서명 URL 만료도 같은 증상으로 나타난다.

X Card Validator를 지금 써도 되는가?

프리뷰 기능이 제거되어 지금 열면 확인할 것이 없다. X는 작성창에 URL을 붙여 미리보기로 보고, Facebook과 카카오는 공식 디버거로 재수집한다. Slack과 Discord는 수동 무효화가 없어 버전 쿼리가 사실상 필수다.

Astro나 Worker 사이트도 같은 확인 기준인가?

크롤러가 받은 head에 절대 URL og:image가 있고 그 URL이 200을 주면 카드는 만들어진다. Next 전용은 generateMetadata와 ImageResponse 구현 경로일 뿐이다. 디버깅은 항상 배포 URL을 크롤러 UA로 curl하는 일이다.