Satori와 Resvg를 사용하여 실시간으로 Open Graph 이미지 생성하기
요약
Puppeteer와 같은 무거운 헤드리스 브라우저 대신 Satori와 Resvg를 사용하여 실시간으로 Open Graph 이미지를 생성하는 방법을 소개합니다. 이를 통해 Edge Function 환경에서 번들 크기를 획기적으로 줄이고 렌더링 속도를 대폭 개선할 수 있습니다.
핵심 포인트
- Satori와 Resvg 조합은 브라우저 없이 JSX를 SVG로, SVG를 PNG로 변환함
- Puppeteer 대비 번들 크기를 280MB에서 8MB 미만으로 축소 가능
- 렌더링 지연 시간을 2초 미만에서 100ms 내외로 대폭 단축
- Edge Function 환경에 최적화된 가볍고 빠른 이미지 생성 파이프라인 구축
-
Satori는 브라우저 없이 JSX를 SVG로 변환하며, 40ms 내에 실행됩니다.
-
Resvg는 Puppeteer 없이 순수 Rust로 SVG를 PNG로 렌더링합니다.
-
페이지별 카드를 캐싱하는 전체 Edge Function 코드
-
각 블로그 포스트는 자동으로 고유한 공유 이미지를 갖게 됩니다.
제가 소셜 미디어에 공유했던 모든 링크는 예전에 모두 동일한 일반적인 배너를 보여주었습니다. 이제 raxxo.shop의 모든 페이지는 제목, 작성자, 깔끔한 레이아웃을 갖춘 자신만의 Open Graph 카드를 실시간으로 생성합니다. 헤드리스 브라우저(Headless browser), 스크린샷 서비스, 빌드 단계가 필요 없습니다. 전체 파이프라인은 다음과 같습니다.
헤드리스 브라우저를 건너뛰어야 하는 이유
공유 카드를 만드는 표준적인 방법은 Puppeteer입니다. 헤드리스 Chromium을 실행하고, HTML 페이지를 로드한 다음, 스크린샷을 찍어 PNG로 저장합니다. 작동은 합니다. 하지만 느리고 무거우며, Edge Function에서 실행하기에는 매우 까다롭습니다.
Chromium은 압축을 풀었을 때 약 280MB에 달합니다. 대부분의 Edge 런타임은 번들 크기를 50MB로 제한하거나 브라우저 실행 자체를 거부합니다. 브라우저가 단 하나의 픽셀을 그리기 전에 부팅되어야 하기 때문에 콜드 스타트(Cold start)가 800ms에서 2초까지 소요됩니다. 저는 한동안 Puppeteer 카드 서비스를 운영했는데, p95 지연 시간(latency)이 약 1.4초 정도였습니다. 소셜 프리뷰의 백그라운드에서 로드되는 이미지로서는 괜찮을 수 있지만, 포스트가 60개가 되고 크롤러가 한꺼번에 접속하기 시작하면 문제가 됩니다.
대안은 Satori와 Resvg의 조합입니다. Satori는 JSX(또는 일반 객체 트리)를 받아 SVG 문자열을 생성하는 라이브러리입니다. Flexbox 엔진을 사용하여 JavaScript에서 레이아웃을 처리하므로 브라우저나 DOM이 필요 없습니다. 그다음 Resvg가 해당 SVG를 가져와 WebAssembly로 컴파일된 Rust를 사용하여 PNG로 래스터화(Rasterize)합니다. 두 가지 모두 일반적인 서버리스(Serverless) 또는 Edge Function 내부에서 실행됩니다.
저를 설득한 수치는 다음과 같습니다. Satori는 일반적인 1200x630 카드를 약 40ms 내에 렌더링합니다. Resvg는 폰트 수에 따라 SVG를 PNG로 변환하는 데 추가로 30~60ms가 소요됩니다. 전체 콜드 패스(Cold path)는 200ms 미만이며, 웜 패스(Warm path)는 90ms에 가깝습니다. 전체 번들 크기는 280MB 대신 8MB 미만입니다.
트레이드오프(tradeoff)가 존재합니다. Satori는 CSS의 일부 서브셋(subset)만 지원합니다. Flexbox는 작동하지만, Grid는 작동하지 않습니다. CSS 애니메이션도 없고, 몇 가지를 제외한 필터(filter)도 사용할 수 없으며, 외부 스타일시트(external stylesheets)도 지원하지 않습니다. 여러분은 flex 컨테이너, 고정 위치(fixed positions), 색상, 그리고 텍스트를 사용하여 카드를 만듭니다. 공유용 카드(share card)를 만드는 데 필요한 어휘(vocabulary)가 정확히 이 정도뿐이므로, 이러한 제한이 문제가 되는 경우는 드뭅니다.
무거운 서버 대신 엣지(edge)에서 실제 작업을 수행하는 더 넓은 패턴을 원한다면, 제가 나머지 스택을 구조화하는 방식에서도 동일한 사고방식이 나타납니다. 배경 설명: Claude Blueprint에서는 제가 어떻게 서비스를 작고 조합 가능한(composable) 상태로 유지하는지 다룹니다.
최소한의 Satori 설정
두 개의 패키지, satori와 @resvg/resvg-js를 설치하세요. 또한 최소 하나 이상의 폰트가 버퍼(buffer)로 필요합니다. Satori는 브라우저가 시스템 폰트로 폴백(fallback)하는 것처럼 서체(typeface)를 추측할 수 없기 때문입니다.
다음은 핵심 함수입니다. JSX 변환(JSX transform) 없이 실행될 수 있도록 실제 JSX 대신 객체 형태를 사용하고 있습니다.
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
...
이것이 전부입니다. Satori는 SVG 문자열을 반환합니다. Resvg가 이를 받아 렌더링하며, asPng()가 image/png로 반환할 수 있는 바이트 버퍼(buffer of bytes)를 전달합니다.
제가 시간을 허비했던 몇 가지 세부 사항입니다. 자식 요소가 두 개 이상인 모든 div는 display: flex를 명시적으로 설정해야 하며, 그렇지 않으면 Satori에서 오류가 발생합니다. Satori는 블록 레이아웃(block layout)을 가정하지 않습니다. 폰트는 .woff2가 아닌 .ttf 또는 .otf여야 합니다. SVG 렌더러가 .woff2를 압축 해제할 수 없기 때문입니다. 만약 제목이 넘칠 경우(overflow), maxWidth를 설정하면 Satori가 자동으로 줄바꿈을 해줍니다. 단, display: flex와 flexWrap 또는 고정 너비가 설정되어 있어야만 가능합니다.
1200x630 크기의 경우, 마법의 숫자는 64px 패딩(padding)과 72px 헤드라인(headline)입니다. 플랫폼이 미리보기를 썸네일 크기로 줄일 때, 이 정도 크기가 깔끔하게 읽힙니다. 텍스트가 더 작으면 썸네일 크기에서 뭉개져 버립니다.
엣지 함수(Edge Function)에서 서비스하기
렌더링 함수는 작업의 절반입니다. 나머지 절반은 URL에 연결하여 ``이 이를 가리킬 수 있도록 만드는 것입니다.
저는 /og?title=...&tag=...와 같은 경로(route)를 노출합니다. 이 함수는 쿼리 파라미터(query params)를 읽고, renderCard를 호출한 뒤, 캐시 헤더(cache headers)와 함께 PNG를 반환합니다. 캐시 헤더는 렌더링 속도보다 더 중요한데, 한 번 카드가 생성되면 크롤러(crawler)가 다시 새로운 렌더링을 트리거하지 않아야 하기 때문입니다.
export default async function handler(req) {
const url = new URL(req.url)
const title = url.searchParams.get('title') ?? 'Untitled'
const tag = url.searchParams.get('tag') ?? 'Lab'
const png = await renderCard({ title, tag })
return new Response(png, {
headers: {
'content-type': 'image/png',
'cache-control': 'public, immutable, max-age=31536000',
},
})
}
immutable, max-age=31536000 라인은 CDN이 결과를 1년 동안 유지하도록 지시합니다. 제목과 태그가 URL에 포함되어 있기 때문에, 게시물 제목이 변경되면 자동으로 새로운 URL과 새로운 캐시 키(cache key)가 생성됩니다. 수동으로 무효화(invalidation)할 필요가 없습니다.
한 가지 함정이 있습니다: 쿼리 스트링(query string)에 가공되지 않은 사용자 텍스트를 전달하면 특수 문자로 인해 오류가 발생합니다. 저는 메타 태그(meta tag)를 만들 때 제목을 encodeURIComponent로 인코딩하며, 함수는 URLSearchParams를 통해 이를 무료로 디코딩합니다. 또한 긴 제목은 레이아웃이 깨질 수 있으므로 약 90자 정도의 길이 제한이 필요합니다. 저는 이를 전달하기 전에 말줄임표(...)를 사용하여 자릅니다(truncate).
폰트는 핸들러(handler) 외부인 모듈 스코프(module scope)에서 한 번만 로드하여, 동일한 인스턴스 내의 호출 사이에서 웜 상태(warm)를 유지하도록 합니다. 매 요청마다 350KB의 폰트 파일을 읽는 것은 불필요한 15ms를 추가했습니다. 이를 밖으로 끌어올림(hoisting)으로써 수천 번의 호출에 걸쳐 웜 지연 시간(warm latency)을 그만큼 줄일 수 있었습니다.
소셜 예약 게시를 위해, 생성된 카드 URL을 Buffer에 직접 전달하여 각 예약된 게시물이 자체 미리보기를 가져오도록 합니다. 이미지를 먼저 생성하고 플랫폼이 한 번 크롤링하게 하면, 대기열(queue)은 빠르게 유지되고 아무것도 두 번 렌더링되지 않습니다. 핵심은 무거운 작업이 단일 오리진 서버(origin server)에서 일어나는 것이 아니라, 크롤러가 사이트맵(sitemap) 전체로 퍼져나갈 때 대기열이 되는 것을 방지하며 사용자와 가까운 엣지(edge)에서 일어난다는 점입니다.
템플릿, 폰트, 그리고 예외 상황들 (Templates, Fonts, and the Edge Cases)
단일 레이아웃은 게시물이 수십 개 정도 쌓이면 지루해집니다. 저는 세 가지 템플릿 함수를 유지하고 있습니다: 튜토리얼용 하나, 사례 연구 (case studies)용 하나, 그리고 기본형 하나입니다. 라우트(route)는 template 파라미터에 따라 하나를 선택합니다. 각 템플릿은 그저 서로 다른 객체 트리(object tree)일 뿐이며, 아마도 다른 강조 색상이나 작은 아이콘 정도의 차이만 있을 것입니다.
이모지(Emoji)는 가장 먼저 문제를 일으키는 요소입니다. Satori는 이모지 폰트를 함께 제공하지 않기 때문에, 🚀와 같은 이모지는 빈 박스로 렌더링됩니다. 해결 방법은 이모지 CDN에서 적절한 글리프(glyph)를 필요할 때마다 가져오는 loadAdditionalAsset 콜백을 전달하는 것입니다. 저는 이러한 가져오기 작업을 코드 포인트(code point)를 키로 하는 Map에 캐싱하여, 워밍업된 인스턴스(warm instance) 내에서 동일한 이모지가 두 번 다운로드되지 않도록 합니다. 12개의 흔한 이모지를 미리 로드해 두었더니, 추가 지연 시간(latency)이 거의 0에 가깝게 줄어들었습니다.
다양한 폰트 두께(font weights)는 예상보다 더 중요했습니다. 하나의 두께만 가진 카드는 평면적으로 보입니다. 저는 Inter 폰트를 400, 600, 700 두께로 로드하고 요소별로 두께를 할당합니다. 번들 크기는 총 약 700KB 정도 증가하지만, 이는 브라우저에 비하면 아무것도 아닙니다. 일반 태그 라벨과 굵은 헤드라인을 혼합하면, 썸네일 크기에서도 읽히는 실제적인 시각적 계층 구조(visual hierarchy)를 카드에 부여할 수 있습니다.
카드 내부의 이미지가 가장 까다로운 부분입니다. Satori는 src가 포함된 <img> 태그를 허용하지만, 이 src는 base64 데이터 URI이거나 완전히 해석 가능한 URL이어야 하며, Resvg가 래스터화(rasterization) 과정 중에 이를 가져와 디코딩해야 합니다. 원격 PNG 로고를 사용하는 경우 40ms에서 120ms의 시간이 추가됩니다. 저는 로고를 템플릿에 구워진(baked) base64 문자열로 인라인(inline) 처리하여, 렌더링 시점에 네트워크 홉(network hop)이 발생하지 않도록 했습니다.
색상과 대비(contrast)는 테스트 과정을 거칠 가치가 있습니다. 저는 모든 템플릿을 한 번씩 렌더링하고 휴대폰 크기의 미리보기로 확인합니다. 왜냐하면 1200px 너비에서 선명해 보이는 것이 300px에서는 사라질 수 있기 때문입니다. #888 정도의 밝은 회색 부제목은 플랫폼에 의해 압축될 때 어두운 배경에서 사라져 버립니다.
이 템플릿 파일들을 어떻게 작고 재사용 가능하게 유지하는지에 대한 더 심도 있는 버전은 Claude Blueprint에서 동일한 모듈형 접근 방식(modular approach)을 다루었습니다. 원칙은 동일합니다: 작고 타입이 지정된 함수(typed functions), 각각 하나의 작업만 수행, 영리한 상속(inheritance) 배제. 카드 템플릿은 30줄에 불과합니다. 네 번째 스타일이 필요할 때, 저는 하나를 복사하고 컬러 토큰(color tokens)을 변경하여 당일에 바로 배포합니다.
요점 (Bottom Line)
Satori와 Resvg의 조합은 280MB 크기의 브라우저를 8MB 크기의 파이프라인(pipeline)으로 대체했습니다. 이 파이프라인은 공유 카드(share card)를 콜드 스타트(cold start) 시 200ms 미만, 웜 스타트(warm start) 시 90ms 미만으로 렌더링합니다. JSX가 입력되면 SVG가 출력되고, PNG 바이트가 반환됩니다. 또한 1년의 캐시 헤더(cache header) 덕분에 CDN이 제어권을 갖기 전까지 각 고유한 제목은 정확히 한 번만 렌더링됩니다. 스크린샷 서비스도, Chromium도, 빌드 단계(build step)도 필요 없습니다.
저에게 가장 오래 걸렸던 설정은 렌더링 코드가 아니었습니다. 바로 폰트 처리(font handling), 이모지 콜백(emoji callback), 그리고 실제 지연 시간(latency)이 발생하는 지점인 캐시 헤더였습니다. 이 세 가지만 제대로 설정하면 나머지는 그저 flexbox처럼 보이도록 객체 트리(object trees)를 설계하는 것뿐입니다.
콘텐츠 사이트나 페이지가 많은 쇼핑몰을 운영 중이라면, 이것은 제가 아는 한 가장 가치 있는 오후 프로젝트가 될 것입니다. 당신이 공유하는 모든 링크가 일반적인 형태가 아닌 의도된 디자인처럼 보이기 시작할 것입니다. 템플릿 하나로 시작하여, 경로(route)를 연결하고, 메타 태그(meta tag)를 그곳으로 지정한 뒤, 진행하면서 스타일을 추가해 나가세요. 위의 코드가 전체 골격입니다. 이를 복사하고 폰트를 바꾸기만 하면, 오늘 밤 안에 고유한 카드들을 가질 수 있습니다.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 부담 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기