YH

블로그 이사하기(1) - Next와 MDX

목차들어가며어떻게 뭘 만들 것인가구현하기(Frontend)Markdown 콘텐츠 다루기삽질하기

들어가며

개발 블로그를 새롭게 만들어보고 싶었고, 하나 만들어보자고 결정했다.

개발자라면 자신이 운영하는 블로그를 하나 정도는 가지고 있어야 하지 않나 하는 느낌도 있었고, 전에 사용하던 Velog 는 누군가에게 보여줘야만 하는 그런 포스트를 작성해야만 한다는 묘한 마음가짐이 생겨 소소하지만 개인적인 내용도 적어넣고 관리할 수 있는 나만의 블로그를 가지고 싶다는 생각이 들었다. 나만의 블로그가 생기면 포스팅을 조금 더 많이 하고 싶어지지 않을까 하는 마음도 있었다.

또, 현재 대부분 터미널과 Markdown 으로 모든 개발 환경과 노트를 관리하고 있기에 같은 테두리 내에서 블로그를 관리하고 싶다는 마음도 기여하는 바가 컸다.

새로운 블로그의 첫 포스트에는 해당 프로젝트를 통해 경험한 이러저러한 사실들을 적어보려고 한다.


어떻게 뭘 만들 것인가

나만의 블로그를 만들기 위해서 다음과 같은 것들을 첫 번째 목표로 잡았다.

  • SSG 를 통해서 최대한 가볍고 빠르게 페이지를 구성할 것
  • CLI 를 통해서 블로그 CMS 해결하기
  • Markdown 문법을 일반적인 범위 내에서 최대한 지원하기

이 기능들에 더해 개인적인 디자인과 필요한 기능들만을 조금 더 추가해 다음과 같이 구성하기로 했다.


SSG와 서버렌더링

첫 번째로, 프로젝트를 어떻게 시작해야할지 선택해야한다.

정적콘텐츠 렌더링 기능을 제공하는 프론트엔드 프레임워크와 방법들은 이미 너무나 많이 존재하기에 중요한 것은 이중에서 선택을 하는 것이었다.

내가 선택할 수 있는 선택지들을 추렸을 때, Gatsby, Astro, Next 와 같은 선택지들이 남았고 이중에서 다음과 같은 이유로 Next 를 선택하기로 결정했다.

  • 직접 기능을 작성할 것이기 때문에 템플릿은 크게 메리트가 없다.
  • 블로그를 위해 새로운 프레임워크를 배우거나 사용하고 싶지 않다.
  • 복잡한 서버나 백엔드 레이어가 필요하지 않다.

Next 는 이미 React를 사용하고 있는 내 입장에서는 고려하지 않을 이유가 없는 선택이었다.

또한 RSCServer action 의 도입으로 이전 버전들 보다 SSG 및 서버환경을 쉽게 설정할 수 있다는 것도 하나의 이유였다.

CMS 선택하기

프레임워크를 선택해 프로젝트의 베이스를 결정했으니, 이제 다음으로 CMS 를 결정하는 일이 남았다.

CMS 또한 서버 환경을 직접 구성해 배포하거나, CMS 솔루션을 사용하거나 하는 다양한 방법 떠올릴 수 있을 테지만, 다음과 같은 이유로 CLI 와 파일시스템으로 CMS 구축하기를 선택하기로 했다.

  • 비교적 가볍고 쉽게 이용 가능하다.
  • 프로젝트내에 콘텐츠를 합쳐서 관리가 가능하다.
  • 콘텐츠를 관리하는 특별한 외부 인터페이스가 필요하지 않다.
  • 콘텐츠나 데이터를 직접적으로 빠르게 관리할 수 있다.

이렇게 가장 핵심적인 부분들을 결정했으니, 이제 직접 구현해 만들어보는 부분만이 남았다.


구현하기 (Frontend)

프로젝트를 할 때마다 삽질은 필수적이다.

이 프로젝트를 진행함에 있어서의 걸림돌은 Node, 파일시스템 그리고 Markdown 파일 들을 잘 한 군데에 엮는 과정이었다.

