THE DEVLOG

scribbly.

Next.js 블로그 프로젝트

2025.05.16 20:03:53

블로그의 핵심 기능인 Markdown 편집기의 구현 내용을 정리한 게시글.
12일차부터 Milkdown/crepe를 적용하여 진행하였다.

Remark

Remark는 마크다운을 AST로 변환하는 대표적인 라이브러리이다.

AST

**AST(Abstract Syntax Tree)**는
프로그래밍 언어나 마크다운처럼 문법이 있는 텍스트
계층적인 트리 구조로 해석한 표현이다.

# Hello **world**

위의 마크다운을 Remark는 아래와 같이 파싱한다.

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        { "type": "text", "value": "Hello " },
        {
          "type": "strong",
          "children": [
            { "type": "text", "value": "world" }
          ]
        }
      ]
    }
  ]
}

Remark 파싱 단계

remark 기반 마크다운 파싱의 전체 흐름

입력 (Markdown 문자열)
↓
1단계: remark-parse (Markdown → MDAST)
↓
2단계: remark 플러그인 (AST 가공, 수정, 변환)
↓
3단계: remark-rehype (MDAST → HAST)
↓
4단계: rehype 플러그인 (HTML AST 가공)
↓
5단계: rehype-stringify or rehype-react (HTML 문자열 or React 엘리먼트)
↓
출력 (HTML or React JSX)

remark-parse: 마크다운 문자열 → MDAST
  • 입력: # Hello

  • 출력: Markdown AST (MDAST)

    {
      "type": "root",
      "children": [
        {
          "type": "heading",
          "depth": 1,
          "children": [{ "type": "text", "value": "Hello" }]
        }
      ]
    }
    
    
  • 원리: 마크다운 문법을 정해진 문법에 따라 토큰화 → 트리 구조로 정리

remark 플러그인 단계 (선택적)
  • 역할: AST를 직접 가공하는 미들웨어
  • 예: remark-gfm, remark-footnotes, remark-breaks
  • AST를 순회하면서 노드를 추가하거나 수정 가능
unified().use(remarkParse).use(remarkGfm) // GitHub Flavored Markdown 확장

remark-rehype: MDAST → HAST 변환
  • Markdown AST(MDAST) → HTML AST(HAST)로 변환

  • 예:

    unified().use(remarkParse).use(remarkRehype)
    
    
  • 이 단계에서 "마크다운 의미"를 HTML 표현으로 매핑 (예: heading<h1>)

rehype 플러그인 단계
  • 역할: HTML AST를 추가적으로 가공
  • 예: rehype-highlight, rehype-autolink-headings
렌더링 단계 (출력 포맷 선택)
  • HAST → HTML 문자열

    .use(rehypeStringify)
    
    
  • HAST → React 컴포넌트

    .use(rehypeReact, { createElement: React.createElement })
    
    
요약
구성요소역할
remark-parse마크다운 문자열 → Markdown AST (MDAST)
remark-xxxMDAST를 수정하거나 기능 확장
remark-rehypeMDAST → HTML AST (HAST)로 변환
rehype-xxxHAST 기반 추가 가공 (하이라이트, 링크 자동 추가 등)
rehype-reactHAST → React 엘리먼트로 렌더링

 

ProseMirror

ProseMirror는 리치 텍스트 에디터를 만들기 위한 모듈형 프레임워크이다.
구조화된 문서를 기반으로 한다.
아래는 유저가 마크다운을 입력할 때에 HTML로 변환하며 화면에 렌더링하는 다이어그램 예제이다.

[User Input]
   └─ "Hello **World**" 입력
         ↓
[Input Parser Layer]  ← ex: markdown-it, custom tokenizer
   └─ 마크다운 문법을 분석
         ↓
[ProseMirror Node Tree 생성]
   └─ 구조화된 트리 형태로 파싱
   {
     type: "doc",
     content: [
       {
         type: "paragraph",
         content: [
           { type: "text", text: "Hello " },
           { type: "text", marks: [{ type: "strong" }], text: "World" }
         ]
       }
     ]
   }
         ↓
[EditorState & EditorView]
   └─ ProseMirror 에디터가 트리를 기반으로 상태를 구성
         ↓
[DOMSerializer / ProseMirror Renderer]
   └─ HTML DOM fragment로 렌더링
         ↓
[Rendered Output in Browser]
   └─ 브라우저 화면
   <p>Hello <strong>World</strong></p>

 

Milkdown

 https://milkdown.dev/

Milkdown은 ProseMirror 기반 마크다운 에디터 라이브러리이다.

  1. 플러그인 기반으로 하여 번들 용량을 최소화할 수 있다.
  2. Milkdown/crepe라는 다양한 플러그인들이 포함된 라이브러리도 제공한다.

이미지 업로드 등 다양한 플러그인들이 이미 상당히 개발이 완료되었고,
Milkdown/crepe는 React와의 호환성까지 고려하여 플러그인이 적용되어 있기에 Milkdown/crepe를 블로그에 도입하게 되었다.

 

ProseMirror 기반의 에디터들을 다룰 때 유의할 점은, DOM이 있어야 렌더링을 할 수 있다. 따라서 Next.js에서는 서버 렌더링을 시도하지 않고 클라이언트에서만 렌더링하도록 ssr: false 옵션과 함께 Dynamic Import를 하여야 한다.

