Next.js opengraph-image에서 한글이 깨지는 이유: Satori 폰트 로딩 완벽 가이드

Next.js `ImageResponse`가 한글을 빈 사각형으로 렌더링하는 이유는 Satori가 OS 폰트를 읽지 않기 때문입니다. `fonts` 배열에 Noto Sans KR을 주입하는 정확한 방법과, 정적 PNG로 전환하는 프래그매틱 대안을 다룹니다.

Next.js opengraph-image에서 한글이 깨지는 이유: Satori 폰트 로딩 완벽 가이드

1. 문제 상황

Next.js 16 프로젝트에 src/app/opengraph-image.tsx를 추가하고 동적 OG 이미지를 렌더링하도록 설정했습니다. 로컬 next dev에서는 한글 제목이 정상으로 보였는데, Vercel에 배포한 뒤 카카오톡으로 링크를 공유하니 제목 자리가 전부 빈 사각형으로 바뀌어 있었습니다. Facebook 공유 디버거로 확인해도 동일했습니다.

증상

  • 영문은 정상 렌더링 (DATA SCIENTIST & AI ENGINEER, 8+ Years 등)
  • 한글만 선택적으로 깨짐 (귀찮음을 코드로 해결하는 개발자 → ☐☐☐☐)
  • 빌드 로그에 에러 없음, 경고 없음
  • 로컬에서 OS에 따라 다르게 보임 (macOS에선 가끔 렌더링되는 것처럼 보이기도 함)
  • /opengraph-image 경로를 브라우저에서 직접 열면 이미지는 생성되지만 한글 자리가 비어 있음

최초 코드

문제가 된 초기 구현은 아래와 같았습니다.

// src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = 'example.com - Data Scientist & AI Engineer';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default function Image() {
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: '80px',
          backgroundColor: '#0a0e1a',
          color: '#e8e8e8',
          fontFamily: 'system-ui, sans-serif', // ← 문제의 핵심
        }}
      >
        <div style={{ fontSize: '64px', fontWeight: 300, lineHeight: 1.1 }}>
          귀찮음을 코드로           {/* ← 한글 텍스트 */}
          <br />
          해결하는 개발자
        </div>
        <div style={{ fontSize: '22px', color: '#888', marginTop: '24px' }}>
          DATA SCIENTIST & AI ENGINEER
        </div>
      </div>
    ),
    { ...size }
  );
}

코드만 보면 문제가 없어 보입니다. fontFamily: 'system-ui, sans-serif'는 일반적인 웹 CSS에서 문제 없이 동작하는 지정이니까요. 하지만 ImageResponse일반 브라우저가 아니라 Satori로 렌더링됩니다. 이 지점에서 가정이 무너집니다.


2. 원인 분석

Satori란 무엇인가

next/ogImageResponse는 내부적으로 Vercel이 만든 Satori를 사용합니다. Satori는 JSX와 CSS-in-JS 일부를 받아 SVG로 변환하고, 그 SVG를 다시 @resvg/resvg-js로 PNG로 래스터화하는 파이프라인입니다.

JSX + style  →  Satori  →  SVG  →  resvg  →  PNG

여기서 중요한 사실이 하나 있습니다. Satori는 폰트를 직접 읽어야 합니다. 브라우저처럼 OS에 설치된 폰트를 자동으로 가져오지 않고, 네트워크 폰트(@font-face)를 해석하지도 않습니다. 호출자가 폰트 바이너리를 명시적으로 넘겨주지 않으면 Satori는 글리프를 그릴 수 없습니다.

fontFamily: 'system-ui'가 동작하지 않는가

일반 웹 페이지에서 system-ui는 "OS의 기본 UI 폰트"를 뜻하는 CSS 키워드입니다. macOS에선 San Francisco, Windows에선 Segoe UI, Linux에선 DejaVu 등으로 해석됩니다. 하지만 Satori에는 OS 개념이 없습니다. Edge runtime 환경에서는 OS 폰트에 접근할 수 있는 파일 시스템 자체가 없습니다.