결국 남는 건 기록 뿐이니, 중요하게 깨달았던 사실들과 삽질들을 모아서 섹션 별로 요약 설명을 해보려고 한다.

먼저 첫 번째는 Frontend와 Markdown 렌더링에 관련된 부분이다.


Markdown 콘텐츠 다루기

나는 이전에 Markdown 파일을 다뤄본 경험이 없었다.

물론 Obsidian 이나 Readme 내에서의 Markdown 문서 작성이야 많이 해봤지만, 하나의 데이터로써 다뤄본적은 없었다.

따라서 먼저 이러한 고민을 필수적으로 해야했다.

Markdown 문서를 렌더링할 데이터로 활용하려면, 어떻게 사용해야할까?


먼저 Markdown 문서의 특징을 떠올려보면 이러한데,

  • String 콘텐츠로 구성되어 있다.
  • HTML 과 같이 형식을 가진 문서이다.
  • 여러 방식으로 확장해서 사용이 가능하다.

이러한 생각은 자연스럽게 다음과 같이 연결되도록 만들어준다.

  • String 으로 IO를 구성해 CMS 에 사용하자
  • Markdown 을 HTML 로 파싱해 화면에 보여줄 수 있도록 하자
  • 필요한 부분이나 기능이 있다면 확장해서 사용하자

MDX

고맙게도 Next 는 몇 가지 패키지들을 통해 Markdown 형식의 콘텐츠를 MDX 라는 Markdown 확장 형식을 통해 활용할 수 있도록 지원한다.

공식 문서를 보면, 프로젝트에서 Markdown을 다루는 두 가지 경우를 찾을 수 있다.

  1. Markdown 콘텐츠를 프로젝트의 일부로써 포함시키기
  2. Remote source 의 Markdown 을 렌더링하기

1번의 경우에는 프로젝트 내에서 *.md, *.mdx 의 확장자를 번들의 일부로 인식 시켜줘야 하기 때문에, next.config 의 수정이 필요하지만

2번의 경우에는 Markdown 형태를 갖춘 String 을 렌더링할 수 있는 parser 만 준비한다면 프로젝트 구성에 특별한 수정은 필요로 하지 않는다.

이번 프로젝트의 경우 Markdown 파일들이 프로젝트에 포함은 되지만 MDX 를 통해 페이지를 구성할 생각은 없었기에 외부에서 불러올 컨텐츠만을 렌더링 하기 위해서 2번째 옵션을 선택했다.

Next-MDX-Remote

Markdown 파싱을 지원하는 패키지는 여러가지(MDX-Bundler, Contentlayer 등) 가 있지만 Next-MDX-Remote 패키지를 사용하기로 했다.

MDX-bundler 의 경우에는 Next-MDX-Remote 의 기능을 커스텀 컴포넌트를 콘텐츠에 사용하거나(Remote source 의 MDX) 상위 호환하여 지원하지만 번들러 기능을 포함시켜야 했기에 현재 상황에서는 과도했고, Contentlayer 현재 적절히 유지되고 있는 것 같지 않았기 때문이다.

패키지의 사용법은 간단하다. 패키지를 인스톨한 뒤, <MDXRemote> 를 다음과 같이 구성하면 된다.

Markdown Example
// RSC 기준으로 작성
import { MDXRemote } from "next-mdx-remote/rsc";
 
function MarkdownRender() {
  //...
  return (
    <MDXRemote
      source={`#마크다운 ##콘텐츠 ###내용`}
      //other props...
    />
  );
}

기존의 방식으로는 source를 사용하기 전에 serialize 를 거쳐야 하는 과정이 필요했었으나 RSC 로 넘어오면서 해당 과정을 분리하지 않는 방식으로 변경했다.

이제 신뢰할 수 있는 곳에서 Markdown 콘텐츠를 불러와 source 로 제공하면 Markdown 콘텐츠를 화면에 렌더링 할 수 있게 된다.

Remark-Rehype

그렇다면 Markdown 파일을 HTML 로 컴파일 하는 과정은 어떻게 이루어지는 것일까?

