Bootstrap 식 사고를 PDF로: gpdf의 12 컬럼 그리드
gpdf는 PDF 레이아웃에 Bootstrap의 12 컬럼 그리드를 차용했다. 정수 span 모델만 남기고 브레이크포인트·거터·order는 전부 버린 설계 판단을 정리한다.
TL;DR
gpdf는 Bootstrap의 12 컬럼 그리드를 그대로 가져왔다. 12를 고른 이유는 1·2·3·4·6으로 깔끔하게 나뉘기 때문이다 — 실무에서 진짜로 쓰는 분할이 전부 들어간다. 정수 span 모델만 남기고 나머지는 전부 버렸다: 브레이크포인트 없음, 거터 없음, order 없음, auto-fill 없음. 페이지는 행의 스택, 행은 한 개의 수평 Box, 그 안의 컬럼은 「12 중 몇 개」 단위로 너비가 결정된다.
그게 전부다. 구현은 Go 약 30줄. 흥미로운 부분은 이식하지 않은 것이다.
이 글을 쓰는 이유
gpdf는 Go용 PDF 생성 라이브러리다. 상위 레이아웃 API는 빌더 패턴이다: page.AutoRow → r.Col(span, fn) → c.Text/Image/Table. 처음 보는 사람이 r.Col(4, ...)를 보고 보통 세 가지를 묻는다.
- 왜 12인가? 16, 24, 아니면 「원하는 만큼」은 안 되는가?
- 이건 CSS Grid? Bootstrap? 아니면 다른 무엇?
- span 합이 12가 안 되면 어떻게 되는가?
이 글은 그 결정의 과정을 순서대로 풀어 답한다. 모든 판단은 한 가지 원칙으로 수렴한다: PDF 렌더링은 「적응적」이 아니라 「예측 가능」해야 한다. 웹 페이지는 리사이즈에 반응해 다시 흐른다. PDF는 그러지 않는다. 이 차이 하나로 웹 그리드 시스템을 어렵게 만드는 요소 대부분이 사라지고, 훨씬 작은 디자인을 출시할 수 있게 된다.
「쓰는 법만 알고 싶다」면 /blog/12-column-grid의 레시피가 더 직접적이다. 이 글은 「왜 이런 모양인가」에 대한 이야기다.
PDF 레이아웃을 짜는 세 가지 선택지
상위 API를 시작했을 때 현실적인 선택지는 셋이었다.
- 절대 좌표. 「(72, 540) pt에 텍스트를 그린다」. Go의 저수준 PDF 라이브러리 대부분이 이 방식이다. 자유도는 최대, UX는 최악. 좌표를 전부 직접 계산해야 한다.
- Flow + flexbox. 콘텐츠를 위에서 아래로 쌓고, 행 안에서는 grow/shrink 비율로 자식을 가로로 분배. 강력하지만 레이아웃 패스가 비자명하다 — 제약 솔버가 필요하고 반올림 오차가 누적된다.
- 고정 그리드 + 비율. 페이지는 행의 스택. 행은 N개의 균등 슬롯으로 나뉜다. 컬럼은 정수 개의 슬롯을 차지한다. 너비 =
슬롯 수 / N × 행 너비. 제약 솔버 없음. grow/shrink 없음.
3번을 골랐다. Bootstrap이 10여 년 전 같은 이유로 같은 결론에 도달했다: 실무에서 필요한 레이아웃의 대부분은 정수비 레이아웃이다. 동일 너비 2 컬럼. 1/3 + 2/3 분할. 4 카드 행. 25-50-25 행. 어느 것도 제약 솔버를 필요로 하지 않는다.
남은 질문은 「N은 몇으로?」였다.
왜 12인가
12는 마법이 아니지만, 임의도 아니다. 문서에서 정말로 원하는 정수 분할들을 떠올려보자:
- 2 컬럼 — 좌우 절반
- 3 컬럼 — 1/3씩 (3 카드 갤러리)
- 4 컬럼 — 1/4씩 (KPI 스트립)
- 6 컬럼 — 1/6씩 (좁은 사이드 패널, 가끔)
- 12 컬럼 — 1/12씩 (희귀, 얇은 구분선)
12의 약수: 1, 2, 3, 4, 6, 12. 즉 1/6까지 「실제로 쓰는」 정수 분할이 전부 들어간다. 10은 1/3을 못 만든다. 16도 못 만든다. 24는 다 만들 수 있지만 인지 부담이 두 배다 — r.Col(8, ...)를 보고 1/3 (24÷3)인지 2/3 (8÷12)인지 매번 머릿속 변환이 필요해진다. 12는 사람들이 자주 쓰는 분할을 모두 커버하는 최소의 수다.
Bootstrap이 2011년에 12에 도달한 것도 같은 이유. 이후 CSS Grid는 추상을 한 단계 더 올려 1fr 2fr 1fr 같은 비율을 직접 쓰게 만들어 매직 넘버를 없앴다. 하지만 분수는 공짜가 아니다 — 읽는 사람에게 「형제를 다 봐야 의미가 정해진다」는 비용을 떠넘긴다. r.Col(4, ...)는 곧장 「행의 1/3」이라고 알 수 있다. r.Col(2fr, ...)는 주변을 다 봐야 의미가 잡힌다.
레이아웃이 고정이고 눈으로 디버깅하는 PDF에서는 정수 모델이 더 잘 맞는다.
Bootstrap에서 가져온 것
세 개뿐이다.
- 12. 분모. 다이얼에 새겨진 유일한 숫자.
- 정수 1〜12의 span. 분수도, CSS 단위도 아니다.
r.Col(4, ...)는 「12 중 4」. - 사고 모델. 페이지는 행의 스택, 행은 컬럼으로 분할. HTML로 10년간 써온 그리드와 같은 형태다.
여기까지는 Bootstrap과 같다. 진짜로 흥미로운 건 그다음이다.
버린 것
브레이크포인트
Bootstrap의 col-md-6 col-lg-4는 「태블릿에선 절반, 데스크톱에선 1/3」 식 지정이다. 웹에선 유용. PDF에선 의미가 없다. PDF 페이지는 고정 캔버스다. 조회할 viewport도, resize 이벤트도, media query도 없다. 브레이크포인트는 통째로 삭제했다.
여기서 절약되는 양은 보이는 것보다 훨씬 크다. CSS 프레임워크가 col-xs-*, col-sm-*, col-md-*, col-lg-*, col-xl-*의 다섯 변종을 끌고 다니는 이유 자체가 브레이크포인트다. gpdf엔 그것들이 전혀 없다. API는 r.Col(span int, fn func(*ColBuilder)). 시그니처 하나, 기억할 축 하나.
거터
Bootstrap 행은 컬럼 사이에 기본 horizontal padding이 들어간다. PDF엔 기본 거터가 필요 없다 — 컬럼 사이의 마진은 그리는 내용에 따라 완전히 달라지기 때문이다. 빽빽한 표는 0, 히어로 섹션은 24pt, 청구서 줄은 0.5pt 짜리 구분선 정도다. 그래서 간격은 명시적으로 만들었다.
거터가 필요하면 직접 넣는다: 컬럼 사이에 c.Spacer(...)를 끼우거나, 안쪽을 padding 있는 Box로 감싼다. 그리드 자신은 요청하지 않은 픽셀을 절대 끼워 넣지 않는다. 모든 점이 의미 있는 인쇄 매체에서는 「거터 없음」이 옳은 기본값이다.
order
CSS는 order: 2로 컬럼의 시각적 순서를 바꿀 수 있다. 같은 DOM이 좁은 화면에서는 다른 순서로 보이게 하는 반응형 기능. PDF에선 쓸 데가 없다. 파일에 등장하는 순서가 곧 페이지에 등장하는 순서다. 검토조차 하지 않았다.
auto-fill / auto-fit
CSS Grid엔 repeat(auto-fit, minmax(200px, 1fr))가 있다. 「200px 이상 컬럼을 들어가는 만큼 채워라」. 웹 갤러리에는 아름답다. PDF는 빌드 시점에 페이지 너비를 알고 있다. 레이아웃 엔진에 추측시킬 필요가 없다.
4 카드 행이 필요하면 r.Col(3, ...)를 네 번. 6 카드면 r.Col(2, ...)를 여섯 번. 「auto」 버전은 사용자 코드의 for 루프 한 줄이면 된다.
for _, item := range items {
r.Col(3, func(c *template.ColBuilder) {
c.Text(item.Name)
})
}
세 줄. 프레임워크에 박을 필요는 없었다.
span 합 강제
의외라 할 만한 부분: gpdf는 컬럼 span의 합이 12이기를 요구하지 않는다. 의도된 것이다.
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) { c.Text("좌 1/3") })
r.Col(4, func(c *template.ColBuilder) { c.Text("중 1/3") })
// 합 = 8. 우측 1/3은 그냥 빈 공간.
})
라이브러리는 각 컬럼을 span/12 × 행 너비로만 처리한다. 한 행에 4 + 4를 넣으면 우측 슬롯이 빈다. 7 + 8을 넣으면 두 번째 컬럼이 행 경계를 넘어 흘러나간다 — 이것도 의도다. 가끔은 페이지보다 넓은 레이아웃 그리드에 맞추려고 일부러 넘쳐야 할 때가 있다. span은 1〜12로 클램프된다 (Col(0, ...) → Col(1, ...), Col(99, ...) → Col(12, ...). gpdf/template/grid.go:120 참조). 자동 wrap도, 자동 밸런싱도 없다.
Bootstrap 구버전의 「합이 12를 넘으면 다음 행으로 wrap」 동작은 웹 반응형 문제에 대한 해법이었다. PDF엔 그 문제가 없다. 그 자리에 더 단순한 계약을 두었다: 쓴 대로 나온다.
container, fluid 모드, no-gutters, offset, push/pull
전부 안 넣었다. container-fluid도, col-md-offset-3도, col-md-push-2도, Bootstrap 유틸리티 클래스 등가물은 하나도 없다. 컬럼을 오른쪽으로 밀고 싶으면 직접 감싼다: 빈 r.Col(3, ...)를 앞에 둔다. 여덟 글자 더, 새 개념은 없음.
gpdf vs Bootstrap vs CSS Grid
| 기능 | Bootstrap (CSS) | CSS Grid (CSS) | gpdf (Go) |
|---|---|---|---|
| 그리드 크기 | 12 컬럼 | 임의 (grid-template-columns) | 12 컬럼 |
| 단위 | 클래스명 | 비율 (fr), px, % | 정수 span 1〜12 |
| 브레이크포인트 | 5종 (xs/sm/md/lg/xl) | media query 통해 | 없음 |
| 기본 거터 | 있음 (gx-* 제어) | 없음 | 없음 |
| 시각 재정렬 | order-* | order 속성 | 없음 |
| auto-fill | 없음 | 있음 | 없음 |
| 합 > 12 시 wrap | 있음(구버전) / 없음(flex) | 해당 없음 | 없음 (오버플로 허용) |
| 구현 규모 | 약 3,000 LoC SCSS | 브라우저 내부 | 약 30 LoC Go |
「30 LoC」는 진짜 숫자다. gpdf/template/grid.go를 열어 세보면 된다: 상수 하나(gridColumns = 12), 정수를 클램프하는 빌더 메서드 하나, 행마다 한 개의 수평 Box를 내고 자식 너비는 Pct(span/12*100)인 build 패스. 측정 패스 없음, flex 알고리즘 없음, 재밸런싱 없음. 너비의 산술이 곧 알고리즘이다.
내부에서 무슨 일이 일어나는가
r.Col(4, fn)을 호출하면 gpdf는 행에 colEntry{span: 4, fn: fn}를 append한다. 문서를 build할 때 각 entry는 Width: document.Pct(33.333…)인 document.Box가 되고, 컬럼 콘텐츠가 그 안에 중첩된다. 행 자신은 Direction: DirectionHorizontal인 Box. PDF Writer(Layer 1)는 문서 순서대로 Box를 순회하며 콘텐츠 스트림을 출력하고, 레이아웃 엔진(Layer 2)은 너비/높이 해석을 하고, 그리드(Layer 3)는 정수 → 퍼센트 변환을 한다. 끝.
이게 30줄로 끝나는 이유는 퍼센트와 정수가 레이아웃 경계에서 반올림 오차 없이 합성된다는 데 있다. 컬럼 안에 컬럼, 그 안에 또 컬럼이 들어가도 결국 float64 위의 Pct 곱셈 체인일 뿐이다. 깊이 중첩된 레이아웃에서도 오차는 1 typographic point 이내에 들어온다.
전체 파이프라인을 보고 싶다면 gpdf가 다른 라이브러리보다 10배 빠른 이유에서 렌더링 파이프라인을 다룬다. 그리드는 그중 가장 가벼운 층이다 — M1에서 페이지당 약 13 µs 중 그리드는 수백 나노초 정도다.
완전히 동작하는 예제
4/8 분할 헤더, 12 전폭 표 행, 3/3/3/3 KPI 스트립:
package main
import (
"os"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := template.NewDocument(document.PageSize(document.A4))
doc.Page(func(p *template.PageBuilder) {
// 4/8 분할: 좌측 로고, 우측 주소
p.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Text("ACME, Inc.", template.FontSize(18), template.Bold())
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("서울특별시 강남구 테헤란로 123", template.AlignRight())
c.Text("우편번호 06234", template.AlignRight())
})
})
p.Spacer(document.Mm(10))
// 전폭(12 span 1 컬럼)의 표
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table([]string{"품목", "수량", "금액"}, [][]string{
{"상품 A", "2", "₩10,000"},
{"상품 B", "1", "₩25,000"},
})
})
})
p.Spacer(document.Mm(10))
// KPI 스트립: 3 span × 4 컬럼
kpis := []struct{ label, value string }{
{"소계", "₩45,000"},
{"부가세 (10%)", "₩4,500"},
{"배송비", "₩0"},
{"합계", "₩49,500"},
}
p.AutoRow(func(r *template.RowBuilder) {
for _, k := range kpis {
k := k
r.Col(3, func(c *template.ColBuilder) {
c.Text(k.label, template.FontSize(8))
c.Text(k.value, template.FontSize(14), template.Bold())
})
}
})
})
f, _ := os.Create("invoice.pdf")
defer f.Close()
doc.Render(f)
}
실제로 동작하는 프로그램이다. go get github.com/gpdf-dev/gpdf 후 실행하면 작업 디렉터리에 invoice.pdf가 생긴다. M1에서 렌더링 시간은 약 130 µs.
정수 모델이 안 맞는 경우
정수 12분의 n 모델이 진짜로 잘못된 선택인 경우는 두 가지다. 솔직히 적어둔다.
- 픽셀 단위 정확한 너비가 필요할 때. 「이 컬럼은 정확히 73.5pt」 같은 요구.
Pct로는 사실상 안 된다 (73.5 / 총 너비 × 12가 정수일 일이 거의 없다). 고정 좌표가 필요한 요소만page.Absolute(...)로 처리하고 나머지는 그리드에 맡긴다. 둘은 같은 페이지에서 공존한다. - 신문식 컬럼 흐름이 필요할 때. 한 컬럼이 차면 다음 컬럼으로 이어지는 텍스트 흐름. 그리드는 안 한다. 컬럼 흐름 텍스트 엔진은 아직 없다. 필요하면 issue를 열어달라 — 부재를 알고 있다.
그 외, 즉 청구서·보고서·계약서·브로슈어·덱에서는 12 그리드가 CSS보다 오히려 더 타이트하게 맞는다.
자주 묻는 질문
Q: 12를 24 같은 다른 값으로 바꿀 수 있나요?
없다. gridColumns는 상수다. 바꾸면 기존 템플릿이 전부 깨진다. 12로 한 번 결정하고 커밋했다.
Q: 컬럼 안에 행을 중첩하고 싶으면?
가능. c.AutoRow(...)로 컬럼 안에 서브 행을 만든다. 서브 행 안의 1〜12는 부모 컬럼 너비 기준이지 페이지 너비 기준이 아니다. 각 레벨이 「부모에 대한 Pct(span/12 × 100)」이라 중첩 합성이 깔끔하다.
Q: 가로 페이지에서도 동작하나요?
한다. 그리드는 페이지 크기에 무관하다. r.Col(6, ...)는 행이 210mm(A4 세로)이든 297mm(A4 가로)든 늘 행의 절반이다.
Q: 2 컬럼 행용 r.Col2(span, span, fn1, fn2) 단축이 왜 없나요?
한 줄 줄이려고 API 표면을 늘리는 건 손해라서다. 같은 행 패턴을 반복한다면 *template.PageBuilder를 받는 Go 함수를 직접 만들어 추가하면 된다. 그리드가 최소이기에 사용자 패턴이 충돌 없이 자랄 수 있다.
Q: CSS Grid의 grid-area나 명명된 라인은?
gpdf엔 없고, 로드맵에도 없다. PDF에는 비용 대비 효과가 안 나온다.
정리
12 컬럼 그리드는 「실제 문서가 필요로 하는 분할」을 최소 비용으로 제공하는 레이아웃 프리미티브다. 숫자는 Bootstrap에서 빌렸고, 정수 모델만 남기고, 브레이크포인트·거터·order·auto-fill·span 합 강제와 그 외 반응형 웹의 짐은 전부 버렸다. 남은 것은 상수 하나, 빌더 메서드 하나, 너비 식 하나 — Go 약 30줄. 중첩으로 깔끔하게 합성되고, 그리드로 표현 불가한 소수의 케이스에는 Absolute와 자연스럽게 공존하며, 작성한 내용을 절대 몰래 재밸런싱하지 않는다.
gpdf 사용해보기
gpdf는 Go용 PDF 생성 라이브러리. MIT, 무의존, CJK 기본 지원.
go get github.com/gpdf-dev/gpdf
다음으로 읽을 것
- gpdf의 12 컬럼 그리드는 어떻게 동작하나요? — 레시피 버전, 더 많은 코드 패턴
- gpdf가 다른 라이브러리보다 10배 빠른 이유 — 렌더링 파이프라인 내부 해설
- Quickstart — 5분 만에 첫 PDF 생성