fontFamily 속성이 있어도 Satori는 이를 힌트로만 취급하고, 실제 글리프는 fonts 옵션으로 전달된 폰트 버퍼에서만 찾습니다. fonts 옵션을 전혀 주지 않으면 Satori는 기본적으로 내장된 하나의 라틴 폰트(현재 구현에서는 Inter)로 폴백합니다.

Inter에는 한글이 없다

Satori의 기본 폴백인 Inter는 라틴 알파벳과 일부 유럽 언어 글리프만 포함하는 폰트입니다. 한글(Hangul), 중국어(Han), 일본어(Kana/Kanji) — 즉 CJK 글리프는 전혀 없습니다. 한글 코드포인트를 그리려 하면 "해당 글리프 없음" 결과가 나오고, Satori는 이를 토후(tofu, ☐) 또는 빈 박스로 렌더링합니다.

왜 영문은 되는가

초기 구현에서 DATA SCIENTIST & AI ENGINEER, 8+ Years, 25+ Projects 같은 영문 텍스트는 정상 렌더링됐습니다. 이유는 간단합니다 — 이 글자들은 Satori 기본 폴백 폰트(Inter)에 글리프가 존재하기 때문입니다. 즉 "영문은 되는데 한글만 안 된다"는 증상이 곧 Satori 폰트 미주입의 결정적 신호입니다.

왜 로컬에서는 가끔 되는가

로컬 next dev에서는 한글이 일부 렌더링되는 것처럼 보이기도 합니다. 이유는 Next.js가 개발 모드에서 Edge runtime을 완전히 시뮬레이션하지 않고, 일부 Node.js API와 로컬 파일 시스템 접근을 허용하기 때문입니다. 이 차이 때문에 개발자는 "로컬에선 되는데 배포 후 깨진다"는 전형적인 증상을 만나고 디버깅에 시간을 쓰게 됩니다.


3. 해결 방법

해결 경로는 두 가지입니다.

  1. 동적 생성을 유지하면서 한글 폰트를 올바르게 주입 (Noto Sans KR .ttffetch)
  2. 동적 생성을 포기하고 정적 PNG로 전환 (디자인 툴에서 PNG를 미리 만들어 public/에 둠)

둘 다 유효하며, 선택은 요구사항에 달려 있습니다. 이 섹션에서는 두 방법을 모두 구체적으로 다룹니다.

해결 1: Noto Sans KR을 fetch로 주입

Google Fonts에서 Noto Sans KR의 정적 .ttf 파일을 가져와 fonts 옵션에 ArrayBuffer로 넘깁니다.

// ✅ 해결: src/app/opengraph-image.tsx
import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = 'example.com';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function Image() {
  // 1. Noto Sans KR 폰트 파일을 네트워크로 가져오기
  //    - Edge runtime은 파일 시스템 접근이 제한되므로 fetch 사용
  //    - raw.githubusercontent.com 직접 경로 사용 (github.com/raw/...는 302 리다이렉트)
  const fontData = await fetch(
    'https://raw.githubusercontent.com/google/fonts/main/ofl/notosanskr/NotoSansKR%5Bwght%5D.ttf'
  ).then((res) => res.arrayBuffer());

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: '80px',
          backgroundColor: '#0a0e1a',
          color: '#e8e8e8',
          fontFamily: '"Noto Sans KR"', // ← 주입한 폰트 이름과 일치
        }}
      >
        <div style={{ fontSize: '64px', fontWeight: 700, lineHeight: 1.1 }}>
          귀찮음을 코드로
          <br />
          해결하는 개발자
        </div>
        <div
          style={{
            fontSize: '22px',
            color: '#888',
            marginTop: '24px',
            letterSpacing: '0.05em',
          }}
        >
          DATA SCIENTIST & AI ENGINEER
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [
        {
          name: 'Noto Sans KR', // ← fontFamily 값과 정확히 일치해야 함
          data: fontData,
          style: 'normal',
          weight: 700,
        },
      ],
    }
  );
}

핵심 변경점 세 가지입니다.

  1. fetch로 폰트 바이너리 로딩 — Edge runtime에선 fs를 쓸 수 없으므로 네트워크 fetch가 유일한 경로입니다.
  2. ImageResponsefonts 배열에 등록name, data, style, weight를 명시합니다.
  3. fontFamilyname을 일치시킴 — 따옴표까지 포함해 정확히 매칭돼야 Satori가 연결합니다.