이 과정에서 사용되는 패키지가 RemarkRehype 패키지이다.

해당 패키지들은 다음과 같이 사용된다.

  • Remark: Markdown 스트링을 읽어들여 AST 를 구성해 파싱할 수 있도록 도와주는 패키지이다. 플러그인을 사용하면 Markdown 문법을 커스텀하게 확장해서 사용할 수 있도록 도와준다.
  • Rehype : Remark 패키지와 비슷한 역할을 HTML 콘텐츠를 대상으로 하는 패키지이다. 역시 플러그인을 통해서 파싱 기능을 확장 하거나 커스텀하게 구현할 수 있다.
  • Remark-Rehype : 두 패키지를 연결시켜주는 역할을 하며, Remark를 통해 파싱된 AST 가 Rehype 패키지에 사용될 수 있도록 AST 트랜스파일 과정을 도와준다.

즉, **.md(string) -> Remark(AST) -> Remark-Rehype(AST) -> Rehype(AST) -> HTML(string) 이러한 연결을 통해서 실제로 웹 상에 콘텐츠를 올릴 수 있도록 해주는 것이다.

Next-MDX-Remote 패키지가 자연스럽게 이 과정을 뒤에서 처리해주고 있기에, 사용자는 해당 과정을 직접 연결하지 않고도 사용할 수 있다.

Example piepline
import { unified } from "unified"; // for pipelining
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeStringify from "rehype-stringify";
 
async function convertMarkdownToHtml(markdown: string): Promise<string> {
  const file = await unified()
    .use(remarkParse) // Markdown → MDAST
    .use(remarkRehype) // MDAST → HAST
    .use(rehypeStringify) // HAST → HTML
    .process(markdown);
 
  return String(file);
}
 
const md = `# **Markdown** Contents!`;
const html = await convertMarkdownToHtml(md);
// => <h1><strong>Markdown</strong>Contents!</h1>

그렇다면 이렇게 "간단하게 연결만 한 뒤 그대로 사용만 하면 되는 가" 하면 그런 것은 아니다. 이렇게 파싱하여 출력 된 HTML 은 그저 기본적인 태그만으로 구성되어있는 HTML 결과물 이기 때문이다.

물론 간단하게 사용한다면 이정도의 결과물로도 충분하겠지만, 필요하다면 plugin 과 컴포넌트들을 통해 추가적인 구성을 추가할 수 있다.

Plugins

플러그인을 고민하게 되는 사항은 대표적으로 두 가지 정도라고 할 수 있을 것 같다.

  1. Markdown 문법을 확장할 필요가 있을 때

기본적인 Markdown 문법/ CommonMark 은 생각보다 당연히 변환 될 거라고 생각 된 기능들이 변환되지 않는 경우들이 있다.

예를 들어서 취소선, 언더라인, 체크박스, 테이블 심지어 줄 바꿈 방식까지도 등의 요소들이 그러한데 Github 에 작성하던 Readme 에 익숙한 사용자들이라면 당황할 수 있다.

이럴 때 Remark Plugin 을 적용해 해결할 수 있다.


GFM(Github Falvored Markdown) Plugin 을 통해 현재 Markdown 파싱을 확장해보자. <MDXRemote> 를 통해 plugins 를 적용하려면 다음과 같이 설정하면 된다.

Remark Plugins
import { MDXRemote } from "next-mdx-remote/rsc";
import remarkGfm from "remark-gfm";
function MarkdownRender() {
  //...
  return (
    <MDXRemote
      source={`...`}
      options={{
        mdxOptions: {
        // plugin 적용
          remarkPlugins: [remarkGfm, ...others],
        },
      }}
    />
  );
}
 

<MDXRemote> 에는 options props 에 값을 넣어줄 수 있는데, 이때 mdxOptions 프로퍼티를 통해서 plugins 들을 삽입할 수 있다. 보통 배열의 형태로 적용을 원하는 순서대로 작성해서 넣어준다.

