trouble.log

Trouble ID 2020-11-13.sphinx-builder

Sphinx 기반 reStructuredText 문서화 관련 자동화 구축

Markdown 포기하는 이야기

한동안 개발자를 위한 문서화로 Markdown 플랫폼을 활용했다.

Markdown 은 문법이 간결하고 컴파일 에러 없는 것이 장점으로 누구든 배우기 쉬우며, 특히 GitHub 에서 이슈나 PR 을 작성하는 개발자라면 습관처럼 쓸 수 있는 마크업 언어이다. Markdown 생태계에는 Typora 같은 든든한 에디터도 있고, GitHub 에는 위키나 Pages 같은 부가 기능이 있어, 방대한 문서를 배포하기에도 Markdown 을 사용하면 좋은 조건을 갖게 되는 편이다.

그러나 문서 규모가 커지면서, Markdown 의 단점으로 인한 영향이 고작 GitHub Pages 에서 기본 제공하는 Jekyll 테마 정도 장점으로는 무시할 수 없는 수준으로 커지기 시작했다. 그나마 Markdown 특유의 파편화된 문법은 GFM 이라는 단일 실표준으로 수렴하면서 어느 정도 회피할 수 있는 문제가 되었지만, 이는 그만큼 많은 확장을 버려야 한다는 의미이기도 하다.

CommonMark 는 이용자 관점에서 이렇다할 장점은 없고 손이 많이 가는 계륵같은 표준이고, PHP Markdown Extra 나 MultiMarkdown 이나 Pandoc 은 … Docker 로 잘 준비해 둬야 CI 빌드를 돌릴 수 있을 거고. GitBook 은 드디어 레거시 CLI를 털고 서비스 솔루션만 남겨 두고 있다.

한때 세상을 지배했던 Markdown 트렌드가 종말을 곧 맞이하겠다는 느낌을 받고 있다.

나는 원래 이럴 때 빠르게 최신기술로 갈아타는 힙스터는 아니다. 물론 그럴듯한 최신기술이 딱히 확보된 것도 아니었다. 안정적인 미래를 도모할 수 있는 기반기술이 어디 없을까? 탐사 과정에서 나는 확장까지도 정확한 명세를 갖도록 만들어졌으며 Markdown 보다 오래된 역사로 충분히 검증된 실용적 대안을 선정하게 되었다.

reStructuredText (reST).

오늘날 수많은 개발 문서가 reStructuredText 로 생산되고 있다. reST 계의 베스트셀러이자 스테디셀러는 reST 툴셋인 Sphinx 로, Sphinx 기반의 Read the Docs 는 Python 이 다른 언어를 제치고 확고하게 자리잡은 현재까지 Python 과 reST 양쪽 생태계에 많은 역할을 해 준 길잡이이며 다수 개발자들에게 참조 문서의 실표준으로 간주된다. Sphinx 는 사실상 GitBook 레거시의 원조라고 봐도 되는데, 빌드 아웃풋 형식으로 HTML 은 물론 man 페이지, ePub 파일, LaTeX 파일까지 지원하니 내공이 상당한 시스템이라는 사실을 짐작할 수 있을 것이다.

reST 문법은 조금 복잡하고 엄격한 편이다. 이 때문에 reST 는 빨리 배우고 익숙해지기에는 어렵지만, 그만큼 스타일 가이드가 사실상 불필요하다. Markdown 스타일 가이드를 지금까지 열 번도 넘게 썼던 나로서는 이런 부분이 오히려 장점으로 보였다. 명백한 단점이라면 괜찮은 에디터가 없어서 계속 html 빌드를 하면서 결과를 확인해야 한다는 것을 들 수 있겠다.

문법에 대해서야 찾아 보면 나오는 것이니 생략하고. Markdown 을 버리면서 GitHub Pages 내장 Jekyll 을 함께 쓰지 못하게 되었으니 이걸 대신해 주는 시스템을 만드는 게 급선무일 것이다.

문서 형상관리와 서빙 계획

우선 스태틱 페이지 서비스 프로바이더로는 GitHub Pages 를 계속 사용하기로 했다.

GitHub Pages 로 HTML 페이지 서빙을 하게 되면 Git 으로 소스코드를 관리하는 동시에 Git 으로 HTML 빌드 결과물도 관리한다는 이점이 생긴다. 이는 빌드 스냅샷을 보존한다는 관점에서 소스의 변화에 따른 빌드의 변화를 추적할 수 있을 뿐만 아니라 빌드의 재현가능성까지 검토해 볼 수 있는 기회가 된다.

