2026-03-10

Next.js에서 OG 이미지 자동 생성하기 — Satori로 삽질한 이야기

발단: OG 이미지가 왜 필요했냐면

블로그를 만들면서 가장 마지막에 신경 쓰는 게 뭔지 아시나요? 바로 OG(Open Graph) 이미지입니다. 카카오톡이나 슬랙에 링크 던졌을 때 나오는 그 미리보기 이미지요. 솔직히 처음엔 "에이, 그거 누가 봐" 하면서 무시했는데, 막상 내 블로그 링크를 카톡에 보내봤더니...

제 얼굴 사진이 떡하니 나오더라고요.

홈페이지 메타 태그에 제 프로필 사진을 OG 이미지로 넣어놨었거든요. 블로그 글 링크 공유할 때마다 제 셀카가 미리보기에 뜨는 건... 좀 민망했습니다. 회사 슬랙에 기술 아티클 공유하는데 제 얼굴이 크게 나오는 거 상상해보세요. 아찔하죠.

그래서 결심했습니다. 제대로 된 OG 이미지 자동 생성 시스템을 만들자.

Next.js의 ImageResponse — Satori 엔진이란

Next.js 13.3부터 next/og 패키지에서 ImageResponse라는 걸 제공합니다. 내부적으로는 Vercel에서 만든 Satori라는 엔진을 사용하는데요, 쉽게 말하면 JSX를 PNG 이미지로 변환해주는 마법 같은 라이브러리입니다.

원리는 이렇습니다:

  • React JSX 문법으로 이미지 레이아웃을 작성

  • Satori가 이걸 SVG로 변환

  • SVG를 PNG로 래스터라이징

  • Edge Runtime 또는 Node.js Runtime에서 동적으로 생성

"오 JSX로 이미지를 만든다고? 그거 완전 쉽겠네!" 라고 생각하셨다면... 저도 그랬습니다. 현실은 좀 달랐어요.

Satori의 제약사항들 — 진짜 많다

Satori는 겉보기엔 "JSX + CSS로 이미지 만들기"인데, 실제로 써보면 CSS의 아주 제한적인 부분집합만 지원합니다. 이거 모르고 들어가면 진짜 삽질합니다.

Flexbox만 된다

네, CSS Grid 안 됩니다. display: grid? 무시됩니다. 오직 display: flex만 사용할 수 있어요. 그래서 모든 레이아웃을 flexbox로 구성해야 합니다. Grid에 익숙해진 분들은 좀 답답할 수 있어요.

의사 요소(Pseudo-elements) 없음

::before, ::after 같은 건 꿈도 꾸지 마세요. 장식용 요소가 필요하면 진짜 div를 하나 더 만들어야 합니다. :hover? 이미지인데 호버가 왜 필요하겠습니까만, 어쨌든 안 됩니다.

background-image 제한적

linear-gradient는 되는데, 복잡한 그라데이션이나 background-image: url()은 좀 까다롭습니다. radial-gradient도 되긴 하는데 브라우저에서 보는 것과 미묘하게 다르게 렌더링될 때가 있어요.

그 외 안 되는 것들

  • box-shadow: 되긴 하는데 복잡한 건 안 됨

  • transform: 기본적인 것만 지원

  • animation: 당연히 안 됨 (이미지인데...)

  • overflow: hidden이 border-radius와 함께 쓸 때 가끔 깨짐

  • position: fixed: 안 됨

  • 모든 요소에 display: flex를 명시해야 하는 경우가 많음 (빈 div도!)

특히 마지막 거, 이거 때문에 한참 삽질했습니다. Satori에서는 텍스트 노드가 아닌 빈 div에도 display: 'flex'를 넣어야 제대로 렌더링되는 경우가 많습니다. 안 넣으면 그냥 안 보여요. 에러도 안 나고.

기본 구조 — Route Handler로 만들기

Next.js App Router에서 OG 이미지를 동적으로 생성하려면 Route Handler를 사용합니다. src/app/api/og/route.tsx 파일을 만들면 /api/og 엔드포인트가 됩니다.

import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import fs from 'fs';
import path from 'path';
 