가변 폰트(variable font)의 함정

Noto Sans KR은 가변 폰트(variable font)로 배포됩니다. 파일 이름이 NotoSansKR[wght].ttf 형식인데, 이 대괄호 문자는 URL에서 %5B, %5D로 인코딩해야 합니다. 인코딩 없이 그대로 쓰면 GitHub Raw가 404를 반환합니다.

// ❌ 인코딩 없이 쓰면 404
'https://raw.githubusercontent.com/google/fonts/main/ofl/notosanskr/NotoSansKR[wght].ttf'

// ✅ URL 인코딩 필수
'https://raw.githubusercontent.com/google/fonts/main/ofl/notosanskr/NotoSansKR%5Bwght%5D.ttf'

해결 1의 현실적 제약

fetch로 폰트를 로딩하는 방식은 원리는 명확하지만, 운영 환경에서는 여러 제약이 따릅니다.

제약 설명 대응
폰트 파일 크기 Noto Sans KR 가변 폰트 약 11MB font-subset으로 필요한 글리프만 추출
Edge 함수 응답 시간 폰트 fetch 레이턴시가 OG 응답에 누적 ISR/캐시 헤더 활용
외부 CDN 의존 GitHub Raw가 느려지면 OG 이미지도 느려짐 자체 public/fonts/에 배치
메모리 상한 Edge runtime당 메모리 제한 존재 Subset + 캐시

운영 팁: 가변 폰트 전체 대신 필요한 글리프만 서브셋팅.ttfpublic/fonts/ 아래에 두고, new URL('../../public/fonts/NotoSansKR-subset.ttf', import.meta.url) 형식으로 불러오는 방법이 훨씬 안정적입니다. 다만 이 경로가 Edge runtime에서 안정적으로 작동하는지는 Next.js 버전에 따라 차이가 있으므로 빌드 테스트로 반드시 확인해야 합니다.

해결 2: 정적 PNG로 전환 (프래그매틱 선택)

단일 OG 이미지로 충분한 포트폴리오/랜딩 페이지에서는 동적 생성을 포기하고 정적 PNG 파일로 대체하는 것이 현실적으로 가장 안정적인 선택입니다.

// ✅ 정적 PNG 접근법
// 1. Figma/스케치에서 1200x630 이미지를 미리 렌더링
// 2. public/og-image.png 로 저장
// 3. metadata에서 정적 경로 참조
// src/app/[locale]/layout.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale } = await params;
  const t = await getTranslations({ locale, namespace: 'metadata' });

  return {
    title: t('title'),
    description: t('description'),
    openGraph: {
      title: t('title'),
      description: t('description'),
      siteName: 'example.com',
      locale: locale === 'ko' ? 'ko_KR' : 'en_US',
      type: 'website',
      images: [
        { url: '/og-image.png', width: 1200, height: 630 }, // ← 정적 파일
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: t('title'),
      description: t('description'),
      images: ['/og-image.png'],
    },
  };
}

그리고 src/app/opengraph-image.tsx 파일은 삭제합니다. Next.js는 이 파일이 없으면 metadata.openGraph.images의 정적 경로를 그대로 사용합니다.

디자인 툴에서 1200x630 만들기 체크리스트

  • 캔버스 크기: 1200 × 630 (OG 기본 규격)
  • 중요 요소는 가운데 1100 × 530 영역 안에 배치 (플랫폼별 safe zone 대응)
  • 폰트는 이미지 내부에 렌더링되어 있으므로 OS 의존 없음
  • 콘트라스트 비율 ≥ 4.5:1 (카카오톡 다크 모드에서도 읽혀야 함)
  • PNG 압축: TinyPNG나 ImageOptim으로 50KB 이하로 축소 권장

4. 핵심 개념 정리

Satori 폰트 처리 요약

항목 동작
기본 내장 폰트 Inter (라틴 위주, CJK 없음)
OS 폰트 자동 로딩 ❌ 안 함
@font-face 해석 ❌ 안 함
fontFamily: 'system-ui' 힌트로만 취급, 실제 로딩 없음
한글/CJK 렌더링 fonts 옵션으로 명시 주입 필수
가변 폰트 지원 단일 weight로 제한 (Satori 버전에 따라 다름)
폴백 글리프 ☐ (토후, tofu)

