링크를 SNS에 공유했는데 밋밋한 URL만 달랑 뜨는 경험. 블로거라면 한 번쯤 겪어봤을 거다. Open Graph 이미지 — 흔히 "카톡 썸네일"이라 부르는 그것. 블로그 글의 첫인상을 결정짓는 요소인데, 매번 수동으로 만들기엔 너무 귀찮다. Postlark에서 이 문제를 어떻게 해결했는지, 그 과정에서 만난 함정들을 기록해둔다.

왜 자동으로 만들어야 했나

블로그를 하나 운영한다면 Canva나 Figma로 대충 만들어도 된다. 근데 Postlark는 수백 개의 블로그에서 매일 글이 올라오는 플랫폼이다. 사용자에게 "OG 이미지를 직접 만들어서 올려주세요"라고 요구하는 건 현실적이지 않다. 특히 MCP나 CLI를 통해 AI 에이전트가 글을 발행하는 경우라면 더더욱 그렇다. 에이전트한테 "포토샵 열어서 썸네일 만들어"라고 할 수는 없으니까.

방향은 처음부터 명확했다. 글 제목, 블로그 이름, 태그 정보를 넣으면 1200×630 크기의 PNG가 자동으로 나오는 구조. 소셜 미디어에 공유할 때 별도 설정 없이 바로 카드 형태로 노출되게 만드는 것.

satori에서 workers-og로

OG 이미지 동적 생성에서 가장 많이 쓰이는 도구가 Vercel의 satori다. JSX로 레이아웃을 잡으면 SVG로 변환해주고, 거기서 PNG로 래스터라이징하는 방식이다. Next.js 생태계에서는 @vercel/og로 널리 쓰인다.

처음에는 satori에 resvg-js를 붙여서 쓸 생각이었다. 그런데 서버리스 환경에서 resvg-js의 WASM 바이너리를 로딩하는 과정이 생각보다 까다로웠다. 번들 크기 문제도 있었고, 콜드 스타트 시간도 신경 쓰였다. 이것저것 시도하다가 결국 workers-og라는 래퍼 패키지를 발견했다. satori를 감싸면서 서버리스 런타임에 맞게 렌더링까지 한 번에 처리해주는 도구다.

커밋 로그를 보면 이 과정이 고스란히 남아있다. 계획 문서에 "satori + resvg-js"라고 적어뒀다가, 나중에 "workers-og로 변경"이라고 수정한 흔적. 삽질이라기보다는 더 맞는 도구를 찾아간 과정이었는데, 하루를 통째로 썼다.

한국어 폰트라는 벽

영문만 지원한다면 Inter 폰트 하나 넣으면 끝이다. 근데 한국어 블로그 플랫폼에서 그건 말이 안 된다.

satori 계열 도구는 시스템 폰트를 쓸 수 없다. 폰트 파일을 ArrayBuffer 형태로 직접 넘겨줘야 한다. 문제는 한국어 폰트의 크기다. 영문 폰트가 보통 수십 KB인 데 반해, Noto Sans KR Regular 하나가 수 MB에 달한다. 한글 음절 조합이 11,172개니 당연한 결과다. 매 요청마다 이 폰트를 네트워크에서 가져오면 응답 시간이 수 초까지 늘어난다.

해결책은 캐싱이었다. 폰트를 최초 한 번만 로드하고 이후 요청에서는 캐시된 데이터를 재사용한다. 생성된 이미지 자체도 캐싱해서, 동일한 글에 대한 두 번째 요청부터는 이미 만들어진 PNG를 바로 반환한다. 첫 요청만 조금 느리고 나머지는 즉시 응답 — 이 구조 덕분에 한국어 폰트의 무게를 체감 없이 처리할 수 있게 됐다.

display:flex 사건

이건 진짜 어이없었던 버그다.

satori에는 독특한 제약이 하나 있다. 모든 <div> 요소에 display: flex를 명시해야 한다. 브라우저라면 display: block이 기본값이지만, satori는 그렇지 않다. 이걸 빼먹으면 텍스트가 겹치고 레이아웃이 완전히 무너진다.

처음 OG 이미지를 생성하고 결과를 열어봤을 때, 제목과 블로그 이름이 전부 한 곳에 뭉쳐있었다. CSS가 잘못된 건가 싶어서 패딩을 바꿔보고, 폰트 사이즈를 조정해보고, flexDirection을 이리저리 돌려봤다. 한참을 헤맨 끝에 satori 공식 문서를 처음부터 다시 읽고 나서야 깨달았다.

// 이렇게 하면 레이아웃이 깨진다
<div style={{ padding: 40 }}>
  <div>제목</div>
  <div>블로그 이름</div>
</div>

// 모든 div에 display: 'flex'를 넣어야 한다
<div style={{ display: 'flex', padding: 40, flexDirection: 'column' }}>
  <div style={{ display: 'flex' }}>제목</div>
  <div style={{ display: 'flex' }}>블로그 이름</div>
</div>

수정 자체는 5분이면 끝나는 일이었다. 문제는 원인을 찾기까지의 시간이다. 에러 메시지가 없고 그냥 결과물이 이상하게 나올 뿐이라, 디버깅 방향을 잡기가 어려웠다. 커밋 메시지에 "add display:flex to all divs (satori requirement)"라고 적어둔 건 미래의 나를 위한 메모였다.

네 겹의 폴백

자동 생성만으로 모든 상황을 커버할 수는 없다. 사용자가 직접 만든 이미지를 쓰고 싶을 수도 있고, 글 본문에 이미 적절한 스크린샷이 들어있을 수도 있다. 그래서 OG 이미지를 결정하는 순서를 네 단계로 나눴다.

  1. 사용자가 직접 지정한 이미지

  2. 글 본문에서 추출한 첫 번째 이미지

  3. 블로그 커버 이미지

  4. 제목 기반으로 자동 생성한 이미지

위에서부터 순서대로 확인하고 처음 발견된 걸 사용한다. 대부분의 글은 4번까지 내려가서 자동 생성 이미지가 붙는데, 기술 블로그처럼 스크린샷이 많은 글에서는 2번에서 걸려서 본문 이미지가 OG로 쓰이기도 한다.

한 가지 재밌었던 시행착오가 있다. 처음에는 피드 카드에서도 자동 생성된 텍스트 기반 OG 이미지를 보여줬다. 근데 카드에 이미 제목과 태그가 텍스트로 표시되고 있으니까, 그 위에 제목과 태그가 적힌 이미지가 또 떠있는 꼴이 됐다. 완전한 중복. 결국 피드에서는 본문의 실제 이미지만 표시하고, 이미지가 없는 글은 깔끔하게 텍스트 카드로만 보여주는 방향으로 수정했다.

보이지 않는 곳의 완성도

OG 이미지는 블로그 안에서는 보이지 않는다. 카카오톡이나 슬랙에 링크를 붙여넣을 때, 트위터에 URL을 올릴 때 비로소 나타난다. 그래서 개발 우선순위에서 자꾸 밀리기 쉬운 기능이다. 근데 막상 없으면 "이 블로그 뭔가 허술한데?" 하는 인상을 준다.

Postlark에서는 글을 발행하면 OG 이미지가 알아서 붙는다. 한국어 제목도 깨지지 않고 정상적으로 렌더링된다. 그 뒤에 폰트 캐싱, 이미지 캐싱, 네 단계 폴백, satori의 기묘한 제약 같은 것들이 숨어있지만 — 사용자 입장에서는 "그냥 되는 것"이다. 플랫폼이란 결국 그런 거다. 복잡한 건 안쪽에서 처리하고, 바깥에서는 자연스럽게 돌아가는 것.