DevInsight

개발의 판단과 맥락을 기록하는 곳

Tools
조회 3약 5분 읽기

당신의 마크다운, 지금도 조용히 깨지고 있다

CommonMark, GFM, 그리고 수많은 마크다운 변종 사이에서 같은 파일이 매번 다르게 렌더링되는 이유를 파헤치고, 제로 디펜던시로 처음부터 다시 만든 렌더러가 이 문제를 어떻게 해결하려 하는지 살펴본다.

DevInsight 편집팀 발행

AI 보조 초안과 편집 검수를 거쳐 발행했습니다.

#Markdown#CommonMark#GFM#Rust#문서작성#렌더링#결정론적출력#WASM#Knuth-Plass#오픈소스

두 사람이 같은 README를 본다. 한쪽은 브라우저에서 깃허브 페이지를 열었고, 다른 한쪽은 터미널에서 glow로 읽고 있다. 테이블 정렬이 틀어져 있고, 링크 일부가 사라졌으며, 어떤 코드 블록은 하이라이팅이 전혀 적용되지 않는다. 원본 파일은 단 하나의 .md 파일이다. 렌더러만 다를 뿐인데 출력 결과가 판이하게 갈린다. 이런 광경은 생각보다 훨씬 흔하다. CI 로그에서 마크다운이 깨져 나오고, 노션 임베드가 표를 와장창 박살내며, 슬랙 미리보기는 목록을 전부 평문으로 쏟아버린다. 그동안 우리는 “그냥 마크다운이니까”라는 말로 무심히 넘겼다.

하지만 마크다운에는 단 하나의 진리도, 단 하나의 표준 구현체도 존재한 적이 없다.

존 그루버가 2004년에 처음 명세를 내놓았을 때만 해도 문제는 간단했다. 이메일 스타일의 평문에 최소한의 마크업을 입혀 HTML로 변환하는 펄 스크립트가 전부였다. 그런데 지난 20년 동안 마크다운은 통제 불능 상태로 분화했다. 깃허브는 GFM을, 레딧은 독자적인 파서를, 디스코드는 또 다른 변종을 채택했다. CommonMark가 등장해 느슨했던 명세를 엄격하게 정리했지만, 아이러니하게도 표준 명세가 생기자 “CommonMark 호환”을 표방하는 구현체들이 제각각 다른 엣지 케이스를 만들기 시작했다. 표준이 오히려 변종을 양산한 셈이다.

여기에 진짜 문제가 있다. 같은 입력이 항상 같은 출력을 내지 않는다는 것, 즉 비결정론적 렌더링이다. CI/CD 파이프라인의 결과물, API 문서, 챗봇 응답, 나아가 LLM이 생성한 마크다운까지, 한 번 렌더링된 결과물이 환경에 따라 모양이 달라진다는 것은 단순한 미관 문제가 아니다. 테이블이 깨지면 파서가 정보를 잃고, 링크가 깨지면 자동화된 문서 파이프라인 전체가 오염된다. 특히 LLM 시대에 들어서면서 이 문제는 훨씬 더 치명적으로 변하고 있다. AI 에이전트가 생성한 마크다운 출력물을 다른 시스템이 파싱하지 못하면, 에이전트 간 인터페이스가 붕괴한다.

되감아서 다시 쓰는 선택

FrankenMarkdown은 바로 이 지점에서 “모든 걸 버리고 다시 만들자”는 극단적인 결정을 내린 프로젝트다. 의존성 제로. C 라이브러리조차 링크하지 않는다. Rust로 처음부터 작성한 파서와 렌더러가 전부다. 이런 접근이 과격해 보일 수 있지만, 마크다운 렌더링의 역사를 돌이켜보면 의존성 사슬이야말로 비결정론의 가장 큰 원천이었다.

기존 렌더러들의 의존성 그래프를 들여다보자. 마크다운 파서는 보통 HTML sanitizer를 끌어오고, sanitizer는 다시 XML 파서를 당기며, XML 파서는 시스템 라이브러리로 연결된다. 이 사슬 어딘가에서 버전이 달라지거나, 플랫폼별로 동작이 미묘하게 갈리거나, 유니코드 정규화 방식이 충돌한다. 결과적으로 동일한 .md 파일이 Ubuntu CI에서는 멀쩡하고 macOS 로컬에서는 깨진다. 의존성이 곧 비결정론의 전파 경로인 셈이다.