export const runtime = 'nodejs';
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') ?? 'zerry.co.kr';
  const description = searchParams.get('description') ?? '';
 
  // 폰트 로딩 (아래에서 자세히 설명)
  let fonts: { name: string; data: Buffer; weight: 400 | 700 }[] = [];
  try {
    const fontBold = fs.readFileSync(
      path.join(process.cwd(), 'public/fonts/Pretendard-Bold.otf')
    );
    const fontRegular = fs.readFileSync(
      path.join(process.cwd(), 'public/fonts/Pretendard-Regular.otf')
    );
    fonts = [
      { name: 'Pretendard', data: fontBold, weight: 700 },
      { name: 'Pretendard', data: fontRegular, weight: 400 },
    ];
  } catch {
    // 폰트 로드 실패 시 기본 폰트로 fallback
  }
 
  return new ImageResponse(
    (
      <div style={{ /* 여기에 이미지 레이아웃 */ }}>
        {title}
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts,
    }
  );
}

핵심 포인트 몇 가지:

  • runtime: 'nodejs' — Edge Runtime에서도 돌릴 수 있지만, 로컬 파일 시스템에서 폰트를 읽으려면 Node.js 런타임이 필요합니다

  • 1200 x 630 — OG 이미지의 표준 사이즈입니다. 대부분의 플랫폼에서 이 비율로 잘라서 보여줘요

  • 쿼리 파라미터로 title과 description을 받아서 동적으로 이미지를 생성합니다

폰트 로딩 — 한글은 무조건 커스텀 폰트

Satori는 기본적으로 영문 폰트만 내장하고 있습니다. 한글을 쓰려면 반드시 커스텀 폰트를 로드해야 해요. 안 그러면 한글이 전부 깨져서 네모(□□□)로 나옵니다.

저는 Pretendard 폰트를 사용했습니다. public/fonts/ 디렉토리에 OTF 파일을 넣고 fs.readFileSync로 읽어옵니다.

const fontBold = fs.readFileSync(
  path.join(process.cwd(), 'public/fonts/Pretendard-Bold.otf')
);
const fontRegular = fs.readFileSync(
  path.join(process.cwd(), 'public/fonts/Pretendard-Regular.otf')
);
fonts = [
  { name: 'Pretendard', data: fontBold, weight: 700 },
  { name: 'Pretendard', data: fontRegular, weight: 400 },
];

여기서 주의할 점:

  • OTF나 TTF 파일을 사용해야 합니다. WOFF/WOFF2는 안 돼요

  • 폰트 파일이 꽤 큽니다. Pretendard Bold + Regular 합치면 한 20MB 정도? 빌드 사이즈에 영향을 주니까 필요한 weight만 넣으세요

  • Edge Runtime을 쓰는 경우 fetch로 CDN에서 폰트를 가져와야 합니다. 로컬 파일 읽기가 안 되거든요

  • try-catch로 감싸는 게 좋습니다. 폰트 파일이 없어도 서버가 죽지 않게요

디자인 삽질기 — "이거 너무 AI가 만든 것 같아"

자, 여기서부터 진짜 이야기입니다.

버전 1: 파란 그라데이션의 절망

처음에 만든 OG 이미지는 전형적인 파란색 그라데이션 배경 + 중앙 정렬 텍스트였습니다. 뭐 이런 느낌이었죠:

// v1: 전형적인 AI 생성물 느낌
return new ImageResponse(
  (
    <div style={{
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      width: '100%',
      height: '100%',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      flexDirection: 'column',
    }}>
      <div style={{ fontSize: 60, color: 'white', fontWeight: 700 }}>
        {title}
      </div>
      <div style={{ fontSize: 24, color: 'rgba(255,255,255,0.8)' }}>
        {description}
      </div>
    </div>
  ),
  { width: 1200, height: 630 }
);

결과물을 보고 제 첫 마디: "이거 너무 AI가 만든 것 같아."

그 흔한 보라-파랑 그라데이션에 가운데 정렬된 흰 글씨. 어디서 많이 본 것 같지 않나요? ChatGPT한테 "OG 이미지 만들어줘" 하면 99% 이런 게 나옵니다. 밋밋하고, 개성이 없고, 천편일률적이고.

버전 2: 그래도 여전히...

두 번째 시도에서는 좀 더 요소를 추가했습니다. 배경에 도트 패턴도 넣고, 브랜드 로고도 넣고, 하단에 저자 정보도 넣었는데... 여전히 뭔가 "템플릿 갖다 쓴" 느낌이었어요.

