Skip to content
Go back

MDX v2 마이그레이션

Posted on:2023년 12월 22일

MDX v2가 나온지 2년 가까이 됐고, 얼마 전에 v3가 나온 시점에서 v1 → v2 마이그레이션 글을 올린 이유는 MDX v2는 Breaking Change가 매우 많기 때문입니다. 😭

도큐사우르스의 예를 보시면 이해가 빠르실거에요.

도큐사우르스팀은 2021년 1월MDX v2 마이그레이션을 계획했습니다. 하지만 2022년 4월이 되어서야 마이그레이션 PR이 머지가 되었습니다. (1년 3개월의 긴 여정)

그리고 MDX v2는 도큐사우르스 v3부터 적용될 예정이었는데 v3 릴리즈 일주일 전(2023년 10월 24일)에 MDX v3가 나왔습니다. v3는 BC가 적었기 때문에 2일만에 PR이 올라왔고, 2023년 10월 31일 도큐사우르스 v3가 릴리즈 되었습니다.

저 또한 v2로 마이그레이션 하는 과정에서 트러블슈팅을 많이 겪어서 이를 글로 남겨두려고 합니다.


MDX v2의 장점

MDX v2는 v1에 비해 뭐가 좋아졌는지 먼저 알아볼게요.

1. 문법이 사용하기 편하게 변했습니다.

<div>*hi*?</div>
<div>
 # hi?
</div>
 
// v1 파싱 결과
<>
 <div>*hi*?</div>
 <div>
  # hi?
 </div>
</>
 
// v2 파싱 결과
<>
 <div><em>hi</em>?</div>
 <div>
  <h1>hi?</h1>
 </div>
</>

2. 다양한 번들러 및 프레임워크 지원

3. 새로운 아키텍쳐 도입

4. 타입스크립트


마이그레이션

새로운 아키텍쳐의 도입과 unified, MDX 생태계는 ESM Only로 변경됐습니다.

이에 맞춰서 마이그레이션을 해줘야하는데, 제가 마주한 트러블 슈팅을 적어볼게요.

변경된 MDAST에 맞추어 remark 플러그인 수정

AST 노드가 변경되었기에 과거의 방식으로 구현된 플러그인을 수정해야했습니다. v2로 변경되고 remark 플러그인을 만들기 훨씬 수월해졌습니다.

아래와 같이 사용되는 MDX 파일이 있다고 가정해보겠습니다.

<Component>
 - foo
 - bar
 - baz
</Component>
<Component>
 - foo
 - bar
 - baz
</Component>
// ...

여기서 foo, bar, baz 에 접근하려면 remark 플러그인을 어떻게 작성해야할까요?

MDX v1 방식부터 보여드릴게요.

import { visit } from "unist-util-visit";
 
visit(
  tree,
  node => {
    if (node.type !== "jsx") {
      return false;
    }
    return node.value === `Component`; // props가 없다는 가정
  },
  (_, startNodeIndex, parent) => {
    const endNodeIndex = parent.children.findIndex((child, currIndex) => {
      return (
        currIndex > startNodeIndex &&
        child.type === `jsx` &&
        child.value === `</Component>`
      );
    });
 
    parent.children.slice(startNodeIndex, endNodeIndex).forEach(node => {
      // 해당 list item node를 활용한 로직
    });
  }
);

Node의 index를 활용해서 <Component> Node보다 index가 크고, </Component> Node보다 index가 작은 Node들을 찾아야했습니다. index로 로직을 처리하다보니, 위와 같이 단순한 예시가 아니라 복잡한 경우 로직을 이해하기도 힘들고 수정도 쉽지 않습니다.

MDX v2에선 어떻게 작성할 수 있는지 알아보겠습니다.

import { visit } from 'unist-util-visit'
 