GitHub Pages 빌트인 Jekyll 을 쓰는 것과 다른 부분이 있다면, Jekyll 에 플러그인을 적용해 직접 사용한다면 어떤 형상이 될지 생각해 보면 되겠다. 이 경우 어떤 플러그인의 어떤 버전을 사용할지, 어떤 테마를 사용할지 등에 대한 정보 역시 빌드를 재현가능하게 만들기 위해서는 필수적이다. 즉 이런 정보들도 Git 으로 관리할 형상의 일부로 취급해야 할 것이다.

물론 문서가 재현가능하게 빌드되는 것에 별 관심이 없다면 그냥 개인 블로그 하듯 느슨하게 하면 된다. 로컬에 Jekyll 설치하고, Jekyll 플러그인 설치하고, 테마 파일은 로컬에 놔두고. 로컬에서 빌드해서 커밋해서 푸시. 그 로컬 파일시스템이 유실되면 빌드 결과물은 재현될 수 없다. Jekyll 빌트인 기능은 빌드 결과물을 일정하게 생산하지만, 플러그인은 그렇지 않으니, 플러그인 버전 변경 가능성도 빌드 결과물을 재현불가능하게 만든다. 그러나 개인 블로그를 Jekyll 과 GitHub Pages 로 꾸리는 사람들 역시 이런 상황에 굳이 편안함을 느끼지는 못했을 것이다.

결론짓자면 이렇다. Jekyll 을 직접 사용한다면 문서화 프로젝트는 근본적으로 Jekyll 에 의존성이 있는 Ruby 프로젝트가 된다. Sphinx 를 사용해 문서를 직접 빌드하기 때문에 문서화 프로젝트는 근본적으로 Python 프로젝트가 된다. 즉 문서화 버전 관리 계획은 Python 프로젝트에 대한 형상관리 계획을 포함해야 한다.

Git 서브모듈 쓰는 이야기

Sphinx 프로젝트는 소스와 빌드를 Git 서브모듈로 관리하는 Git 브랜치로 관리하기로 했다.

왜 이런 결정을 했는지 말하자면 많은 사람들이 끔찍하게 헷갈려하는 Git 서브모듈의 정체에 대한 이야기를 안 할 수 없겠다.

Git 에 대해 공부를 좀 한 사람이라면 Git 저장소는 오브젝트와 참조들로 이루어져 있고 Git 명령어는 그에 대한 연산이라는 것을 알고 있을 것이다. 파일시스템은 폴더와 파일로 이루어져 있고, Git 은 이를 tree 와 blob 이라는 두 가지 유형의 오브젝트로 분류한다. git-add 는 저장소 루트에서부터 파일시스템을 추적해 tree 와 blob 오브젝트로 이루어진 트리로 만들어 .git 폴더에 저장해 두는 연산이다.

커밋의 개념을 더하면 오브젝트 분류에 commit 이라는 세 번째 유형이 생긴다. commit 오브젝트는 Git 저장소 루트 tree 오브젝트를 참조한다. 그래서 특정 커밋을 체크아웃할 때 git-checkout 은 해당 커밋 ID 를 가진 commit 오브젝트를 찾아 저장소 루트 tree 오브젝트로부터 트리를 순회하며 Git 저장소를 특정 상태로 만들게 된다.

git-init 으로 로컬에 Git 저장소를 만들고, 저장소 루트에 README.md 파일을 추가해서 첫 커밋을 하면 실제로 아래와 같이 딱 3개의 오브젝트만 있는 저장소가 만들어진다.

git-objects-illustrated-1

이 저장소에 src/main.c 파일을 추가해서 두 번째 커밋을 하고, README.md 파일 수정을 추가해서 세 번째 커밋을 한다면, 저장소에 있는 오브젝트들의 참조 관계는 다음과 같게 된다.

git-objects-illustrated-2

Git 은 위와 같이 tree 나 blob 오브젝트가 자동으로 재사용되어 효율성을 담보하는 구조를 사용한다. 이를 위해서는 오브젝트를 참조하기 위한 단일 ID 가 필요한데, 이것이 바로 커밋 ID 에도 사용되는 SHA1 체크섬이다. Git 에서 모든 오브젝트는 그 내용을 SHA1 체크섬한 것으로 오브젝트 ID 를 갖는다. 커밋 ID 역시 commit 오브젝트의 ID 이다.

우리가 평소에 다른 ID 를 만날 일이 없으니 tree 오브젝트나 blob 오브젝트도 ID 를 갖는다는 게 조금 생소할 수 있겠는데, 여기에 Git 서브모듈 시스템이 구현되는 방법의 핵심이 있다. commit 오브젝트나 tree 오브젝트가 다른 tree 오브젝트나 blob 오브젝트를 참조할 때 오브젝트 ID 를 기입하는데, tree 오브젝트가 하위 파일 참조로 commit 오브젝트의 ID 를 기입하면 파일시스템 내에 다른 커밋을 포함하는 구조가 되기 때문이다.

