◆ ESSAY
5월에 Bun이 코드베이스를 Zig에서 Rust로 옮겼다는 소식을 보고 글을 한 편 썼다. 그 글은 거버넌스 이야기였는데, 다 쓰고 나서도 한 가지가 계속 남았다. Jarred Sumner는 나처럼 프론트엔드 개발자였고, JavaScript 도구의 속도와 무게라는 같은 고민에서 Bun을 시작한 사람이다. 나도 2022년에 JavaScript 개발자를 위한 Rust를 연재하며 빌림 검사기(borrow checker, 참조의 소유권과 수명을 컴파일 타임에 강제하는 장치)와 씨름했고 wasm도 몇 번 만져 봤지만, 실무에 Rust를 가져간 적은 없었다. 같은 배경에서 출발한 사람이 에이전트를 붙여 언어를 통째로 갈아탔다면, 나도 훨씬 작은 것 하나는 옮길 수 있지 않을까. 시작은 그 호기심이었다.
대상은 매일 쓰는 것 중에서 골랐다. 이 블로그의 마크다운을 검사하는 markdownlint-cli2 v0.22.1이다. 새 이름은 rust-markdownlint이고, 목표는 하나였다. 명령줄, 설정 파일, 인라인 주석, 출력, exit code, --fix 결과까지 원본과 바이트 단위로 같을 것. 지금은 이 블로그의 마크다운 20,966개(node_modules 포함)를 두 도구로 돌리면 오류 264,114건이 한 글자도 다르지 않게 나온다. 다만 그 문장을 쓸 수 있게 되기까지 시간이 어디에 들어갔는지는 예상과 달랐다.
저장소의 첫 커밋은 8월 24일이고 마지막 커밋은 9월 7일이다. 달력으로는 보름이고 커밋이 있는 날은 열흘이다. 규칙 53개 중 51개를 옮긴 커밋은 8월 26일 12시 6분부터 17시 52분 사이에 몰려 있다. 그 전후로는 파서와 CLI의 뼈대를 세우고, 원본과 출력을 대조하고, 배포와 성능을 다듬었다. 27일에는 실전 저장소 9개와 원본의 명령줄 시나리오를 대조하며 JavaScript 의미론 차이를 고쳤고, 28일과 29일에는 LSP 서버(편집기에 진단을 넘겨주는 Language Server Protocol 구현)와 --diff를 붙이다가 rumdl과의 속도 격차를 발견했다. 닷새를 비운 뒤 9월 6일에 적대 리뷰가 찾은 11건을 고쳐 v0.1.3을 내고, 7일에 남은 속도 격차를 재고 출력 경로를 손본 것이 마지막이다.
커밋 날짜가 작업 시간의 비율을 알려 주지는 않는다. 다만 규칙 대부분을 옮긴 뒤에도 교체해 쓸 도구를 만들기 위한 일이 많이 남았다는 것은 보여 준다. 이 글은 그 과정에서 내린 결정들에 관한 기록이다.
Bun에서 가져온 것은 방법이다. Bun 팀은 Claude 에이전트를 병렬로 돌려 Zig 535,496줄을 11일 만에 Rust로 옮겼고, 기존 TypeScript 테스트 스위트로 새 구현을 검증했다. 나는 같은 레시피를 훨씬 작은 규모로 따라갔다. 원본 프로그램과 테스트 자료는 있었지만, Rust 구현을 거기에 연결해 대조할 체계는 직접 만들어야 했다.
이 글의 수치는 전부 저장소 안 문서와 벤치 기록에 있는 값이다. 호환 기준은 markdownlint-cli2 v0.22.1(markdownlint v0.40.0)이고, 9월 7일의 세 도구 성능 비교에는 cli2 v0.23.2를 썼다. 파서는 markdown-rs 1.0.0을 수정한 것, 비교 대상 rumdl은 8월 측정이 0.2.61, 9월 7일 측정이 0.2.67이다. 속도는 Apple M 시리즈 10코어에서
hyperfine --warmup 3으로 쟀고, 9월 7일의 세 도구 비교만 GitHub Codespaces 4 vCPU(AMD EPYC 7763, Ubuntu 24.04)에서 쟀다. 상세 조건은 각 절에 적었다. 규칙 포팅과 적대 리뷰에는 코딩 에이전트(Claude Code, 적대 리뷰는 Codex)를 썼고, 어느 판단이 사람의 것이고 어느 것이 에이전트의 것인지는 본문에서 구분했다.
markdownlint-cli2는 Node.js 도구다. 마크다운 파일을 glob(**/*.md 같은 와일드카드 경로 패턴)으로 모아서 markdownlint 규칙 53개를 돌리고, 결과를 파일:줄:열 규칙 설명 형식으로 찍는다. 이걸 Rust로 옮기고 싶었던 이유는 흔한 것이었다. Node 없이 바이너리 하나로 돌고, 파일 단위로 병렬화하고 싶었다. 다만 그 목표를 "빠른 마크다운 린터"로 잡으면 이미 rumdl 같은 것이 있다. 내가 잡은 목표는 그것과 달랐다. 기존 .markdownlint-cli2.jsonc와 .markdownlint.jsonc를 한 글자도 안 고치고 실행 파일 이름만 바꿔 끼울 수 있을 것, 그리고 결과가 같을 것.
이 정의를 설계 문서 첫 절에 네 줄로 적어 두었다. 같은 설정 파일을 수정 없이 읽을 것, 같은 입력에 같은 위치와 규칙과 메시지와 exit code를 낼 것, --fix 결과 파일이 같을 것, 원본 저장소의 테스트 픽스처(fixture, 기대 결과가 정해져 있는 입력 파일)를 통과할 것. 뒤에 나오는 결정은 거의 전부 이 네 줄에서 파생된다. 무엇을 만들지보다 무엇을 만들지 않을지가 여기서 정해졌는데, JavaScript 플러그인(customRules, markdownItPlugins)과 .cjs/.mjs 설정 파일은 처음부터 지원하지 않기로 했고, 대신 그런 키를 만나면 조용히 무시하지 않고 경고를 찍기로 했다.
Bun과 달랐던 점은 기존 테스트를 새 구현에 연결하는 방법이었다. Bun의 TypeScript 테스트는 런타임의 구현 언어와 무관하게 실행할 수 있다. markdownlint의 테스트는 JavaScript API에 연결되어 있어 Rust 구현에 그대로 돌릴 수는 없지만, 입력 픽스처와 기대 출력은 가져올 수 있었다. 여기에 원본 프로그램 그 자체를 기준으로 삼아, 같은 입력을 두 프로그램에 넣고 출력을 diff 하는 하네스를 만들었다. 테스트를 통과했다는 것은 그 입력에서 같았다는 근거다. 어디까지 같은지를 알려면 입력과 대조 범위를 넓혀야 했고, 그 일이 예상보다 컸다.
markdownlint 규칙은 micromark의 토큰 트리 위에서 돈다. micromark는 CommonMark(마크다운의 표준 명세) 파서인데, 흔히 쓰는 AST(mdast, 문단이나 헤딩 같은 의미 단위로 정리된 트리)가 아니라 그 아래의 concrete token tree(공백과 줄바꿈, 기호 하나까지 원문의 모든 바이트를 토큰으로 남기는 트리)를 만든다. atxHeadingSequence, whitespace, lineEnding, listItemPrefix 같은 토큰으로 파일의 모든 바이트가 설명된다. 규칙들은 이 토큰을 순회하면서 "헤딩 앞 공백 토큰이 있는가", "리스트 접두어 토큰의 길이가 얼마인가"를 본다. 즉 규칙의 정의가 micromark의 토큰 구조에 묶여 있다.
이 토큰을 공개 API로 주는 Rust 크레이트는 없었다. pulldown-cmark는 이벤트가 너무 거칠고 autolink literal(꺾쇠 없이 적은 https://…가 저절로 링크가 되는 GFM 문법)이 없다. comrak은 바이트 오프셋(토큰이 원문의 몇 번째 바이트에서 시작하고 끝나는지)이 없다. mdast 위에서 원문을 다시 스캔해 규칙을 재구현하는 방법도 있었는데, 그러면 규칙 절반이 재해석이 되어 호환을 검증할 기준이 사라진다. 남은 선택은 markdown-rs였다. micromark 저자가 직접 만든 1:1 포트라 내부에 같은 토큰 이벤트가 있다. 다만 공개 API는 mdast와 HTML뿐이라, 크레이트 1.0.0을 통째로 저장소에 넣고(43,802줄) 내부 모듈을 pub으로 여는 패치를 가했다. 그 위에 원본 MicromarkToken과 같은 모양의 트리를 만드는 어댑터를 두었다.
패치는 처음 3건으로 시작해서 지금 15건이다. 처음 것은 모듈 공개와 실패한 참조 링크 기록 같은 구조적인 것이었는데, 나중 것들은 성격이 다르다. markdown-rs가 micromark와 미묘하게 다르게 동작하는 지점을 micromark와 같게 되돌리는 패치다. 예를 들어 리스트가 닫힌 직후 시작한 문단에서 2.로 시작하는 줄이 새 리스트로 잘리는 문제, ## # a의 안쪽 #이 본문에 안 들어가는 문제, 열린 [ 안에서 autolink literal이 시작되어 ]를 URL로 삼키는 문제가 그렇다. 전부 CommonMark 스펙 해석의 문제가 아니라 두 구현 사이의 우연한 차이였고, 그 차이가 규칙 결과의 차이로 나타났다. 이 이야기는 뒤에서 더 커진다.
설계 문서를 쓰면서 GitHub 이슈 71개를 미리 만들었다. 뼈대 작업 14개, 규칙당 1개씩 53개, 마무리 4개. 규칙 이슈에는 원본 소스 경로(lib/md0XX.mjs), 문서 경로, 테스트 픽스처 경로, 파라미터 표를 본문에 넣었다. 에이전트에게 줄 작업 단위가 이슈 하나였기 때문이다.
뼈대는 8월 24일과 25일 이틀이었다. markdown-rs를 벤더링해 이벤트 API를 열고, micromark 모양의 토큰 트리 어댑터를 만들고, 원본 픽스처 388개를 JS micromark로 덤프한 토큰 오라클(oracle, 정답을 내주는 기준 구현)과 대조하고, 규칙 트레이트와 레지스트리, 설정 로딩과 extends, front matter(문서 맨 앞을 ---로 감싼 메타데이터 블록)와 인라인 주석(<!-- markdownlint-disable --> 같은 본문 안의 설정 주석), --fix 적용, CLI의 인자 파싱과 glob 열거까지. 규칙은 이 단계에서 견본으로 MD047과 MD018 둘만 옮겼다. 26일 오전에 디렉토리별 설정 캐스케이드, 출력 포맷과 exit code, 규칙별 스냅샷(한 번 확인한 출력을 파일로 저장해 두고 이후 실행과 비교하는 테스트 방식) 하네스와 벤치 하네스가 붙었고, 12시 6분에 첫 규칙 커밋이 들어왔다. 규칙 포팅은 배치로 돌렸다. 배치 하나에 규칙 10개 안팎을 골라서, 규칙마다 서브 에이전트를 git worktree로 격리해 병렬로 띄웠다. 각 에이전트가 만질 수 있는 파일은 4개로 제한했다. 규칙 파일 하나, mod.rs의 한 줄, 레지스트리, 스냅샷 테스트 목록. 원본 규칙을 함수 단위로 1:1 옮기고, 그 규칙의 픽스처 스냅샷이 원본 기대값과 일치하면 끝이다. 에이전트가 끝나면 내가 완료 순서대로 cherry-pick 해서 스택 PR(앞 PR의 브랜치 위에 다음 PR을 쌓는 방식)을 만들고, 벤치는 스택의 끝에서 규칙별로 한 번씩 돌려 따로 커밋했다. PR마다 CI가 바뀐 규칙의 벤치를 돌려 코멘트를 남기도록 해 두었다.
이 절차는 Bun의 것과 구조가 같다. 작업을 파일 단위로 쪼개고, 에이전트를 병렬로 돌리고, 충돌은 사람이 순서를 정해 푼다. 규모만 다르다. Bun은 최대 64개 에이전트에 6,502 커밋이었고 여기는 배치당 10개에 v0.1.3까지 279 커밋, PR 116개다. 커밋 시각을 보면 배치 다섯 개가 12시, 14시 반, 15시 반, 16시, 17시 반에 들어왔고, 각각 13개, 11개, 10개, 10개, 7개다(MD019와 MD021은 원본처럼 한 파일이다). 그리고 대부분의 규칙이 첫 시도에 픽스처를 통과했다. 배치 3의 10개는 전부 한 번에 맞았다. 안 맞은 것은 규칙이 아니라 파서였다. MD029와 MD027이 실패했을 때 원인은 규칙 코드가 아니라 markdown-rs의 컨테이너 종료 처리와 어댑터의 공백 배정이었고, 그래서 파서 수정 PR을 스택 중간에 끼워 넣어야 했다.
여기까지가 사흘이다. 견본 규칙 둘과 뼈대를 먼저 준비한 덕에 나머지 51개 규칙의 포팅을 하루에 모을 수 있었다. 8월 26일 18시 반에는 53개 규칙이 원본 픽스처 388개에서 오류 3,218건을 바이트 단위로 같게 냈고, rayon으로 파일 단위 병렬화를 넣었고, 태그를 밀면 릴리즈가 나가는 워크플로를 붙였다. 그 시점에서 나는 거의 다 됐다고 생각했다.
픽스처 388개는 원본 저장소가 규칙을 테스트하려고 만든 파일이다. 규칙이 잡아야 할 패턴을 모아 둔 것이라 밀도는 높지만 다양성은 낮다. 한글도 이모지도 pnpm의 symlink도 없다. 그래서 그날 저녁, 릴리즈 워크플로를 붙인 지 30분 뒤에 이 블로그 저장소 전체를 **/*.md로 두 도구에 넣어 봤다. 결과가 달랐다. 버그 6건이 나왔고, 그중 셋은 마크다운 파싱과 무관한 곳에 있었다.
| 발견 | 원인 |
|---|---|
| HTML 주석 안에 한글이 있으면 panic | 주석 본문을 .으로 치환한 뒤 인덱스를 원본 길이로 계산 |
| 이모지 뒤 열 번호에서 range 검증 panic | 파서의 열은 UTF-16 코드 유닛(JS의 .length)인데 검증은 코드 포인트로 셈 |
| pnpm의 node_modules 아래 파일이 열거되지 않음 | fast-glob은 symlink를 따라가는데(followSymbolicLinks: true) ignore 크레이트는 기본이 아님 |
## # a에서 MD003 오탐 | markdown-rs가 안쪽 #을 헤딩 본문에 넣지 않음 |
[a][https://x]y에서 MD052 누락 | 열린 [ 안에서 autolink literal이 시작됨 |
apps/blog/posts/**/*.md가 node_modules까지 전부 순회 | 열거 후 필터링. fast-glob은 순회 중 가지치기 |
이 여섯 건이 방향을 바꿨다. 픽스처만으로는 "같다"를 말할 수 없다는 것이 확인됐으니, 같다를 말할 수 있는 근거를 하나씩 쌓아야 했다. 그렇게 쌓은 것이 결국 이 표다. 다만 여섯 중 둘은 규칙보다 먼저 있었다. 토큰 오라클은 8월 25일에 규칙이 하나도 없는 상태에서 어댑터를 검증하려고 만든 것이고, 규칙 스냅샷 하네스는 26일 오전 첫 규칙 배치를 띄우기 직전에 만들었다. 나머지 넷은 26일 밤부터 27일 사이에 생겼다.
| 대조 | 대상 | 결과 | 이 대조만 잡은 것 |
|---|---|---|---|
| 토큰 오라클 | 원본 픽스처 388개를 JS micromark로 덤프해 토큰 트리 대조 | 376/388 일치 | 파서와 어댑터의 구조 차이. 규칙 결과에는 안 보이는 것까지 |
| 규칙 스냅샷 | 픽스처 388개, 기본 설정, 오류 3,218건 | 바이트 일치 | 규칙 포팅 오류 |
| cli2 시나리오 | 원본 명령줄 테스트 216개 중 JS 로딩이 필요한 57개 제외 | 159개 통과, 알려진 차이 0 | glob 부정 패턴 순서, ./와 ../, 설정 파일 오류 문구, js-yaml의 flow collection 거부 규칙 |
--fix 대조 | 픽스처 388개 중 174개가 바뀜 | 파일 0 diff | 수정 적용 순서와 줄 끝 문자 다수결 |
| 실전 저장소 9개 | airflow, electron, eslint, mocha 등 1,534 파일 1,825건 | diff 6줄, 원인 1개 | 이모지가 든 줄의 MD013 Actual 값이 1 작음. line.length는 UTF-16 |
| 블로그 저장소 | 20,966 파일 264,114건 | 0 diff | 위 표의 6건 |
각 대조는 서로 다른 종류의 차이를 드러냈다. 토큰 오라클에서는 규칙 결과가 같아도 토큰 구조가 다른 12개 파일이 드러났고(리스트 안 fenced code(백틱 세 개로 감싼 코드 블록) 뒤의 lazy continuation(들여쓰기 없이 이어 쓴 다음 줄) 같은 것), cli2 시나리오는 명령줄과 설정 파일의 동작을 잡았고, 실전 저장소에서는 UTF-16 길이 문제가 열 계산 말고 chars().count()를 쓴 17곳에 더 있다는 것이 드러났다. 입력 파일만 늘려서는 명령줄 동작이나 --fix가 쓴 파일의 차이를 확인할 수 없다. 무엇을 넣을지와 무엇을 비교할지를 함께 넓혀야 했다.
여섯 가지 대조에서 나온 차이를 모아 놓고 보면 공통점이 있다. 마크다운 해석의 차이는 거의 없다. 대부분은 JavaScript가 그 일을 하는 방식의 차이다.
line.length는 UTF-16 코드 유닛 수라 이모지가 2로 센다. fs.readFile(file, "utf8")은 잘못된 바이트를 U+FFFD로 바꾸고 계속 읽는다. 그래서 glob에 png가 섞이면 원본은 그 png를 끝까지 lint 해서 오류 2,605건을 내고, read_to_string을 쓴 rust-markdownlint는 exit 2로 죽었다. NUL 문자는 micromark가 전처리에서 U+FFFD로 바꿔 토크나이즈하지만 token.text는 원본 슬라이스라 NUL이 남는데, markdown-rs는 HTML 출력에서만 바꾸므로 _ 옆의 NUL이 강조 판정에서 다른 부류로 분류돼 MD049 결과가 어긋났다. 치환본을 파싱하고 이벤트의 바이트 인덱스를 원본으로 되돌리는 코드로 맞췄다.
YAML 설정 파일에서 나온 차이는 이 유형의 극단이다. .yaml 이름으로 저장된 JSONC 문서(// Comment 줄이 있는)를 원본은 js-yaml이 missed comma between flow collection entries로 거부한다. 새 구현의 serde-saphyr는 YAML 스펙을 따라 그걸 { "// Comment \"config\"": ... }로 정상 파싱한다. 스펙만 놓고 보면 새 구현이 옳다. 그런데 호환 기준으로는 틀렸다. 결국 YAML을 파싱하기 전에 스캐너 토큰을 훑어 js-yaml의 규칙(flow collection, 즉 {}나 []로 적는 표기 안에서 암시적 키의 :는 키가 시작한 줄에 있어야 하고, 여러 줄 plain scalar, 즉 따옴표 없는 문자열의 이어지는 줄은 감싸는 블록보다 한 칸 이상 들여써야 한다)을 재현하는 코드를 넣었다. 스펙에 맞게 동작하는 라이브러리를 스펙에 어긋나게 감싼 것이다.
파서에서도 같은 일이 있었다. **a [b] c**는 micromark에서 짝 없는 [가 최상위에 따로 남는데 markdown-rs는 data 토큰 하나로 합쳐 버려 MD036이 오탐을 냈다. 원인은 리졸버(resolver, 토큰화가 끝난 뒤 이벤트 목록을 다시 훑어 짝을 맞추고 합치는 후처리 단계) 실행 순서다. micromark는 data 병합을 label(링크의 대괄호)과 attention(*와 _ 강조 기호) 리졸버보다 먼저 돌리고, markdown-rs는 반대다. markdown-rs의 순서가 더 정연해 보이지만 결과가 다르니 되돌려야 했다. 순서를 바꾸면 이벤트 인덱스를 쓰는 다른 코드가 깨져서, 전부 합친 뒤 경계를 기록해 두었다가 micromark라면 남겼을 것만 되살리는 리졸버를 맨 뒤에 추가했다.
명령줄도 마찬가지였다. 인자 파서로 clap을 쓰지 않았다. 원본은 --flag=value 꼴을 지원하지 않고 모르는 --xyz를 glob 패턴으로 취급하는데, clap을 쓰면 그 두 동작을 재현할 수 없다. 원본과 같은 단일 패스 파서를 직접 썼다. glob 열거도 globset을 그냥 쓰지 못했다. globby(원본이 쓰는 Node의 glob 라이브러리로, 안에서 fast-glob을 쓴다)는 부정 패턴이 자기보다 앞에 온 양의 패턴에만 적용되고, #**/node_modules 같은 패턴은 순회 중에 디렉토리째 가지치기하며, symlink를 따라간다. 이 세 가지를 fast-glob 소스를 읽어 가며 맞췄다.
이 지점에서 호환의 대상이 무엇인지가 분명해졌다. markdownlint의 문서화된 규칙이 아니었다. JavaScript 문자열의 길이 단위, Node의 파일 읽기 방식, js-yaml의 파서 버그, micromark의 리졸버 순서, globby의 패턴 해석 순서였다. 이것들은 어디에도 스펙으로 적혀 있지 않고, 원본 저자도 의도해서 만든 게 아닐 가능성이 높다. 그런데 사용자의 설정 파일과 문서는 그 위에서 6년째 돌고 있고, 드롭인이라는 약속은 그것까지 포함한다. 규칙 중에서도 MD027, MD037, MD038, MD051부터 MD053까지는 micromark의 토큰 quirk(의도한 설계가 아니지만 굳어진 동작) 자체가 규칙 정의라, 파서를 "더 올바르게" 고치면 규칙이 틀려진다.
호환이 잡힌 뒤에 속도를 봤다. 픽스처 388개는 프로세스 기동이 지배해서 6.6배(366.2ms 대 55.1ms)였고, 이 블로그 포스트 441개(7.2MB)는 13.4배(1,411.2ms 대 105.0ms)였다. 그런데 코어당으로 나누면 이야기가 달라진다. 10코어 병렬 효과를 빼면 단일 스레드에서 3배 남짓이고, 그 절반 이상이 파싱이었다. samply(샘플링 프로파일러)로 프로파일을 뜨니 markdown-rs 토크나이저가 55%, 이벤트를 토큰 트리로 바꾸는 어댑터가 15%, 규칙 53개가 25%였다.
그래서 파서를 바꿀지 검토했다. pulldown-cmark로 같은 441개를 파싱하면 9.8ms다. markdown-rs는 365.6ms, 어댑터까지 524.7ms였다. 37배 차이다. 이 숫자만 보면 바꾸지 않을 이유가 없어 보인다.
| 구간 | 시간 (441 포스트, 단일 스레드, 10회 중 최선) |
|---|---|
| pulldown-cmark 0.13.4, 이벤트 소비만 | 9.8ms |
markdown-rs parser::parse, 이벤트만 | 365.6ms |
| markdown-rs + 어댑터, 토큰 582,321개 | 524.7ms |
lint_content 전체, 규칙 53개 | 643.9ms |
그런데 규칙 비용을 보면 다르다. 파싱과 어댑터를 빼고 남는 규칙과 나머지(인라인 설정, HTML 주석 치환, 줄 분할) 비용이 119ms다. 파서와 어댑터를 0ms로 만들어도 119ms가 남는다는 뜻이고, 원본 cli2가 같은 코퍼스에 1,475ms니까 코어당 상한은 12.4배다. 이슈에 적어 둔 목표는 코어당 20배였고, 그러려면 74ms 아래여야 한다. 규칙 비용보다 작다. 어떤 파서를 가져와도 규칙을 손대지 않는 한 닿을 수 없는 숫자였다.
게다가 pulldown-cmark의 9.8ms는 이벤트를 소비만 한 값이다. 규칙이 쓰는 토큰 58만 개를 만들어 트리로 잇는 비용이 빠져 있다. 규칙과 헬퍼가 문자열로 참조하는 micromark 토큰 종류는 89종인데, pulldown-cmark 이벤트가 범위째로 주는 것은 20종 안팎이다. linePrefix, listItemPrefix, codeFencedFenceInfo, undefinedReference 같은 나머지는 원문에서 다시 잘라내야 하고, 그 재구성 코드는 추정 2,000줄에서 3,000줄이다. 블록 구조를 비교해 보니 pulldown-cmark와 markdown-rs가 99% 일치했는데, 불일치는 전부 $ 수식 확장의 의미 차이였다. 그 차이를 맞추려면 pulldown-cmark도 벤더링해서 고쳐야 하고, 그러면 지금 markdown-rs에서 한 일을 micromark 설계와 더 먼 코드베이스에서 반복하게 된다.
결론은 바꾸지 않는 것이었고, 근거를 문서로 남겼다. 대신 있는 파서 안에서 줄였다. 어댑터의 토큰별 String 할당을 정적 종류와 원문 범위로 바꿔 159ms를 45ms로, markdown-rs 토크나이저의 EditMap(이벤트 목록에 가할 삽입과 삭제를 모아 두는 내부 구조)을 BTreeMap으로 바꾸고 테이블 헤드 스캔을 사전 검사로 건너뛰어 320ms를 203ms로, 규칙 53개는 파일마다 컴파일하던 정규식을 LazyLock으로 옮기고 토큰마다 children.clone()하던 것을 없애 125ms를 44ms로 줄였다.
그리고 여기서 두 번 잘못 쟀다. 첫 번째는 비교 대상이다. rumdl과 비교했을 때 두 도구가 90ms에서 105ms 사이로 같게 나와서 "동률"이라고 적어 두었다. 8월 29일에 다시 재니 rumdl이 2.3배 빨랐다(234.5ms 대 101.9ms, 443 포스트, hyperfine(명령줄 벤치마크 도구) 50회). 첫 비교에 이 블로그의 .markdownlint.json을 그대로 썼는데 그 파일이 default: false로 규칙 대부분을 끄고 있었다. 규칙이 꺼진 상태에서는 두 도구 모두 glob과 파일 IO에 묶여서 같아 보였던 것이다.
두 번째는 원인이다. 규칙을 하나씩 켜 가며 CLI 시간을 재서, 규칙 0개에서 1개로 갈 때 붙는 56ms를 파싱으로, 나머지 145ms를 규칙 본체로, 그중 47ms를 MD013으로 적어 두었다. 이슈 제목에도 그렇게 썼다. 9월 6일에 다시 파 보니 그 145ms의 대부분은 규칙이 아니었다. 규칙을 켜고 끈 CLI 시간의 차이에는 규칙 본체 외에 진단을 모으고 정렬하고 출력하는 비용이 함께 들어 있는데, MD013만 켜도 이 코퍼스에서 진단이 15,138건 나온다. 그리고 정렬 비교기 locale_compare가 비교마다 양쪽 파일명의 정렬 키를 Vec으로 새로 만들고, 1차 키가 같으면 대소문자 키를 두 개 더 만들고 있었다. 같은 파일 안의 진단끼리 비교할 때도 예외가 아니었다. 같은 문자열이면 바로 돌려보내고 나머지는 이터레이터로 직접 비교하게 바꾸자 진단 16,764건의 정렬이 128.0ms에서 23.2ms로, CLI 전체가 343.1ms에서 179.0ms로 줄었다(Apple M1, 445 포스트, 20회 평균). 같은 자리에서 rumdl 0.2.61은 175.9ms였다. 파싱과 줄 분할을 끝낸 뒤 rule.check만 따로 재면 MD013은 약 13ms다. 47ms의 규칙이 아니었다.
정렬을 고친 뒤 남은 격차를 9월 7일에 GitHub Codespaces(4 vCPU, Ubuntu 24.04)에서 다시 봤다. 같은 세션에서 할당기와 링크 타임 최적화를 A/B로 재니 jemalloc(메모리 할당기)이 블로그 코퍼스에서 7.3%, Thin LTO(링크 시점에 모듈 경계를 넘어 최적화하는 옵션)가 3.6%를 줄였고, 그 세션에서 rumdl과의 평균 격차는 43.3ms에서 10.8ms로 줄었다. 둘을 릴리즈 빌드 기본값으로 넣고(jemalloc은 Linux CLI만) 릴리즈로 배포하는 musl(정적 링크용 C 라이브러리) 정적 빌드로 세 도구를 재면 이렇다. 도구마다 3회 준비 실행 뒤 24회, 여섯 가지 실행 순서를 순환했고, 프로세스 시작부터 종료까지의 시간이다.
| 코퍼스 | rust-markdownlint | rumdl 0.2.67 | markdownlint-cli2 0.23.2 |
|---|---|---|---|
| 블로그 포스트 445개 (7.50MB) | 434.4 ± 16.1 | 380.1 ± 5.3 | 4,792.9 ± 104.4 |
| markdownlint 픽스처 388개 (0.25MB) | 83.8 ± 2.4 | 89.8 ± 1.2 | 1,192.4 ± 23.6 |
| 픽스처 10배 복사 3,880개 (2.45MB) | 762.9 ± 25.9 | 748.3 ± 14.6 | 6,501.8 ± 157.1 |
rumdl은 규칙 집합이 달라 블로그 코퍼스에서 진단 17,527건을 내고 rust-markdownlint와 cli2는 16,764건을 내므로, 같은 일을 한 시간은 아니다. 그걸 감안하고 보면 rust-markdownlint는 블로그 코퍼스에서 rumdl보다 14% 느리고, 픽스처에서는 더 빠르며, 10배 코퍼스에서는 2% 차이다. 같은 날 단일 스레드로 단계별 계측을 넣어 보니 어댑터와 규칙을 줄인 뒤라 markdown-rs 파서 본체가 코어 시간의 74%, 어댑터가 10%, 규칙 53개가 12%였고, MD013은 27.3ms로 코어 전체의 2.5%였다. 이제 내부 실행 시간을 더 줄이려면 교체하지 않기로 한 파서 본체를 살펴봐야 한다. 다만 이 비중만으로 rumdl과의 시간 차이를 전부 설명할 수는 없다. 위 코퍼스에서는 cli2보다 약 8.5배에서 14배 빨랐고, rumdl과는 앞뒤가 바뀌었다.
같은 날 오후에 한 군데를 더 줄였다. 기본 포매터가 진단마다 중간 문자열을 만들어 stderr에 바로 쓰고 있었는데, 64KiB 단위로 버퍼링하고 필드를 직접 써 넣게 바꾸자 블로그 코퍼스에서 write 호출이 33,531번에서 32번으로 줄었다. 같은 세션에서 musl 빌드를 짝지어 재니 픽스처 84.1ms가 78.3ms(6.9%), 블로그 434.9ms가 408.5ms(6.1%), 10배 코퍼스 771.5ms가 710.0ms(8.0%)였다. 위 세 도구 표는 이 변경 전의 측정이다. 같은 실험에서 파서 이벤트 벡터의 소유권을 옮기는 후보도 함께 재 봤지만 개선이 측정 편차 안에 머물러 채택하지 않았다. 정렬에 이어 출력도, 규칙이나 파서가 아니라 진단을 내보내는 길에서 줄어든 셈이다.
Bun 글에서 방법론으로 가장 눈에 띈 것은 적대 리뷰어였다. 구현 에이전트 하나에 리뷰 에이전트 둘 이상을 붙이고, 리뷰어에게는 원본 Zig 코드 없이 diff만 주면서 "이 코드는 틀렸다고 가정하라"고 지시했다. 그 리뷰어들이 머지 전에 use-after-free 하나, trunc가 floor여야 했던 것 하나, 조건을 검사하기 전에 panic 하는 unwrap_or 하나를 잡았다.
v0.1.2를 릴리즈하고 나서 같은 것을 해 봤는데, 리뷰어에게 주는 것을 바꿨다. diff가 아니라 오라클을 줬다. Codex에게 저장소와 bench/node_modules 안의 원본 cli2 0.22.1을 주고, 이렇게 지시했다. 코드를 읽고 위험해 보인다고 말하는 것은 발견이 아니다. 실제로 두 도구를 돌려 출력이 다른 것만 발견이다. 발견마다 입력 파일, 설정, 원본 출력, 대상 출력, 원인 추정을 채워라. 그리고 이미 검증된 여섯 가지 대조는 다시 하지 말고, 그 대조들이 못 보는 곳을 찔러라. 규칙 파라미터의 비기본값 조합, 인라인 주석 변종, 설정 파일 경계, 입력 바이트, 명령줄 경계, 병렬 실행의 결정성, 내장 포맷터, 그리고 markdown-rs 패치 15건이 "결과는 같다"고 주장하는 조건 밖의 입력.
약 1,600회를 돌렸다. 규칙 옵션 조합 323건, 패치 15건을 겨냥한 파서 경계 코퍼스 1,113 파일, --fix 29건을 각 2회, 포맷터 26건, 명령줄과 설정 26건, 인라인 주석과 바이트 경계 82건. 발견은 11건이었고 보조 1건이 더 있었다.
가장 심각한 것은 MD044였다. 설정에 names: ["K", "S"]를 주고 AKB AſB(켈빈 기호 U+212A와 long s U+017F)를 --fix 하면, 원본은 파일을 그대로 두고 rust-markdownlint는 AKB ASB로 바꿔 쓴다. 양쪽 다 exit 0이고 출력이 없다. 파일만 다르다. 원인은 Rust regex의 (?i)가 유니코드 케이스 폴딩(대소문자를 무시하려고 문자를 정규형으로 접는 것)을 하는데, 원본의 new RegExp(..., "gi")는 u 플래그가 없어 ASCII 폴딩만 하기 때문이다. 발생 확률은 낮지만 이 프로젝트가 정의한 가장 나쁜 실패다. 조용히 파일이 달라진다.
한국어 사용자에게 가장 현실적인 것은 front matter였다. 사용자 정의 frontMatter 패턴으로 ^\w+\n을 주면, 첫 줄이 한글일 때 원본은 그 줄을 본문으로 보고 MD041을 내는데 새 구현은 front matter로 먹어서 exit 0이다. JavaScript의 \w는 u 플래그가 있어도 ASCII고, Rust의 \w는 유니코드다. 같은 이유로 MD051의 ignored_pattern과 MD025, MD041의 front_matter_title도 어긋났다.
규칙도 파서도 아닌 곳에서도 나왔다. sub/loop -> .. 같은 symlink 순환이 있을 때 sub/*/sub/a.md라는 유한한 glob을 주면, fast-glob은 그 경로를 따라가서 파일을 찾는데 ignore 크레이트는 조상으로 되돌아가는 symlink를 오류로 보고 끊는다. 그 오류를 flatten()이 버려서 파일이 조용히 빠지고 exit 0이 됐다.
11건을 원인별로 묶으면 세 종류뿐이었다.
| 원인 | 건수 | 내용 |
|---|---|---|
| JavaScript 정규식 방언 | 7 | \w의 범위, [^](JS에서는 모든 문자), []], 잘못된 정규식을 원본은 규칙 예외로 보고하는데 대상은 조용히 무시 |
| JavaScript 타입 강제 변환 | 2 (규칙 9개) | 원본은 Number("3"), String(7), 배열 아닌 값에 .map을 불러 throw. 대상은 기본값으로 대체 |
| 런타임 라이브러리 | 2 | fast-glob의 symlink 순환 처리, Node 에러 객체 출력 형식 |
발견은 설정 값의 해석과 런타임 라이브러리의 동작에 몰려 있었다. 기존 여섯 대조에도 설정과 오류 시나리오는 있었지만, 정규식 방언이나 예상과 다른 타입의 값, symlink 순환까지 충분히 건드리지는 못했다. 내가 가장 약할 것이라 예상했던 markdown-rs 패치 15건은 1,113 파일에서 한 건도 어긋나지 않았다. 다만 그 패치들은 이미 차이를 발견해 수정하고 검증한 곳이다. 이 결과에서 읽을 수 있는 것은, 이미 의심하고 확인한 곳은 버텼고 두 언어가 같게 동작할 거라고 가정한 곳에서 차이가 더 나왔다는 점이다.
Bun 글의 회귀 사례에서도 비슷한 경계가 보였다. Zig의 assert()를 Rust의 debug_assert!로 옮겨 릴리즈에서 검사가 사라진 경우, 홀수 바이트를 무시하던 Zig의 reinterpretSlice()를 bytemuck::cast_slice로 옮겨 panic이 난 경우다. 두 포팅의 오류를 전부 설명하는 법칙은 아니지만, 다음 리뷰에서 문자열 길이, 정규식, 숫자 변환, 파일 읽기처럼 언어와 라이브러리가 동작을 정해 주는 곳부터 확인할 이유는 된다.
11건을 고치는 데 소스 21개 파일에서 1,528줄이 늘고 236줄이 지워졌다. 고치는 방식이 발견보다 더 이 글의 주제에 가깝다.
JavaScript 정규식을 Rust 정규식으로 옮기는 번역기를 새로 썼다. \w, \d, \b는 u 플래그와 무관하게 ASCII로, .은 LF뿐 아니라 CR과 U+2028, U+2029도 제외하도록, [^]는 모든 문자로, []는 아무것도 매치하지 않는 클래스로, 클래스 안의 [와 &와 ~는 Rust에서 메타문자라 이스케이프하도록, \z 같은 정의되지 않은 이스케이프는 그 글자 자체로. 컴파일에 실패하면 V8의 Invalid regular expression: /src/flags: reason 문구를 그대로 만들어 규칙 실패 메시지로 낸다. Number("3")을 맞추려고 ECMAScript의 StringToNumber를 통째로 구현했다. 0x, 0o, 0b 접두, Infinity, 십진 리터럴 문법, 그리고 반대 방향의 Number.prototype.toString(1e21 이상은 지수 표기, -0은 0)까지.
가장 멀리 간 것은 MD044다. names에 숫자가 섞이면 원본은 a.localeCompare is not a function을 던지는데, 그 메시지가 어느 원소에서 나오는지는 V8이 배열을 정렬하면서 comparator를 부르는 순서에 달려 있다. V8의 TimSort(배열 정렬 알고리즘)는 64개 미만이면 run 하나로 binary insertion을 하는데, 그 호출 순서를 그대로 밟아 첫 번째 TypeError를 찾는 함수를 넣었다. 규칙이 예외를 던지면 1번 줄에 오류 하나를 내고 그 규칙의 이후 오류는 전부 버리는 handleRuleFailures의 의미도 재현했다. padEnd가 한도를 넘거나 repeat(Infinity)가 되는 경우까지 규칙 실패로 나온다.
여기까지 하고 멈춘 곳이 있다. extends가 가리키는 파일이 없거나 읽기 권한이 없을 때, 원본은 Node의 Error 객체를 스택 트레이스와 cause까지 통째로 stderr에 찍는다. Rust 구현은 Error: Unable to use configuration file '...'; No such file or directory (os error 2) 한 줄이다. 둘 다 exit 2다. 이건 맞추지 않았다. 배너 문자열도 마찬가지로 원본 이름 대신 이 도구의 이름을 찍는다.
이 선을 어디에 그었는지 적어 두면 이렇다. 결과가 조용히 달라지는 것은 끝까지 쫓고, 실패했다는 사실이 같으면 실패의 형식은 놓아둔다. V8의 정렬 순서를 따라간 것은 그게 exit 1과 stderr 한 줄의 차이를 만들기 때문이고, Node의 스택 트레이스를 안 따라간 것은 그게 exit 2 안에서의 형식 차이이기 때문이다. 기준이 있으면 "어디까지 했나"가 자랑이 아니라 판단이 된다고 생각한다.
고친 뒤 같은 1,600회를 다시 돌리면 남는 차이는 세 가지다. 배너, Node 에러 형식, 그리고 extends가 순환할 때 원본은 10초 안에 안 끝나고 새 구현은 스택 오버플로로 죽는 것. 마지막 것은 원본의 정상 종료 출력이 없어 "같게" 만들 기준이 없는데, 죽는 것은 호환과 무관하게 고쳐야 했다. 순환을 감지하면 설정 파일을 쓸 수 없다는 오류로 exit 2를 내게 고쳤고, 원본은 10초 안에 종료하지 않았다는 관찰과 함께 README에 적었다. 배너와 에러 형식에 이어 호환의 선 밖에 두기로 판단한 세 번째 항목이다.
Bun에서 가져온 작업 방식은 여기서도 쓸 수 있었다. 이슈로 나누고, worktree로 격리한 에이전트를 병렬로 돌리고, 원본의 기대 출력으로 완료 여부를 확인하는 것. 규칙 51개를 하루에 옮길 수 있었던 데에는 작업 단위가 분명했고, 그 전에 파서 어댑터와 토큰 오라클, 규칙 스냅샷 하네스를 준비해 둔 것이 컸다고 생각한다.
다음에는 실전 입력과 설정 경계의 대조도 더 일찍 준비하려고 한다. 원본 프로그램을 같은 인자로 실행하는 하네스에 외부 저장소의 문서와 비기본 설정을 넣고, 규칙이 구현되는 대로 비교할 수 있게 하는 것이다. 이번에는 규칙 포팅 뒤에야 추가한 대조가 많았다. 미리 준비한다고 뒤의 일이 모두 사라지지는 않겠지만, 이미 옮긴 여러 규칙에서 같은 가정을 되짚는 일은 줄일 수 있을 것 같다.
리뷰어에게는 언어 경계와 사용자가 설정에 넣을 수 있는 값을 먼저 확인하게 할 생각이다. 발견의 기준은 이번처럼 재현 가능한 차이로 둔다. 측정도 같은 원칙으로 가려고 한다. 규칙을 켜고 끈 시간의 차이를 바로 규칙 비용으로 읽기 전에, 파싱과 규칙 실행, 진단 정렬, 출력 구간을 직접 잰다.
규칙을 옮기는 속도가 빨라질수록 무엇을 같다고 볼지 결정하고 확인하는 일이 더 중요해졌다. 이 프로젝트에서는 그 기준이 기능 선택에도 영향을 줬다. --flavor와 자체 포매터는 제외했고, 캐시도 넣지 않았다. 대신 --diff, 셸 completions, LSP 서버, pre-commit 훅을 붙였다. 그리고 지원하는 입력에서 결과가 같은지 확인하는 일과, 지원하지 않거나 다르게 처리하는 동작을 기록하는 일을 함께 이어 가야 했다.
9월 7일 기준으로 확인한 호환 범위는 다음과 같다. 기준 버전은 markdownlint-cli2 v0.22.1이다.
| 대조 | 결과 |
|---|---|
| 규칙 53개, 원본 픽스처 388개 | 오류 3,218건 바이트 일치. --fix로 바뀌는 174개 파일도 0 diff |
| 원본 명령줄 시나리오 216개 | JavaScript 로딩이 필요한 57개를 제외한 159개 전부 스냅샷 일치 |
| 실전 저장소 9개, 1,534 파일 | 오류 1,825건 일치 |
| 이 블로그 20,966 파일 | 오류 264,114건 일치 |
| 적대 케이스 약 1,600회 | 남은 차이 3건. 배너 문구, Node 에러 객체의 출력 형식, extends 순환(원본은 10초 내 미종료, 새 구현은 exit 2) |
지원하지 않는 것은 JavaScript를 불러야 하는 전부다. customRules와 markdownItPlugins는 경고 후 무시하고, .cjs와 .mjs 설정 파일은 exit 2다. 텍스트 directive(:name[label] 꼴의 확장 문법)와 unicode-width가 다르게 재는 일부 문자도 원본과 다르다.
성능 비교에 쓴 cli2는 v0.23.2로, 호환 기준 버전과 다르다. 앞의 세 코퍼스에서 이 버전보다 약 8.5배에서 14배 빨랐고, rumdl과는 코퍼스에 따라 앞뒤가 바뀌었다. 그 뒤 출력 버퍼링으로 같은 세션 기준 6%에서 8%가 더 줄었다. 이 벤치마크를 v0.23.2 전체에 대한 호환 검증으로 보지는 않는다.
코드와 대조 기록은 전부 github.com/yceffort/rust-markdownlint에 있다. npm으로 설치하면 플랫폼별 바이너리를 optional dependency로 받아오고, 기존 markdownlint-cli2 설정 파일은 그대로 읽는다.
npm i -D @yceffort/rust-markdownlint
npx rust-markdownlint "**/*.md" "#node_modules"
이 글을 David Anson에게 감사하는 말로 끝내고 싶다. 이 작업을 가능하게 한 자료는 거의 전부 그가 만들어 둔 것이었다. 규칙마다 딸린 픽스처 388개, 기대 출력이 스냅샷으로 남아 있는 명령줄 시나리오 216개, 실전 저장소 9개를 커밋을 고정해 대조하는 테스트, 파라미터까지 표로 정리된 규칙 문서. 그가 2015년부터 markdownlint와 markdownlint-cli2를 다듬으며 쌓아 온 이 재료 덕에 규칙을 빠르게 옮기고, 그 결과를 대조할 수 있었다. 내가 한 일은 그 재료를 다른 언어에서 다시 실행할 수 있게 엮은 것에 가깝다.
스펙대로 고치면 깨진다고 적은 quirk들도, 그 위에서 이미 돌아가는 문서와 설정 파일이 있다는 점에서는 함부로 바꾸기 어려운 동작이다. 원본이 그동안 지켜 온 호환성이 이번 포팅에서는 내가 따라야 할 기준이 됐다. 이 도구가 rumdl과 다른 자리에 있다면, 그 자리를 먼저 만들어 둔 사람은 나보다 앞서 markdownlint를 만든 사람이다. 고맙다는 말을 여기에 적어 둔다.
docs/parser-replacement.md), cli2 시나리오 결과(docs/cli2-scenarios.md), 실전 저장소 대조(docs/test-repos.md), 벤치 기록(bench/RESULTS.md), 남은 성능 차이의 A/B와 단계별 계측(bench/remaining-gap-2026-09-07.md), 출력 버퍼링 실험(bench/optimization-plan-2026-09-07.md), markdown-rs 패치 목록(crates/markdown-rs/PATCHES.md)