gpdf가 다른 Go PDF 라이브러리보다 10–30배 빠른 이유
단일 페이지 13 µs, 100페이지 보고서 683 µs. 튜닝이 아니라 세 가지 아키텍처 결정이 쌓인 결과. 실제 코드 경로를 짚어본다.
TL;DR
gpdf는 단일 페이지를 13 µs, 4×10 인보이스 표를 108 µs, 100페이지 분할 보고서를 683 µs에 생성한다. 그다음으로 빠른 유지보수 중인 Go PDF 라이브러리 jung-kurt/gofpdf는 같은 100페이지를 11.7 ms에 처리 — 약 17배 느리다. 튜닝 차이가 아니다. 세 가지 설계 결정이 쌓인 결과다.
- 단일 패스 레이아웃. Builder API와 PDF 콘텐츠 스트림 사이에 중간 AST가 없다.
- 핫패스의 구체 타입. 레이아웃 루프 안에 리플렉션,
interface{}, 가상 디스패치 없음. - cmap을 한 번만 해석하는 TrueType 서브세터. 글리프마다도, 페이지마다도 아닌 단 한 번.
각각 하나씩이 2–3배. 셋을 합하면 한 자릿수 차이가 된다.
이 글은 그 숫자를 만든 코드 경로를 그대로 따라간다. 벤치마크 소스는 _benchmark/benchmark_test.go에 공개되어 있다. 클론해서 자신의 머신에서 돌려보고, 숫자가 맞지 않으면 이슈를 열어 주면 된다.
선공개 바이어스: 우리는 gpdf를 만드는 팀이다. "우리 게 더 빠르다"의 정직한 번역은 "우리는 다른 트레이드오프를 선택했다"이고, 흥미로운 질문은 그 속도를 위해 무엇을 포기했나다. 후반부에서 그 이야기를 한다.
여기서 "빠르다"는 무엇인가
아키텍처 전에, 설명하려는 스코어보드를 먼저 (Apple M1, Go 1.25, CGO 비활성, -benchmem 활성):
| 워크로드 | gpdf | gofpdf | go-pdf/fpdf | signintech/gopdf | Maroto v2 |
|---|---|---|---|---|---|
| 단일 페이지 Hello World | 13 µs | 132 µs | 135 µs | 423 µs | 237 µs |
| 4×10 인보이스 표 | 108 µs | 241 µs | 243 µs | 835 µs | 8,600 µs |
| 100페이지 분할 보고서 | 683 µs | 11,700 µs | 11,900 µs | 8,600 µs | 19,800 µs |
| 복잡한 CJK 인보이스 | 133 µs | 254 µs | n/a | 997 µs | 10,400 µs |
설명을 시작하기 전에 보이는 두 가지 모양이 있다. 페이지 수가 늘수록 격차가 벌어진다 (Hello World 10배, 100페이지 17배). 복잡도가 올라갈수록 격차가 벌어진다 (표 단독으로 108 µs인데 Maroto의 gofpdf 백엔드로는 8.6 ms).
두 모양 모두 원인은 같다. gpdf의 레이아웃 루프는 공통 경로에서 할당하지 않기 때문에 요소당 비용이 거의 평탄하다. 왜 그런지 아래에서 설명한다.
아무도 읽고 싶어 하지 않는 면책 조항: 대부분의 PDF 워크로드에서 절대 속도는 생각만큼 중요하지 않다. 가장 큰 문서가 한 페이지짜리 영수증이라면 이 표의 유지보수 중인 어떤 라이브러리든 요청 경로에서 생성 가능하다. 격차가 의미를 갖는 임계점은 "100개 인보이스를 큐로 미루지 않고 동기 생성 가능한가" 근처부터다.
결정 1: 중간 AST를 만들지 않는다
일반적인 PDF Builder 라이브러리는 이렇게 동작한다:
builder API → 문서 트리 (AST) → 레이아웃 패스 → 직렬화기 → 바이트
가운데의 문서 트리 단계가 문제다. 매 .Text()마다 노드를 할당하고, 매 .Row()마다 컨테이너를 할당한다. 레이아웃 패스는 트리를 돌아 위치를 계산하고, 직렬화기는 다시 트리를 돌아 바이트를 낸다. 세 번의 패스, 세 번의 할당, 동일 데이터에 대한 세 번의 CPU 캐시 왕복.
gpdf에는 그 2단계가 없다. Builder는 레이아웃 컨텍스트에 직접 쓰고, 레이아웃 컨텍스트는 콘텐츠 스트림에 직접 쓴다. 한 번의 패스.
텍스트 요소의 실제 코드 경로 (template/col_builder.go에서 길이 조정):
func (c *ColBuilder) Text(s string, opts ...TextOption) {
opt := c.resolveOptions(opts)
box := c.currentBox()
w := c.measureText(s, opt)
h := opt.FontSize.Pt() * opt.LineHeight
c.writer.BeginText()
c.writer.SetFont(opt.Font, opt.FontSize)
c.writer.MoveTo(box.X, box.Y-opt.FontSize.Pt())
c.writer.ShowString(s)
c.writer.EndText()
c.advance(w, h)
}
노드가 트리에 쌓이지 않는다. 위치가 지연되지 않는다. writer는 io.Writer(보통 bytes.Buffer)를 가진 *pdf.Writer이고, BeginText / MoveTo / ShowString은 BT / Td / Tj / ET PDF 연산자를 그 버퍼에 즉시 쓴다.
gofpdf가 같은 논리 연산을 어떻게 하는지 비교. gofpdf는 page 객체에 연산의 슬라이스를 가진다. 각 SetXY + Cell 호출이 그 슬라이스에 append. 마지막에 Output (또는 OutputFileAndClose)이 슬라이스를 돌며 바이트를 낸다. 셀당 할당 두 번 — 연산 구조체 하나, 문자열 복사 하나 — 과 데이터에 대한 추가 패스 하나.
100페이지 보고서에 페이지당 약 40줄이면, gpdf가 하지 않는 추가 할당이 4,000개다.
단일 패스가 아픈 곳
당연한 질문: 바이트 출력을 시작하기 전에 최종 페이지 레이아웃을 알아야 하는 건 어떻게 하나. 페이지 번호 넣은 헤더. 페이지를 넘나드는 표. 본문 마지막 줄 아래에 고정된 푸터.
두 가지 답. 첫째, 버퍼링의 단위는 문서가 아니라 페이지다. 페이지는 수십 KB 단위의 경계 단위다. 다음 AddPage()가 실행되면 현재 페이지 콘텐츠 스트림이 확정되고(Length, Filter, 오프셋) xref 엔트리가 쓰이고, 페이지 버퍼는 리셋된다. 메모리 최고 수위는 O(페이지 하나) 크기로 유지된다.
둘째, 진짜 전역 요소("Page 3 of 27")에 대해서는 그 범위만 fix-up 패스로 지연한다. 나머지 내용은 이미 스트림에 있다. fix-up은 짧은 deferred-reference 마커 리스트를 돌며 패치한다. 코드베이스에서 AST 비용에 근접한 비용을 내는 유일한 지점이고, 실제로 필요한 부분에만 낸다.
포기한 것: 노드 트리에 대한 임의의 후처리가 불가능하다. 노드 트리가 없기 때문이다. "bold: true인 Text 노드 전부 재정렬" 같은 플러그인은 쓸 수 없다. 그런 모양의 API가 필요하면 Maroto v2를 쓰면 된다.
gpdf가 타깃으로 하는 용도에는 이 트레이드오프가 옳다고 본다. PDF 대부분은 왼쪽에서 오른쪽으로, 위에서 아래로, 구성 시점에 알려진 레이아웃으로 생성된다. 소수 사례를 위해 AST를 유지하는 비용을 다수가 모든 페이지에서 지불해 왔다. 그 비율을 뒤집었다.
결정 2: 핫패스에 리플렉션과 interface를 두지 않는다
이야기로는 덜 흥미롭지만 프로파일로 보면 남은 속도 차의 절반이 여기서 나온다.
gofpdf의 CellFormat 시그니처:
func (f *Fpdf) CellFormat(w, h float64, txtStr, borderStr string,
ln int, alignStr string, fill bool, link int, linkStr string) { ... }
문제없다. Maroto의 컴포넌트 트리를 보자. Row는 []Component를 가진다. Component는 interface다. 레이아웃 연산마다 가상 디스패치: component.Render(ctx). Text와 Spacer가 든 하나의 Col이면 세 번의 디스패치. 100페이지 × 페이지당 30줄 × 3개 컴포넌트면 9,000번.
Go interface 디스패치는 회당 2–3 ns 정도. 단독으로 죄는 아니다. 하지만 interface는 컴파일러가 박싱된 값을 힙에 두도록 강제한다 — Go 컴파일러가 항상 해 주지 않는 devirtualization 없이는 interface 너머로 스택 할당이 안 된다. 비용은 디스패치 자체가 아니라 그걸 먹이는 할당이다.
gpdf의 레이아웃 엔진은 구체 구조체를 쓴다:
type RowBuilder struct {
doc *Document
parent *pageState
spans [12]int
cols [12]ColBuilder // 값 배열, 포인터도 interface도 아님
n uint8
}
type ColBuilder struct {
row *RowBuilder
span int
cursor document.Point
writer *pdf.Writer
}
cols는 그리드 최대 열 수(12)에 맞춘 값 배열. 힙 할당 없음. 행이 컬럼을 순회할 때 interface 디스패치 없음. Builder가 writer의 포인터를 가지고, writer는 Builder 트리의 존재를 모른다.
콜백 패턴 (r.Col(4, func(c *ColBuilder) { ... }))은 우연이 아니다. 프로토타입한 다른 모양 — 체이닝 가능한 struct 반환 API, Component interface의 박싱 트리 — 전부 느렸다. 이 클로저가 제로 할당인 이유는 ColBuilder가 호출자가 포인터 매개변수로 가진 값이고, 클로저 자체가 대부분 escape analysis로 스택에 올라가기 때문이다.
효과를 확인한 방법
gpdf에서 go test -run=XXX -bench=BenchmarkSinglePage -memprofile=mem.out:
BenchmarkSinglePage-8 91270 13120 ns/op 8321 B/op 52 allocs/op
전체 PDF 페이지 하나에 52회 할당. 거의 전부가 초기 페이지 버퍼, 폰트 메트릭스 조회(폰트당 한 번, 글리프당이 아님), 마지막 bytes.Buffer 확장. 레이아웃 루프는 제로 할당 — 프로파일을 보면 보인다.
같은 페이지의 gofpdf:
BenchmarkGofpdfSinglePage-8 7500 132400 ns/op 71200 B/op 430 allocs/op
430회 할당. 대부분 연산 슬라이스와 그걸 채우는 문자열 복사. 할당 약 8배 차이가 GC를 거쳐 실행 시간 약 10배 차이로 이어지는 건 자연스러운 귀결.
포기한 것
핫패스 에르고노믹스 제로는 곧 확장 지점이 적다는 뜻이다. gpdf 레이아웃에 플러그인되는 커스텀 요소 타입 — Maroto에서 Component를 구현하는 것과 같은 일 — 은 쓸 수 없다. 만족시킬 interface가 없다. 대신 제공하는 것은 template.WithWriterSetup()이다. PDF writer에 대한 훅으로 커스텀 어노테이션, PDF/A 메타데이터, 암호화 등을 주입할 수 있다. 레이아웃 레벨 확장은 사용자가 호출하는 것과 동일한 Builder 메서드를 호출하는 헬퍼 함수로 작성한다.
확장 지점이 적은 것은 진짜 비용이다. 현재 판단에서는 균형이 맞는다. 프로젝트 방향이 바뀌어 그 판단이 성립하지 않으면 재검토한다.
결정 3: 재주행하지 않는 TrueType 서브세터
CJK 벤치마크(gpdf 133 µs 대 gofpdf 254 µs)의 격차 대부분이 여기서 나온다.
TrueType 서브세팅이 하는 일 요약. PDF에 일본어 폰트를 임베드할 때 20,000+ 글리프 전부를 넣고 싶지 않다 — 100 KB 문서에 15 MB 폰트 데이터다. 문서가 실제로 쓰는 글리프만 PDF 리더가 디코드할 수 있는 유효한 서브셋 TTF로 패키징하고 싶다.
절차:
- 완전한 TTF 테이블 파싱:
cmap(문자→글리프 매핑),glyf(아웃라인),loca(glyf 오프셋),hmtx(수평 메트릭) 등. - 문서의 각 문자에 대해 cmap으로 글리프 ID 조회.
- 합성 글리프가 참조하는 하위 글리프를 추이적으로 수집.
- 그 글리프만 번호를 새로 매긴 TTF 출력.
핫패스는 2단계 — cmap 조회. gofpdf 구현은 글리프 조회마다 cmap 테이블을 맨 위부터 걷는다. Latin만 쓰는 페이지는 문제없다 — cmap이 작고 캐시가 잘 돈다. 150개 고유 글리프가 있는 CJK 페이지는 테이블 전체 주행을 150번 한다.
cmap format 12 (대부분의 현대 CJK 폰트가 사용)는 (start, end, startGlyphID) 트리플을 정렬한 배열. 1회 주행은 범위 수에 대해 O(n), NotoSansJP의 경우 200–500 범위. 150회 조회 × 400 범위당 비교 = 필요한 것보다 훨씬 많은 일.
gpdf는 폰트 최초 로드 시 cmap 전체를 map[rune]uint16으로 풀어낸다. 이후 모든 조회는 O(1). NotoSansJP의 경우 일회성 비용 약 150 µs, 이후 문자당 10 ns.
// pdf/font/ttf.go 단순화
type Font struct {
runeToGID map[rune]uint16 // 로드 시 1회 해석
glyphs []glyph // GID로 인덱싱
metrics []glyphMetric
}
func (f *Font) GlyphFor(r rune) uint16 {
return f.runeToGID[r] // O(1), 캐시 친화적, 테이블 주행 없음
}
rune으로 인덱싱된 맵 하나, cmap 테이블의 선형 스캔 한 번으로 구성. 같은 폰트를 여러 페이지(보통 전 페이지)에 쓰는 문서에서 글리프 조회가 "페이지 × 글리프의 거의 이차"에서 "총 글리프 수 + 상수"로 바뀐다.
왜 "format 12"가 중요한가
많은 오래된 Go PDF 라이브러리는 Latin만 신경 쓰던 시절에 작성되었고, 구현한 cmap은 format 4 — Basic Multilingual Plane (U+0000–U+FFFF) 분할 범위. BMP 바깥의 일본어(드물지만 일부 이체 Kanji)는 format 12가 필요. go-pdf/fpdf의 AddUTF8Font는 NotoSansJP-Regular.ttf에서 panic한다. format 12 파서가 끝까지 작성되지 않아서다.
이건 비방이 아니다. 유물이다. gofpdf는 2015년경 Latin 중심 웹 앱에 필요한 것으로서 훌륭한 라이브러리였고, 포크는 그 스코프를 그대로 물려받았다. 세상이 바뀌었다. CJK는 "다른 사람 문제"에서 "일본어와 중국어 Go 생태계 다수의 문제"가 되었다. gpdf는 cmap 명세를 완전히 구현했다. 하지 않으면 "品目"에 두부 박스가 박힌 인보이스가 생긴다 — 공개 첫 주에 실제 들어온 버그 리포트다.
문서 수가 아닌 폰트 수로 스케일하는 캐시
폰트 캐시는 Document별이고 전역이 아니다. 같은 폰트로 PDF 10,000개 생성하면 150 µs 해석 비용을 10,000번 낸다 — 문서 간 Font 인스턴스를 공유하지 않는 이상. API는 gpdf.WithSharedFont(preloadedFont)를 지원.
고처리량 배치 생성(SaaS gpdf-api가 이 방식)에서 공유 폰트 패턴이 P95 레이턴시를 예측 가능하게 만든다. 문서에 있다. OSS 사용자 대부분은 필요 없다.
결합된 효과
세 결정을 100페이지 벤치(gpdf 683 µs, gofpdf 11.7 ms)에 대입:
| 시간의 출처 | gofpdf (페이지당 개략) | gpdf (페이지당 개략) |
|---|---|---|
| 연산 슬라이스 구축 | 약 60 µs | 0 (스트림 직접) |
| 연산 직렬화 | 약 35 µs | 0 (이미 기록됨) |
| 글리프 조회 (40자) | 약 6 µs | 약 0.4 µs |
| 할당 / GC 압박 | 약 20 µs | 약 2 µs |
| 합계 | 약 120 µs | 약 7 µs |
숫자는 프로파일 추정이고 실제 분해는 내용에 따른다. 모양은 맞다. 세 가지 중 어느 하나도 단독으로 10배를 내지 못한다. 쌓여서 10배가 된다.
따라서: 기존 라이브러리에 하나만 복사해도 2–3배는 얻을 수 있다. 10배를 원하면 셋 다 필요하고, 첫 번째(단일 패스)를 AST 기반 라이브러리에 뒤늦게 넣는 건 재작성 말고는 길이 없다.
포기한 것 (정직한 섹션)
빙빙 돌려 말했다. 전부 나열:
AST 기반 후처리. 플러그인 아키텍처 없음. "노드 트리 돌며 변환 적용" 없음. 렌더링 전에 문서 전체 텍스트 스타일을 일괄 편집하고 싶으면 Builder 호출 전에 한다.
인트로스펙션. doc.Components()로 넣은 걸 돌려주는 메서드는 없다. 의미 있는 메서드가 돌아갈 수 있는 시점엔 문서가 이미 연산자 스트림이다. 대부분 사용자는 쓸 일 없다. 문서 조작 도구를 만드는 소수 사용자는 쓴다.
리플렉션 기반 직렬화. 임의 struct를 PDF로 바꾸는 json.Unmarshal 스타일 API는 없다. JSON Schema 진입점(template.FromJSON)은 지원 형태를 명시한다. 의도적. 임의 Go struct를 넣어서 PDF를 받는 API가 필요하면 unidoc 영역.
interface의 확장성. Component를 구현해 커스텀 요소를 등록할 수 없다. Builder 호출을 감싸는 헬퍼 함수는 쓸 수 있다. 실용상 95% 요구를 커버하지만 모델이 다르다.
전부 의도한 결과다. 하나라도 채택하면 속도가 죽는다. "빠르고 고집 있는 것"이 맞는 사용자 버킷을 우선하고, "유연하고 플러그인 풍부"가 필요한 버킷은 Maroto v2나 unidoc이 더 맞는다.
벤치 재현 가능한가
가능하다. 코드를 공개한 목적이 바로 그것이다.
git clone https://github.com/gpdf-dev/gpdf
cd gpdf/_benchmark
go test -bench=. -benchmem -benchtime=5s
해당 디렉토리 README에 네 가지 워크로드와 측정 내용이 있다. 같은 CPU 아키텍처, 같은 Go 버전에서 20% 이상 차이가 나면 이슈를 열어 주기 바란다 — drift는 실재한다.
두 가지 단서:
- 벤치는
-benchmem와 함께 돈다. 끄면 전반적으로 약 5% 향상되지만, 실제 코드 운용 방식이 아니라 공개 수치에는 넣지 않는다. - CGO 비활성. CGO로 FreeType 백엔드를 붙이면 폰트 연산이 빨라질지 질문받아 실험했다. FFI 경계의 마샬링 비용이 이득을 삼켰다. PDF 생성기의 접근 패턴에는 순수 Go 서브세터가 이긴다.
FAQ
왜 아카이브된 gofpdf와 비교하나? 여전히 GitHub "go pdf" 검색 1위이고, gpdf로 착지하는 팀 대부분이 거기서 이주해 오기 때문. 벤치는 이 청중에 "이주할 가치가 있나"에 답해야 한다. 짧은 답: 있다. 이주 가이드도 있다.
PDF 생성에서 10배 빠른 게 실질적으로 의미 있나? 워크로드에 따라. 요청당 한 문서면 — 딱히 없다, 양쪽 다 "요청 경로에서 생성" 임계를 넘는다. 배치(야간 명세, 대량 인보이스, DB 쿼리 기반 보고서 생성)에서는 격차가 그대로 머신 수 감소로 번역된다. 배치 파이프라인을 처음 이주한 팀에서 "워커 수가 10분의 1"이라는 피드백을 들었고, 계산을 감사하지 않았지만 벤치 모양과 정합한다.
CJK 숫자의 함정은?
폰트 파일은 직접 실어야 한다. gpdf가 서브세팅해 주지만 3 MB NotoSansJP TTF는 3 MB다. Go 바이너리에 임베드하거나 기동 시 os.ReadFile 한다. distroless 이미지에서는 영향을 준다. SaaS gpdf-api는 이미지에 대표 폰트를 동봉해 해결. OSS 사용자는 직접 다룬다.
기능이 늘면 느려지나? 가장 신경 쓰는 질문. 답: 릴리스마다 이전 버전과 벤치마크를 재고, 네 워크로드 중 하나라도 5% 이상 악화되면 릴리스를 막는다. 벤치가 라이브러리와 같은 리포지토리에 있는 이유가 바로 그것이다.
이름의 유래는? gpdf = Go + PDF. 영리할 것 없다. 의도적으로 단순.
gpdf를 써 본다
gpdf는 PDF를 생성하는 Go 라이브러리다. MIT, 제로 의존성, 네이티브 CJK.
go get github.com/gpdf-dev/gpdf
다음에 읽을 것
- 2026 Go PDF 라이브러리 비교 — 라이선스와 의존 포함 전체 라이브러리 비교.
- gofpdf는 아카이브됐다. gpdf로 이주하는 법 — Before/After API 다섯 쌍, 전부 실행 가능.
- 벤치마크 코드:
_benchmark/benchmark_test.go.