git-objects-illustrated-3

위 그림은 두 커밋의 관계를 보여 주고 있다. 두 커밋은 서로 부모 관계 방향으로 관계가 없다. 한 커밋은 저장소 파일시스템에 src/main.c 파일을 포함하고 있고, 또 docs 라는 이름으로 다른 commit 오브젝트를 참조하고 있다. 참조된 커밋은 파일시스템에 README.md 파일을 포함하고 있다.

이것이 서브모듈 관계이다. Git 서브모듈은 서브모듈 폴더 하위에서 알아서 버전 관리를 해야 하며, 커밋이나 체크아웃 같은 연산도 상위 저장소와 완전히 독립적으로 이루어진다. 이것이 Git 의 단순한 구현 근본과 너무나 밀접하게 맞닿아 있는 구조이다 보니, Git 서브모듈에 대한 연산이 Git 오브젝트에 대한 원시적 연산을 그대로 노출하여, Git 을 사용하는 개발자들이 신경을 곤두세우게 되는 끔찍한 UI가 된 것이다.

그렇다면 외부 프로젝트를 코드째로 참조해야 하는 경우라면 몰라도, 내부 코드에서 Git 서브모듈을 자발적으로 써야 좋을 경우가 있긴 할까?

sphinx-quickstart 를 이용해 Sphinx 프로젝트를 하나 시작해 보자. 중간에 아래와 같은 질문이 나오면 기본값인 n 으로 진행한다.

> Separate source and build directories (y/n) [n]:

이제 Sphinx 프로젝트 루트에서 본 하위 파일시스템은 이렇게 생겼다.

+-- conf.py
+-- index.rst
+-- make.bat
+-- Makefile
+-- _build/
+-- _static/
+-- _templates/

_build 폴더 하위에는 빌드 결과물이 생성되는데, _build/doctrees/ 하위에는 Python pickle 모듈로 만든 캐시파일이 만들어지고, 빌드 명령이 make html 인 경우 _build/html/ 하위에는 HTML, CSS, JavaScript 파일이 만들어진다.

Python 프로젝트이기 때문에 Python 3 을 가정하고 venv 환경을 하나 만들자. Sphinx 도 venv 하위에 설치한다고 가정하자.

$ python3 -m venv venv
$ source ./venv/bin/activate
(venv) $ pip install sphinx
...
(venv) $ pip freeze > requirements.txt

이제 Sphinx 프로젝트 루트에 venv 폴더와 requirements.txt 파일이 추가된다.

+-- conf.py
+-- index.rst
+-- make.bat
+-- Makefile
+-- requirements.txt
+-+ venv/
| +-+ bin/
| | +-- ...
| +-+ include/
| | +-- ...
| +-+ lib/
| | +-- ...
| +-- pyvenv.cfg
+-- _build/
+-- _static/
+-- _templates/

자, 이 상태에서 동일하게 make html 명령을 실행하면 어떻게 될까? 갑분싸… 빌드 메시지는 venv 에 설치된 패키지들에 있는 reST 문서 파일들 때문에 난장판이 된다.

소스 폴더를 분리하자. venv 만들고, pip 로 sphinx 설치하고, sphinx-quickstart 에서 아래 메시지에 y 로 대답한다.

> Separate source and build directories (y/n) [n]: y

이제 Sphinx 프로젝트 루트에서부터 파일시스템이 이렇게 정리된다. Sphinx 3.3.1 기준. make html 결과.

+-+ build/
| +-+ doctrees/
| | +-- environment.pickle
| | +-- index.doctree
| +-+ html/
| | +-- .buildinfo
| | +-- genindex.html
| | +-- index.html
| | +-- objects.inv
| | +-- search.html
| | +-- searchindex.js
| | +-+ _sources/
| | | +-- index.rst.txt
| | +-- _static/
| | | +-- basic.css
| | | +-- doctools.js
| | | +-- documentation_options.js
| | | +-- file.png
| | | +-- jquery-3.5.1.js
| | | +-- jquery.js
| | | +-- language_data.js
| | | +-- minus.png
| | | +-- plus.png
| | | +-- pygments.css
| | | +-- pygments_dark.css
| | | +-+ scripts/
| | | | +-- main.js
| | | | +-- main.js.map
| | | +-- searchtools.js
| | | +-+ styles/
| | | | +-- styles/default.css
| | | | +-- styles/default.css.map
| | | | +-- styles/furo-extensions.css
| | | | +-- styles/furo-extensions.css.map
| | | | +-- styles/furo.css
| | | | +-- styles/furo.css.map
| | | +-- translations.js
| | | +-- underscore-1.3.1.js
| | | +-- underscore.js
+-- make.bat
+-- Makefile
+-- requirements.txt
+-+ source
| +-+ conf.py
| +-+ index.rst
| +-+ _static/
| +-+ _templates/
+-+ venv/
  +-- ...