Next.js OG 이미지 구현 방식 비교

방식 구현 한글 지원 빌드 안정성 페이지별 커스텀 추천 상황
opengraph-image.tsx (Satori) 동적 폰트 주입 시 ✅ 환경 의존적 블로그/글 별 동적 OG
정적 PNG (public/) 정적 포트폴리오/단일 랜딩
외부 OG 서비스 API 서비스마다 대규모 자동화

fonts 배열 옵션 구조

type FontOptions = {
  name: string;         // fontFamily와 매칭될 이름
  data: ArrayBuffer;    // 폰트 바이너리 (TTF/OTF/WOFF)
  weight?: number;      // 100~900
  style?: 'normal' | 'italic';
  lang?: string;        // 언어 힌트 (선택)
};

여러 weight를 쓰려면 각 weight마다 별도 엔트리를 추가해야 합니다. 가변 폰트 하나로 여러 weight를 자동 커버하려는 기대는 현재 Satori 구현에선 통하지 않습니다.


5. 베스트 프랙티스

OG 이미지 구축 체크리스트

  • [ ] opengraph-image.tsx를 쓰기 전에 "정말 페이지별로 동적이어야 하는가"를 먼저 자문하세요.
  • [ ] 단일 이미지면 정적 PNG가 항상 더 안정적입니다.
  • [ ] 동적이 필요하면 반드시 fonts 배열에 CJK 폰트를 명시 주입하세요.
  • [ ] fontFamily 문자열은 fonts[].name따옴표까지 포함해 정확히 일치해야 합니다.
  • [ ] 폰트 파일은 서브셋팅해서 11MB → 수백 KB로 줄이세요.
  • [ ] 로컬에서만 테스트하지 말고 실제 배포 후 카카오톡/Facebook 디버거로 확인하세요.
  • [ ] Facebook 공유 디버거로 캐시를 초기화하고 재확인하세요.
  • [ ] 콘트라스트는 WCAG AA(4.5:1) 이상을 유지해 다크 모드 썸네일에서도 가독성을 확보하세요.

디버깅 3단계

Step 1: 로컬에서 /opengraph-image 직접 열기

# 개발 서버 실행 후
open http://localhost:3000/opengraph-image

이 경로는 Next.js가 자동으로 생성한 엔드포인트입니다. 브라우저에서 이미지가 정상으로 보이는지 먼저 확인합니다. 한글이 이 단계에서 이미 빈 사각형이면 폰트 미주입 문제가 확실합니다.

Step 2: 배포 후 같은 경로 확인

curl -sI https://example.com/opengraph-image
# HTTP/2 200
# content-type: image/png

curl -s https://example.com/opengraph-image -o og.png && open og.png

로컬과 배포의 렌더링이 다르면 Edge runtime 관련 차이입니다. 대부분 fetch로 받은 폰트가 Edge에서 실패한 경우입니다.

Step 3: Satori 디버그 출력

ImageResponse는 디버그 플래그를 직접 노출하지 않지만, 실패 원인을 로그로 보려면 fetch 결과를 먼저 확인해야 합니다.

export default async function Image() {
  const res = await fetch(FONT_URL);
  console.log('[og] font fetch status:', res.status, res.headers.get('content-length'));
  const fontData = await res.arrayBuffer();
  console.log('[og] font bytes:', fontData.byteLength);
  // ...
}

Vercel 대시보드의 Function Logs에서 위 로그를 확인할 수 있습니다. 바이트 수가 0이거나 예상치보다 훨씬 작으면 CDN 응답이 잘못 온 것입니다.

실수를 피하는 3가지 원칙

  1. Satori는 브라우저가 아니다 — OS 폰트, @font-face, CSS 변수 대부분이 통하지 않습니다.
  2. 로컬과 배포는 다를 수 있다 — Edge runtime은 Node와 다르며, 실제 배포 후 테스트가 유일한 검증입니다.
  3. "조금이라도 복잡해지면 정적 PNG" — 페이지별 커스텀이 필수가 아니라면 정적이 가장 싸고 안정적입니다.

