전체 글

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에서 테이블을 그릴 때 실수가 일어나는 세 가지:

  1. 컬럼 너비. 웹에는 CSS <col>colgroup이 있다. PDF에는 없다. 점(pt) 단위로 직접 계산하거나 라이브러리의 균등 분할을 받아들이거나 둘 중 하나다.
  2. 줄무늬. 본문 한 줄 걸러 옅은 회색으로 칠해 가독성을 높이고 싶다. 저수준 라이브러리는 직접 행 루프를 쓰고 i % 2를 추적해야 한다 — 테이블 렌더링 버그의 절반이 여기서 나온다.
  3. 페이지 분할. 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 관습.

이 옵션이 있는 이유: gofpdfgopdf에서는 직접 루프를 쓰고, 행마다 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. 페이지 시스템은 들어간 부분을 현재 페이지로 내보내고, 새 페이지를 열고, 새 페이지 가용 높이로 오버플로 테이블을 다시 레이아웃 엔진에 먹인다. 오버플로가 빌 때까지 반복.

두 가지 귀결:

  1. 헤더는 행 루프가 아니라 tbl.Header에 산다. 오버플로 테이블이 같은 Header 슬라이스를 재사용하므로 후속 페이지 위쪽에서 헤더가 자동 반복된다. 스타일도 컬럼 너비도 전부 동일.
  2. "헤더가 이 페이지에 안 들어감" 코너 케이스를 생각할 필요 없음. 레이아웃 엔진이 본문 행을 측정하기 전에 헤더용 공간을 예약한다. 한 페이지에 헤더와 본문 행 하나도 못 넣으면 테이블 전체를 다음 페이지로 보낸다.

푸터 — 도큐먼트 층에서 쓸 때 — 도 같은 방식. 후속 페이지 아래쪽에서 자동으로 매번 그려진다.

없는 기능: "이 행 그룹은 분리하지 마" 어노테이션, 특정 행에서 분할 억제, "이 테이블은 새 페이지에서 시작" 지시. 앞 둘은 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.Widthdocument.Value 타입으로 Pt / Mm / Cm / In / Em / Pct, 그리고 특별한 Auto를 받는다. 한 테이블 안에서 섞을 수 있다. Auto 컬럼은 고정과 퍼센트 컬럼을 빼고 남은 걸 공유한다. 빌더의 퍼센트 전용 모델보다 CSS <col> 요소에 더 가깝다.

TableCell.ColSpanRowSpan은 정수, 기본값 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/gopdf50〜80줄수동수동더 저수준
johnfercher/maroto v2약 15줄자동행마다 WithBackgroundColor 수동gofpdf 위에 구축. API는 깔끔하지만 의존성 상속
unidoc/unipdf약 12줄자동행 스타일 헬퍼 있음상용 라이선스 필수

빌더 줄 수만 보면 차이가 좁아 보인다. 진짜 차이는 6개월 후에 드러난다. 요구가 흘러갈 때 — 새 컬럼이 다른 정렬을 원하고, 보고서가 일본어 버전을 필요로 하고, 고객이 푸터에 행 수 표시를 요구하고 — gofpdfgopdf로는 매번 행 루프를 건드려야 한다. 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

⭐ GitHub에서 별 주기 · 문서 읽기

관련 읽을거리