다시 한번: "아니 이거 너무 AI가 만든 것 같다고. 다시 해."

이때쯤 되면 AI가 좀 지쳤을 겁니다. 저도 지쳤고요. 하지만 포기할 수 없었습니다. 카톡에 링크 보낼 때마다 부끄러운 건 참을 수 없잖아요.

버전 3: "개발자 블로그답게 만들어"

세 번째 시도에서 방향을 확 바꿨습니다. 그라데이션 배경 말고, 개발자 블로그다운 비주얼을 만들자. 구체적으로:

  • 다크 테마 (코드 에디터 느낌의 #0f172a 배경)

  • 좌측에 텍스트, 우측에 코드 에디터 비주얼

  • 터미널 카드를 떠 있는 형태로 배치

  • 은은한 그라데이션 블롭으로 깊이감 추가

이 방향이 맞았습니다.

최종 디자인 분석

최종 버전의 구조를 하나씩 뜯어보겠습니다.

배경 레이어

메인 배경은 #0f172a (Tailwind의 slate-900)입니다. 여기에 두 개의 그라데이션 블롭을 absolute로 배치했어요:

{/ 배경 그라데이션 블롭 /}
<div style={{
  position: 'absolute',
  top: '-100px',
  right: '-50px',
  width: '700px',
  height: '700px',
  borderRadius: '50%',
  background: 'radial-gradient(circle, rgba(99,102,241,0.2) 0%, rgba(59,130,246,0.08) 40%, transparent 70%)',
  display: 'flex',
}} />
<div style={{
  position: 'absolute',
  bottom: '-200px',
  left: '-100px',
  width: '500px',
  height: '500px',
  borderRadius: '50%',
  background: 'radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 60%)',
  display: 'flex',
}} />

이 블롭들이 미묘한 깊이감과 분위기를 만들어줍니다. 너무 강하면 다시 "AI 느낌"이 나니까 투명도를 낮게 잡는 게 포인트예요. opacity: 0.2 이하로.

그리고 보셨나요? 빈 div인데도 display: 'flex'가 들어가 있습니다. Satori에서 이거 안 넣으면 렌더링이 안 되거든요. 이거 찾느라 한 30분 날렸습니다.

좌측: 텍스트 영역

좌측은 세 부분으로 나뉩니다:

  • 상단: 브랜드 로고 + 도메인 (zerry.co.kr)

  • 중앙: 글 제목 + 설명

  • 하단: 저자 정보 (이름 + 직함)

{/ 좌측: 텍스트 콘텐츠 /}
<div style={{
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'space-between',  // 상중하 균등 배치
  padding: '52px 40px 48px 56px',
  width: '620px',
  position: 'relative',
}}>
  {/ 브랜드 /}
  <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
    <div style={{
      width: '32px', height: '32px',
      borderRadius: '8px',
      background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: '16px', fontWeight: 700, color: '#fff',
    }}>
      Z
    </div>
    <div style={{ fontSize: '16px', fontWeight: 700, color: 'rgba(255,255,255,0.6)' }}>
      zerry.co.kr
    </div>
  </div>

{/ 제목 /}

{/ 저자 /}

제목 폰트 사이즈를 제목 길이에 따라 동적으로 조절하는 부분이 중요합니다. 제목이 길면 42px, 짧으면 50px. 안 그러면 긴 제목이 잘리거나 레이아웃이 깨져요.

우측: 코드 에디터 비주얼

이게 이 OG 이미지의 핵심이자, 가장 삽질을 많이 한 부분입니다. 실제 코드를 보여주는 게 아니라, 코드처럼 보이는 색상 블록을 배치했어요:

// 코드 에디터 가짜 라인들 (시각적 장식용)
const codeLines = [
  { indent: 0, segments: [
    { w: 50, c: '#c084fc' },  // 보라색 — 키워드
    { w: 90, c: '#93c5fd' },  // 파란색 — 변수
    { w: 30, c: '#fbbf24' },  // 노란색 — 문자열
  ]},
  { indent: 1, segments: [
    { w: 70, c: '#6ee7b7' },  // 초록색 — 함수
    { w: 45, c: '#f9a8d4' },  // 핑크색 — 숫자
    { w: 80, c: '#93c5fd' },
  ]},
  { indent: 2, segments: [
    { w: 60, c: '#fbbf24' },
    { w: 100, c: '#a5b4fc' },
  ]},
  // ... 더 많은 라인들
];

각 "코드 라인"은 들여쓰기 레벨(indent)과 색상 세그먼트(segments)로 구성됩니다. 세그먼트는 너비(w)와 색상(c)을 가지고 있어서, 실제 코드 에디터의 구문 강조처럼 보이게 만들어요.

이걸 렌더링하는 코드:

{codeLines.map((line, i) => (
  <div key={i} style={{
    display: 'flex',
    alignItems: 'center',
    gap: '6px',
    paddingLeft: ${line.indent * 24}px,
    height: '12px',
  }}>
    {/  번호 /}
    <div style={{ width: '20px', fontSize: '10px', color: '#334155', display: 'flex' }}>
      {i + 1}
    </div>
    {/ 코드 세그먼트 /}
    {line.segments.map((seg, j) => (
      <div key={j} style={{
        width: ${seg.w}px,
        height: '10px',
        borderRadius: '3px',
        background: seg.c,
        opacity: 0.5,
        display: 'flex',
      }} />
    ))}
  </div>
))}

opacity: 0.5로 살짝 투명하게 해서 배경과 자연스럽게 어우러지게 했습니다. 줄 번호까지 있으니까 진짜 코드 에디터 같죠?

터미널 카드

코드 에디터 아래에 떠 있는 터미널 카드도 있습니다. ./gradlew build, npm run build 같은 명령어와 성공 메시지가 표시돼요. 개발자 블로그라는 정체성을 확실하게 보여주는 요소입니다.

{/ 있는 터미널 카드 /}
<div style={{
  display: 'flex',
  flexDirection: 'column',
  position: 'absolute',
  bottom: '20px',
  left: '-40px',      // 에디터 밖으로 살짝 삐져나옴
  width: '280px',
  background: 'rgba(15,23,42,0.9)',
  border: '1px solid rgba(148,163,184,0.15)',
  borderRadius: '12px',
  padding: '16px 20px',
  gap: '10px',
}}>
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
    <div style={{ fontSize: '12px', color: '#4ade80', display: 'flex' }}>
      >
    </div>
    <div style={{ fontSize: '12px', color: '#94a3b8' }}>
      ./gradlew build
    </div>
  </div>
  <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
    <div style={{ fontSize: '11px', color: '#4ade80', display: 'flex' }}>

    </div>
    <div style={{ fontSize: '11px', color: '#64748b' }}>
      BUILD SUCCESSFUL
    </div>
  </div>
</div>

left: '-40px'으로 에디터 영역 밖으로 살짝 삐져나오게 한 게 포인트입니다. 이런 디테일이 "그냥 박스 나열"과 "디자인"의 차이를 만들어요.

하단 그라데이션 라인

마지막으로 이미지 하단에 얇은 그라데이션 라인을 추가했습니다:

<div style={{
  position: 'absolute',
  bottom: '0',
  left: '0',
  right: '0',
  height: '3px',
  background: 'linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899, #3b82f6)',
  display: 'flex',
}} />

3px짜리 얇은 라인인데, 이게 있고 없고의 차이가 큽니다. 이미지에 "완성된 느낌"을 줘요.

블로그 글에서 OG 이미지 연결하기

이미지를 만들었으면, 각 블로그 글의 메타데이터에 연결해야겠죠. Next.js App Router에서는 generateMetadata 함수를 사용합니다:

// src/app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug);

return {

/api/og 엔드포인트에 title과 description을 쿼리 파라미터로 넘기면, 해당 글에 맞는 OG 이미지가 동적으로 생성됩니다. 별도로 이미지 파일을 만들어서 저장할 필요가 없어요.

홈페이지 OG도 바꿨다

앞서 말한 "제 얼굴 사진 OG" 문제도 해결했습니다. 홈페이지의 메타데이터도 같은 API를 사용하도록 변경했어요:

// src/app/layout.tsx 또는 src/app/page.tsx
export const metadata: Metadata = {
  openGraph: {
    images: [
      {
        url: '/api/og?title=zerry.co.kr&description=Backend Engineer 조용진의 기술 블로그',
        width: 1200,
        height: 630,
      },
    ],
  },
};

이제 홈페이지 링크를 공유해도 멋진 다크 테마 OG 이미지가 나옵니다. 더 이상 제 셀카가 슬랙 채널에 뜨는 일은 없습니다. 다행이에요, 정말.

삽질하면서 배운 팁들

1. 디버깅은 브라우저에서

OG 이미지 개발할 때 가장 편한 방법은 브라우저에서 직접 /api/og?title=테스트를 호출하는 겁니다. 이미지가 바로 렌더링되니까 실시간으로 확인할 수 있어요. next dev로 개발 서버 켜놓고 새로고침 하면서 작업하면 됩니다.

2. 스타일은 인라인만

Satori에서는 CSS 클래스나 Tailwind가 안 됩니다. 모든 스타일을 style 프로퍼티로 인라인으로 작성해야 해요. JSX니까 style={{ }}` 형태로요. 처음엔 불편한데 익숙해지면 괜찮습니다. 어차피 이미지 하나 만드는 거니까 코드가 길어져도 크게 상관없어요.

3. 조건부 렌더링 활용

description이 비어있을 때, 제목이 길 때 등 다양한 케이스를 고려해야 합니다. 저는 제목 길이에 따라 폰트 사이즈를 동적으로 조절하고, description이 60자를 넘으면 잘라내고 말줄임표를 붙였어요:

{description && (
  <div style={{ fontSize: '18px', color: 'rgba(148,163,184,0.8)' }}>
    {description.length > 60
      ? description.slice(0, 60) + '…'
      : description}
  </div>
)}

4. 색상은 rgba()로

투명도 조절이 많이 필요하므로 rgba()를 적극 활용하세요. Tailwind 색상 팔레트에서 RGB 값을 가져와서 알파 값만 바꿔주면 일관된 색감을 유지하면서 투명도를 조절할 수 있습니다.

5. 빈 요소에도 display: flex

다시 한번 강조합니다. 빈 div에도 display: 'flex'를 넣으세요. 안 넣으면 렌더링이 안 되거나 레이아웃이 이상하게 됩니다. 이것 때문에 삽질하는 시간을 줄이려면 그냥 모든 div에 display: 'flex'를 기본으로 넣는 게 정신 건강에 좋습니다.

성능 이야기

동적 OG 이미지 생성은 매 요청마다 이미지를 렌더링하는 거라 성능이 걱정될 수 있습니다. 실제로 Satori가 이미지를 생성하는 데 수백 밀리초 정도 걸려요. 하지만 걱정할 필요는 별로 없습니다:

  • OG 이미지는 크롤러만 가져갑니다. 일반 사용자가 페이지를 방문할 때는 OG 이미지를 로드하지 않아요

  • Vercel에 배포하면 자동으로 CDN 캐싱됩니다. 같은 URL에 대한 반복 요청은 캐시에서 서빙돼요

  • 필요하다면 Cache-Control 헤더를 직접 설정할 수도 있습니다

// 캐싱 헤더를 추가하고 싶다면
const response = new ImageResponse(/ ... */);
response.headers.set(
  'Cache-Control',
  'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800'
);
return response;

마무리

OG 이미지 하나 만드는 데 이렇게 삽질을 많이 할 줄은 몰랐습니다. "JSX로 이미지 만들기"라는 개념 자체는 정말 좋은데, Satori의 CSS 제약사항들이 처음엔 좀 당황스러워요. 특히 Grid를 못 쓰는 것과 빈 요소에 display: flex를 넣어야 하는 것.

그래도 한번 세팅하면 모든 블로그 글에 자동으로 예쁜 OG 이미지가 생기니까, 초기 투자 대비 효과가 큰 작업이었습니다. 링크 공유할 때 미리보기가 예쁘게 나오면 클릭률도 올라간다고 하더라고요.

그리고 무엇보다, 더 이상 제 셀카가 OG 이미지로 나오지 않습니다. 이것만으로도 이 삽질의 가치가 충분합니다.

혹시 비슷한 작업을 하시는 분이 계시다면, 이 글이 삽질 시간을 좀 줄여드리길 바랍니다. Satori 공식 문서보다는 직접 부딪혀보면서 "아 이건 안 되는구나"를 체감하는 게 가장 빠른 학습법이긴 합니다만, 그래도 미리 알면 좋잖아요.

다음엔 이 OG 이미지에 블로그 카테고리 태그도 추가하고, 글 작성일도 넣어볼 생각입니다. Satori 삽질은... 계속됩니다. 😇

관련 글

벡터 유사도 기반