Go PDF에서 페이지 번호, 헤더, 푸터를 제대로 넣기
gpdf로 Go PDF에 헤더, 푸터, 'Page X of Y'를 추가하는 방법. 두 개의 builder 메서드와 2단계 페이지네이션이 총 페이지 수를 자동으로 채운다.
60페이지짜리 재무 보고서. 누군가 인쇄 큐에서 12페이지를 열고 한 가지를 묻는다. 지금 몇 페이지고, 몇 페이지 남았어? 푸터가 그냥 12만 표시하면 아무도 모른다. 12 / 60이라고 써야 한다.
이 60이 대부분의 PDF 라이브러리가 잘못 처리하는 부분이다. 푸터를 그리는 시점에 총 페이지 수를 알 수 없거나, AliasNbPages 같은 토큰 뒤에 숨겨두고 빌드 후에 호출해야 하거나, 문서를 두 번 렌더링해서 첫 번째를 버린다.
gpdf는 builder 메서드 두 개와 내부 2단계 페이지네이션으로 깔끔하게 해결한다. 이 글에서는 API의 모양, 동작 방식, 그리고 알아둘 만한 한 가지 거친 부분을 다룬다.
TL;DR
doc.Header(fn)와doc.Footer(fn)는 모든 페이지에서 실행되는 클로저를 등록한다.- 클로저 안에서는 본문과 동일한 12열 그리드를 사용한다.
- 현재 페이지는
c.PageNumber(), 총 페이지는c.TotalPages(). - 총 페이지는 페이지네이션 완료 후 2단계 패스에서 자동으로 해결된다. 두 번 빌드하는 로직을 직접 쓸 필요 없음.
- 한 가지 거친 부분:
"3 of 12"처럼 한 줄짜리 인라인 문자열로 표시해주는c.PageNumberOf(total)헬퍼는 없다. 세 컬럼으로 조합해야 한다. 아래에서 설명.
본문의 모든 코드는 gpdf/_examples/builder/26_page_number_test.go에서 가져온 실제 코드다. 테스트 스위트의 일부라서 빌드는 보장된다.
한 파일로 끝나는 전체 예제
완전히 동작하는 프로그램이다. main.go로 저장하고 go run main.go를 실행하면 4페이지 PDF가 생성되고, 각 페이지 헤더에 총 페이지 수, 푸터에 현재 페이지 번호가 표시된다.
package main
import (
"os"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(20))),
)
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("분기 보고서", template.Bold(), template.FontSize(10))
})
r.Col(6, func(c *template.ColBuilder) {
c.TotalPages(template.AlignRight(), template.FontSize(9),
template.TextColor(pdf.Gray(0.5)))
})
})
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Line(template.LineColor(pdf.RGBHex(0x1565C0)))
c.Spacer(document.Mm(3))
})
})
})
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Spacer(document.Mm(3))
c.Line(template.LineColor(pdf.Gray(0.7)))
c.Spacer(document.Mm(2))
})
})
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("Generated by gpdf", 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 _, title := range []string{"서론", "배경", "분석", "결론"} {
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(title, template.FontSize(18), template.Bold())
c.Spacer(document.Mm(5))
c.Text(title + " 섹션 본문.")
})
})
}
out, err := doc.Generate()
if err != nil {
panic(err)
}
_ = os.WriteFile("report.pdf", out, 0o644)
}
4페이지가 생성되고, 헤더 우측에 4, 푸터 우측에 1〜4가 표시된다. "이 문서는 4페이지"라고 코드에 한 번도 적지 않았다. gpdf 자신도 페이지네이션이 끝날 때까지는 모른다.
"Page X of Y"의 Y가 어려운 이유
Y는 페이지 1을 그릴 시점에 아직 확정되지 않았기 때문에 까다롭다. 50페이지 보고서에서 47페이지가 테이블 행이 안 맞아서 페이지 경계에 걸쳐 두 페이지로 쪼개질 수도 있다. 총 50은 페이지네이션이 끝난 뒤에만 알 수 있다. 그런데 1페이지 푸터는 훨씬 전에 그려졌다.
모든 PDF 라이브러리가 이 벽에 부딪힌다. Go 주요 라이브러리들의 우회법:
| 라이브러리 | "Page X of Y" 처리 |
|---|---|
| gofpdf | pdf.AliasNbPages("{nb}"). 본문에 {nb}를 문자열로 쓰면 출력 스트림에 사후 치환이 들어간다. 동작은 하지만 호출을 잊으면 안 되고, 플레이스홀더가 매직 스트링이다. |
| go-pdf/fpdf | gofpdf의 fork. 같은 메커니즘. |
| signintech/gopdf | 네이티브 지원 없음. 문서를 한 번 빌드해서 페이지 수를 세고, 다시 빌드한다. |
| maroto v2 | gpdf와 비슷한 Header/Footer 등록 방식. 내부적으로도 비슷한 2단계 패스. 다만 베이스가 gofpdf라 일반 워크로드에서 gpdf보다 약 10배 느리다. |
| gpdf | c.PageNumber() / c.TotalPages(). 타입이 있는 메서드 호출, 매직 스트링 없음, 내부 2단계 패스로 해결. |
페이지 번호 프리미티브를 타입화된 builder API의 일부로 가진 것은 gpdf뿐이다. gofpdf에서 {nb}를 {nB}로 오타 내면 그대로 PDF에 {nB}가 인쇄된다. c.TotalPages()에서 일어날 수 있는 최악은 호출 누락이고, 그럼 숫자가 안 나올 뿐이다. 틀린 숫자가 나오지는 않는다.
2단계 패스 작동 방식
내부적으로 c.PageNumber()는 플레이스홀더 문자열로 렌더링된다. 실제 폰트의 어떤 글리프와도 매칭되지 않는 sentinel 값이다. 페이지네이터가 모든 페이지 레이아웃을 완료하고 총 수를 알고 나면, 렌더된 텍스트 명령을 순회하면서 치환한다:
- 1단계 (페이지네이션): 헤더/푸터 포함 모든 페이지를 렌더링.
PageNumber와TotalPages는 고정폭 토큰으로 취급. 총 페이지 수를 계산. - 2단계 (해결): 페이지 트리를 다시 순회해 각 sentinel을 실제 현재/총 페이지 번호로 치환.
플레이스홀더 폭은 예상 최대 페이지 수에 맞춰 미리 잡아두기 때문에 (휴리스틱), 치환 후 레이아웃이 어긋나지 않는다. 9 → 10페이지로 자릿수가 늘어도 우측 정렬 페이지 번호가 그대로 정렬을 유지한다.
2단계 코드를 직접 짤 필요 없다. 문서를 두 번 렌더하지 않아도 된다. doc.Generate()를 호출하면 바이트가 돌아온다.
헤더와 푸터는 그냥 보통의 레이아웃이다
gofpdf에서 넘어온 사람이 여기서 헤맨다. 거기는 SetHeaderFunc가 고정 Y 좌표에서 콜백되고 절대 좌표 Cell(...)로 텍스트를 놓는다. gpdf에서 헤더 클로저는 *template.PageBuilder를 받는다 — 본문과 같은 타입이다. 그리드도, 행과 열도, 스타일 옵션도 동일하다.
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(2, func(c *template.ColBuilder) {
c.Image("logo.png", template.ImageHeight(document.Mm(12)))
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("Annual Report 2026", template.Bold(), template.FontSize(14))
})
r.Col(2, func(c *template.ColBuilder) {
c.TotalPages(template.AlignRight())
})
})
})
좌측에 로고, 중앙에 제목, 우측에 총 페이지 수를 배치한 헤더. 컬럼 폭 합계가 12로 본문 행과 같은 규칙이다.
헤더 높이는 자동으로 측정된다. gpdf는 본문 레이아웃 전에 헤더 클로저를 한 번 실행해 렌더 결과의 높이를 재고, 각 페이지의 본문 가용 높이에서 뺀다. 푸터도 같다. headerHeight를 인자로 넘길 필요 없다. 헤더에 행을 추가하면 본문이 그만큼 줄어든다.
두 영역 모두 콘텐츠 오버플로우로 만들어진 페이지를 포함한 모든 페이지에서 반복된다. 긴 테이블이 12페이지까지 흘러도 12페이지에도 헤더/푸터가 나온다. "첫 페이지만" 플래그는 현재 없다 (아래 참고).
거친 부분: "Page X of Y"를 한 줄로
API에서 솔직히 더 좋아질 수 있다고 생각하는 곳. c.PageOf("Page %d of %d") 같은 헬퍼가 없다. "Page 3 of 12"라는 한 문자열을 만들려면 c.Text()와 c.PageNumber()가 독립된 컬럼 자식이라 컬럼을 조합해야 한다:
r.Col(12, func(c *template.ColBuilder) {
c.AutoRow(func(r *template.RowBuilder) {
r.Col(3, func(c *template.ColBuilder) {
c.Text("Page", template.AlignRight())
})
r.Col(2, func(c *template.ColBuilder) {
c.PageNumber(template.AlignCenter())
})
r.Col(2, func(c *template.ColBuilder) {
c.Text("of", template.AlignCenter())
})
r.Col(3, func(c *template.ColBuilder) {
c.TotalPages(template.AlignLeft())
})
r.Col(2, func(c *template.ColBuilder) {})
})
})
동작은 하고 보기에도 괜찮다. 다만 한 줄의 포맷 문자열로 끝날 일을 네 컬럼으로 펼쳐놓은 셈이라 살짝 거슬린다. fmt.Sprintf 스타일의 %d 플레이스홀더를 받는 c.PageOf(format string, opts ...TextOption) 헬퍼 추가를 검토 중이다. API 형태에 의견이 있으면 GitHub 이슈에 남겨주면 좋겠다.
지금 실용적인 단축은 "Page"를 빼고 슬래시로 잇는 것:
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight())
})
r.Col(1, func(c *template.ColBuilder) {
c.Text("/", template.AlignCenter())
})
r.Col(5, func(c *template.ColBuilder) {
c.TotalPages(template.AlignLeft())
})
3 / 12는 푸터로 충분히 읽힌다. 현재 3 / 전체 12처럼 한국어 표현으로 만들고 싶으면 같은 방식으로 c.Text 칸을 더 끼우면 된다.
자주 쓰는 배치
실무에서 자주 등장하는 몇 가지.
제목 아래 구분선. AutoRow를 하나 더 추가해서 c.Line()을 넣는다. 글 앞쪽의 예제가 이 형태.
중앙 "기밀" 푸터. 한 행 한 컬럼, AlignCenter.
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("기밀 — 내부용",
template.AlignCenter(),
template.FontSize(8),
template.TextColor(pdf.Gray(0.5)))
})
})
})
한국 기업 문서에서는 "인쇄일: 2026년 5월 19일", "문서번호: DOC-2026-0517" 같은 줄을 같이 넣는 경우가 많다. c.Text(...)를 두세 줄 쌓으면 된다. 전자세금계산서 첨부용 PDF에도 같은 패턴이 들어간다.
좌측 로고, 우측 페이지 번호. 8/4 또는 6/6으로 두 컬럼. 왼쪽에 c.Image(...), 오른쪽에 c.PageNumber() + AlignRight.
"다음 페이지에 계속" 푸터. 현재 미지원. 헤더/푸터 클로저는 PageBuilder만 받고 현재 페이지 인덱스를 모르기 때문에 "마지막 페이지인가"로 분기할 수 없다. 본문 측에 마지막 페이지 빼고 "계속" 줄을 추가하려면 결국 총 페이지를 미리 알아야 해서 모순. 요청 목록에 있음.
첫 페이지만 다른 헤더. 같은 이유로 미지원. 우회책은 페이지 1의 본문 맨 위에 스페이서를 넣어 헤더를 실질적으로 비우고 2페이지부터 흐름에 맡기는, 좀 어색한 방식. doc.HeaderOn(pages, fn) 변형을 설계 중이다.
CJK 그대로 동작
gpdf는 CGO 없이 TrueType 폰트를 서브셋한다. 한국어, 중국어, 일본어를 헤더/푸터에 c.Text(...)로 그대로 쓸 수 있다. AddUTF8Font 같은 의식 없이, 폰트가 해당 글자를 지원하면 두부 박스도 안 나온다.
doc := template.New(
template.WithPageSize(document.A4),
template.WithFont("NotoSansKR", notoSansKRRegular),
)
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("기밀", template.FontFamily("NotoSansKR"), template.FontSize(8))
})
r.Col(6, func(c *template.ColBuilder) {
c.PageNumber(template.AlignRight(), template.FontSize(8))
})
})
})
최종 PDF에 임베드되는 서브셋은 "실제 사용된 글리프만" 들어간다. 60페이지 보고서 푸터에 "기밀"만 있다면 NotoSansKR에서 두 글리프만 임베드된다. 2만 글리프가 아니다. 전자문서 인증 등 파일 크기에 민감한 환경에서 효과가 크다.
성능
규모를 키울 때 의미가 있는 부분.
2단계 패스는 공짜는 아니지만 싸다. M1 기준 100페이지 문서에서 2단계는 50µs 미만. 전체 생성 시간의 1% 미만. gpdf 단일 페이지 벤치마크는 13µs, 100페이지는 683µs. 페이지 번호 해결은 페이지 복잡도와 무관한 상수 인자.
비교로, gofpdf의 AliasNbPages는 압축 결정 이후 콘텐츠 스트림 전체에 문자열 치환을 걸고, alias가 들어 있는 스트림을 다시 압축한다. gofpdf 자체 벤치마크에서 100페이지 문서 총 시간의 2〜4% 정도. gpdf 쪽은 스트림 인코딩 이전에 치환이 들어가므로 더 빠르다.
하루에 100만 개 PDF를 만든다면 차이가 난다. 하루에 10개면 의미 없다.
FAQ
헤더/푸터 높이가 페이지 마진을 잠식하나요?
아니요. gpdf는 헤더와 푸터의 실제 높이를 측정하고, 본문 가용 높이를 pageHeight - top_margin - headerHeight - footerHeight - bottom_margin으로 계산한다. 상단 마진 20mm에 헤더 15mm면, 본문은 페이지 상단에서 35mm부터 시작한다.
페이지별로 헤더 높이를 다르게 할 수 있나요? 없다. 헤더 클로저는 측정용으로 한 번만 평가되고 결과는 문서 전체에 고정된다. 가변 높이가 필요하면 최대 높이를 고정하고 공백으로 조정해야 한다.
본문이 비어 있는 페이지에도 헤더/푸터가 나오나요? gpdf는 빈 페이지를 만들지 않는다. 본문이 3페이지에 다 들어가면 PDF는 3페이지가 된다. 헤더/푸터도 그 3페이지에만 나온다.
세로/가로 혼합 문서에서 가로 페이지에만 다른 헤더를 쓰고 싶을 때는?
페이지별 WithPageSize(...)로 방향을 바꾸는 건 지원되지만, 헤더/푸터 클로저는 방향과 상관없이 같은 것이 적용된다. 실무적으로는 양쪽 방향에서 모두 무난하게 보이는 중앙 정렬 디자인이 안전하다.
JSON 템플릿 입구에서도 동작?
동작. JSON 스키마에 header, footer와 {"type": "pageNumber"}, {"type": "totalPages"}가 있다. gpdf/_examples/json/26_page_number_test.go가 같은 시나리오를 JSON으로 돌려 builder 버전과 동일한 golden PDF가 나오는지 검증한다.
Go text/template 입구는?
동작. gpdf/_examples/gotemplate/26_page_number_test.go가 같은 시나리오를 통과시킨다. 입구가 builder, JSON, Go template 중 무엇이든 아래에서는 같은 2단계 페이지네이션이 돈다.
다음 단계
헤더, 푸터, 페이지 번호는 보고서에서 가장 눈에 띄지 않는 부분이지만, 보고서가 "완성된 것"처럼 보이게 만드는 핵심이기도 하다. 저수준 PDF 라이브러리 위에서 매번 이걸 직접 짜고 있었다면, 이 글의 몇 줄이 전부다. 예제를 복사해 문자열만 바꿔서 배포한다.
미해결 부분 — c.PageOf(...) 단일 문자열 포맷팅, 첫 페이지만 다른 헤더, "마지막 페이지" 감지 — 는 리스트에 있다. 무엇이든 막힌다면 GitHub 이슈에 적어달라. 추상적 요청보다 구체적인 유스케이스가 API 형태를 더 잘 결정한다.
gpdf를 사용해보기
gpdf는 Go용 PDF 생성 라이브러리. MIT, 제로 의존성, CJK 지원.
go get github.com/gpdf-dev/gpdf