당신의 문서, 테스트, 코드는 동일한 이야기를 제대로 전달하지 못하는 세 개의 파일이다.

소스의 함수 시그니처를 업데이트한다. README 예제를 잊는다. 일주일 후, 신입 사원이 오래된 스니펫을 프로덕션에 복사한다. 테스트 파일은 여전히 예전 동작을 예상 결과로 인코딩하고 있다. 이제 버그가 두 개, 문서 티켓이 하나 생겼다.

이것이 동기화 문제다. 모든 코드베이스가 가지고 있다. 대부분의 팀은 규율로 이를 해결하지만, 누군가 급해지는 순간 실패한다.

Literate programming은 산문 파일을 single source of truth로 삼아 이 문제를 해결한다. 사람이 읽는 Markdown 문서를 작성한다. 작은 도구가 코드 블록을 실행 가능한 Python 파일로 추출한다. 테스트, 구현체, 설명이 모두 한 곳에 존재한다. 산문을 변경하면 코드도 변경된다.

Literate programming이란?

Donald Knuth가 1984년에 이 용어를 만들었다. 아이디어는 단순했다. 에세이를 쓰듯 프로그램을 작성하라. 자연어로 로직을 설명하고 코드를 끼워 넣은 뒤, tangle이라는 도구가 실행 가능한 소스를 추출하고 weave가 서식이 적용된 문서를 생성하게 하라.

Knuth의 WEB 시스템 같은 고전적인 도구는 Pascal과 TeX에 밀접하게 결합되어 있었다. 주류 소프트웨어 개발에서는 결코 대중화되지 않았다. 워크플로우가 낯설게 느껴졌고, 도구가 무거웠으며, 대부분의 프로그래머는 PDF가 아닌 IDE에서 코드를 읽기를 원했다.

하지만 핵심 통찰은 여전히 유효하다. 산문과 코드는 single source of truth를 공유해야 한다. 현대 언어와 Markdown은 Knuth가 상상했던 것보다 이를 훨씬 쉽게 만든다. 특별한 컴파일러가 필요하지 않다. Python 약 30줄과 Markdown 파서가 필요하다.

하나의 Markdown 파일이 테스트 스위트가 되는 방법

메커니즘은 간단하다. .md 파일에 fenced code blocks를 작성한다. 각 블록 내부의 헤더 주석이 추출기에게 해당 블록이 어떤 파일에 속하는지 알려준다. 추출기는 동일한 헤더를 가진 블록을 하나의 Python 모듈로 연결한다. 그런 다음 결과에 대해 pytest를 실행한다.

완전한 예제를 보자. calculator.md로 저장하라:

# Calculator: addition and its properties

The `add` function is trivial. That is the point. Even trivial code deserves context about why it exists and what invariants it preserves.

```python
# calc.py
def add(a: int, b: int) -> int:
    """Return the sum of two integers."""
    return a + b

Addition commutes. We verify this explicitly because it is a property future maintainers might break by accident if they switch to a different implementation.

# test_calc.py
from calc import add

def test_addition_commutes():
    assert add(2, 3) == add(3, 2)

def test_identity():
    assert add(7, 0) == 7

`# calc.py`와 `# test_calc.py` 주석은 마법의 구문이 아니다. 우리 스크립트가 이해하는 규약이다.

## 추출 스크립트

이것이 전체 도구다. `tangle.py`로 저장하라:

```python
import re
import tempfile
import subprocess
from pathlib import Path


def tangle(md_path: Path, out_dir: Path) -> Path:
    """Extract code blocks from Markdown into runnable Python files."""
    content = md_path.read_text()
    files: dict[str, list[str]] = {}

    # Extract all python code blocks
    for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
        lines = block.strip().split("\n")

        # First line like '# filename.py' sets the target file
        if lines and lines[0].startswith("# "):
            filename = lines[0][2:].strip()
            code = lines[1:]
        else:
            filename = "module.py"
            code = lines

        files.setdefault(filename, []).extend(code)

    # Write extracted files
    out_dir.mkdir(parents=True, exist_ok=True)
    for filename, code_lines in files.items():
        (out_dir / filename).write_text("\n".join(code_lines) + "\n")

    return out_dir


if __name__ == "__main__":
    out = tangle(Path("calculator.md"), Path("build"))
    result = subprocess.run(
        ["python", "-m", "pytest", str(out), "-v"],
        capture_output=False,
    )
    raise SystemExit(result.returncode)

실행하라:

$ python tangle.py
============================= test session starts ==============================
build/test_calc.py::test_addition_commutes PASSED
build/test_calc.py::test_identity PASSED
============================== 2 passed in 0.01s

이제 build/ 디렉터리에는 calc.pytest_calc.py가 포함된다. 이를 임포트하거나, 타입 체크를 하거나, 패키지로 배포할 수 있다. Markdown 파일이 정규 소스다. 그 외 모든 것은 생성된다.

하나의 파일이 도움이 되는 때와 해가 되는 때

이 접근 방식은 라이브러리, 알고리즘, 그리고 무엇만큼 중요한 모든 것에서 빛난다. API 문서, 연구 코드, 구성 파이프라인은 모두 구현체에 결합된 산문으로부터 혜택을 받는다.

보일러플레이트가 많은 애플리케이션 코드에서는 빛나지 않는다. 15개의 데코레이터 임포트가 있는 Django 뷰에 에세이는 필요 없다. 모듈이 주로 프레임워크 배관이라면, literate 구조의 오버헤드는 명확성 없이 마찰만 더한다.

또 다른 제약은 도구 지원이다. IDE는 .py 파일에서 코드를 찾기를 기대한다. 정의로 이동, 인라인 린팅, 자동 완성은 모두 생성된 파일이 먼저 존재해야 작동한다. 이는 tangle.py를 pre-commit hook이나 빌드 단계의 일부로 실행함으로써 해결할 수 있다. 하지만 그것은 추가 단계다. 팀이 이미 빌드 복잡성에 어려움을 겪고 있다면, 커스텀 추출 파이프라인을 추가하는 것이 가치가 없을 수 있다.

이것을 하는 실제 도구

위의 30줄 스크립트로 시작하기에 충분하다. 프로덕션 준비가 된 것을 원한다면, 성숙한 옵션이 있다.

Entangled는 어떤 언어와도 작동하는 현대적인 literate programming 도구다. 약간 다른 구문을 사용하지만, 아이디어는 동일하다. 코드 블록 간의 의존성을 추적하고 여러 출력 파일을 지원한다.

Jupyter notebooks는 데이터 과학에서 유사한 문제를 해결한다. 하나의 파일에 산문, 코드, 출력을 혼합한다. 단점은 노트북이 버전 관리에 최악이라는 것이다. diff는 읽을 수 없고, 병합 충돌은 흔하다.

Org-mode with Babel이 가장 강력한 구현체다. 이미 Emacs를 사용한다면, 필적할 수 없다. 그렇지 않다면 학습 곡선이 가파르다.

대부분의 팀에게 간단한 Markdown 추출기가 실용적인 중간 지점이다. 모두가 이미 이해하는 도구를 사용한다.

FAQ

타입 체커와도 작동하는가?

그렇다. 먼저 .py 파일을 생성한 다음, 빌드 디렉터리에 대해 mypypyright를 실행하라.

Python이 아닌 언어는 어떻게 되는가?

tangle.py 스크립트는 언어에 구애받지 않는다. 정규식을 python에서 rustgo로 변경하면 동일하게 작동한다. 하나의 문서에서 언어를 혼합할 수도 있다.

블록 간 임포트는 어떻게 처리하는가?

추출기는 Markdown에 표시된 순서대로 동일한 파일명을 가진 모든 블록을 연결한다. 해당 파일의 첫 번째 블록에 임포트를 유지하거나 반복하라. Python은 중복된 임포트를 우아하게 처리한다.

하나의 모듈부터 시작하라

코드베이스 전체를 다시 작성할 필요는 없다. 문서가 항상 코드보다 뒤처지는 것처럼 보이는 작은 모듈 하나를 선택하라. Markdown 파일로 변환하고, 추출 스크립트를 CI 파이프라인에 추가한 뒤, 생성된 출력에 대해 테스트를 실행하라.

문서가 알림 없이 최신 상태를 유지하기 시작한다면, 접근 방식이 효과가 있다는 것을 알게 될 것이다. 오버헤드처럼 느껴진다면 포기하라. Literate programming은 도구이지 종교가 아니다. 목표는 Knuth를 감동시키는 것이 아니다. 세 개의 서로 다른 파일에서 자신에게 거짓말을 그만두는 것이다.