FrankenMarkdown이 제로 디펜던시를 고수하는 이유는 단순한 미니멀리즘이 아니다. “같은 입력이면 항상 같은 AST가 나오고, 항상 같은 HTML이 나온다”는 불변식을 보증하기 위한 설계적 선택이다. 이걸 깨뜨릴 수 있는 외부 요소를 아예 원천 차단한 것이다.

Knuth-Plass가 필요한가요?

여기서 한 걸음 더 나아간다. 대부분의 마크다운 렌더러는 줄바꿈을 브라우저에 맡기거나, 단순한 greedy 알고리즘으로 처리한다. 텍스트를 주어진 너비에 맞춰 한 줄씩 채워 나가는 방식이다. 이러면 줄 간격이 울퉁불퉁해지고, 특히 한글 같은 조합형 문자에서 단어 단위 줄바꿈이 엉망이 된다.

FrankenMarkdown은 TeX의 조판 알고리즘으로 유명한 Knuth-Plass 라인 브레이킹을 마크다운 렌더링에 도입하려 시도한다. 원래 Knuth-Plass는 전체 문단을 동시에 고려하며, 각 줄의 “나쁨 정도(badness)”를 계산해 전역적으로 최적인 줄바꿈 지점을 찾아낸다. Greedy 알고리즘이 근시안적으로 처리하는 것을, 전역 최적화로 풀어내는 것이다.

이 접근이 흥미로운 이유는 단순히 수학적 우아함 때문만이 아니다. 고정폭 컨테이너에 렌더링할 때 줄 간격이 시각적으로 균일해지고, 특히 코드 블록이나 표 같은 복잡한 요소 안에서도 레이아웃 붕괴가 일어나지 않는다. Knuth-Plass는 글쓰기 도구에서는 이제 막 실험 단계지만, 마크다운 렌더러에 적용하려는 시도는 여태껏 거의 없었다. 이걸 진지하게 구현하려는 것 자체로도 FrankenMarkdown의 설계 방향을 짐작할 수 있다. 보여주기 위한 마크다운이 아니라, 계산 가능하고 예측 가능한 문서 형식으로서의 마크다운을 진지하게 받아들이는 것이다.

WASM 한 방에 들어오는 이식성

Rust로 작성된 제로 디펜던시 렌더러가 자연스럽게 얻는 부수입이 하나 있다. wasm-pack으로 컴파일하면 브라우저에서도 Node.js에서도 동일한 바이너리가 돌아간다. 서버에서는 네이티브로, 브라우저에서는 WASM으로, 그리고 Bun이나 Deno 같은 대체 런타임에서도 재컴파일 없이 동작한다. 이것도 단순한 기술적 편의가 아니라, 결정론의 관점에서 의미가 크다. “어디서 실행하느냐”가 더 이상 렌더링 결과를 바꾸지 않기 때문이다.

실무에서 이 장점은 꽤 구체적으로 드러난다. 예컨대 서버 사이드에서 미리 렌더링해둔 HTML과, 클라이언트 사이드에서 실시간 미리보기로 생성한 HTML이 완전히 같아야 하는 상황. Notion이나 Obsidian 같은 도구가 수년째 골머리를 앓고 있는 문제인데, FrankenMarkdown의 접근은 이걸 WASM 하나로 무력화한다. 렌더링 로직이 단일 코드베이스에서 단일 바이너리로 나오니, 서버와 클라이언트가 완전히 동일한 파싱 결정을 내린다.

그래서 운영에서 어디서 터지는데?

현장에서 마크다운 렌더링 문제가 가장 자주 폭발하는 지점은 의외로 평범한 곳이다. 중첩된 목록 안에 있는 코드 블록, 표 안에 들어간 링크, 백틱 세 개짜리 펜스 코드 블록 안에 또 다른 백틱이 들어간 케이스. CommonMark 명세를 아무리 꼼꼼히 읽어도, 이런 엣지 케이스가 실제 구현체에서 어떻게 처리될지는 아무도 장담할 수 없다. GFM의 테이블 확장은 CommonMark 명세 바깥에 있고, Mermaid 다이어그램은 또 다른 렌더러를 필요로 한다. 마크다운이란 결국 “명세 + 확장 + 구현체의 기분”이라는 삼각 편차 속에서 살아가는 형식이다.