6. FAQ

Q: fontFamily만 지정하면 왜 안 되나요?

A: Satori는 브라우저가 아니라 SVG 렌더러입니다. CSS의 fontFamily는 폰트를 "참조"하는 이름일 뿐, 실제 폰트 파일을 가져오는 메커니즘이 아닙니다. 브라우저는 OS와 @font-face를 통해 해당 이름의 폰트를 자동으로 찾아주지만, Satori는 fonts 배열로 명시적으로 받은 것만 사용합니다. fonts 배열이 비어 있으면 기본 폴백인 Inter만 쓰이고, Inter에는 한글 글리프가 없어 토후로 표시됩니다.

Q: Noto Sans KR 말고 다른 한글 폰트를 쓸 수 있나요?

A: 가능합니다. TTF/OTF/WOFF 형식의 한글 폰트라면 어떤 것이든 fonts 배열에 ArrayBuffer로 넘길 수 있습니다. 상용 폰트 라이선스만 주의하세요. 대안으로는 Pretendard, Spoqa Han Sans Neo, IBM Plex Sans KR 등이 있습니다. 가변 폰트는 단일 weight로 로딩되므로, 여러 weight가 필요하면 weight별로 개별 파일을 주입해야 합니다.

Q: 로컬에선 한글이 보이는데 왜 배포하면 깨지나요?

A: Next.js 개발 서버는 Edge runtime을 완전히 재현하지 않습니다. 개발 모드에선 Node.js 환경의 일부 API와 OS 폰트에 접근할 수 있어서 한글이 우연히 렌더링될 수 있습니다. 배포 후 Vercel Edge 환경에선 OS 폰트 접근 자체가 불가능하므로 fonts 배열에 명시하지 않은 글리프는 모두 깨집니다. 따라서 로컬 테스트만으로는 검증이 불충분합니다.

Q: 폰트 파일을 public/ 폴더에 두고 import하면 안 되나요?

A: new URL('../../public/fonts/foo.ttf', import.meta.url) 패턴이 일부 Next.js 버전에서 작동하지만, Edge runtime의 파일 시스템 접근은 제한적이어서 빌드/배포 환경에 따라 실패할 수 있습니다. 가장 안전한 경로는 여전히 네트워크 fetch이고, 외부 CDN 의존을 피하려면 자체 도메인의 public/fonts/ URL을 fetch하는 방식을 권장합니다(예: fetch(new URL('/fonts/NotoSansKR.ttf', request.url))).

Q: 카카오톡 공유 디버거에서 여전히 구 버전 이미지가 보이는데요?

A: OG 이미지는 대부분의 플랫폼에서 캐시됩니다. 카카오톡은 developers.kakao.com의 OG 캐시 초기화 도구로, Facebook은 공유 디버거의 "다시 스크랩" 버튼으로 강제 갱신할 수 있습니다. 트위터/X는 URL 끝에 ?v=2 같은 쿼리 파라미터를 붙여 새 URL로 공유하는 우회가 일반적입니다.


7. 참고 자료


8. 다음 단계

이 글은 Satori 렌더링 단에서의 한글 폰트 문제에 집중했습니다. 하지만 다국어 사이트에서는 OG 이미지가 올바르게 생성돼도 미들웨어의 redirect가 크롤러에게 메타 태그를 전달하지 못하는 별도 문제가 발생할 수 있습니다.

사용자가 루트 경로(example.com)에서 OG 미리보기를 공유했는데 이미지가 아예 안 나오는 증상을 겪고 있다면, 원인이 미들웨어의 307 redirect일 가능성이 높습니다. 해당 주제는 시리즈 다음 글에서 다룹니다.

시리즈 목차:

  1. Next.js opengraph-image에서 한글이 깨지는 이유: Satori 폰트 로딩 완벽 가이드 ← 현재 글
  2. Next.js i18n 미들웨어에서 OG 이미지가 안 보이는 이유: redirect vs rewrite 완벽 가이드
  3. OG 이미지 구현의 두 전환점: 단순한 답이 이긴 두 번의 리팩토링