자. 이제 venv 문제는 해결됐고, 빌드된 HTML 파일을 어떻게 GitHub Pages 로 스태틱 페이지 서빙 할지 궁리해 보면 된다. build/html/index.html 이 Pages 웹 주소 경로 상의 최상위에 오는 게 좋기 때문에, build/html 을 Git 서브모듈로 만들고, gh-pages 브랜치에 발행한다.

git-submodule-structuring-for-sphinx-1

다음은 소스가 변경되었을 때 CI 가 HTML 을 빌드하도록 하고, 이 결과물을 커밋했을 때 소스와 상호 참조 가능한 기준점을 만드는 것이다. 대부분의 경우 requirements.txt 는 변하지 않으므로, source 를 Git 서브모듈로 만들고, src 브랜치로 한다. 프로젝트 루트의 브랜치 이름은 ci 로 짓는다.

git-submodule-structuring-for-sphinx-2.300ppi

설계가 끝났다. reST 문서 소스 변경이 웹 문서 빌드 배포로 이어지는 구조는 다음과 같게 된다.

  1. 리모트 src 브랜치가 푸시되는 경우에 대해 GitHub 시스템에서 발생시킨 웹훅 (webhook) 을 통해 CI 가 이를 인지한다.
  2. CI 는 ci 브랜치 HEAD 를 체크아웃하고 서브모듈을 모두 init & update 해서, source 서브모듈에 src 브랜치의 최신 커밋을 체크아웃한다.
  3. CI 는 build/doctrees/**, build/html/** 파일을 전부 삭제한다. (유의: build/html 은 서브모듈이기 때문에 build/html/.git 파일을 유지해야 한다.)
  4. CI 는 Python 3 venv 를 생성하고, pip install -r requirements.txt 명령을 실행해 PyPI 패키지를 설치한 후, make html 명령을 실행해서 HTML 의 클린 빌드를 만든다. (유의: make cleanbuild 폴더 내부를 전부 삭제하므로 하지 않는다.)
  5. build/html 서브모듈 저장소를 전체 커밋하고 리모트 gh-pages 브랜치에 푸시한다.
  6. Sphinx 프로젝트 루트를 전체 커밋하고 리모트 ci 브랜치에 푸시한다.

이 설계의 이점은 CI가 커밋한 것과 사람이 커밋한 것이 전혀 섞이지 않는다는 점이다. 아주 많은 실무자들이 고의로든 실수로든 기계가 커밋을 할 수 있다는 사실을 잊는 편이다. 기계가 리모트에 올린 커밋이 있어서 이 커밋이 사람이 갖고 있는 로컬 커밋보다 일부 앞에 있다면, 사람은 이 커밋이 메인라인에 자신보다 먼저 올라와 있음을 존중해야 한다. 그런데 많은 사람들이 로컬 커밋 그래프가 앞선 부분을 리모트에 푸시 (push) 하기 위해 기계가 앞서 있는 부분을 어찌하지 못하고 리모트에 대해 풀 (pull) 을 돌려 버린다. 정말 끔찍한 일이다. 형상관리 정책의 설계에 따라, 사람에게는 사람만의 메인라인을 주고, 기계에게는 기계만의 메인라인을 주는 것이 이런 문제를 예방할 수 있는 방법이 된다.

설계의 단점이 있다면 위 파일 트리 기준으로 설정값 중 conf.pysrc 에서 관리하지만 requirements.txtci 에서 관리한다는 것이다. 조금 검토가 필요한 부분이다. 기본적으로는 사람이 requirements.txt 를 편집해서 ci 브랜치에 올릴 수 있겠는데, ci 브랜치를 기계만의 메인라인으로 보장한다는 관점에서 원칙을 침해하는 면이 있다. 지금 생각하기에는 src/requirements.txt 로 파일을 옮겨도 될 것 같긴 한데, 검증은 되지 않았다.

마무리

별 것 아닌 이 문서 빌드 시스템은 지금 한 Jenkins 서버에 구현되어 실제로 작동하고 있다. 오늘도 잡설이 길었기 때문에 읽기에 따라 어렵게 느껴질 수 있으나 결국 Git 으로 소스를 수정하고 Git으로 빌드를 발행했으며 소스 저장소와 빌드 스냅샷 저장소를 분리하면서 참조에 Git 서브모듈을 썼다는 흔한 이야기로 요약된다.

git-submodule-structuring-for-sphinx-3

흐름을 위해 몇 가지 디테일을 빠뜨렸으므로 이곳에 남겨 둔다.