Go PDF 테이블: 컬럼 너비, 줄무늬, 페이지 분할
Go에서 PDF 테이블 그리는 일은 사고 나기 쉽다. gpdf는 컬럼 너비·줄무늬·헤더 반복을 단 한 번의 Table 호출로 압축한다. API 전체와 트레이드오프.
한 줄 요약
테이블은 PDF 생성에서 주말을 날리는 부분이다. 합이 안 맞는 컬럼 너비, 2페이지에서 사라지는 헤더, 행 루프 off-by-one으로 그려지는 줄무늬. gpdf는 이걸 한 번의 호출로 접는다:
c.Table(header, rows,
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
컬럼 너비, 줄무늬, 페이지 분할 시 헤더 자동 반복 까지 한 번에. 행 루프 안 쓴다. PageBreak 옵션 없다. 테이블이 안 들어가면 레이아웃 엔진이 알아채고 다음 페이지 위쪽에 Header 슬라이스를 다시 그린다. ColSpan, RowSpan, 매 페이지 반복 푸터가 필요할 때만 document.Table로 내려간다 — 같은 부품을 더 세밀하게 조립하는 층이다.
이 글은 테이블 설계에서 진짜 중요한 세 축 (컬럼 너비, 줄무늬, 페이지 분할)에 대해 gpdf가 각각 무엇을 하는지, 추상화를 어디서 의도적으로 멈추는지를 다룬다.
이 글이 필요한 이유
gpdf는 Go용 PDF 생성 라이브러리다. MIT, 외부 의존 0, 단일 페이지 렌더링 약 13µs. 고수준 API에서 테이블 부분은 작다 — TableOption이 8개뿐이다 — 가 거기에 걸린 설계 압력은 크다. Go의 PDF 프로젝트는 거의 다 테이블에서 막힌다.
Go에서 테이블을 그릴 때 실수가 일어나는 세 가지:
- 컬럼 너비. 웹에는 CSS
<col>과colgroup이 있다. PDF에는 없다. 점(pt) 단위로 직접 계산하거나 라이브러리의 균등 분할을 받아들이거나 둘 중 하나다. - 줄무늬. 본문 한 줄 걸러 옅은 회색으로 칠해 가독성을 높이고 싶다. 저수준 라이브러리는 직접 행 루프를 쓰고
i % 2를 추적해야 한다 — 테이블 렌더링 버그의 절반이 여기서 나온다. - 페이지 분할. 200행 보고서는 A4 한 페이지에 안 들어간다. 라이브러리는 (a) 적절한 위치에서 본문을 자르고, (b) 현재 페이지를 닫고, (c) 새 페이지를 열고, (d) 새 페이지 위쪽에 헤더를 다시 그려야 한다. 하나라도 빠지면 그 테이블은 못 쓴다.
이 글은 gpdf가 각 문제를 어떻게 푸는지와 설계상 트레이드오프를 순서대로 설명한다. 복사-붙여넣기 레시피만 원하면 끝의 관련 링크를 보면 된다. 이 글은 "10,000행짜리 월간 명세서를 이 API에 맡겨도 되는가"를 판단하려는 사람을 위한 긴 버전이다.
API의 모양
빌더 층의 진입점은 하나:
func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)
헤더는 문자열 슬라이스, 행은 문자열 슬라이스의 슬라이스, 가변 opts가 나머지를 설정한다. 옵션 생성자는 8개:
| 옵션 | 제어 대상 |
|---|---|
ColumnWidths(...float64) | 부모 Col 너비에 대한 퍼센트, 컬럼당 하나 |
TableHeaderStyle(...TextOption) | 헤더 배경색과 글자색 |
TableStripe(pdf.Color) | 본문 교대 행 배경색 |
TableCellVAlign(document.VerticalAlign) | 본문 셀의 수직 정렬 |
WithTableBorder(BorderSpec) | 테이블 전체 외곽선 |
WithTableCellBorder(BorderSpec) | 모든 셀에 같은 테두리 — 그리드 모양 |
WithTableBorderCollapse(bool) | CSS border-collapse: collapse 의미 |
WithTableBackground(pdf.Color) | 테이블 전체 배경 |
빌더 층 표면은 이게 전부다. 빌더로 만들 수 있는 건 이 8개로 다 만든다. 그 너머 — ColSpan, RowSpan, 푸터, 고정 pt 너비 — 는 document.Table로 간다. 뒤에 다룬다.
동작 코드: 6개월치 청구 원장
완전 실행 가능한 예시. main.go로 저장하고 go run . 실행, ledger.pdf가 나온다.
package main
import (
"fmt"
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
brand := pdf.RGBHex(0x1A237E)
stripe := pdf.RGBHex(0xF5F5F5)
hairline := template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)
header := []string{"날짜", "청구번호", "고객", "금액"}
rows := make([][]string, 0, 120)
for i := 1; i <= 120; i++ {
rows = append(rows, []string{
fmt.Sprintf("2026-%02d-%02d", (i%6)+1, (i%28)+1),
fmt.Sprintf("INV-%05d", 10000+i),
fmt.Sprintf("고객 #%d", i),
fmt.Sprintf("₩%d", (100+i*7)*1000),
})
}
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("2026 상반기 원장", template.FontSize(18), template.Bold())
c.Spacer(document.Mm(4))
c.Table(header, rows,
template.ColumnWidths(20, 20, 40, 20),
template.TableHeaderStyle(
template.TextColor(pdf.White),
template.BgColor(brand),
),
template.TableStripe(stripe),
template.WithTableCellBorder(hairline),
)
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("ledger.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
A4에 120행은 약 5페이지에 걸친다. 매 페이지 위쪽에 짙은 파랑 헤더가 다시 그려지고, 본문은 이전 페이지가 끝난 곳부터 이어지며, 줄무늬 패턴은 페이지를 넘어도 일관된다. 이걸 위해 추가로 쓸 코드는 없다.
이 코드에서 봐야 할 건 없는 것 이다. 행 루프도, 페이지 카운터도, if i == lastRowOnPage 분기도, PageBreak() 호출도, 헤더 재그리기도 없다. 옵션 4줄이 테이블 모양을 선언하고, 엔진이 "언제 어디서 자를지"를 담당한다.
컬럼 너비: 퍼센트는 무엇의 퍼센트인가
ColumnWidths(40, 15, 20, 25)는 CSS의 <col width="40%">와 비슷하다. 다른 점이 셋 있다.
부모 Col에 대한 퍼센트, 페이지가 아니다. r.Col(6, ...)은 행 콘텐츠 너비의 절반을 차지한다. 그 안에 ColumnWidths(50, 50) 테이블을 두면 각 컬럼은 행 너비의 25% 이지 페이지 너비의 50%가 아니다. 퍼센트는 테이블이 놓인 위치에 로컬이다. 테이블을 전체 너비 행에서 나란한 레이아웃으로 옮길 때 옵션 호출은 안 바꿔도 된다.
정규화 안 한다. 합이 90이면 오른쪽이 10% 빈다. 110이면 가장 오른쪽 컬럼이 부모를 넘어 페이지 밖으로 흘러나간다. gpdf는 계산 결과를 그대로 쓴다. 경고도 없다 — 있어선 안 된다. 사용자가 쓴 값을 자동으로 고치는 게 그 버그보다 더 해롭다.
뒤쪽 누락은 자동 분배. 컬럼 수보다 적은 너비를 주면 남는 컬럼이 나머지를 균등 분배한다:
// 5컬럼 테이블, 3개 지정
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15% (남은 30%를 둘이 나눠 가짐)
"이 컬럼들 너비만 신경 쓰고 나머진 알아서"가 유용한 트릭이다. 0을 명시적으로 주면 그 컬럼도 자동:
template.ColumnWidths(0, 30, 30) // 3컬럼 테이블 → 40% / 30% / 30%
너비의 코너 케이스 디테일은 컬럼 너비 레시피 참고. 요약: 퍼센트가 95%의 레이아웃을 커버한다, 안 되는 5%는 한 층 내려간다. 뒤에 다룬다.
줄무늬: 직접 안 쓰는 행 루프
template.TableStripe(pdf.RGBHex(0xF5F5F5))
이게 끝. gpdf는 0부터 시작하는 인덱스 i로 본문 행을 순회하고 i % 2 == 1인 행에 색을 칠한다. 헤더는 별도 슬라이스라 카운트 대상이 아니라서, 첫 본문 행은 깨끗하고 두 번째가 음영이 진다 — Bootstrap 관습.
이 옵션이 있는 이유: gofpdf나 gopdf에서는 직접 루프를 쓰고, 행마다 SetFillColor를 호출하고, CellFormat에 채우기 플래그를 넘긴다. 8〜10줄 코드에 off-by-one 발생률이 너무 높아서 StackOverflow에 전용 답변 세트가 있을 정도. 이걸 옵션 하나로 접으면 버그 클래스가 통째로 사라진다.
제약은 의도적:
- 줄무늬 색은 하나, 두 색 교대 안 됨. "파랑과 회색 교대" 같은 지정 불가. 페이지가 이미 흰색이니 칠하지 않은 행은 자동으로 흰색이다. 세 번째 색 사이클을 추가하면 독자에게 인지 부담만 늘린다 — 줄무늬는 그 반대를 위해 존재한다.
- 홀짝 반전 수단 없음. 첫 본문 행은 항상 깨끗, 두 번째는 항상 음영. 정말 반전하고 싶으면 데이터 앞에 빈 행을 넣어라. 하지만 그러고 싶은 사람은 없다.
- 줄무늬는 페이지를 넘어도 정확히 유지. 본문 14번째 행이 2페이지로 넘어가도 14번째인 채로 패리티를 유지한다. 엔진이 분할을 가로질러 인덱스를 이어 간다.
색 선택과 다크 테마 변형은 줄무늬 레시피 참조. 이 글의 요지: 테이블의 속성 (교대 패턴) 은 테이블 호출 측에서 선언하는 것이지 행 단위로 지시하는 것이 아니다.
페이지 분할: 진짜 어려운 부분
대부분의 Go PDF 이야기가 여기서 무너지고, gpdf 설계가 가장 보상받는 곳도 여기다.
간단 버전: 한 페이지에 안 들어갈 양의 행을 테이블에 넣으면 gpdf가 자동 분할한다. Header 슬라이스는 모든 후속 페이지 위쪽에서 재그려진다. 켜는 옵션도, 호출할 메서드도 없다. 레이아웃 엔진의 기본 동작이다.
자세히 보면 더 흥미롭다. 블록 레이아웃 엔진 (document/layout/block.go) 은 가용 높이로 테이블을 레이아웃한다. 본문이 안 들어가면 결과에 Overflow 필드가 붙는다 — 같은 Header, 같은 Footer, 남은 본문 행 으로 된 새 *document.Table. 페이지 시스템은 들어간 부분을 현재 페이지로 내보내고, 새 페이지를 열고, 새 페이지 가용 높이로 오버플로 테이블을 다시 레이아웃 엔진에 먹인다. 오버플로가 빌 때까지 반복.
두 가지 귀결:
- 헤더는 행 루프가 아니라
tbl.Header에 산다. 오버플로 테이블이 같은Header슬라이스를 재사용하므로 후속 페이지 위쪽에서 헤더가 자동 반복된다. 스타일도 컬럼 너비도 전부 동일. - "헤더가 이 페이지에 안 들어감" 코너 케이스를 생각할 필요 없음. 레이아웃 엔진이 본문 행을 측정하기 전에 헤더용 공간을 예약한다. 한 페이지에 헤더와 본문 행 하나도 못 넣으면 테이블 전체를 다음 페이지로 보낸다.
푸터 — 도큐먼트 층에서 쓸 때 — 도 같은 방식. 후속 페이지 아래쪽에서 자동으로 매번 그려진다.
없는 기능: "이 행 그룹은 분리하지 마" 어노테이션, 특정 행에서 분할 억제, "이 테이블은 새 페이지에서 시작" 지시. 앞 둘은 TODO. 셋째는 페이지 층에서 — 테이블 포함 행 앞에서 doc.AddPage()를 호출.
빌더 API를 넘어설 때
빌더는 일반적인 경우에 강하다. 셀 합치기, 고정 pt 너비, 매 페이지 반복 푸터, 셀마다 다른 콘텐츠 타입을 섞을 때는 document.Table로 내려간다.
import (
"github.com/gpdf-dev/gpdf/document"
)
footer := document.TableRow{
Cells: []document.TableCell{
{
Content: []document.DocumentNode{
&document.Text{Content: "합계", TextStyle: document.DefaultStyle()},
},
ColSpan: 3, // ← 앞 3컬럼에 걸침
RowSpan: 1,
},
{
Content: []document.DocumentNode{
&document.Text{Content: "₩48,720,000", TextStyle: document.DefaultStyle()},
},
ColSpan: 1,
RowSpan: 1,
},
},
}
tbl := &document.Table{
Columns: []document.TableColumn{
{Width: document.Pct(20)},
{Width: document.Pct(20)},
{Width: document.Auto},
{Width: document.Pt(80)}, // 페이지 너비와 무관하게 고정 80pt
},
Header: /* ... */,
Body: /* ... */,
Footer: []document.TableRow{footer},
}
몇 가지. TableColumn.Width는 document.Value 타입으로 Pt / Mm / Cm / In / Em / Pct, 그리고 특별한 Auto를 받는다. 한 테이블 안에서 섞을 수 있다. Auto 컬럼은 고정과 퍼센트 컬럼을 빼고 남은 걸 공유한다. 빌더의 퍼센트 전용 모델보다 CSS <col> 요소에 더 가깝다.
TableCell.ColSpan과 RowSpan은 정수, 기본값 1. 예시는 앞 3컬럼을 합쳐 "합계"를 적고 4번째에 금액을 둔다 — 클래식 청구서 푸터.
document.Table.Footer는 []TableRow로 헤더처럼 매 페이지 반복된다. 빌더 API가 노출하지 않는 건 짧은 테이블 대부분이 안 쓰기 때문 — 쓸 필요가 생기면 이미 "일반 경우"를 벗어났다.
이게 gpdf 전반의 패턴이다. 고수준 빌더가 90%를 쾌적하게 커버하고, 도큐먼트 층이 나머지 10%를 위해 옆에 앉아 있다. 별개 라이브러리가 아니다. 같은 도큐먼트에 빌더 행과 수동 행을 섞을 수 있다. 빌더는 같은 document.Table 노드의 생성자일 뿐이다.
테두리와 박스 모델
테두리 옵션은 셋, 각자 다른 일을 한다:
template.WithTableBorder(spec) // 테이블 전체 외곽선
template.WithTableCellBorder(spec) // 모든 셀에 같은 테두리
template.WithTableBorderCollapse(true) // 인접 셀 테두리 병합
기본은 테두리 없음. 외곽만 원하면 WithTableBorder. 그리드를 원하면 WithTableCellBorder. 둘 다 더하면 "외곽 + 그리드". BorderSpec 자체는 template.Border(template.BorderWidth(...), template.BorderColor(...))로 만든다.
WithTableBorderCollapse(true)는 CSS 동명 속성과 같은 의미: 인접 셀 테두리가 한 줄로 병합된다 (각 셀의 변에서 두 번 그리지 않음). 헤어라인 그리드에선 이쪽이 깔끔하다, 일부러 두께를 두 배로 보고 싶은 굵은 테두리에선 끄면 된다. 기본은 분리.
실용적인 조합은 헤어라인 셀 테두리 + 옅은 줄무늬:
c.Table(header, rows,
template.ColumnWidths(40, 20, 15, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
template.WithTableCellBorder(template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)),
template.WithTableBorderCollapse(true),
)
회계사의 Excel 인쇄 미리보기 그 느낌. 청구서, 명세서, 원장, 경비 정산 — 재무 인접 문서의 합리적 기본값이다.
다른 라이브러리와 비교
참고로 같은 "다중 페이지 + 줄무늬" 테이블이 보통 gpdf로 대체되는 라이브러리에서 어떻게 나오는지:
| 라이브러리 | 테이블 코드 줄 수 | 페이지 분할 시 헤더 반복 | 줄무늬 | 비고 |
|---|---|---|---|---|
| gpdf | 약 10줄 | 자동 | TableStripe(...) | 빌더와 저수준 둘 다 사용 가능 |
| jung-kurt/gofpdf (2021 보관) | 40〜60줄 | 수동: Y 추적, AddPage, 헤더 재그리기 | 행 루프 안에서 SetFillColor | 토대지만 유지 보수 종료 |
| go-pdf/fpdf (2025 보관) | 40〜60줄 | 동일 | 동일 | gofpdf 포크. 같은 모델 |
| signintech/gopdf | 50〜80줄 | 수동 | 수동 | 더 저수준 |
| johnfercher/maroto v2 | 약 15줄 | 자동 | 행마다 WithBackgroundColor 수동 | gofpdf 위에 구축. API는 깔끔하지만 의존성 상속 |
| unidoc/unipdf | 약 12줄 | 자동 | 행 스타일 헬퍼 있음 | 상용 라이선스 필수 |
빌더 줄 수만 보면 차이가 좁아 보인다. 진짜 차이는 6개월 후에 드러난다. 요구가 흘러갈 때 — 새 컬럼이 다른 정렬을 원하고, 보고서가 일본어 버전을 필요로 하고, 고객이 푸터에 행 수 표시를 요구하고 — gofpdf나 gopdf로는 매번 행 루프를 건드려야 한다. gpdf는 옵션 리스트가 길어지고 본문은 그대로다.
µs 단위 벤치마크는 gpdf가 빠른 이유. 더 넓은 축의 비교는 2026 라이브러리 총람.
테이블 안의 CJK
위 비교표에서 안 보이는 사실: gpdf는 CJK 글리프를 네이티브로 렌더링한다. 한국어용 "테이블 모드"는 없다 — 폰트를 한 번 등록하면 테이블도 그걸 사용한다.
ttf, _ := os.ReadFile("NotoSansKR-Regular.ttf")
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithFont("NotoSansKR", ttf),
gpdf.WithDefaultFont("NotoSansKR"),
)
c.Table(
[]string{"날짜", "청구번호", "고객", "금액"},
[][]string{
{"2026-04-01", "INV-10001", "샘플 주식회사", "₩120,000"},
{"2026-04-02", "INV-10002", "야마다 상점", "₩38,500"},
},
template.ColumnWidths(20, 20, 40, 20),
)
헤더가 한국어, 본문이 한국어, 컬럼 너비는 여전히 퍼센트, 페이지 분할 시 헤더 반복도 그대로 작동. 폰트는 도큐먼트가 사용하는 글리프만 서브셋되므로 단일 페이지 출력은 50KB 정도, 전체 Noto Sans KR는 약 6MB와 대비된다.
폰트 등록 자체는 한국어 TrueType 폰트 임베딩 레시피 (같은 패턴이 한국어에도 적용). 이 글의 요점: 데이터가 CJK여도 테이블 API는 변하지 않는다.
자주 묻는 질문
Q: 행별 스타일 지정 가능한가?
빌더 API에선 불가. 빌더는 본문에 [][]string을 받으므로 모든 본문 셀이 컬럼 유래의 같은 Style을 공유한다. 행마다 다른 스타일을 원하면 document.Table 층에서 조립 — 각 TableCell이 자체 CellStyle을 가진다. 패턴은 단순, [][]string의 편의만 잃는다.
Q: 셀 안에 이미지나 중첩 테이블을 넣을 수 있나?
document.Table 층이면 가능. TableCell.Content는 []DocumentNode로 *Text, *Image, 심지어 중첩된 *Table까지 받는다. 빌더의 문자열 API는 노출하지 않지만 (대부분 사용자에게 날카로운 모서리이기 때문) 기저 모델은 지원한다.
Q: gpdf는 어디서 페이지를 자르는지 어떻게 결정하나?
행 단위로. 레이아웃 엔진이 본문 행을 순서대로 측정해서 다음 행이 가용 높이를 초과할 직전까지 현재 페이지에 더한다. 그 행이 오버플로 테이블의 첫 행이 된다. "이 행들은 떼지 마라" 어노테이션은 아직 없다 — 모든 행이 분할 가능하다. 청구서 라인 항목의 논리 그룹을 한 페이지에 두고 싶으면 그룹 앞에서 수동으로 페이지를 열거나 도큐먼트 층에서 분할 힌트를 끼워 넣어야 한다.
Q: gpdf가 렌더링할 수 있는 최대 테이블 크기는?
A4에서 본문 10,000행은 검증됨. 정확히 페이지 분할되고 매 페이지 헤더가 다시 그려지며 출력 PDF는 약 150페이지 수백 KB. 병목은 테이블 레이아웃이 아니라 셀 콘텐츠의 텍스트 셰이핑, O(행 × 열). 100,000행 이상이 필요하면 디스크에 청크로 쓰거나 (10,000행마다 Generate) document.Table 층에서 사전 셰이핑된 run을 넘긴다.
Q: 푸터를 마지막 페이지에만 표시할 수 있나?
내장 기능 아님. document.Table.Footer는 설계상 매 페이지 반복된다 — 흔한 용도가 페이지별 컬럼 합계라서. 도큐먼트 끝에 한 번만 띄우는 요약을 원하면 테이블 안이 아니라 테이블 다음에 별도 행 블록으로 추가해라.
Q: WithTableCellBorder는 헤더에도 영향을 주나?
영향을 준다. 셀 테두리는 헤더와 본문에 일관되게 적용. 헤더만 다른 테두리 (예: 헤더 아래쪽이 더 두껍게) 를 원하면 도큐먼트 층에서 헤더를 만들고 셀별 CellStyle.Border를 지정해라.
설계의 큰 그림
하나만 가져간다면: gpdf 테이블 API가 작은 건 테이블 문제 거의 전부가 결국 같은 세 문제로 귀결되기 때문이다. 컬럼 너비, 줄무늬, 페이지 분할. 나머지는 long tail이다. 일반적인 경우를 빌더에, long tail을 도큐먼트 층에 두는 게 그 거래 — 매일 나오는 용도는 5줄로 쓰고, 빌더로 표현 못 하는 일을 할 때 추상화 비용을 안 낸다.
대가는 솔직하다: setRowStyle(i, ...) 단축키는 없다, 앞으로도 없을 것이다. 4행과 5행을 다른 스타일로 만들고 싶다면 빌더가 다루지 않으려는 복잡도 선을 이미 넘은 것이다. 한 층 내려가라. 경계는 명확하고 안정적이다.
전체는 여기까지. 한 번 읽고 이후엔 더 생각 안 해도 되는 API 부분에 대한 20분짜리 글이다.
gpdf 시도해 보기
gpdf는 Go용 PDF 생성 라이브러리. MIT, 외부 의존 0, 네이티브 CJK.
go get github.com/gpdf-dev/gpdf
관련 읽을거리
- gpdf 테이블에서 컬럼 너비를 어떻게 설정하나? —
ColumnWidths코너 케이스 - 줄무늬 (제브라) 테이블 행을 어떻게 만드나? — 색 선택과 다크 테마
- PDF를 위한 Bootstrap 사고: gpdf의 12컬럼 그리드 — 테이블 퍼센트가 무엇의 너비에 대한 퍼센트인지
- Go로 50줄 미만 청구서 PDF 만들기 — 완전한 도큐먼트 안의 실세계 테이블
- gpdf가 gofpdf / gopdf / Maroto보다 빠른 이유 — 비교표 뒤의 µs 숫자