/main/components/markdown/markdown-editor.tsx

import dynamic from "next/dynamic";

const MilkdownWrapper = dynamic(() => import("@/components/markdown/milkdown-app/milkdown-wrapper"), {
  ssr: false,
});

export default function MarkdownEditor({ markdown }: { markdown: string }) {
  return <MilkdownWrapper markdown={markdown} />
}

 

마크다운 편집기 with SSR 프리뷰

/main/components/markdown/markdown-editor.tsx

Milkdown이 클라이언트 측에서 바로 마운트되도록 하려 하였으나, 편집을 자주 하지 않음에도 불구하고 Milkdown을 마운트하는 것이 오버헤드가 크다고 생각하여 명시적으로 클라이언트 측에서 상호작용을 할 때에만 import/mounted 되도록 하였다.

  1. SSR이 가능한 Remark 기반 마크다운 파서를 이용해 렌더링한다.

  2. 클라이언트에서 편집모드를 켜면 ProseMirror 기반의 마크다운 편집기를 import하며 동작한다.

    1. 이때 position: absloute;로 ProseMirror편집기가 Remark파서와 겹쳐서 노출되도록 하여 Layout Shift를 방지한다.
  3. ProseMirror 기반 마크다운 편집기와 State를 공유하는 CodeMirror 기반의 Raw 편집기도 추가.

프리뷰 (react-markdown)

/main/components/markdown/milkdown-app/milkdown-preview.tsx

export default function ReactMarkdownApp({ children }: { children?: string }) {
  return (
    <ReactMarkdown
      remarkPlugins={[remarkGfm, remarkParse, remarkBreaks]}
      rehypePlugins={[rehypeHighlight]}
      components={{
        img: ({ src, alt = "" }) => {
          if (!src)
            return (
              <Image
                src={
                  "data:image/svg+xml;base64," +
                  "..."
                }
                alt="image-error"
                width="0"
                height="0"
                sizes="(max-width: 768px) 100vw, 50vw"
                className="w-auto h-[180px] shadow-glass"
              />
            );
          return (
            <Image
              src={src}
              alt={alt}
              width="0"
              height="0"
              sizes="(max-width: 768px) 100vw, 50vw"
              className="w-auto h-[180px] sm:h-[220px] md:h-[260px] lg:h-[300px] xl:h-[340px]"
            />
          );
        },
      }}
    >
      {children}
    </ReactMarkdown>
  );
}
  • rehypeHighlight플러그인으로 코드 블럭에 하이라이트를 준다.
  • components 옵션에서 img 태그를 next/image 태그로 변경하여 성능최적화를 하였다.

Wysiwyg 편집기 (milkdown/crepe)

/main/components/markdown/milkdown-app/milkdown-app.tsx

const MilkdownEditor = ({
  markdown,
  setMarkdown,
  onImageUpload,
  isFocused,
}: {
  markdown: string;
  setMarkdown: (markdown: string) => void;
  onImageUpload?: (file: File) => Promise<string>;
  isFocused: boolean;
}) => {
  const uploadImageToServer = async (_file: File) => {
    return "";
  };

  const crepeRef = useRef<Crepe | null>(null);

  const [body, setBody] = useState<string>(markdown);

  useEffect(() => {
    if (isFocused) {
      setMarkdown(body);
    }
  }, [body, isFocused, setMarkdown]);

  useEditor((root) => {
    const crepe = new Crepe({
      root,
      defaultValue: markdown,
...
    });

    crepe.on((listener) => {
      listener.markdownUpdated((_, updatedMarkdown, prevMarkdown) => {
        if (updatedMarkdown !== prevMarkdown) {
          const cleaned = updatedMarkdown.replace(/<br\s*\/?>/gi, "&nbsp;");
          setBody(cleaned);
        }
      });
    });

    crepeRef.current = crepe;
    return crepe;
  }, []);

  useEffect(() => {
    if (!isFocused && crepeRef.current) {
      try {
        crepeRef.current.editor.action((ctx) => {
          const view = ctx.get(editorViewCtx);
...
          const state = view.state;
...
          let tr = state.tr;
...

          view.dispatch(tr);
        });
      } catch (e) {
        console.error(e);
      }
    }
  }, [isFocused, markdown]);

  return <Milkdown />;
};

export default MilkdownEditor;
  • useEditor를 이용해 업로드에 필요한 플러그인을 추가한다. 밀크다운은 </br>태그를 통해 trailling 공백을 처리하는데, 이를 &nbsp로 파싱하도록 수정하여 remark와 호환되도록 했다. (정상적인 </br>태그도 파싱되는 단점.)
  • useEffect를 이용하여 외부에서 markdown이 변경되었을 때 이를 반영하도록 수정한다. Milkdown 홈페이지의 playground에서 코드를 따왔다. github - Milkdown/website/Playground

RAW 편집기 (CodeMirror)

/main/components/markdown/milkdown-app/markdown-raw-editor.tsx 

github - Milkdown/website/Playground의 코드를 참고하여 작성하였고, 다크 모드를 감지하는 로직만 추가하였다.

편집기 렌더러

/main/components/markdown/milkdown-app/milkdown-wrapper.tsx 

편집모드가 켜지면 이에 맞는 편집기를 보여준다.

Wysiwyg과 Raw가 둘 다 켜지면, grid를 적용하여 좌 우에