Hugo 로 바둑판 도해 그리기 Part 1: 왜 shortcode 였나
이 글은 Claude Opus 5 를 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
들어가며#
이 블로그에 바둑 입문 시리즈를 쓰다가 벽에 부딪혔습니다. 바둑 글에는 바둑판 그림이 필요한데, 그릴 방법이 마땅치 않았습니다.
결국 goban 이라는 이름의 Hugo shortcode 를 직접 만들었습니다. 본문에는 이렇게 쓰고,
{{< goban caption="빈 삼각형" >}}
a b c d e
1 . X . . .
2 X O * . .
3 . . . . .
{{< /goban >}}
렌더링은 SVG 바둑판으로 나가는 방식입니다. 실제 결과는 이렇습니다.
이 글은 그 과정의 기록입니다. Part 1 에서는 Hugo 가 무엇인지 짧게 소개하고, 왜 이미지도 mermaid 도 답이 아니었는지, 그리고 첫 버전을 어떻게 만들었는지를 다룹니다. Part 2 에서는 이 표기를 이미 발행된 포스트 4편의 도해 65장에 적용하면서 겪은 일 — 특히 조용히 틀릴 뻔했던 지점 — 을 다룹니다.
1. Hugo 를 모르는 분을 위한 짧은 소개#
Hugo 를 이미 아신다면 2장으로 건너뛰셔도 됩니다.
1.1. 정적 사이트 생성기란#
Hugo 는 마크다운 파일을 HTML 로 바꿔 주는 도구입니다. 이런 마크다운 파일을 하나 쓰면,
+++
title = "첫 글"
date = "2026-08-12T13:00:00+09:00"
+++
안녕하세요. **첫 글** 입니다.
hugo 명령 한 번으로 public/ 아래에 완성된 HTML 파일들이 생깁니다. 목록 페이지, 태그 페이지, RSS 피드, sitemap 까지 알아서 만들어 줍니다. 그 결과물을 정적 파일 호스팅에 올리면 그게 곧 블로그입니다.
WordPress 같은 도구와 결정적으로 다른 점은 요청이 올 때 아무 일도 하지 않는다 는 것입니다. 데이터베이스도 없고 서버 쪽 코드도 돌지 않습니다. 미리 만들어 둔 HTML 파일을 그대로 내보낼 뿐입니다. 그래서 빠르고, 깨질 곳이 적고, 호스팅이 사실상 공짜입니다.
이 블로그는 글 468편, 생성 파일 1,700여 개 규모인데 전체 빌드가 4.3초 걸립니다.
1.2. shortcode 란#
마크다운은 문단, 목록, 링크, 표 정도를 표현하도록 만들어진 문법입니다. 그 이상이 필요할 때 쓰라고 Hugo 가 마련해 둔 확장 지점이 shortcode 입니다.
본문에 이렇게 쓰면,
{{< youtube dQw4w9WgXcQ >}}
Hugo 가 layouts/shortcodes/youtube.html 이라는 템플릿을 찾아 실행하고, 그 결과 HTML 을 그 자리에 끼워 넣습니다. 본문에는 짧은 한 줄만 남고, 지저분한 <iframe> 은 템플릿 안에 숨습니다.
핵심은 본문 소스가 읽을 만한 상태로 남는다 는 점입니다. 마크다운 파일을 열었을 때 HTML 덩어리가 아니라 의도가 보입니다. 이 성질이 뒤에 나올 판단의 근거가 됩니다.
1.3. 이 글에서 쓰는 Hugo 문법 세 가지#
Part 1·2 를 읽는 데 필요한 만큼만 추립니다.
| 문법 | 뜻 |
|---|---|
{{ .Inner }} | shortcode 를 여닫는 태그 사이에 들어 있는 본문 |
{{ .Get "caption" }} | shortcode 에 넘긴 이름 붙은 인자 |
{{ range $i, $v := $list }} | 목록을 순회. $i 는 인덱스, $v 는 값 |
Hugo 템플릿은 Go 의 text/template 문법을 씁니다. 처음 보면 {{- ... -}} 같은 표기가 낯선데, 앞뒤 하이픈은 그 자리의 공백을 지운다 는 뜻입니다. SVG 를 뽑을 때 빈 줄이 끼는 것을 막는 용도로 뒤에서 계속 나옵니다.
2. 문제: 바둑판을 어떻게 그릴 것인가#
바둑 입문 글에서 도해는 장식이 아니라 본문입니다. “이 모양은 빈 삼각형이라 나쁘다” 같은 문장은 그림 없이는 아무 뜻도 전달하지 못합니다.
시리즈 한 편에 도해가 20장 넘게 들어갑니다. 그러니 도해를 만드는 비용이 곧 글을 쓰는 비용이 됩니다.
요구사항을 정리하면 이랬습니다.
- 한 편에 20장 이상 을 부담 없이 만들 수 있을 것
- 글을 고칠 때 도해도 같이 고칠 수 있을 것
- 나중에 도해 하나가 틀린 걸 발견했을 때 어디가 틀렸는지 diff 로 보일 것
- 다크 테마인 이 블로그에서 흑돌과 백돌이 구분될 것
2.1. 후보 1 — 이미지#
가장 쉬운 길입니다. 바둑 프로그램에서 화면을 캡처해 static/img/ 에 넣고 참조하면 됩니다.
실제로 이 블로그의 다른 시리즈가 그렇게 되어 있었습니다. 그리고 그게 어떤 상태인지 알고 있었습니다.
- 도해 하나를 고치려면 바둑 프로그램을 다시 열어야 합니다
- 파일이 바뀌면 diff 는
Bin 57207 -> 0 bytes라고만 알려 줍니다. 무엇이 어떻게 바뀌었는지 알 수 없습니다 - 65장이 10MB 를 차지합니다
- 독자의 글꼴 크기를 따라가지 못하고, 다크 테마 위에 흰 사각형으로 뜹니다
요구사항 2·3 을 정면으로 위반합니다.
2.2. 후보 2 — 코드 블록 안의 ASCII#
처음 쓴 방법입니다. 마크다운 코드 블록에 이렇게 적었습니다.
a b c d e
1 . X O . .
2 X O . O .
3 . X O . .
장점이 분명합니다. 쓰기 쉽고, 고치기 쉽고, diff 가 완벽하게 보입니다. 요구사항 1·2·3 을 전부 만족합니다.
문제는 독자 쪽입니다. 바둑을 처음 배우는 사람에게 X 와 O 가 늘어선 격자를 주고 “여기서 백이 잡힙니다"라고 말하는 건, 입문서가 할 일을 독자에게 떠넘기는 것 입니다. 격자선도 없어서 무엇이 붙어 있고 무엇이 떨어져 있는지 눈에 들어오지 않습니다.
2.3. 후보 3 — mermaid#
이 블로그는 이미 mermaid 를 쓰고 있습니다. 순서도나 시퀀스 다이어그램을 코드로 적으면 브라우저에서 그림으로 그려 주는 도구입니다. 도해도 여기서 해결되면 가장 깔끔합니다.
그래서 실제로 시험해 봤고, 답이 아니라는 결론을 얻었습니다. 이 블로그에 물려 있는 mermaid v10 기준입니다.
| 시도한 다이어그램 | 결과 |
|---|---|
block-beta | 격자선을 그릴 수 없습니다. 게다가 칸 크기가 내용물에 맞춰 정해져서, 돌이 있는 칸과 빈 칸의 너비가 달라집니다. 열이 어긋나면 바둑판이 아닙니다 |
quadrantChart | 사분면 구분선과 축이 강제로 그려집니다. 바둑판 한가운데 십자선이 지나갑니다 |
mermaid 에는 교차점 위에 무언가를 놓는 격자 를 그리는 다이어그램 종류가 아예 없습니다. 없는 기능을 비슷한 것으로 흉내 내려다 보니 매번 원하지 않는 장식이 따라붙었습니다.
여기서 얻은 교훈이 하나 있습니다. 가진 도구로 안 되는 일을 억지로 시키면, 결과물이 아니라 우회 흔적이 남습니다. block-beta 로 만든 판은 “격자선 없는 표"였지 바둑판이 아니었습니다.
3. 결정: 소스는 ASCII, 렌더링은 SVG#
세 후보를 놓고 보니 답이 보였습니다.
ASCII 는 쓰는 사람에게 좋았고, 그림은 읽는 사람에게 좋았습니다. 둘 중 하나를 고를 게 아니라, 소스는 ASCII 로 두고 렌더링만 그림으로 바꾸면 됩니다.
그게 정확히 shortcode 가 하는 일입니다.
{{< goban >}} ← 본문에 남는 것: 읽을 수 있는 ASCII
a b c d e
1 . X O . .
{{< /goban >}}
<figure class="goban"> ← 독자가 보는 것: SVG 바둑판
<svg viewBox="0 0 ...">
정리하면 이렇습니다.
| 요구사항 | 이미지 | ASCII | shortcode |
|---|---|---|---|
| 20장을 부담 없이 | ❌ | ✅ | ✅ |
| 글과 함께 수정 | ❌ | ✅ | ✅ |
| diff 로 보임 | ❌ | ✅ | ✅ |
| 독자가 알아봄 | ✅ | ❌ | ✅ |
4. v1 구현#
4.1. 입력을 좌표로 바꾸기#
shortcode 가 받는 것은 여닫는 태그 사이의 문자열 하나뿐입니다. 이것을 격자로 해석해야 합니다.
{{- $lines := slice -}}
{{- range (split (chomp .Inner) "\n") -}}
{{- $t := trim . " \t\r" -}}
{{- if ne $t "" -}}{{- $lines = $lines | append $t -}}{{- end -}}
{{- end -}}
{{- $cols := findRE `\S+` (index $lines 0) -}}
{{- $rows := after 1 $lines -}}
.Inner 를 줄 단위로 자르고 빈 줄을 버립니다. 그리고 첫 줄을 열 라벨로 삼습니다. 첫 줄에 토큰이 5개면 5열 판이고, 이후 각 줄의 첫 토큰이 행 라벨, 나머지가 칸이 됩니다.
판 크기를 인자로 받지 않는 것이 중요합니다. 소스에 이미 적혀 있는 정보를 다시 적게 하면 둘이 어긋날 수 있습니다. 열 라벨을 하나 지우면 판도 한 칸 줄어드는 게 맞습니다.
findRE 로 공백 구분 토큰을 뽑기 때문에, 소스에서 열을 맞추려고 공백을 몇 개 넣든 결과가 같습니다. 사람이 읽기 좋게 정렬해 둘 자유가 생깁니다.
4.2. SVG 좌표 계산#
{{- $s := 34 -}} {{/* 칸 간격 */}}
{{- $px := 36 -}} {{/* 좌측 여백: 행 라벨 자리 */}}
{{- $py := 38 -}} {{/* 상단 여백: 열 라벨 자리 */}}
{{- $r := 13 -}} {{/* 돌 반지름 */}}
{{- $w := add $px (add (mul (sub $nc 1) $s) 20) -}}
{{- $h := add $py (add (mul (sub $nr 1) $s) 16) -}}
Go 템플릿에는 산술 연산자가 없어서 add, sub, mul 함수를 씁니다. 처음엔 답답하지만 계산 자체는 단순합니다. 격자선은 첫 열에서 마지막 열까지, 첫 행에서 마지막 행까지 직선을 긋습니다.
여기서 판의 가장자리가 저절로 해결됩니다. 9x9 전체 판을 그리면 가장 바깥 선이 곧 판의 테두리가 되고, 일부만 잘라낸 도해는 테두리 없이 격자만 나옵니다. 바둑 도해의 관례와 맞습니다.
4.3. 색: 다크 테마를 상속하지 않기로#
이 블로그는 다크 테마입니다. 자연스러운 선택은 판도 어둡게 만드는 것이었습니다.
그런데 그렇게 하면 흑돌이 배경에 묻혀 빈 원처럼 보입니다. 바둑 글에서 이건 그냥 못생긴 정도가 아니라 틀린 신호 입니다. 독자는 “여기 돌이 없구나"라고 읽습니다.
그래서 판만 밝은 나무색으로 두기로 했습니다.
.goban__board { fill: #d9c9a3; } /* 나무판 */
.goban__black { fill: #14140f; }
.goban__white { fill: #fbfaf6; stroke: #4a3f2c; }
사이트 테마와의 일관성보다 도해가 전달해야 할 정보 를 앞에 둔 판단입니다. 일관성은 취향이지만, 흑돌이 빈 점으로 보이는 것은 오류입니다.
4.4. v1 의 표기#
여기까지가 첫 커밋입니다. 칸에 쓸 수 있는 기호는 넷이었습니다.
| 기호 | 뜻 |
|---|---|
. | 빈 점 |
X | 흑돌 |
O | 백돌 |
* | 표시된 빈 점(급소 등) |
바둑 입문 시리즈 Pt.2 의 도해 23장을 여기에 옮겼습니다. 소스 텍스트는 한 글자도 바뀌지 않았습니다. 코드 블록을 감싸던 세 개의 backtick 이 shortcode 태그로 바뀐 것이 전부입니다.
이때 본문의 표기법 설명도 함께 고쳤습니다. 전에는 X 는 흑돌, O 는 백돌이라고 소스 문자를 설명했지만, 이제 독자는 소스를 보지 않습니다. “검은 돌 = 흑돌”, “붉은 점 = 눈여겨볼 자리"처럼 화면에 보이는 것 으로 다시 썼습니다.
4.5. 곧바로 드러난 부족함#
Pt.1 의 도해를 옮기려니 네 기호로는 모자랐습니다. Pt.1 은 수순을 보여 주고(“흑1, 백2, 흑3”), 본문에서 특정 지점을 이름으로 가리키고(“A 자리에 두면”), 이미 놓여 있던 돌 하나를 구별해야 했습니다.
기호 네 개를 더했습니다.
| 기호 | 뜻 |
|---|---|
1 2 3 … | 수순. 홀수는 흑, 짝수는 백 |
A B C … | 빈 점에 붙이는 라벨 |
x | 표시가 붙은 흑돌 |
o | 표시가 붙은 백돌 |
수순 번호는 관례를 그대로 따랐습니다.
{{- else if findRE `^[0-9]+$` $cell -}}
{{- $black := eq (mod (int $cell) 2) 1 }}
<circle class="goban__{{ if $black }}black{{ else }}white{{ end }}" .../>
<text class="goban__num-{{ if $black }}on-black{{ else }}on-white{{ end }}" ...>{{ $cell }}</text>
홀수면 흑, 짝수면 백. 흑이 먼저 두니까 당연한 규칙입니다.
이 두 줄이 다음 날 문제를 일으킵니다. Part 2 에서 다룹니다.
라벨은 대문자 한 글자만 받습니다. X 와 O 는 이미 돌이라 라벨로 쓸 수 없다는 제약이 여기서 나옵니다. 그리고 라벨 아래에는 판 색깔 원을 하나 깔았습니다. 그러지 않으면 격자선이 글자를 관통합니다.
<circle class="goban__labelbg" cx="{{ $cx }}" cy="{{ $cy }}" r="10"/>
<text class="goban__pointlabel" x="{{ $cx }}" y="{{ $ty }}">{{ $cell }}</text>
이렇게 두 편의 도해 39장이 표기로 옮겨졌습니다. 여기까지가 첫날입니다.
5. 첫날 정리#
전체 shortcode 는 105줄입니다. 클라이언트 스크립트는 한 줄도 없습니다. 브라우저는 그냥 SVG 를 받습니다.
돌아보면 판단의 갈림길은 셋이었습니다.
- 가진 도구(mermaid)로 안 되는 것을 확인하는 데 시간을 썼습니다. 그냥 되겠거니 하고 시작했다면 block-beta 로 열이 어긋난 판을 만들어 놓고 CSS 로 우겨넣고 있었을 것입니다. 안 된다는 것을 왜 안 되는지까지 확인해 둔 덕분에 다음 선택이 빨랐습니다.
- 쓰는 사람의 편의와 읽는 사람의 편의를 하나로 합치려 하지 않았습니다. 소스는 ASCII, 출력은 SVG. 둘은 다른 문제였고 다르게 풀면 됐습니다.
- 일관성보다 정확성을 골랐습니다. 다크 테마에 맞추는 대신 판을 밝게 뒀습니다.
Part 2 에서는 이 표기를 이미 발행된 다른 시리즈 4편 — PNG 이미지 65장 — 에 적용합니다. 눈으로 옮겨 적으면 안 되는 이유, 이미지를 기계로 판독한 방법, 그리고 4.4 의 그 두 줄이 어떻게 렌더링은 멀쩡한데 내용만 틀린 도해 를 만들 뻔했는지를 다룹니다.