unipdf는 AGPL 또는 유료. gpdf로 마이그레이션하는 가이드
UniDoc의 unipdf는 AGPL v3 또는 개발자별 유료 상용 라이선스. MIT, 의존성 0, 라이선스 키 불필요한 gpdf로 옮기는 방법.
TL;DR
gpdf는 MIT 라이선스, 외부 의존성 0, 라이선스 키 등록 단계가 없는 순수 Go PDF 라이브러리다. CJK나 AcroForm 때문에 unidoc/unipdf를 쓰고 있는데, AGPL 조항이 법무팀의 배포 승인을 막거나 상용 라이선스 비용을 정당화하기 어렵다면, 이 글이 unipdf creator API에서 gpdf로의 마이그레이션 지도가 된다.
지난 분기, 지인의 핀테크 회사에서 github.com/unidoc/unipdf/v3를 OSS 승인 흐름에 올렸다. 다음 날 돌아온 답은 AGPL-3.0 옆의 빨간 X와 법무팀의 메모였다 — "클로즈드소스 배포 제품에 링크할 수 없음. 상용 라이선스 취득 또는 제거 필요." 상용 견적은 개발자 1명당 연간 비용으로 왔고, 12명 팀에 곱하니 모두 다시 검색 결과를 열었다.
이것이 README에 나오지 않는 unipdf의 한 면이다. 기술적으로는 훌륭하다 — 성숙하고, 기능이 깊고, 잘 유지보수된다. 동시에 듀얼 라이선스다: 오픈 사용은 AGPL v3, 그 외는 유료 상용 라이선스. AGPL v3는 일반적으로 사용되는 copyleft 중 가장 강한 것에 속한다. unipdf를 사용자가 네트워크로 접근하는 서비스에 링크하면, §13에 따라 전체 대응 소스를 공개해야 한다. 대부분 회사의 법무는 안 된다고 한다.
이미 unipdf 코드가 운영 중이고 라이선스가 감사에서 걸렸거나 갱신이 다가왔다면, 이 글이 마이그레이션 지도다. 새 프로젝트를 시작하면서 가장 잘 정리된 문서 때문에 무심코 unipdf를 잡았다면, 이건 청구서가 따라오지 않는 대안이다.
"AGPL 또는 유료"가 실제 운영에서 의미하는 것
많은 Go 라이브러리가 별생각 없이 "AGPL" 라벨이 붙어 있지만, unipdf는 그렇지 않다. 저장소의 라이선스 파일은 순수 AGPL v3, README는 상용 사용에 키가 필요하다고 명시하며, 바이너리 자체가 이를 강제한다. 시작 시 라이선스를 등록하지 않고 unipdf API를 호출하면 에러가 나거나 모든 출력 페이지에 워터마크가 찍힌다.
대략 세 모드 중 하나에 속하게 된다:
- AGPL 모드. 자사 코드를 AGPL v3로 공개한다. unipdf에 닿는 모든 바이트, 그리고 그것에 링크된 모든 코드는 네트워크로 서비스를 사용하는 누구든 받아갈 수 있어야 한다. 사내 도구나 SaaS 제품에는 사실상 불가능하다.
- 상용 모드. UniDoc에 개발자당 연 단위로 비용을 지불한다. 가격은 변동하지만 최근 공개 견적은 개발자 1명당 연 4자리 달러대였고, 모든 바이너리가 시작 시 metering 또는 라이선스 키 등록 호출을 해야 한다. 키는 시크릿으로 취급되므로 시크릿 매니저에 두고 모든 컨테이너에 주입한다.
- 트라이얼 / 평가 모드. 기간 한정 무료. 출력에 워터마크. 운영에는 못 쓴다.
세 모드 모두 본질적으로 잘못된 건 아니다. UniDoc은 실제 회사가 실제 엔지니어로 만들고 유지하는 제품이고, 가격은 그 비용을 반영한다. 요점은, 라이선스 결정이 모든 레이어에 스며든다는 것 — 법무 검토, 시크릿 로테이션, 재무 갱신, 배포 면 (모든 컨테이너에 키 필요). gpdf는 MIT라서 그 한 열을 스프레드시트에서 통째로 지운다.
잃는 것과 남는 것
API 이야기 전에 솔직하게. unipdf가 하고 gpdf가 못하는 게 있다:
| 기능 | unipdf | gpdf |
|---|---|---|
| PDF 생성 | ✅ | ✅ |
| TrueType / CJK 폰트 | ✅ | ✅ (CGO 미사용, 자동 서브셋팅) |
| AES-128/256 암호화 | ✅ | ✅ (ISO 32000-2 Rev 6, 순수 Go) |
| PKCS#7 / PAdES 서명 | ✅ | ✅ (RFC 3161 TSA 지원) |
| PDF/A-1b/2b | ✅ | ✅ |
| AcroForm — 기존 필드 작성 | ✅ | ✅ (flatten만, 신규 필드 생성은 미지원) |
| AcroForm — 신규 필드 작성 | ✅ | ❌ |
| PDF 파싱 / 텍스트 추출 | ✅ | ❌ (gpdf는 생성 전용) |
| OCR | ✅ | ❌ |
| PDF 마스킹 (redaction) | ✅ | ❌ |
| HTML 렌더링 | 부분 | ❌ (별도 렌더러로 만들어 merge) |
PDF 파싱, OCR, redaction이 필요한 경로는 이 마이그레이션으로 끝까지 갈 수 없다. 그 경로만 unipdf를 남기거나 (해당 바이너리는 여전히 상용 라이선스 필요), 읽기 측을 다른 라이브러리로 분리한다. 생성, 암호화, 서명, 폰트, CJK 경로 — 대부분의 unipdf 청구서가 실제로 사용하는 것 — 에는 gpdf가 완전한 대체다.
라이선스 등록 코드 삭제
전체 마이그레이션에서 가장 작은 diff이지만 나머지를 실감하게 만드는 한 걸음. unipdf 바이너리는 시작 시 키를 등록해야 하며 변형이 몇 가지 있다:
// API 키 (metering)
import "github.com/unidoc/unipdf/v3/common/license"
func init() {
if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
log.Fatal(err)
}
}
// 오프라인 라이선스 파일
func init() {
licenseKey, _ := os.ReadFile("/etc/unidoc/license.txt")
if err := license.SetLicenseKey(string(licenseKey), "Acme Corp"); err != nil {
log.Fatal(err)
}
}
gpdf에는 대응물이 없다. init() 블록 전체를 삭제. UNIDOC_API_KEY를 시크릿 매니저, CI 변수, 컨테이너 매니페스트에서 빼낸다. 이미지에서 라이선스 파일을 제거한다. import할 것은 github.com/gpdf-dev/gpdf뿐, 요구 사항은 어딘가에서 gpdf.NewDocument를 호출하는 것뿐이다.
이게 전부다. 마이그레이션 완료 판정 기준이기도 하다: grep -r unidoc .이 끝났을 때 0건을 반환해야 한다.
API 매핑 표
치트 시트. 이후 섹션에서 다섯 쌍을 구체적으로 본다. unipdf의 고수준 빌더는 Creator, gpdf는 Document. 모양이 충분히 비슷해서 대부분의 코드는 보면서 옮길 수 있다.
| 하고 싶은 일 | unipdf (creator) | gpdf |
|---|---|---|
| 빌더 생성 | c := creator.New(); c.SetPageSize(creator.PageSizeA4) | doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4)) |
| 마진 설정 | c.SetPageMargins(L, R, T, B) | gpdf.WithMargins(document.UniformEdges(document.Mm(20))) |
| 새 페이지 | c.NewPage() | page := doc.AddPage() |
| 한 줄 텍스트 | p := c.NewParagraph("hi"); c.Draw(p) | c.Text("hi") (컬럼 안에서) |
| 자동 줄바꿈 텍스트 | p := c.NewStyledParagraph(); p.SetText(...); c.Draw(p) | c.Text(body) (자동 줄바꿈) |
| 폰트 등록 | model.NewCompositePdfFontFromTTFFile(path) | gpdf.WithFont("Name", ttfBytes) (생성 시점) |
| 텍스트 폰트 지정 | style.Font = font; style.FontSize = 12 | template.FontFamily("Name"), template.FontSize(12) per-text |
| 색상 | style.Color = creator.ColorRGBFromHex("#1A237E") | template.TextColor(pdf.RGBHex(0x1A237E)) |
| 테이블 | t := c.NewTable(4); t.SetColumnWidths(...); c.Draw(t) | c.Table(headers, rows, template.ColumnWidths(...)) |
| 이미지 | img, _ := c.NewImageFromFile(path); img.ScaleToWidth(w); c.Draw(img) | c.Image(imgBytes, template.FitWidth(document.Mm(50))) |
| 헤더 / 푸터 | c.DrawHeader(fn) / c.DrawFooter(fn) | doc.Header(fn) / doc.Footer(fn) |
| 페이지 번호 | DrawFooter 인자에서 수동 추적 | c.PageNumber() / c.TotalPages() (플레이스홀더) |
| 암호화 | model.PdfWriter + Encrypt로 재인코딩 | gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms) |
| 서명 | model.NewPdfAppender(...).Sign(...) | gpdf.SignDocument(pdfBytes, signer, opts) |
| 라이선스 등록 | init()의 license.SetMeteredKey(...) | (없음 — 삭제) |
| 파일 출력 | c.WriteToFile("out.pdf") | data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644) |
| Writer 출력 | c.Write(w) | doc.Render(w) |
기억해야 할 구조 변화 두 가지. unipdf의 creator는 상태를 가진다 — Paragraph나 Table을 만들고 c.Draw(thing)으로 커밋한다. gpdf는 선언적이다 — 행과 컬럼의 트리를 묘사하고 레이아웃 엔진이 배치한다. 두 번째는 gpdf가 Bootstrap처럼 12 컬럼 그리드를 가진다는 것. 모든 행은 암묵적으로 12 단위 너비, r.Col(n, fn)으로 소비한다. 밀리미터로 너비를 따라다니지 않게 되면, 대부분의 레이아웃이 두세 줄로 줄어든다.
Before / After 1: 가장 작은 PDF
"hello world" 쌍. unipdf 쪽이 길어 보이는 건 라이선스 호출 의식 때문이다.
Before — unipdf:
package main
import (
"log"
"os"
"github.com/unidoc/unipdf/v3/common/license"
"github.com/unidoc/unipdf/v3/creator"
)
func init() {
if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
log.Fatal(err)
}
}
func main() {
c := creator.New()
c.SetPageSize(creator.PageSizeA4)
p := c.NewParagraph("Hello, World!")
p.SetFontSize(24)
if err := c.Draw(p); err != nil {
log.Fatal(err)
}
if err := c.WriteToFile("hello.pdf"); err != nil {
log.Fatal(err)
}
}
After — gpdf:
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Hello, World!", template.FontSize(24), template.Bold())
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
세 가지 차이. init() 블록이 사라졌다 — 키 없음, 환경변수 없음. 생성은 mutating 대신 옵션 전달. 텍스트는 자유 Paragraph가 아니라 행과 컬럼 안에 들어간다. 그리드가 배치를 하므로 좌표를 고를 필요가 없다.
Before / After 2: 스타일이 적용된 인보이스 항목 테이블
unipdf의 creator API에서 테이블은 길어진다. Table을 만들고, 절대 비율로 SetColumnWidths를 호출하고, NewCell / SetContent로 셀을 하나씩 만들고, 각 셀의 테두리와 정렬을 직접 설정한다.
Before — unipdf:
table := c.NewTable(4)
table.SetColumnWidths(0.5, 0.15, 0.15, 0.2)
headerStyle := c.NewTextStyle()
headerStyle.Font, _ = model.NewStandard14Font("Helvetica-Bold")
headerStyle.FontSize = 11
headerStyle.Color = creator.ColorWhite
drawHeaderCell := func(text string) {
cell := table.NewCell()
cell.SetBackgroundColor(creator.ColorRGBFromHex("#1A237E"))
cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.5)
p := c.NewStyledParagraph()
chunk := p.Append(text)
chunk.Style = headerStyle
cell.SetContent(p)
}
for _, h := range []string{"항목", "수량", "단가", "금액"} {
drawHeaderCell(h)
}
for _, row := range items {
for _, cellText := range row {
cell := table.NewCell()
cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.3)
p := c.NewParagraph(cellText)
p.SetFontSize(11)
cell.SetContent(p)
}
}
if err := c.Draw(table); err != nil {
log.Fatal(err)
}
테두리, 셀별 content, 헤더 그리는 루프 — 전부 기계적인 작업이다.
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table(
[]string{"항목", "수량", "단가", "금액"},
[][]string{
{"프론트엔드 개발", "40 시간", "₩150,000", "₩6,000,000"},
{"백엔드 개발", "60 시간", "₩150,000", "₩9,000,000"},
{"UI 디자인", "20 시간", "₩120,000", "₩2,400,000"},
},
template.ColumnWidths(50, 15, 15, 20),
template.TableHeaderStyle(
template.Bold(),
template.TextColor(pdf.White),
template.BgColor(pdf.RGBHex(0x1A237E)),
),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
})
})
ColumnWidths는 테이블이 위치한 컬럼 너비의 퍼센트다. 페이지의 절대 비율이 아니다. 같은 테이블을 r.Col(6, ...)에 넣으면 퍼센트는 그대로 — 테이블이 행의 절반을 차지하고 컬럼이 같은 비율로 재분배된다. 페이지 분할은 자동, 본문이 하단 마진을 넘으면 다음 페이지에 헤더가 자동으로 반복된다.
수치 하나만 짚어두자. 100행 인보이스 벤치에서 unipdf의 Table은 렌더링당 약 8.6 ms. gpdf는 같은 작업을 108 µs (약 80배). 레이아웃 엔진이 각 행을 한 번 측정하고 단일 패스로 페이지를 쓰는 구조 덕분이다. 인보이스 한 장으로는 안 보이지만, cron으로 대량 배치를 돌리면 "큐가 필요한가"의 경계가 바뀐다.
전자세금계산서나 전자문서 인증 (KISA) 양식의 항목들은 레이아웃으로는 gpdf에서 그대로 표현 가능하다. 타임스탬프가 필요하면 gpdf.SignDocument의 RFC 3161 TSA 옵션 쪽에서 처리한다.
Before / After 3: composite font 의식 없이 한국어
unipdf도 CJK는 지원하지만 경로가 장황하다. 디스크의 TTF로 composite font를 구성하고, style font로 설정하고, 모든 paragraph에 통과시킨다. 폴백을 원하면 직접 배선한다.
Before — unipdf:
font, err := model.NewCompositePdfFontFromTTFFile("NotoSansKR-Regular.ttf")
if err != nil {
log.Fatal(err)
}
c := creator.New()
c.SetPageSize(creator.PageSizeA4)
style := c.NewTextStyle()
style.Font = font
style.FontSize = 14
p := c.NewStyledParagraph()
p.Append("안녕하세요, 세계.").Style = style
if err := c.Draw(p); err != nil {
log.Fatal(err)
}
c.WriteToFile("ko.pdf")
TTF는 지정한 경로에 런타임 시점, 바이너리가 도는 호스트에 존재해야 한다. 컨테이너 이미지에 폰트를 같이 넣어야 한다. NewCompositePdfFontFromTTFFile은 폰트를 사용하는 draw 호출 전에 발생해야 하므로 글로벌에 두거나 의존성으로 들고 다녀야 한다.
After — gpdf:
package main
import (
_ "embed"
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
//go:embed NotoSansKR-Regular.ttf
var notoKR []byte
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithFont("NotoSansKR", notoKR),
gpdf.WithDefaultFont("NotoSansKR", 14),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("안녕하세요, 세계.")
c.Text("동해물과 백두산이 마르고 닳도록")
c.Text("서울특별시 강남구 테헤란로 152")
})
})
data, _ := doc.Generate()
if err := os.WriteFile("ko.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
세 가지가 다르다. 폰트는 바이트, 경로가 아니다 — //go:embed로 바이너리에 컴파일 포함, 런타임 이미지에 폰트 디렉터리가 필요 없어진다. 폰트는 생성 시점에 한 번 등록한다. paragraph마다 style을 끌고 다닐 필요가 없다. 그리고 gpdf의 TrueType 서브셋팅은 CJK cmap 포맷 (4, 6, 12)과 Identity-H 인코딩을 이해하므로, 출력 PDF는 실제 사용한 글리프만 담는다. 한글 200자 인보이스가 약 30 KB 폰트 서브셋이 된다. 4 MB 풀 임베드가 아니다.
본명조 (Source Han Sans), 나눔고딕, 폴백 체인 등 자세한 건 한국어 폰트 글에서 다룬다.
Before / After 4: 모든 페이지에 헤더, 푸터에 페이지 번호
unipdf의 패턴은 c.DrawHeader(fn) / c.DrawFooter(fn). 둘 다 현재 block과 페이지 번호를 담은 컨텍스트를 받는다. 페이지 번호는 컨텍스트의 PageNum / TotalPages 필드에서 꺼낸다.
Before — unipdf:
c.DrawHeader(func(block *creator.Block, args creator.HeaderFunctionArgs) {
p := c.NewParagraph("ACME 주식회사")
p.SetFontSize(12)
p.SetPos(40, 30)
block.Draw(p)
})
c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) {
p := c.NewParagraph(fmt.Sprintf("%d / %d 페이지", args.PageNum, args.TotalPages))
p.SetFontSize(8)
p.SetPos(0, 20)
p.SetTextAlignment(creator.TextAlignmentCenter)
block.Draw(p)
})
헤더 / 푸터 모두 절대 좌표로 그리는 block. Y 좌표나 마진이 틀리면 페이지 크기를 바꿀 때마다 좌표를 다시 맞춰야 한다.
After — gpdf:
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("ACME 주식회사", template.Bold(), template.FontSize(12))
c.Line(template.LineColor(pdf.Gray(0.7)))
c.Spacer(document.Mm(4))
})
})
})
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("ACME 주식회사",
template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight(),
template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
})
})
for i := 0; i < 10; i++ {
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(fmt.Sprintf("%d 페이지의 본문.", i+1))
})
})
}
PageNumber와 TotalPages는 플레이스홀더, 페이지 확정 후 레이아웃 엔진이 해석한다. 헤더와 푸터 자체가 트리이지 직접 위치를 잡는 block이 아니다. 엔진이 모든 페이지에 자동으로 공간을 확보한다. A4에서 Letter로 페이지 크기를 바꿔도 다른 건 손댈 필요가 없다.
Before / After 5: AES-256 암호화
라이선스 차이가 가장 뚜렷한 쌍. unipdf의 암호화는 model.PdfWriter를 거치는 경로로, 이는 상용 사용에 해당하며 라이선스 등록 체크를 발동한다. gpdf 측은 단일 함수 옵션, AES-256 (ISO 32000-2 Rev 6) 구현은 오픈소스 MIT 코어에 들어 있다.
Before — unipdf:
// creator로 생성 후 model.PdfWriter로 재인코딩하여 암호화 부착.
// 라이선스 체크가 여기에서 발동.
c := creator.New()
// ... 콘텐츠 그리기 ...
var buf bytes.Buffer
if err := c.Write(&buf); err != nil {
log.Fatal(err)
}
reader, err := model.NewPdfReader(bytes.NewReader(buf.Bytes()))
if err != nil {
log.Fatal(err)
}
writer := model.NewPdfWriter()
encryptOpts := &model.EncryptOptions{Algorithm: model.RC4_128bit, Permissions: model.PermPrinting}
if err := writer.Encrypt([]byte("user-pwd"), []byte("owner-pwd"), encryptOpts); err != nil {
log.Fatal(err)
}
for i := 1; i <= reader.NumPage; i++ {
page, _ := reader.GetPage(i)
writer.AddPage(page)
}
f, _ := os.Create("encrypted.pdf")
defer f.Close()
writer.Write(f)
After — gpdf:
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithEncryption(
gpdf.AES256,
"user-pwd",
"owner-pwd",
gpdf.PermPrinting|gpdf.PermCopyContent,
),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("기밀")
})
})
data, _ := doc.Generate()
os.WriteFile("encrypted.pdf", data, 0o644)
옵션 하나, 기본 AES-256, 별도 writer 패스 없음. 전체 암호화 경로가 MIT 코어 안에 — 같은 모듈, 같은 go get. 서명도 같은 이야기: gpdf.SignDocument(pdfBytes, signer, gpdf.WithTSA("http://timestamp.digicert.com"))로 PKCS#7 + RFC 3161 타임스탬프를 후처리로 부착한다. 추가 패키지도, 키 등록도 없다.
얼마나 빠른가
_benchmark/benchmark_test.go를 Apple M1 / Go 1.25에서 실행한 결과. unipdf는 라이선스상 비교 코드를 함께 배포하기가 까다로워 우리 커밋된 스위트에 직접 포함되어 있지 않다. 아래 수치는 같은 하드웨어, 같은 워크로드에서 unipdf v3에 대해 우리가 별도 수집한 값이다.
| 벤치마크 | gpdf | unipdf* | gofpdf | Maroto v2 |
|---|---|---|---|---|
| 단일 페이지 | 13 µs | ~180 µs | 132 µs | 237 µs |
| 4×10 인보이스 테이블 | 108 µs | ~8.6 ms | 241 µs | 8.6 ms |
| 100 페이지 리포트 | 683 µs | ~95 ms | 11.7 ms | 19.8 ms |
| 복잡한 CJK 인보이스 | 133 µs | ~12 ms | 254 µs | 10.4 ms |
* unipdf 수치는 별도 실행에서 수집했고, 같은 Apple M1 / Go 1.25 / 작성 시점 unipdf v3 기준. 참고용; 우리 커밋 스위트에는 포함되지 않는다.
모양은 gofpdf 비교와 같다. 실제 워크로드에서 gpdf가 10–80배 빠르다. 테이블이 많은 페이지 한 장이 108 µs라는 건, 코어 한 개로 초당 약 9,000개의 인보이스가 가능하다는 뜻. PDF 생성을 캐싱하거나 비동기 큐에 넣을지 고민할 필요가 없어진다. 요청 경로에서 생성하는 게 거의 다 충분하다.
gpdf에 없는 것은 어떻게
unipdf 청구서가 OCR, redaction, PDF 파싱을 떠받치고 있다면, 이 마이그레이션으로는 끝까지 갈 수 없다. 솔직한 선택지:
- OCR. gpdf는 OCR을 하지 않으며 당분간 할 계획도 없다. Tesseract를 gosseract으로 호출하거나 호스팅 OCR API를 쓴다. 생성은 gpdf, OCR은 별도 경로.
- PDF 파싱 / 텍스트 추출. gpdf는 설계상 생성 전용. 읽기 측은 pdfcpu (Apache 2.0)가 흔한 케이스를 폭넓게 커버한다. unipdf를 파싱 전용으로 남기면 시트 수를 줄일 수 있을 가능성도 있다.
- AcroForm 필드 신규 작성. gpdf는 기존 AcroForm 필드를 flatten할 수 있지만 신규 필드 작성은 아직. 뷰어에서 사용자가 입력하는 폼 PDF를 만든다면 여기가 갭. 로드맵에 있다.
- Redaction (마스킹). gpdf 로드맵에 없다. redaction은 무엇을 가릴지 알기 위해 렌더러 측 지식이 필요하며 생성과는 다른 아키텍처.
생성, 암호화, 서명, 폰트, CJK 경로 — 대부분의 unipdf 청구서가 실제로 사용하는 것 — 는 완전히 대체 가능하다.
FAQ
gpdf는 unipdf의 fork인가? 아니다. gpdf는 순수 Go의 클린 재구현. PDF 와이어 포맷, 레이아웃 엔진, TrueType 서브셋터, AES, PKCS#7 — 전부 처음부터 작성. unipdf와 공유 코드 없음, 혈통 없음, 라이선스 분쟁 가능성도 없음 (복사한 게 없으므로).
정말 MIT인가? "어떤 조건에서 AGPL"이 되는 부속 조항은? 없다. 저장소의 LICENSE는 MIT 그 자체. 부록 없음, 사용 분야 제한 없음, 상용 등급 분리 없음. 클로즈드소스 배포 제품에 사용, 상용 SaaS에 임베드, 온프레 어플라이언스에 배포 — 모두 가능. 의무는 배포물에 라이선스와 저작권 표기를 포함하는 것뿐.
전이적 의존성에 copyleft가 숨어 있지 않은가?
gpdf 코어의 go.mod require 블록은 비어 있다. 전이적 AGPL도 GPL도 아무것도 없다. go get 후 go mod graph | grep gpdf로 확인할 수 있다.
라이선스 키 제거가 그렇게 중요한가? 어떤 팀에는 전부다. 라이선스 키는 시크릿 매니저에 들어가야 하고, 로테이션 대상이고, 감사 대상이고, 모든 컨테이너 이미지에 포함되어야 하고, 로그에 누출되면 안 된다. 멀티테넌트 SaaS에서 pod이 수백 개라면 실제 운영 면이 된다. 요구 자체를 없애면 사고 한 부류가 사라진다.
기존 unipdf 코드는 creator.Block.SetPos로 절대 좌표를 많이 쓴다. gpdf에 등가물은?
있다 — page.Absolute(x, y, fn)으로 명시적 좌표에 서브트리를 둘 수 있다. 다만 코드가 절대 좌표 위주라면 레이아웃 엔진 모델은 문법 전환이 아니라 사고방식 전환이다. 견적 전에 12 컬럼 그리드 글을 읽는 걸 추천. 다시 쓴 코드가 보통 원본보다 짧다.
미래에 UniDoc이 unipdf를 MIT로 재라이선스하면? 선택지가 하나 늘어난다. gpdf의 베팅은 "unipdf가 영원히 AGPL"이 아니라, "시작 시 등록 호출을 요구하는 라이선스와 개발자별 재무 갱신은 대부분의 워크로드에 존재하지 않아도 되는 비용"이라는 점. 내일 재라이선스되더라도 라이선스 키의 운영 면은 그들이 제거할 때까지 남는다.
gpdf 사용해 보기
gpdf는 Go의 PDF 생성 라이브러리. MIT, 외부 의존성 0, 라이선스 키 불필요, 네이티브 CJK 지원.
go get github.com/gpdf-dev/gpdf