예시에 사용된 remark-gfm 플러그인을 적용하면, 우리가 익히 사용해 왔던 기본 바탕의 Markdown 문법을 사용할 수도록 확장된다.

  1. HTML 변환과정을 확장하거나 커스텀한 기능을 넣고 싶을 때

두 번째 상황은 파싱 과정에서 커스텀한 컴포넌트를 구현해 사용하거나 혹은 직접 구현하기 어려운 컴포넌트를 구현하도록 도와주는 과정에서 plugins 를 생각해볼 수 있다.

이러한 컴포넌트의 대표적인 예시가 code highlight 기능이 있는 code preview 기능일 것이다.

이번엔 Remark plugins 대신 Rehype Plugins 를 통해 기능을 적용해보자.

code highlight plugin 을 적용하는 방법에도 역시 여러가지 패키지가 있으나, 제일 쉽고 간편하게 적용이 가능하다고 느껴졌던 패키지 조합은 Rehype-Pretty-CodeShiki 의 조합이었다.

Shiki 는 Syntax-Highlight 엔진으로 Highlight 기능의 대부분을 구현해주고 Rehype-Pretty-Code 는 해당 기능을 Rehype 에 쉽게 적용할 수 있도록 도와준다.

Rehype Plugins
import { MDXRemote } from "next-mdx-remote/rsc";
import rehypePrettyCode from "rehype-pretty-code";
 
  const shikiCodeOptions: RehyepePrettyCodeOptions = {
    theme: {
      light: "github-light",
      dark: "material-theme-darker",
    },
  };
 
function MarkdownRender() {
  //...
  return (
    <MDXRemote
      source={`...`}
      options={{
        mdxOptions: {
        // plugin 적용
          rehypePlugins: [[rehypePrettyCode, options], ...others],
        },
      }}
    />
  );
}

역시 Remark plugins 와 마찬가지로 간단하게 optionsplugins 을 사용하겠다고 명시해주면 된다. Shiki는 단순히 테마 뿐 아니라 많은 기능(diff, highlight, line number...etc)을 지원하는데 Docs 를 활용하면 여러가지 기능을 추가해 code preview 를 꾸며볼 수 있을 것이다.


+ 하나 주의해야할 점은 Shiki를 통해 여러가지 테마를 구성할 때 자동으로 적용되지 않고, CSS variable 을 통해서 이를 직접 관리해줘야 하는데 이 부분을 잘못하면 놓칠 수 있다.

Shiki multi themes

pre 요소에 설정된 모든 테마의 값이 변수로 들어가기 때문에 이 값을 활용해서 스스로 스타일 분기를 적용해주어야만 한다.

[data-theme="light"] {
  pre[data-theme*="github-light"],
  code[data-theme*="github-light"] {
    color: var(--shiki-light);
    background-color: var(--shiki-light-bg);
  }
 
  code[data-theme*="github-light"] span {
    color: var(--shiki-light);
  }
}
 
[data-theme="dark"] {
  figcaption[data-rehype-pretty-code-title=""],
  pre[data-theme*="everforest-dark"],
  code[data-theme*="everforest-dark"] {
    color: var(--shiki-dark);
    background-color: var(--shiki-dark-bg);
  }
 
  code[data-theme*="everforest-dark"] span {
    color: var(--shiki-dark);
  }
}
  1. 커스텀 plugin 을 만들어보자

위에 있는 두 가지 기능들만 추가해도 웬만한 블로그에 필요한 마크업들은 대부분 구현이 가능하다. 하지만 나같은 경우 Markdown 을 작성할 때, nesting 되어있는 리스트 요소들을 많이 사용하는 편인데, 아쉽게도 이를 자동으로 인식해서 HTML 로 변환해주는 기능은 제공되지 않는 것 같았다. 그래서 이를 직접 구현해 보기로 했다.

내가 원했던 기능은 다음과 같다.

  • 자동으로 nesting 깊이를 인식해 다른 스타일을 적용하도록 하기
  • ulol 을 인식해 다른 구분자 지정하기

요구사항의 기능은 HTML로 변환하는 과정에서 적용될 수 있는 과정이기에 Rehype Plugin 을 구현해야한다. Rehype 의 인터페이스를 이해하고 이 과정을 적용한다면 커스텀한 plugin 을 추가하는 과정은 어렵지 않다.

