순수 Go로 SVG를 PNG로 렌더링하기, 그리고 oksvg가 포기하는 지점
oksvg와 rasterx를 사용하여 CGo와 헤드리스 브라우저 없이 서버 사이드에서 OG 이미지 생성하기. 순수 Go 파이프라인과 oksvg가 제대로 작동하지 않는 두 가지 경우.
이 블로그의 모든 게시물에는 소셜 공유 이미지가 필요합니다. 즉, 누군가 채팅이나 타임라인에 링크를 붙여넣을 때 나타나는 1200x630 PNG입니다. 커버 아트는 시사적인 소품을 든 작은 마스코트를 담은 인라인 SVG이며, 객체 스토리지에 저장되기 전에 서버에서 PNG로 변환되어야 합니다. 이를 위한 명백한 도구인 헤드리스 브라우저나 ImageMagick은 둘 다 컨테이너에 큰 네이티브 종속성을 끌고 들어옵니다. 이 게시물은 순수 Go와 oksvg 및 rasterx를 사용하여 이 작업을 수행하는 방법과, oksvg만으로는 더 이상 충분하지 않은 두 가지 특정 지점에 대해 다룹니다.
짧게 요약하자면, 순수 Go를 사용하면 16진수 색상을 입력하고 직접 자르기를 수행하는 한, 단순한 모양의 아트에 완벽한 결정적이고 종속성이 없는 래스터라이저를 얻을 수 있습니다. 필터, 전체 그라데이션 또는 실제 텍스트 레이아웃이 필요한 순간, 해답은 다시 브라우저가 됩니다.
도대체 왜 서버에서 SVG를 렌더링해야 하는가
소셜 미리보기 이미지는 브라우저에서 계산될 수 없습니다. Slack, Discord, 또는 검색 엔진이든, 이를 읽는 크롤러는 여러분의 JavaScript를 절대 실행하지 않기 때문입니다. 크롤러는 HTML을 가져오고, og:image 메타 태그를 읽은 다음, 거기에 있는 URL을 다운로드합니다. 따라서 PNG는 첫 번째 요청 전에 실제 파일로 존재해야 하며, 이는 서버가 이를 생성해야 함을 의미합니다.
여기서의 설계 제약 조건은 서버가 Cloud Run, 즉 하나의 작은 컨테이너에서 GPU나 디스플레이 없이 실행된다는 것이었습니다. 저는 그 이미지에 브라우저나 네이티브 이미징 라이브러리를 포함시키고 싶지 않았습니다. 헤드리스 Chrome 레이어만 해도 수백 메가바이트에 달하며 자체적인 시스템 라이브러리 세트가 필요합니다. ImageMagick은 다루는 모든 형식에 대해 위임 라이브러리를 가져옵니다. 둘 다 작동은 하지만, 컨테이너를 더 크게 만들고, 콜드 스타트를 더 느리게 하며, 빌드를 더 취약하게 만듭니다.
커버 아트 자체는 간단합니다. 평평한 모양, 몇 가지 채우기, 사진 콘텐츠 없음. 이런 경우가 바로 전체 브라우저가 과한 경우입니다. 아트가 몇 개의 경로와 원으로 이루어져 있다면, 순수 Go 래스터라이저는 외부 프로세스 없이 1밀리초 만에 이를 PNG로 변환할 수 있습니다.
하나의 의도적인 선택이 다른 모든 것을 결정했습니다. 바로 PNG에 텍스트를 넣지 않는 것입니다. 텍스트를 렌더링한다는 것은 폰트를 포함해야 한다는 것을 의미하며, 한국어, 일본어, 그리고 두 가지 중국어 스크립트를 모두 포함하는 폰트를 포함하는 것은 큰 폰트 페이로드와 실제 텍스트 셰이핑 엔진을 필요로 함을 의미합니다. 대신 이미지는 모양만 담고, 제목은 그 주변의 HTML에 맡겨집니다. 그 단 하나의 결정이 서버 사이드 렌더링의 가장 어려운 부분을 시작하기도 전에 제거했습니다.
순수 Go 스택: oksvg와 rasterx
두 패키지가 작업을 수행합니다. github.com/srwiley/oksvg는 SVG 문서를 그릴 수 있는 아이콘으로 파싱합니다. github.com/srwiley/rasterx는 스캔라인 채우기(scan-line fill)를 사용하여 벡터 경로를 픽셀로 변환하는 래스터라이저입니다. 둘 다 순수 Go로 작성되었습니다. CGo도, 링크된 C 라이브러리도 없으며, 컴파일된 바이너리 외에 컨테이너에 설치할 것도 없습니다.
바로 이 속성 때문에 이 패키지들을 사용합니다. 순수 Go 빌드는 Dockerfile이 최소한의 기반 위에 정적 바이너리가 될 수 있고, 시스템 라이브러리가 이동해도 빌드가 중단되지 않으며, 교차 컴파일이 그냥 작동한다는 것을 의미합니다. 또한 출력이 결정적이라는 의미이기도 합니다. 동일한 SVG 문자열은 폰트 힌팅이나 GPU 드라이버의 차이 없이 모든 머신에서 매번 동일한 PNG 바이트를 생성합니다. 결정적 출력은 캐싱을 사소하게 만들고 이미지를 내용 주소 지정 가능(content-addressable)하게 만듭니다. 즉, 바이트를 해시하고, 그 해시값 아래에 저장하면, 다시 생성하거나 중복 저장할 필요가 없습니다.
흐름은 네 단계로 이루어지며, 각 단계는 일반 함수입니다.
- 마스코트와 소품을 포함하여 SVG를 문자열로 빌드합니다.
oksvg로 파싱하여 아이콘으로 만듭니다.rasterx로 아이콘을image.RGBA로 래스터화합니다.- 표준 라이브러리를 사용하여 해당 이미지를 PNG로 인코딩합니다.
1단계와 4단계는 일반적인 Go 코드입니다. 2단계와 3단계는 oksvg와 rasterx가 사용되는 곳이며, 아래의 두 가지 한계점이 나타나는 부분이기도 합니다.
oksvg는 hsl()을 이해하지 못합니다
첫 번째 장벽은 색상입니다. 저는 HSL 색상을 사용하여 커버 아트를 만드는데, HSL을 사용하면 팔레트를 쉽게 만들 수 있기 때문입니다. 색조(hue)를 선택한 다음, 채도(saturation)는 고정한 채로 명도(lightness)를 조절하여 그림자와 하이라이트를 표현합니다. 그래서 SVG 문자열에는 hsl(210, 50%, 60%)와 같은 채우기(fill)가 있었습니다.
oksvg는 hsl()을 파싱하지 못합니다. 이름이 지정된 색상과 16진수(hex)는 처리하지만, hsl(...) 채우기는 아무런 오류 없이 적용에 실패하고, 도형은 채우기 없이 또는 기본값으로 렌더링됩니다. 오류가 없다는 점이 문제를 더 악화시킵니다. 파싱은 성공하고, 그리기도 실행되지만, 도형이 그냥 잘못 그려지는 것입니다.
해결책은 색상이 SVG 문자열에 도달하기 전에 HSL을 16진수(hex)로 변환하는 것입니다. HSL에서 RGB로의 변환은 짧고 잘 정의된 함수이므로, Go에서 계산하여 #rrggbb를 출력합니다.
package cover
import (
"fmt"
"math"
)
// hslToHex converts an HSL color to the #rrggbb string oksvg accepts.
// h is in [0, 360), s and l are in [0, 1].
func hslToHex(h, s, l float64) string {
c := (1 - math.Abs(2*l-1)) * s
hp := h / 60
x := c * (1 - math.Abs(math.Mod(hp, 2)-1))
var r, g, b float64
switch {
case hp < 1:
r, g, b = c, x, 0
case hp < 2:
r, g, b = x, c, 0
case hp < 3:
r, g, b = 0, c, x
case hp < 4:
r, g, b = 0, x, c
case hp < 5:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
m := l - c/2
ri := uint8(math.Round((r + m) * 255))
gi := uint8(math.Round((g + m) * 255))
bi := uint8(math.Round((b + m) * 255))
return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi)
}
이 헬퍼를 사용하면 팔레트는 Go 코드에서 추론하기 쉬운 HSL로 유지되고 SVG는 오직 16진수(hex)만 보게 됩니다. 이 함수는 각 채널을 반올림하므로 출력은 안정적이고 전체 파이프라인은 결정론적으로 유지됩니다. 두 호출자가 다르게 반올림하게 하면 바이트 단위의 재현성(byte-for-byte reproducibility)이 깨지므로 단일 변환 경로를 유지하세요.
일반적인 교훈은 oksvg가 SVG 색상 사양의 서브셋을 구현하므로, 명명된 색상과 16진수(hex)를 넘어서는 모든 것은 먼저 자신의 코드에서 16진수(hex)로 변환해야 한다고 가정하는 것이 안전하다는 것입니다. 여기에는 hsl(), hsla() 및 최신 색상 함수가 포함됩니다. 이것들을 미리 변환하면 래스터라이저(rasterizer)가 처리할 수 없는 색상을 보지 않게 됩니다.
preserveAspectRatio slice는 신뢰할 수 없으므로 직접 잘라내세요
두 번째 장벽은 종횡비입니다. 목표는 1200x630, 즉 종횡비 약 1.905입니다. 커버 아트는 마스코트를 위한 자연스러운 캔버스이므로 정사각형에 가까운 viewBox에서 제작됩니다. 따라서 소스와 출력의 모양이 같지 않으므로 무언가를 포기해야 합니다.
브라우저에서는 preserveAspectRatio="xMidYMid slice"를 설정하면 렌더러가 CSS의 background-size: cover처럼 아트워크를 확장하여 상자를 덮고 넘치는 부분을 잘라냅니다. oksvg는 preserveAspectRatio를 부분적으로 지원하지만, 소스와 대상의 비율이 많이 다를 때 slice 모드는 신뢰할 수 없습니다. 실제로는 아트워크가 늘어나 원이 타원이 되거나, 잘못된 위치에서 잘렸습니다. 동작이 너무 일관성이 없어서 더 이상 신뢰하지 않게 되었습니다.
신뢰할 수 있는 방법은 oksvg에 아무것도 맞추도록 요청하지 않는 것입니다. viewBox 종횡비로 렌더링하고, 대상을 완전히 덮도록 확대한 다음, 표준 라이브러리를 사용하여 결과를 정확한 대상 크기로 중앙에서 잘라냅니다. 잘라내기는 oksvg가 관여하지 않는 단순한 픽셀 복사이므로 잘못될 리가 없습니다.
먼저, CSS cover가 사용하는 것과 동일한 계산법으로 대상 상자를 덮는 렌더링 크기를 계산합니다.
// coverSize scales the source viewBox so it fully covers the target box,
// matching CSS background-size: cover. The result is >= target on both axes.
func coverSize(vbW, vbH, targetW, targetH float64) (int, int) {
scale := math.Max(targetW/vbW, targetH/vbH)
return int(math.Ceil(vbW * scale)), int(math.Ceil(vbH * scale))
}
해당 크기로 래스터화:
import (
"fmt"
"image"
"strings"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func rasterize(svg string, w, h int) (*image.RGBA, error) {
icon, err := oksvg.ReadIconStream(strings.NewReader(svg), oksvg.WarnErrorMode)
if err != nil {
return nil, fmt.Errorf("parse svg: %w", err)
}
icon.SetTarget(0, 0, float64(w), float64(h))
img := image.NewRGBA(image.Rect(0, 0, w, h))
scanner := rasterx.NewScannerGV(w, h, img, img.Bounds())
raster := rasterx.NewDasher(w, h, scanner)
icon.Draw(raster, 1.0)
return img, nil
}
그런 다음 정확한 타겟으로 center-crop합니다.
import (
"image"
"image/draw"
)
// centerCrop copies the middle target-sized rectangle out of a larger image.
func centerCrop(src *image.RGBA, targetW, targetH int) *image.RGBA {
b := src.Bounds()
x0 := b.Min.X + (b.Dx()-targetW)/2
y0 := b.Min.Y + (b.Dy()-targetH)/2
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
draw.Draw(dst, dst.Bounds(), src, image.Pt(x0, y0), draw.Src)
return dst
}
coverSize는 렌더링된 이미지가 양쪽 축 모두에서 대상보다 크거나 같도록 보장하므로, 자르기 오프셋은 절대 음수가 되지 않으며 복사본에는 항상 충분한 소스 픽셀이 있습니다. 아트는 균일하게 크기가 조절되므로 늘어나는 부분이 없으며, 자르기는 마스코트가 있는 중앙을 기준으로 이루어집니다. 이것은 slice가 해야 했던 역할을 사용자가 제어하는 코드에서 수행합니다.
엔드투엔드 전체 파이프라인
이를 종합하면, 하나의 함수가 SVG 문자열과 대상 크기를 입력받아 PNG 바이트를 반환합니다.
import (
"bytes"
"image/png"
)
// RenderPNG rasterizes an SVG at the viewBox aspect, crops to the target box,
// and encodes to PNG. The output is deterministic for a given input.
func RenderPNG(svg string, vbW, vbH, targetW, targetH int) ([]byte, error) {
rw, rh := coverSize(float64(vbW), float64(vbH), float64(targetW), float64(targetH))
full, err := rasterize(svg, rw, rh)
if err != nil {
return nil, err
}
cropped := centerCrop(full, targetW, targetH)
var buf bytes.Buffer
if err := png.Encode(&buf, cropped); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
외부 프로세스, 임시 파일, 네트워크 호출이 없습니다. 함수는 순수한 계산이므로 테스트하기 쉽습니다. 고정된 SVG를 입력하고, 바이트를 해시한 다음, 해시를 어설션하면 됩니다. 전체 경로가 결정적이므로 해당 테스트는 여러 머신과 Go 버전에서 계속 통과하며, 동일한 속성 덕분에 호출자는 콘텐츠 해시를 스토리지 키로 사용할 수 있습니다. 한 번 생성하여 og/{hash}.png 아래에 저장하면, 이후 동일한 커버 아트에 대한 모든 요청은 재성성 없이 동일한 객체로 확인됩니다.
비용 프로필은 이 작업에 원하는 바로 그 모습입니다. 수십 개의 경로로 이루어진 커버는 약 1밀리초 만에 인코딩되고, 두어 개의 RGBA 버퍼를 할당한 후 반환됩니다. 생성할 브라우저도, 회수할 프로세스도, 접근을 직렬화할 공유 네이티브 상태도 없습니다. 특별한 처리 없이 요청 경로 또는 수집 작업 내에서 원활하게 실행됩니다.
oksvg가 렌더링하는 것과 생략하는 것
솔직히 말해, oksvg는 SVG 사양의 일부만 구현하며 전체를 구현하지는 않습니다. 플랫한 모양의 아트에는 충분합니다. 더 풍부한 기능의 경우 조용히 기능을 생략합니다. 이 사용 사례에서 어떤 기능이 유지되고 어떤 기능이 유지되지 않았는지 대략적인 개요는 다음과 같습니다.
| SVG 기능 | oksvg 지원 | 참고 |
|---|---|---|
| Paths, rects, circles, polygons | 전체 | 커버 아트의 핵심으로, 깔끔하게 렌더링됩니다 |
| Hex 및 이름 있는 채우기 및 획 | 전체 | hsl()이 아닌 이것들을 사용하세요 |
| hsl() 및 최신 색상 함수 | 없음 | Go에서 먼저 hex로 변환하세요 |
| 선형 그라데이션 | 부분적 | 단순한 경우는 작동하지만, 복잡한 중단점은 어긋날 수 있습니다 |
| preserveAspectRatio slice | 신뢰할 수 없음 | 네이티브 비율로 렌더링하고 직접 자르세요 |
| 필터 (blur, drop shadow) | 없음 | 구현되지 않았으며, 조용히 무시됩니다 |
| 실제 글꼴 레이아웃이 있는 텍스트 | 피하세요 | 글꼴과 셰이핑이 필요하며, 설계상 제외되었습니다 |
실패 전반에 걸친 패턴은 동일합니다. oksvg가 무언가를 지원하지 않을 때, 보통 오류를 발생시키지 않습니다. 파싱하고 그리지만, 누락된 기능은 결과물에서 그냥 보이지 않을 뿐입니다. 그 때문에 빠른 시각적 확인으로는 실패를 놓치기 쉬우며, 골든 이미지 테스트로 잡아내는 것이 중요합니다. 아트를 렌더링하고, 모든 픽셀을 한 번 살펴본 후, 바이트 해시로 출력을 고정하여 나중에 지원되지 않는 기능을 건드리는 수정 사항이 diff로 나타나도록 하세요.
여기의 커버 아트의 경우, 제약 조건에 맞춰 아트가 디자인되었기 때문에 제약 조건을 감수하기 쉬웠습니다. 플랫 채우기, hex 색상, 필터 없음, 내장된 텍스트 없음. 원본 아트가 이러한 제한을 염두에 두지 않고 그려졌다면, oksvg가 충실하게 렌더링하기 전에 이를 단순화해야 할 것입니다.
실제로 브라우저가 필요한 경우
SVG가 단순하고 사용자가 직접 제어할 수 있는 경우 순수 Go가 올바른 도구입니다. 명확한 한계선에 도달하면 더 이상 올바른 도구가 아니며, 래스터라이저의 한계를 넘어 씨름하지 않도록 그 한계선을 명시하는 것이 좋습니다.
아트가 SVG 필터를 사용하거나, oksvg가 잘못 렌더링하는 그레이디언트나 마스크에 의존하거나, 여러 스크립트에 걸쳐 글꼴로 레이아웃해야 하는 실제 텍스트를 포함하는 경우에는 헤드리스 브라우저를 사용하세요. 브라우저는 완전한 텍스트 셰이핑 스택을 갖춘 완전한 SVG 및 CSS 엔진이며, oksvg가 절반을 누락할 복잡한 문서도 올바르게 렌더링합니다. 그 대가는 이 전체 접근 방식이 피하고자 했던 것, 즉 큰 컨테이너, 느린 콜드 스타트, 무거운 빌드입니다. 그 대가는 결과물이 그것을 요구할 때는 지불할 가치가 있지만, 그렇지 않을 때는 낭비입니다.
여기서 나온 규칙은 다음과 같습니다: 렌더러를 아트에 맞춰라. 표지가 텍스트 없는 평면 도형과 hex 색상으로 이루어져 있다면, 순수 Go 래스터라이저는 Go가 실행되는 모든 곳에서 실행되는 작고 결정적이며 의존성이 없는 경로를 제공합니다. 아트에 실제 SVG 사양이 필요하다면, 실제 SVG 사양을 구현하는 엔진을 사용하고 그에 따르는 컨테이너를 받아들이세요. 실수는 복잡한 아트를 oksvg를 통해 처리하려고 하거나, 순수 Go로 1밀리초 만에 끝낼 수 있는 작업에 브라우저를 끌어들이는 것입니다.
관련 글
Redis 키스페이스 알림과 리스를 이용한 GPU 워커 풀 로드 밸런싱
GPU는 비싸고 한 번에 하나의 무거운 작업만 실행하므로, 라운드 로빈 라우팅은 바쁜 워커 뒤에서 지연됩니다. 여기에 Redis 리스와 키스페이스 알림으로 구축된 바쁨을 인지하는 스케줄러가 있습니다.
MongoDB 변경 스트림을 이용한 Cron 스케줄 핫 스와핑
하드코딩된 cron은 스케줄이 변경될 때마다 재배포를 의미합니다. 데이터베이스에 스케줄을 저장하고 변경 스트림으로 데몬이 편집에 반응하도록 합니다. 여기에 그 설계와 실패 처리 방법이 있습니다.
단일 실행기 작업을 위한 CAS 및 하트비트를 사용하는 임대 잠금
일반적인 잠금은 홀더가 죽으면 영원히 교착 상태에 빠집니다. 리스는 만료됩니다. 여기서는 compare-and-swap 획득과 하트비트를 사용하여 리스를 구축하는 방법과, 설계를 결정하는 실패 모드에 대해 설명합니다.
멱등성 업서트를 위한 결정론적 ULID
데이터베이스가 자동 쓰기 재시도를 금지하는 경우, 모든 쓰기는 그 자체로 멱등성을 가져야 합니다. 콘텐츠에서 파생된 ID를 사용하면 이를 비용 없이 달성할 수 있습니다. 여기에 그 유도 과정과 안전성을 보장하는 한 가지 불변식이 있습니다.
Firestore의 MongoDB 호환성에서 `retryWrites=false` 함정
Firestore는 MongoDB 와이어 프로토콜을 지원하지만, 재시도 가능 쓰기는 거부합니다. 드라이버에서 이 기능이 기본적으로 활성화되어 있으면, 쓰기가 무작위로 실패하는 것처럼 보입니다. 그 이유와 해결 방법은 다음과 같습니다.
Rust에서 Go로 백엔드 이전, 그리고 빌드 시간이 결정적이었던 이유
Rust는 우리에게 거의 필요하지 않은 안전성을 주었고, 매일 싸워야 하는 컴파일 루프를 안겨주었습니다. 한 서비스를 Go로 전환하게 만든, 수치를 포함한 솔직한 트레이드오프가 여기에 있습니다.