바이브코딩
동적 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 | 정규 URL | canonical과 어긋나면 다른 페이지가 캐시됨 |
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가 비면 프리뷰 도메인이 프로덕션 카드에 박힌다.

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은 초 단위 하루다. 폰트 바이트를 매 카드마다 다시 받지 않게 한다.

크롤러 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 / Instagram | Sharing Debugger | 정상. Scrape Again으로 강제 재수집 |
| 카카오톡 | 공유 디버거 | 정상. 로그인 후 캐시 초기화 |
| X | 공식 검증기 | 프리뷰 제거됨. 작성창에 URL을 붙여 미리보기로 확인 |
| Slack | 없음 | 30분 내외 자동 만료. 같은 대화방에 1시간 내 재공유하면 언퍼링 안 됨 |
| Discord | 없음 | 자체 프록시 캐시. URL에 ?v=가 사실상 필수 |
| Post 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이다.

빈 카드의 나머지 원인
로컬 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를 주기 전에는 공유 카드가 준비된 것이 아니다.
출처와 링크
- opengraph-image 파일 컨벤션 (Next.js): 정적 최적화와 파일 크기 상한
- generateMetadata (Next.js): metadataBase, URL 합성, 얕은 병합, 스트리밍 메타데이터
- ImageResponse (Next.js): 번들 500KB, ttf/otf/woff만, grid 미지원
- Satori CSS 지원 범위: flex 제약
- Images in Link Shares (Meta): 권장 1200×630, 8MB 상한
- Facebook Sharing Debugger: 강제 재수집
- 카카오 공유 디버거: 캐시 초기화
- Open Graph protocol: 태그 규약
- Next.js discussion 62742: revalidatePath와 OG 이미지 라우트
- DB 런타임 발행: 페이지 캐시와 발행 루프
조사 기준: 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하는 일이다.
용어
관련 용어
Google이 오픈소스로 공개한 AI 에이전트 CLI 도구로, 터미널에서 코드 이해, 파일 조작, 명령어 실행, 대규모 코드베이스 편집을 지원한다. Google의 Gemini 모델을 기반으로 하며, Gemini의 멀티모달 능력을 활용하여 텍스트뿐 아니라 이미지와 비디오 생성까지 가능하다는 것이 독특한 차별점이다. 예를 들어, '이 UI의 스크린샷을 보고 React 컴포넌트를 만들어줘'와 같은 시각적 입력 기반 코딩이 가능하다. 오픈소스 프로젝트로 커뮤니티 기여가 가능하며, Google Cloud 생태계(Cloud Functions, Firebase, Vertex AI 등)와 긴밀하게 통합된다. Gemini Code Assist(IDE 기반 도구)와도 연동되어, CLI에서 시작한 작업을 IDE에서 이어서 할 수 있다. Claude Code, Codex CLI와 함께 3대 CLI 코딩 에이전트를 형성하며, Google 계정만 있으면 무료로 사용할 수 있어 진입 장벽이 낮다.
ai MixboardGoogle Labs의 실험형 AI 콘셉트 보드. 텍스트·업로드 이미지와 Nano Banana 편집으로 무드보드를 만들고, 2026-09-28 종료 예고가 메일로 고지됐다.
AI 모델·프로바이더 제미나이Google DeepMind가 개발한 멀티모달 AI 모델 시리즈로, 텍스트·이미지·오디오·비디오·코드를 통합적으로 처리할 수 있는 것이 가장 큰 차별점이다. 다른 모델이 주로 텍스트 기반으로 동작하는 반면, Gemini는 스크린샷을 보고 UI 코드를 생성하거나, 다이어그램을 이해하고 관련 코드를 작성하는 등 시각적 입력을 코딩에 활용할 수 있다. Gemini 2.0 Flash(빠르고 저렴), Gemini Pro(고성능) 등 다양한 변형이 있으며, 용도에 따라 선택할 수 있다. Gemini Code Assist(IDE 기반 코딩 도구)와 Gemini CLI(터미널 에이전트)를 통해 코딩을 지원하며, Google Cloud 생태계(Cloud Functions, Firebase, BigQuery, Vertex AI 등)와 긴밀하게 통합되어 있어 Google Cloud 사용자에게 특히 유리하다. Google의 방대한 데이터와 인프라를 기반으로 하므로, 정보 검색 능력(Grounding with Google Search)이 뛰어나 최신 라이브러리나 API 정보를 반영한 코드 생성에 강점이 있다.
링크
관련 링크
Google이 종료한 서비스와 제품을 한눈에 모아, 플랫폼 의존성과 제품 지속성 리스크를 판단하게 해 주는 ‘Google 묘지’ 아카이브입니다.
VIBE 코딩 레퍼런스 웹사이트 해부도 · Website Anatomy MapAI와 웹사이트를 함께 만들 때 ‘그 부분’이 아니라 정확한 UI·웹 용어로 지시할 수 있게 돕는 영-한 시각 사전입니다.
VIBE 코딩 보안 테이텀 시큐리티 Threads쉬운 보안을 지향하는 한국어 보안 계정으로, AI·VIBE 코딩 흐름에서 놓치기 쉬운 보안 감각을 되짚는 데 유용합니다.
관련 글
관련 글
Recommended
구글 애드센스 고시 SEO 점검 개선 후 승인 | fire-your-seo-agency 스킬
사이트를 만들어 두고 Google 애드센스에 신청했다가, 몇 달째 통과하지 못한 적이 있다. 거절 메일을 받을 때마다 무엇을 고쳐야 하는지가 한 줄로 떨어지지 않았고, AI에게 「SEO 점검하고 개선해 줘」라고만 말해 보기도 했다. 그때마다 뭔가 바뀌기는 했지만, 기준이 없어서 다음에 같은 점검을 다시 시킬 수가 없었다.
이거, 메일 받아 보신 분? 많으실 겁니다. 애드센스를 하다 보면 마주치는 그 팔짱 낀 아저씨 쪽에, 이런 사유가 적혀 있었다.
그림. 애드센스 안내에서 자주 보이는 팔짱 낀 아저씨
바이브 코딩 게임으로 돈 버는 과정 | 국내 유료 웹게임 정식 오픈까지
브라우저에서 돌아가게 만든 게임을, 자기 사이트에서 돈을 받고 한국에서 정식으로 여는 순서이다. 서버를 켜고 카드 결제만 붙이면 끝나는 일이 아니다.
웹에서 실행된다고 해서 그냥 웹서비스로만 보면 빠지는 법이 있다. 오락을 하게 만든 프로그램이면 게임 쪽 법을 본다. 이용자에게 돈을 받으면 전자상거래 쪽 법을 본다. 회원 정보를 받으면 개인정보 쪽 법을 본다. 영업으로 하면 세금이 붙는다. 이 글은 그 네 갈래를, 혼자 만드는 사람이 따라 할 수 있는 순서로 정리한 것이다. 특정 게임의 법률 자문이 아니고, 조문과 공식 안내를 읽은 뒤의 절차 설명이다. 신청 직전에는 국가법령정보센터의 현행 조문을 다시 연다.
조사 기준일은 2026년 10월 3일이다. 대상은 자기 사이트에서 국내 이용자에게 돈을 받고 여는 1인 웹게임이다. 오락실 허가나 앱 마…