visit(
 tree,
 node => {
 if (node.type !== `mdxJsxFlowElement`) {
 
  return false;
 }
 
 return node.name === `Component`;
 },
 (node) => {
  node.children.forEach((childrenNode, index) => {
   // 해당 list item node를 활용한 로직
  }
 }
)

기존에는 <Component></Component> 는 서로 다른 Node였던 반면에, 이제는 <Component></Component> 는 한 Node가 되었습니다 🙌

그로 인해 이제 Component 내부에 있는 Node들을 children으로 조회가 가능해졌고, 이에 따라 플러그인을 만들기 훨씬 수월해졌습니다.

그리고 mdxJsxFlowElement, mdxJsxTextElement, mdxJsxAttribute, mdxjsEsm 등 새로운 Node type이 추가되었는데 자세한 내용은 mdast-util-mdx-jsx 에서 확인할 수 있습니다.

변경된 HAST에 맞춰 rehype 플러그인 수정

코드블록의 메타데이터를 처리하는 방식이 달라졌습니다.

아래 같은 코드블록이 있을 때,

```javascript title=테스트 theme=dark
console.log("!");
```

v1 에서는 properties에 메타데이터 titletheme이 들어가있었습니다.

{
  "type": "element",
  "tagName": "pre",
  "properties": {},
  "children": [
    {
      "type": "element",
      "tagName": "code",
      "properties": {
        "className": "language-javascript",
        "metastring": "title=테스트 theme=dark",
        "title": "테스트",
        "theme": "dark"
      },
      "children": [{ "type": "text", "value": "console.log('!');\n" }]
    }
  ]
}

하지만 v2에서는 data.meta 에 string으로만 나오게 되었습니다.

{
  "type": "element",
  "tagName": "pre",
  "properties": {},
  "children": [
    {
      "type": "element",
      "tagName": "code",
      "properties": { "className": ["language-javascript"] },
      "children": [{ "type": "text", "value": "console.log('!');\n" }],
      "data": { "meta": "title=테스트 theme=dark" }
    }
  ]
}

이에 대한 내용은 공식문서에 적혀 있어서 rehype-mdx-code-props 플러그인을 설치하여 해결해주었습니다.

TOC (mdast-util-toc)

기존에는 JSX 컴포넌트 안에 헤더가 있을 경우, mdast-util-toc 에서 인식이 가능했습니다. (mdast-util-toc를 사용하는 remark-toc에서도 동일)

<Something>
 
# Heading
 
</Something>

이전에는 <Something>, #Heading </Something> 이 모두 같은 위계의 Node였기 때문에 문제 없이 헤딩을 가져올 수 있었습니다.

하지만 AST가 변경된 이후에 JSX 컴포넌트 안에 있는 헤딩은 포함이 안되는 문제가 발생했습니다. mdast-util-tocposition에 대한 로직으로 인해 처리를 안하는 것을 확인할 수 있었고, 코드를 그대로 가져와 순회하는 영역만 원하는대로 수정하여 해결했습니다.

마크다운 테이블

이전에는 잘 동작하던 마크다운 테이블 문법이 갑자기 적용이 안되기 시작했습니다.

| 제목 | 내용 | 설명 |
| ---- | ---- | ---- |
| foo  | bar  | baz  |
| foo  | bar  | baz  |
| foo  | bar  | baz  |

알아보니 MDX v2 이후부터 테이블 문법을 지원해주던 remark-gfm이 default가 아니게 변경되어 remark-gfm을 v3 이후로 설치해줘야했습니다.
(next-mdx-remote를 사용중이라면 remark-gfm@4 간에 이슈가 존재하여 v3을 설치해야합니다.)

ReferenceError (JS 표현식 문법)

기존에 MDX에서 {something}이 들어간 문구를 썼다면 변경해주어야 합니다. 왜냐하면 MDX v2부터는 {} 를 JSX처럼 JS 표현식으로 인식하여 something을 변수로 받아들이기 때문입니다.

// as-is
GET /post/{id}
 
// to-be
GET /post/\{id\}

HTML 주석 문제

<!-- Comment -->

MDX v2 이후, MDX에서 HTML 주석 문법 사용을 막아놨습니다. 그 이유는 MDX는 JSX의 동작방식에 더 가까워지게 하기위한 메인테이너의 철학이 녹아져있습니다..

그래서 MDX에서 주석을 처리하고 싶다면 JSX의 주석처럼 {/* 주석 */} 방식을 사용해야 합니다.

이번 버전업에선 MDX 파일에서의 수정을 최소화하고자, remark-comment 라는 플러그인을 설치해 MDX 파싱 시에 HTML 주석을 제거하도록 했습니다.

추후에는 MDX 파일에서 HTML 주석 문법을 JSX 주석 문법으로 수정할 생각입니다.

ESM Only

jest

jest ECMASCript Modules에 설명되어 있지만, 해결이 잘 되지 않아 이 이슈를 참고하여 jest config에 transformIgnorePatterns 에 ESM Only 모듈을 적용해주었습니다.

etc

jest 이외에도 Next.js + yarn PnP 를 사용함에 있어서 ESM Only 라이브러리와 호환이 잘 안되는 문제도 발생했었습니다.

prettier

prettier v2.5.0보다 낮은 버전을 쓰고 있다면 MDX에서 아래 방식을 자동으로 포맷팅해서 주석 처리가 정상적으로 동작하지 않습니다.

// as-is
{/* 주석 */}
 
// to-be formatted 🫠
{_/ 주석 /_}

그래서 prettier v2.5.0 이상으로 버전업을 해야합니다.

그리고 prettier에서 MDX의 포맷팅에 대한 이슈가 계속 제기되고 있는데 1년째 해결되지 않고 있습니다. 🥲

MDX의 컨트리뷰터인 woorm은 remark-cli + remark-mdx 조합을 사용하길 권장하고 있습니다.

——

결론

BC가 많아 겪은 트러블슈팅이 많았습니다.

그리고 마이그레이션을 하면서 도큐사우르스 를 정말 많이 참고했는데요.

1년이 넘는 긴 마이그레이션 여정이 얼마나 힘들었는지 알 수 있고, 정말 다양한 트러블슈팅을 확인할 수 있으니 관심이 있으신 분은 도큐사우르스의 PR을 한번 봐보시는걸 추천드립니다!


참고