한글 깨짐 없이 OG 이미지를 자동 생성하는 법 — Satori + 로컬 폰트 번들링 제작기

블로그를 운영하면서 가장 귀찮으면서도 결코 소홀히 할 수 없는 작업이 있습니다. 바로 SNS나 링크 공유 시 첫인상을 결정하는 OG(Open Graph) 이미지 제작입니다. 글 하나를 쓰는 데 온 에너지를 쏟고 나면, 썸네일 이미지를 만들기 위해 포토샵이나 피그마를 켜는 일은 무척 번거롭게 느껴집니다.

이 귀찮음을 없애려고 많은 개발자가 코드로 OG 이미지를 자동 생성합니다. 하지만 한글 콘텐츠를 다루는 블로거에게는 곧바로 벽이 하나 나타납니다. 텍스트-이미지 렌더링 라이브러리가 기본적으로 한글 글리프를 갖고 있지 않다는 점입니다. 이 글에서는 본 블로그(somsompapa.com)가 실제로 이 문제를 어떻게 해결했는지, 코드와 함께 그대로 공개합니다.


1. 왜 한글이 깨질까: Satori의 폰트 제약

이미지 렌더링에는 Vercel이 오픈소스로 공개한 Satori를 사용합니다. Satori는 HTML/CSS와 비슷한 구조의 JSON을 SVG로 변환해 주는 라이브러리인데, 실제 브라우저 엔진이 아니기 때문에 운영체제에 설치된 시스템 폰트를 참조하지 못합니다. 렌더링에 쓸 폰트 파일을 바이너리 형태로 직접 넘겨주지 않으면 아예 글자를 그리지 못합니다.

기본 제공되는 폰트나 흔히 쓰는 라틴 문자용 폰트만 넘기면 영문·숫자는 멀쩡히 나오지만, 한글 음절은 빈칸이나 깨진 사각형(이른바 ㅁㅁㅁ 현상)으로 출력됩니다. 즉 “한글이 깨지는” 근본 원인은 렌더링 엔진의 버그가 아니라, 한글 글리프를 포함한 폰트를 명시적으로 공급하지 않았기 때문입니다.

여기서 한 가지 실제로 검증된 제약이 더 있습니다. Satori는 폰트 포맷으로 TTF, OTF, WOFF만 지원하며, 최신 웹폰트에서 흔히 쓰이는 WOFF2는 지원하지 않습니다. 웹에서 흔히 구할 수 있는 웹폰트 CDN 링크를 그대로 가져다 쓰면 이 포맷 문제로 막히는 경우가 많습니다.


2. 검토했지만 채택하지 않은 방법: 요청 시점 동적 폰트 로딩

가장 먼저 떠오르는 아이디어는 “제목에 쓰인 글자만 실시간으로 서브셋 폰트를 받아와서 가볍게 렌더링하자”는 방식입니다. 매력적으로 보이지만, 이 블로그의 실제 구조와는 맞지 않아 채택하지 않았습니다.

비교 항목 요청 시점 동적 폰트 로딩 (미채택) 빌드타임 정적 생성 + 폰트 번들링 (실제 채택)
실행 시점 방문자가 페이지를 열 때마다 글을 발행하는 GitHub Actions 파이프라인 실행 시 1회
외부 네트워크 의존성 폰트 서버 응답에 의존 없음 (저장소에 폰트 파일 내장)
결과물 매 요청마다 동적 렌더링 미리 생성된 PNG 정적 파일
실패 지점 폰트 서버 장애 시 이미지 깨짐 빌드 시점에만 확인하면 됨

이 블로그는 Astro로 빌드되는 완전 정적 사이트이고, 배포도 Cloudflare Workers(Static Assets)로 정적 파일을 서빙하는 구조입니다. 즉 애초에 “요청마다 실행되는 서버 로직” 자체가 없습니다. 여기에 동적 폰트 로딩을 얹는 건 없던 네트워크 의존성과 실패 지점을 새로 만드는 것과 다름없다고 판단해, 처음부터 빌드타임에 완결되는 방식으로 설계했습니다.


3. 실제 채택한 방법: 폰트 파일을 저장소에 통째로 번들링

결론적으로 채택한 해법은 단순합니다. 한글을 포함하는 오픈소스 폰트(Pretendard, OFL 라이선스) 파일을 저장소에 직접 커밋해 두고, 빌드 스크립트가 이를 읽어 Satori에 넘기는 것입니다.