다음의 코드는 이 기능을 구현한 부분이다.

Rehype AddLiDepth plugin
/**
 * A rehype plugin that adds `data-depth` and `data-parent` to <li> elements
 * depending on how deeply they are nested inside <ul> or <ol>.
 *
 * It also adds a `li-depth-{n}` class for Tailwind styling.
 */
export default function rehypeAddLiDepth() {
  return (tree: unknown) => {
    const walk = (node: any, depth: number = 0, parentType: "ul" | "ol" | null = null) => {
      if (node.type === "element") {
        const tag = node.tagName;
 
        // If we're entering a new list
        if (tag === "ul" || tag === "ol") {
          parentType = tag;
          depth += 1;
        }
 
        // If we're inside a list item
        if (tag === "li") {
          node.properties ??= {};
 
          // Add data attributes
          node.properties["data-info"] = `${depth}-${parentType}`;
          node.properties.className = [...(node.properties.className ?? []), `md-list-depth`];
        }
      }
 
      // Recurse into children
      if (Array.isArray(node.children)) {
        node.children.forEach((child: Element) => walk(child, depth, parentType));
      }
    };
 
    walk(tree);
  };
}
  • Rehypeplugins 배열의 함수들을 차례로 실행시킨다.
  • 이 과정에서 실행된 plugin 함수는 trasnformer 라는 callback 을 반환해야하고 이는 AST 를 탐색하는데 사용된다.
  • transformer callback 은 파싱된 AST Tree 를 인수로 전달받는다.

내가 구현한 부분은 이 AST 를 재귀적으로 모두 순회하면서 중첩된 ul 이나 ol 을 만나면 depth 를 추가해가며 classname 을 붙여주는 부분이었다. plugin을 구현한 뒤 HTML 에 정상적으로 기능이 작동하는 부분을 확인하고, classname 에 따라 스타일을 적절히 설정해주면 이렇게 된다.


plugin example

plugin example

Rendering & Component

짧게 짚고 넘어가면 좋을 부분은, Compoenent<MDXRemote> 에 사용하는 방법이다. <MDXRemote> 는 파싱과정에서 확인된 요소라면 커스텀 컴포넌트로 변경해 해당 컴포넌트를 대신 마운트 해주는 방식으로 사용할 수 있다.

이 기능은 아마 특별한 구조나 기능을 붙이고 싶다거나, Tailwind를 통해 스타일링을 하고 있다면 한 번 생각해봐도 좋을 것 같다.

나는 두 가지 다 해당 했기 때문에 다음과 같이 필요한 컴포넌트를 따로 만든뒤 한 번 래핑해서 사용했다.

MarkdownRenderer
//...
const MarkdownRenderer = ({ source, ...props }: MDXRemoteProps) => {
  const components: MDXComponentOption = {
    h1: H1,
    h2: H2,
    h3: H3,
    h4: H4,
    hr: Hr,
    p: Pargraph,
    strong: Bold,
    em: Italic,
    //... components
  };
 
  return (
    <section>
      <MDXRemote
        options={{
          mdxOptions: {
            remarkPlugins: [...],
            rehypePlugins: [...],
          },
        }}
        components={{ ...components }}
        source={source}
        {...props}
      />
    </section>
  );
};
 
//...
const Page = () => {
 
//...
return (
<MarkdownRender source={...}/>
)
}


삽질하기

HMR 설정

Next 환경에서 어플리케이션에 포함되지 않는 파일의 변경 사항은 프로젝트를 리로딩 시킬 수 없다.

내 프로젝트 상태에서는 포스트들을 어플리케이션 번들에 전혀 포함시키지 않기 때문에 이 부분은 문제가 되었다.

포스트를 작성할 때마다 새로고침에서 화면을 확인해야 한다니...

이러한 내용을 어딘가 흘러가는 게시글에서 본 것 같은 기억이 있었고 그래서 그 사람은 Astro 를 선택했다고 했었던 것 같다.

