목록으로

mermaid 를 붙이며 만난 조용한 실패 3건

5 조회
개발 도구 신뢰성
#Next.js#디버깅#정적 검사#설계 결정#마크다운

TL;DR

  • 코드 색칠 플러그인이 먼저 돌면 mermaid 펜스의 원문이 줄 단위 span 으로 쪼개져 그림 대신 색칠된 코드블록이 나온다
  • mermaid 의 render 는 문법 오류에서 예외를 던지기 전에 에러 그림을 문서에 붙이고 지우지 않는다
  • 번들 확인을 청크 문자열 grep 으로 하면 오판한다. first-load 청크에 잡힌 mermaid 는 Prism 의 문법 정의였고 본체 415KB 는 별도 청크에 있었다
  • 셋 다 화면이 멀쩡해 보여서 눈으로는 안 잡힌다. 가드 테스트 3개로 고정했다

그림 자산을 만들 수 없는 자리

블로그 글에 설명용 그림이 안 들어간다는 이야기에서 시작했다. 처음에는 글쓰기 규약 문서에 그림을 넣으라는 항목이 없어서라고 봤다.

파이프라인을 열어 보니 그 층의 문제가 아니었다. 이 블로그에서 본문 이미지는 브라우저가 R2 로 직접 올린다. 앱 서버는 서명만 발급하고 파일은 서버를 지나지 않는다. 어드민 화면에 로그인해서 올리는 경로 하나뿐이다. 글을 쓰는 자리에서는 그림 파일이라는 자산 자체를 만들 수 없다.

그래서 파일이 아니라 텍스트로 적는 다이어그램을 붙이기로 했다. mermaid 코드펜스를 쓰면 그림이 본문 content 안에 그대로 남는다. 업로드도, 안 쓰는 파일을 걷어내는 일도, 대체 텍스트도 필요 없다.

렌더러 구성은 이랬다.

<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  rehypePlugins={[rehypePrismPlus]}
  components={markdownComponents}
>

색칠이 먼저 지나간 펜스

mermaid 펜스를 그림 자리로 바꾸는 rehype 플러그인을 하나 만들어 배열에 넣었다. 넣는 위치를 rehypePrismPlus 뒤로 두면 그림이 안 나온다.

rehype-prism-plus 는 코드블록의 텍스트 노드를 색칠용 span 으로 잘게 나눈다. 줄마다 <span class="code-line"> 이 생기고 그 안이 토큰 단위로 또 쪼개진다. 이 단계를 지난 뒤에는 원래 문장을 되돌릴 방법이 없다. 그림을 그리려면 graph TD 로 시작하는 원문 그대로가 필요한데, 남아 있는 것은 색칠 정보가 섞인 조각들이다.

flowchart TD
  A[마크다운 mermaid 펜스] --> B{플러그인 순서}
  B -->|mermaid 가 먼저| C[div.mermaid-block 으로 빠짐]
  B -->|색칠이 먼저| D[줄 단위 span 으로 쪼개짐]
  C --> E[그림]
  D --> F[색칠된 코드블록]

순서를 바꾼 뒤 mermaid 펜스는 색칠 단계에 도달하지 않는다. 앞 단계에서 이미 div.mermaid-block 으로 바뀌어 나가고 다른 언어의 펜스만 Prism 에게 간다.

이 실패가 눈에 안 띄는 이유는 화면에 아무 문제가 없어 보이기 때문이다. 그림이 있어야 할 자리에 빈칸이 뜨는 것이 아니라 잘 색칠된 코드블록이 뜬다. 마크다운을 잘못 썼거나 아직 지원이 안 되는 문법이라고 읽힌다.

문서에 남는 에러 그림

문법이 틀린 다이어그램을 일부러 하나 넣고 화면을 봤다. 본문 자리에는 의도한 대로 원문이 그대로 떴다. 그런데 페이지 맨 아래, 푸터 아래쪽에 폭탄 아이콘과 Syntax error in text 라는 큰 글자가 따로 떠 있었다.

mermaid 의 render 는 문법 오류를 만나면 예외를 던지기 전에 에러 그림을 먼저 문서에 붙인다. 그 노드를 지우지 않기 때문에 호출한 쪽에서 catch 로 받아 원문을 그려도 화면에는 두 가지가 같이 남는다. 내 컴포넌트는 실패를 제대로 받아 원문을 그리고 있었다. 그래서 오히려 원인이 더 안 보였다.

parse 로 먼저 검사하면 그 경로를 밟지 않는다.

const parsed = await mermaid.parse(chart, { suppressErrors: true });
if (!parsed) {
  setSvg("");
  return;
}

const rendered = await mermaid.render(diagramId, chart);
setSvg(rendered.svg);

suppressErrors 를 주면 parse 는 던지지 않고 거짓을 돌려준다. 검사를 통과하고도 실패하는 경우가 남아 있어서 catch 쪽에는 d 접두가 붙은 임시 노드를 지우는 처리를 같이 뒀다.