// site/automation/generate-og-images.mjs (실제 코드 발췌)
const FONT_PATH = path.join(__dirname, 'assets/Pretendard-Bold.ttf');

async function renderCard(title, fontData) {
  const svg = await satori(
    {
      type: 'div',
      props: {
        style: {
          width: '100%', height: '100%',
          display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
          padding: '64px',
          background: 'linear-gradient(135deg, #14161a 0%, #1d4ed8 100%)',
          color: '#ffffff',
          fontFamily: 'Pretendard',
        },
        children: [
          { type: 'div', props: { style: { fontSize: 32, opacity: 0.85 }, children: 'somsompapa.com' } },
          { type: 'div', props: { style: { fontSize: 56, fontWeight: 700, lineHeight: 1.35 }, children: title } },
        ],
      },
    },
    { width: 1200, height: 630, fonts: [{ name: 'Pretendard', data: fontData, weight: 700, style: 'normal' }] }
  );

  const resvg = new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } });
  return resvg.render().asPng();
}

이 스크립트는 heroImage가 비어 있는 글을 찾아 제목 텍스트로 카드를 렌더링하고, public/og/<slug>.png에 저장한 뒤 프런트매터에 경로를 채워 넣습니다. Satori가 SVG를 만들면 @resvg/resvg-js(네이티브 바인딩)가 이를 PNG로 변환합니다. 전체 과정이 GitHub Actions의 Node.js 환경에서 한 번 실행되고 끝나는 빌드타임 배치 작업입니다.


4. “Cloudflare Workers 번들 크기 제한”이 애초에 문제가 되지 않는 이유

폰트 파일을 통째로 번들링한다고 하면 “Cloudflare Workers 무료 플랜은 번들 크기 제한이 있는데 괜찮은가”라는 질문이 자연스럽게 나옵니다. 실제로 Workers 무료 플랜의 코드 번들 크기 제한은 gzip 압축 기준 3MB(유료 플랜은 10MB)입니다.

다만 이 프로젝트 구조에서는 이 제한 자체가 적용되지 않습니다. 폰트 파일(약 2.6MB)과 Satori·resvg 라이브러리는 GitHub Actions가 실행하는 Node.js 스크립트에서만 쓰이고, 실제로 배포되는 Cloudflare Worker 번들에는 전혀 포함되지 않기 때문입니다. 배포되는 것은 렌더링이 끝난 결과물인 PNG 파일(장당 200KB 내외)뿐이고, 이 파일들은 코드 번들이 아니라 정적 자산(Static Assets)으로 서빙됩니다. 빌드 도구의 제약과 런타임 배포 환경의 제약을 같은 것으로 착각하기 쉬운데, 이 둘은 완전히 분리되어 있습니다.


5. 레이아웃 설계 시 유의할 점

Satori는 CSS 전체가 아니라 Flexbox 레이아웃 모델의 제한된 하위 집합만 지원합니다. 실제 코드에서도 이 제약에 맞춰 다음 원칙을 지켰습니다.


자주 묻는 질문

Q. 매번 새 글이 추가될 때마다 이 스크립트를 수동으로 돌려야 하나요?

A. 아니요. 콘텐츠 자동 발행 파이프라인(content-pipeline.yml)의 한 단계로 포함돼 있어, 새 글이 생성될 때마다 heroImage가 비어 있으면 자동으로 카드가 만들어지고 프런트매터에 경로가 채워집니다.

Q. 폰트를 다른 것으로 바꾸고 싶다면?

A. automation/assets/에 원하는 TTF 파일을 넣고 FONT_PATH와 스타일의 fontFamily 값을 바꾸면 됩니다. 다만 Satori가 지원하는 포맷(TTF/OTF/WOFF)인지, 그리고 사용하려는 언어의 글리프를 포함하는지부터 확인해야 합니다.


외부 API 호출도, 실시간 렌더링 서버도 없이 저장소 안의 폰트 파일 하나로 문제를 해결했다는 점이 이 구조의 핵심입니다. 화려한 아키텍처보다, 애초에 실패할 지점을 최대한 줄이는 단순한 설계가 결국 더 오래 문제없이 돌아간다는 걸 다시 확인한 작업이었습니다.