이미 프레임워크를 선택한 상황에서 되돌아갈 순 없었기에 HMR 이 작동하도록 설정해야 했다.

문제 1: Turbopack

TrubopackNext 14 즈음에서 부터 Webpack 을 대체하기 위해 새롭게 도입된 번들러로 훨씬 효율이 좋은 빌드 퍼포먼스를 보여준다. 현재 Next 15 환경에서는 dev 환경에서는 Turbopack 을 사용해 개발환경을 구성할 것을 제안한다.

그런데 문제는 Trubopack 이 현재 Webpack 만큼의 빌드 커스텀을 허락하지 않는다는 것이다. 현재로써는 hook 관련 같은 디테일한 커스터마이제이션 옵션을 열어놓지 않고 있다.

그렇다면 이 말은 HMR 을 위해서 Turbopack 을 포기해야한다는 말과 같았다.

문제 2: Webpack Hook

먼저 우선 package.json 에서 "dev:watch" : "next dev" 의 새로운 포스팅 전용 커맨드를 만들었다.

그 다음 외부 의존성에 대해서 생각해봐야 했다. 어떻게 하면 번들에 포함되지 않는 디렉토리를 감시하도록 할 수 있을까?

다행히도 webpack 은 번들링 과정의 다양한 타이밍에 호출할 수 있는 hook 함수를 제공한다.

이를 잘 활용하면 contextDependencies 라는 필드를 추가할 수 있는데, 컴파일 결과물에 의존성을 추가할 수 있게 해주는 것이다.

webpack plugin 을 간단하게 작성하여 다음과 같이 next.config.ts 에 작성했다.

next.config.ts
//...
  webpack(config, { dev }) {
    // ...
    // if runs on dev:watch, HMR for posts directory after first bundle / after refresh
    if (dev) {
      config.plugins.push({
        apply: (compiler: Compiler) => {
          compiler.hooks.afterCompile.tap("WatchPostContents", (compilation) => {
            const postsDir = getContentPath(); // lib function
            compilation.contextDependencies.add(postsDir);
          });
        },
      });
    }
 
    return config;
  },
// ...
  • plugin 인터페이스에 맞춰 apply 함수를 작성
  • compiler 의 hook 타이밍 중 afterCompile 에 callback 추가
  • contextDependenciesadd 를 통해 경로 추가

compilation 을 전달받을 수 있는 hook 이 많이 존재하지 않고, 그 중에서도 프로젝트 번들 자체에는
간섭해서는 안되기 때문에 afterCompile 타이밍이 최적이라고 판단했다.

이렇게 작성하고 난뒤, 포스팅을 작성하고 저장해보니 webpack 이 필요한 외부 의존성을 인식하고 HMR 이 작동하기 시작했다!

그런데 문제가 있었다...

바로 첫 번째 컴파일에는 변경사항을 감지를 못하고 반드시 첫 컴파일 이후 새로고침을 해줘야한다는 것이었다.

문제 3: 첫 번째 컴파일

이는 webpackafterCompile hook 을 통해 의존성을 추가해주기 때문에 발생하는 문제였다.

등록한 callback 자체는 첫번째 컴파일 과정에도 의도한대로 실행되며 문제가 없었다.

하지만 첫 번째 과정에서 진행된 컴파일 결과물에는 이미 컴파일에 필요한 의존성 추적이 끝난 상태이고, 내가 원한 디렉토리가 추가된 상태가 아니기 때문에, 아무리 포스팅을 변경을 해도 리로딩을 호출할 수 가 없었던 것이다.

더 나아가 기능을 직접적으로 건드려볼 수 있겠지만, 이는 무리라고 판단했다. 그래도 다행인 것은 간단한 설정으로 한 번의 새로고침만으로 HMR 기능을 사용할 수 있게 되었으니 이것으로 만족해야 할 듯 싶다.



마무리

프로젝트의 Frontend 부분은 이정도로 마쳐도 좋을 듯 하다. 다음은 Markdown 을 제외하고 CLI, 최적화 등을 구성하면서 겪었던 내용들을 다뤄보려고 한다.