청크에 잡힌 문자열과 실제로 들어간 것

mermaid 는 무겁다. 그림이 없는 글에까지 따라붙으면 안 되므로 정적 import 를 쓰지 않고 그림이 있을 때만 await import("mermaid") 로 불러온다. 이게 실제로 먹었는지 빌드 결과에서 확인하려고 청크를 뒤졌다.

grep -rl "mermaid" .next/static/chunks/*.js

첫 화면에 필요한 청크 목록 안에서 두 파일이 잡혔다. 하나는 598,858 바이트였다. 동적 import 가 안 먹고 공용 청크로 들어간 것처럼 보였다.

문자열이 있다는 것과 그 라이브러리가 들어갔다는 것은 다른 이야기다. 잡힌 자리를 열어 보면 이렇게 되어 있었다.

function aT(e){e.languages.mermaid={comment:{pattern:/%%.*/,greedy:!0}, ...
aT.displayName="mermaid",aT.aliases=[]

rehype-prism-plus 가 들고 있는 refractor 의 mermaid 문법 정의다. 코드블록에 ```mermaid 라고 적었을 때 색칠하는 규칙이고 mermaid 도입 전부터 번들에 있었다. 문자열은 그 청크 전체에서 두 번 나온다.

페이지가 실제로 처음에 받는 파일은 app-build-manifest.json 에 페이지별로 적혀 있다.

import json, io, os
m = json.load(io.open('.next/app-build-manifest.json', encoding='utf-8'))
files = m['pages']['/(localized)/[locale]/(blog)/blog/[slug]/page']
for f in files:
    s = io.open('.next/' + f, encoding='utf-8', errors='ignore').read()
    if 'flowchart-v2' in s:
        print(f, os.path.getsize('.next/' + f))

flowchart-v2 처럼 mermaid 본체에만 있는 문자열로 다시 찾으면 답이 갈린다. 그 문자열을 가진 청크는 414,956 바이트짜리 파일 하나이고 첫 화면 청크 14개 안에 없다. 동적 import 는 먹고 있었다. 오판한 것은 검사 방법이었다.

처리와 검증

세 가지를 코드로 고정했다.

  • 플러그인 순서. rehypeMermaidBlockrehypePrismPlus 보다 앞에 있는지 배열을 정적으로 읽어 확인한다
  • 동적 import. await import("mermaid") 가 있고 최상위 import ... from "mermaid" 가 없는지 본다
  • parse 선검사. 호출이 남아 있는지 본다

셋 다 scripts/ci/blog-mermaid-guard.test.mjs 한 파일에 있다. 화면을 봐서는 안 잡히는 종류라 사람 검토에 맡기지 않았다.

빌드와 브라우저에서 확인한 값은 이렇다.

  • npm test 187개 통과. 이번에 늘어난 것이 9개다
  • 첫 화면 청크 14개 합계 1,324,594 바이트. 그 안에 mermaid 본체 없음
  • 서버가 내려주는 HTML 에 다이어그램 원문이 <pre><code> 로 담긴다. JS 를 실행하지 않는 크롤러는 그림 대신 그 텍스트를 읽는다
  • 다크와 라이트를 오갈 때 그림이 다시 그려진다. next-themesresolvedTheme 을 의존성에 뒀다

곁다리로 나온 속성 하나

서버가 내려준 HTML 을 확인하다가 제목 태그에서 이걸 봤다.

<h2 id="다이어그램-확인" node="[object Object]">

react-markdowncomponents 로 넘긴 함수에 node 라는 값을 같이 준다. 파싱 결과 객체다. 커스텀 컴포넌트를 이렇게 쓰면 그대로 DOM 으로 나간다.

h2: ({ children, ...props }) => <h2 id={id} {...props}>{children}</h2>

node 를 따로 빼내면 사라진다. 발행글 하나에 h2 가 9개 있으니 그만큼 찍히고 있었다. 브라우저는 모르는 속성을 무시하므로 화면에서는 아무 일도 일어나지 않는다.

h2: ({ children, node, ...props }) => <h2 id={id} {...props}>{children}</h2>

남은 것

  • 어드민 편집기는 preview="edit" 라 미리보기가 없다. 다이어그램은 저장한 뒤 화면에서 확인해야 한다. 편집기를 미리보기 모드로 바꾸면 mermaid 는 안 그려진다. 그 미리보기는 블로그 렌더러가 아니라 편집기 자체 것이라 플러그인 구성이 다르다
  • 화면 캡처처럼 그림 파일이 있어야 하는 것은 여전히 어드민 업로드뿐이다. 명령줄에서 R2 로 올리는 경로는 안 만들었다. mermaid 로 안 되는 그림이 실제로 필요해질 때 만들 생각이다

댓글

댓글 작성