FrankenMarkdown이 취하는 전략은 여기서도 단순명료하다. 명세에 없는 것은 지원하지 않는다. GFM 테이블, 태스크 리스트, 취소선 같은 확장은 명시적으로 플러그인 형태로 분리하고, 어떤 확장을 활성화했는지에 따라 AST가 완전히 동일하게 결정되도록 강제한다. “이 레포지토리에서는 GFM 테이블이 되는데, 저기서는 안 돼” 같은 일이 원천적으로 발생하지 않게 만드는 것이다.

더 흥미로운 건, 이렇게 생성된 결정론적 AST가 검증 파이프라인과 만날 때의 시너지다. CI에서 마크다운 파일을 커밋할 때마다 AST 해시를 검사해, 동일한 내용이 의도치 않은 렌더링 변화를 일으키지 않는지 자동으로 확인할 수 있다. 마크다운 문서에 대한 스냅샷 테스트가 가능해지는 셈이다. 이제까지 “문서”는 너무 물렁해서 검증 대상으로 간주되지 않았지만, 결정론적 렌더링이 들어오는 순간 문서도 엄연한 빌드 아티팩트로 취급할 수 있게 된다.

변종이 변종을 낳는 구조를 부수려면

마크다운 생태계가 이렇게까지 난잡해진 근본 원인은 단순하다. 모두가 “조금만 더 편하게”를 외치며 명세에 없는 기능을 구현체 맘대로 추가했고, 그렇게 만들어진 변종들이 다시 새로운 변종의 기반이 되었다. 깃허브가 테이블을 넣으면 모두가 따라가고, Obsidian이 콜아웃을 넣으면 그걸 또 지원해야 하고, 이 과정에서 공식 명세는 유명무실해졌다.

이 순환을 끊으려면 결국 “결정론적 출력”이라는 한 가지 약속이 필요하다. 입력 마크다운과 활성화된 확장 목록, 그리고 렌더러 버전 세 가지가 같으면, 출력 AST와 HTML은 항상 비트 단위로 동일해야 한다. FrankenMarkdown은 이 약속을 의존성 제거, WASM 단일 바이너리, Knuth-Plass 조판, 플러그인 명시적 분리라는 네 가지 축으로 실현하려 한다.

물론 이 모든 시도가 당장 내일 깃허브의 렌더러를 대체하거나, 수백만 개의 레거시 마크다운 파일을 자동으로 교정해주지는 않는다. 마크다운은 이미 세상에 너무 많이 퍼져 있고, 깨지는 걸 알면서도 그냥 쓰는 데 익숙해진 사람들이 대다수다. 하지만 적어도 “마크다운 렌더링에 결정론이라는 개념을 진지하게 들이민 첫 프로젝트”로서의 의미는 결코 작지 않다. 앞으로 LLM이 생성하는 출력물의 대부분이 마크다운이 될 세상에서, 에이전트 간 통신이 한 줄의 링크 파싱 실패로 무너지는 일을 막으려면 결국 이 지점으로 수렴할 수밖에 없다.

아직 갈 길은 멀다. Knuth-Plass 조판을 모든 문단에 실시간 적용하는 것은 연산 비용이 만만치 않고, GFM을 비롯한 수많은 확장을 결정론적으로 재구현하는 작업도 녹록지 않다. 하지만 방향은 분명하다. 마크다운이 “대충 읽을 수 있으면 된다”는 관용 위에 세워진 형식에서, “동일한 입력은 반드시 동일한 문서를 생산한다”는 엄격한 계약으로 진화하는 것. 그 첫 번째 발걸음이 이미 시작되었다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Tools 전체 보기

이전 글

AI 에이전트는 서버에서 태어났지만 브라우저에서 산다

다음 글

Decap CMS 유저가 Sveltia로 넘어올 때 가장 많이 묻는 질문

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독