<scshin />

Milkdown 에디터 사용법 정리

0.44

마크다운 기반 WYSIWYG 에디터를 프로젝트에 적용하는 방법

웹 서비스를 개발하다 보면 게시글 작성, 공지사항 작성, 매뉴얼 작성, 위키 문서 작성처럼 사용자가 긴 글을 입력해야 하는 기능이 필요합니다. 이때 단순한 <textarea>만으로는 편집 기능이 부족하고, 일반적인 리치 텍스트 에디터를 사용하면 데이터 저장 방식이나 마크다운 관리가 복잡해질 수 있습니다.

이러한 상황에서 사용할 수 있는 에디터 중 하나가 Milkdown입니다.

Milkdown은 마크다운을 기반으로 동작하는 WYSIWYG 에디터 프레임워크입니다. WYSIWYG는 “What You See Is What You Get”의 약자로, 사용자가 화면에서 보는 형태 그대로 결과물이 만들어지는 편집 방식을 의미합니다.

Milkdown은 사용자가 보기 좋은 편집 화면에서 글을 작성할 수 있도록 지원하면서도, 개발자는 결과 데이터를 마크다운 형태로 저장하고 관리할 수 있도록 도와주는 에디터입니다.

1. Milkdown이란 무엇인가요?

Milkdown은 웹 프로젝트에 적용할 수 있는 마크다운 중심의 에디터 프레임워크입니다.

일반적인 리치 텍스트 에디터는 HTML 중심으로 동작하는 경우가 많습니다. 사용자가 글을 작성하면 내부 데이터가 HTML이 되거나, 별도의 JSON 구조로 저장되는 경우도 있습니다. 반면 Milkdown은 마크다운을 핵심 데이터로 다룹니다.

즉, 사용자는 편집 화면에서 보기 좋게 글을 작성하고, 개발자는 작성된 내용을 마크다운 문자열로 저장할 수 있습니다.

Milkdown은 내부적으로 ProseMirror와 Remark를 기반으로 동작합니다. ProseMirror는 웹 기반 문서 편집기를 만들기 위한 프레임워크이며, Remark는 마크다운을 처리하기 위한 도구입니다. Milkdown은 이 두 가지 구조를 바탕으로 마크다운 기반의 편집 경험을 제공합니다.

2. Milkdown을 사용하는 이유

Milkdown을 사용하는 이유는 여러 가지가 있습니다.

첫 번째 이유는 마크다운 기반 저장이 가능하다는 점입니다.

게시글, 기술 문서, 공지사항, 매뉴얼 같은 데이터는 HTML보다 마크다운으로 저장하는 것이 더 깔끔한 경우가 많습니다. 마크다운은 사람이 읽기 쉽고, 다른 플랫폼으로 옮기거나 HTML로 변환하기도 편리합니다.

두 번째 이유는 WYSIWYG 편집 경험을 제공한다는 점입니다.

사용자가 # 제목, **굵게**, - 목록 같은 마크다운 문법을 정확히 몰라도 편집 화면에서 자연스럽게 글을 작성할 수 있습니다. 개발자는 마크다운 데이터를 관리할 수 있고, 사용자는 일반 문서 편집기처럼 글을 작성할 수 있습니다.

세 번째 이유는 플러그인 기반 구조를 가진다는 점입니다.

Milkdown은 필요한 기능을 플러그인 방식으로 추가할 수 있습니다. CommonMark, GFM, 히스토리, 클립보드, 리스너, 업로드, Slash 명령어, 툴팁, 테이블, 코드 블록 같은 기능을 프로젝트 상황에 맞게 조합할 수 있습니다.

네 번째 이유는 커스터마이징이 자유롭다는 점입니다.

완성형 에디터처럼 빠르게 붙여서 사용할 수도 있고, 프로젝트 요구사항에 맞게 직접 에디터 구성을 조립할 수도 있습니다. 관리자 화면, 블로그 작성 화면, 문서 관리 시스템처럼 다양한 형태의 화면에 적용하기 좋습니다.

다섯 번째 이유는 React, Vue, Svelte, Solid, Next.js, Nuxt 같은 프레임워크와 함께 사용할 수 있다는 점입니다.

프론트엔드 프레임워크를 사용하는 프로젝트에서도 Milkdown을 적용할 수 있으며, Vanilla TypeScript 환경에서도 사용할 수 있습니다.

3. Milkdown과 Crepe의 차이

Milkdown을 처음 사용할 때 헷갈릴 수 있는 개념이 있습니다.

바로 MilkdownCrepe의 차이입니다.

간단히 정리하면 다음과 같습니다.

  • Milkdown은 에디터를 만들기 위한 핵심 프레임워크입니다.

  • Crepe는 Milkdown 위에 만들어진 완성형 에디터입니다.

Milkdown을 직접 사용하면 필요한 플러그인, 테마, 명령어, UI를 직접 조합해야 합니다. 반면 Crepe를 사용하면 기본 UI와 주요 기능이 포함된 에디터를 빠르게 적용할 수 있습니다.

처음 Milkdown을 프로젝트에 적용한다면 Crepe부터 사용하는 방식을 추천합니다.

Crepe는 기본적인 편집 UI, 마크다운 작성 기능, 테마, 주요 편집 기능을 포함하고 있기 때문에 빠르게 결과물을 확인할 수 있습니다.

4. 설치 방법

가장 빠르게 시작하려면 @milkdown/crepe를 설치하면 됩니다.

npm install @milkdown/crepe

pnpm을 사용한다면 다음과 같이 설치합니다.

pnpm add @milkdown/crepe

yarn을 사용한다면 다음과 같이 설치합니다.

yarn add @milkdown/crepe

Crepe를 설치하면 Milkdown 기반의 완성형 마크다운 에디터를 바로 사용할 수 있습니다.

5. 기본 사용 예제

먼저 HTML에 에디터가 들어갈 영역을 생성합니다.

<div id="app"></div>

그다음 TypeScript 또는 JavaScript 파일에서 Crepe 인스턴스를 생성합니다.

import { Crepe } from '@milkdown/crepe'

import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'

const crepe = new Crepe({
  root: '#app',
  defaultValue: '# Hello Milkdown\n\nMilkdown 에디터를 시작합니다.',
})

await crepe.create()

위 코드를 실행하면 #app 영역에 Milkdown 기반 에디터가 생성됩니다.

여기서 중요한 설정은 세 가지입니다.

첫 번째는 root입니다.
root는 에디터가 붙을 DOM 영역을 지정하는 옵션입니다. '#app'처럼 CSS 선택자를 사용할 수도 있고, document.getElementById('app')처럼 실제 DOM 객체를 넘길 수도 있습니다.

두 번째는 defaultValue입니다.
defaultValue는 에디터가 처음 열릴 때 표시할 기본 마크다운 내용을 설정하는 옵션입니다.

세 번째는 CSS import입니다.
Crepe는 테마 CSS를 import해야 화면이 정상적으로 표시됩니다. 공통 스타일을 먼저 import하고, 그다음 원하는 테마 CSS를 import하면 됩니다.

6. 테마 적용 방법

Crepe는 여러 가지 테마를 제공합니다.

대표적인 테마는 다음과 같습니다.

  • frame 테마입니다.

  • crepe 테마입니다.

  • nord 테마입니다.

  • frame-dark 테마입니다.

  • crepe-dark 테마입니다.

  • nord-dark 테마입니다.

테마를 적용할 때는 공통 스타일을 먼저 import하고, 그다음 사용할 테마 CSS를 import합니다.

import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'

다크 테마를 사용하고 싶다면 다음과 같이 변경합니다.

import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame-dark.css'

관리자 페이지나 CMS 화면에서는 frame 또는 nord 계열 테마가 무난합니다.
다크 테마 기반의 대시보드나 관제 화면에 적용한다면 frame-dark 테마도 좋은 선택입니다.

7. 현재 작성된 마크다운 가져오기

에디터에서 작성된 내용을 DB에 저장하려면 현재 내용을 마크다운 문자열로 가져와야 합니다.

Crepe에서는 getMarkdown() 메서드를 사용할 수 있습니다.

const markdown = crepe.getMarkdown()

console.log(markdown)

저장 버튼과 연결하면 다음과 같이 사용할 수 있습니다.

const saveButton = document.getElementById('saveButton')

saveButton?.addEventListener('click', async () => {
  const markdown = crepe.getMarkdown()

  await fetch('/api/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: '게시글 제목',
      content: markdown,
    }),
  })
})

이 방식으로 사용자가 작성한 내용을 마크다운 문자열로 가져온 뒤 서버 API로 전송할 수 있습니다.

8. 내용 변경 이벤트 감지하기

에디터 내용이 변경될 때마다 특정 작업을 해야 하는 경우가 있습니다.

예를 들어 자동 저장, 글자 수 계산, 미리보기 갱신, 임시 저장 기능을 구현할 때 내용 변경 이벤트가 필요합니다.

Crepe에서는 crepe.on()을 사용하여 리스너를 등록할 수 있습니다.

crepe.on((listener) => {
  listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
    console.log('현재 마크다운:', markdown)
    console.log('이전 마크다운:', prevMarkdown)
  })

  listener.focus((ctx) => {
    console.log('에디터 포커스')
  })

  listener.blur((ctx) => {
    console.log('에디터 블러')
  })
})

자동 저장 기능은 다음과 같이 구현할 수 있습니다.

let timer: ReturnType<typeof setTimeout> | null = null

crepe.on((listener) => {
  listener.markdownUpdated((ctx, markdown) => {
    if (timer) {
      clearTimeout(timer)
    }

    timer = setTimeout(() => {
      localStorage.setItem('draft-content', markdown)
      console.log('임시 저장 완료')
    }, 500)
  })
})

위 코드는 사용자가 글을 입력할 때마다 바로 저장하지 않고, 0.5초 동안 입력이 멈추면 임시 저장을 수행하는 방식입니다.

이러한 구조를 사용하면 서버 요청을 너무 자주 보내지 않으면서도 자동 저장 기능을 구현할 수 있습니다.

9. 읽기 전용 모드 적용하기

게시글 상세보기 화면에서는 사용자가 내용을 수정하지 못하도록 읽기 전용 모드를 적용해야 할 수 있습니다.

Crepe에서는 setReadonly() 메서드를 사용할 수 있습니다.

crepe.setReadonly(true)

다시 수정 가능한 상태로 변경하려면 다음과 같이 사용합니다.

crepe.setReadonly(false)

읽기 전용 모드는 게시글 상세보기, 공지사항 상세보기, 매뉴얼 조회 화면 등에 사용할 수 있습니다.

10. 에디터 제거하기

SPA 환경에서는 화면을 이동하거나 컴포넌트가 사라질 때 에디터 인스턴스를 정리해야 합니다.

이때는 destroy() 메서드를 사용합니다.

crepe.destroy()

에디터 인스턴스를 정리하지 않으면 이벤트 리스너나 DOM 참조가 남아 메모리 누수가 발생할 수 있습니다.

React, Vue 같은 프레임워크에서는 컴포넌트가 unmount될 때 에디터를 정리하는 구조를 잡는 것이 좋습니다.

11. React에서 Milkdown 사용하기

React 프로젝트에서는 @milkdown/react를 함께 사용할 수 있습니다.

먼저 필요한 패키지를 설치합니다.

npm install @milkdown/crepe @milkdown/react @milkdown/kit

기본 구조는 다음과 같습니다.

import React from 'react'
import { Crepe } from '@milkdown/crepe'
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'

import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'

const CrepeEditor = () => {
  useEditor((root) => {
    return new Crepe({
      root,
      defaultValue: '# 제목\n\n내용을 입력하세요.',
    })
  }, [])

  return <Milkdown />
}

export default function EditorPage() {
  return (
    <MilkdownProvider>
      <CrepeEditor />
    </MilkdownProvider>
  )
}

React에서 Milkdown을 사용할 때는 MilkdownProvider로 에디터 영역을 감싸고, useEditor()를 사용하여 에디터 인스턴스를 생성합니다.

Milkdown 컴포넌트는 실제 에디터가 렌더링되는 영역입니다.

12. React에서 저장 버튼 만들기

React에서 저장 버튼을 만들려면 에디터 인스턴스에 접근해야 합니다.

useInstance()를 사용하면 현재 생성된 에디터 인스턴스를 가져올 수 있습니다.

import React from 'react'
import { Crepe } from '@milkdown/crepe'
import { Milkdown, MilkdownProvider, useEditor, useInstance } from '@milkdown/react'
import { getMarkdown } from '@milkdown/kit/utils'

import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'

const Editor = () => {
  useEditor((root) => {
    return new Crepe({
      root,
      defaultValue: '# 게시글 제목\n\n내용을 입력하세요.',
    })
  }, [])

  return <Milkdown />
}

const EditorControls = () => {
  const [loading, getEditor] = useInstance()

  const handleSave = () => {
    if (loading) {
      return
    }

    const editor = getEditor()

    if (!editor) {
      return
    }

    const markdown = editor.action(getMarkdown())

    console.log('저장할 마크다운:', markdown)
  }

  return (
    <button type="button" onClick={handleSave}>
      저장
    </button>
  )
}

export default function EditorPage() {
  return (
    <MilkdownProvider>
      <Editor />
      <EditorControls />
    </MilkdownProvider>
  )
}

여기서 중요한 점은 EditorControls 컴포넌트가 반드시 MilkdownProvider 내부에 있어야 한다는 점입니다.

이 구조를 사용하면 React 화면에서 에디터와 저장 버튼을 분리해서 관리할 수 있습니다.

13. 기존 게시글 수정 화면 만들기

게시글 수정 화면에서는 서버에서 가져온 마크다운 내용을 에디터에 넣어야 합니다.

가장 단순한 방식은 defaultValue에 서버에서 조회한 내용을 넣는 방식입니다.

const Editor = ({ content }: { content: string }) => {
  useEditor((root) => {
    return new Crepe({
      root,
      defaultValue: content,
    })
  }, [content])

  return <Milkdown />
}

하지만 실제 프로젝트에서는 게시글 내용이 API 호출 이후 늦게 들어오는 경우가 많습니다.
이 경우 에디터가 이미 생성된 뒤에 내용을 교체해야 할 수 있습니다.

Milkdown에서는 전체 내용을 교체할 때 replaceAll 매크로를 사용할 수 있습니다.

import { replaceAll } from '@milkdown/kit/utils'

const handleLoadContent = () => {
  const editor = getEditor()

  if (!editor) {
    return
  }

  editor.action(replaceAll('# 서버에서 가져온 제목\n\n서버 내용입니다.'))
}

수정 화면의 일반적인 처리 흐름은 다음과 같습니다.

  1. 게시글 상세 API를 호출합니다.
  2. 서버에서 마크다운 내용을 가져옵니다.
  3. 에디터를 생성합니다.
  4. 조회된 마크다운을 에디터에 반영합니다.
  5. 사용자가 내용을 수정합니다.
  6. 저장 버튼 클릭 시 getMarkdown()으로 현재 내용을 가져옵니다.
  7. 수정 API로 마크다운 내용을 전송합니다.

이 흐름으로 구성하면 등록 화면과 수정 화면을 동일한 에디터 구조로 관리할 수 있습니다.

14. @milkdown/kit으로 직접 에디터 만들기

Crepe는 바로 사용할 수 있는 완성형 에디터입니다.
하지만 기능을 더 세밀하게 제어하고 싶다면 @milkdown/kit을 사용하여 직접 에디터를 구성할 수 있습니다.

먼저 패키지를 설치합니다.

npm install @milkdown/kit

기본 에디터는 다음과 같이 만들 수 있습니다.

import { Editor } from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'

import '@milkdown/kit/prose/view/style/prosemirror.css'

const editor = await Editor.make()
  .use(commonmark)
  .create()

이 방식은 Crepe보다 초기 설정이 많지만, 필요한 기능만 선택하여 구성할 수 있다는 장점이 있습니다.

프로젝트에서 툴바, 명령어, 업로드, 미리보기, 단축키 등을 직접 제어해야 한다면 @milkdown/kit 기반으로 구성하는 방식이 적합합니다.

15. 히스토리 기능 추가하기

사용자가 글을 작성하다가 실행 취소와 다시 실행을 할 수 있어야 한다면 history 플러그인을 추가합니다.

import { Editor } from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { history } from '@milkdown/kit/plugin/history'
import { nord } from '@milkdown/theme-nord'

import '@milkdown/theme-nord/style.css'

const editor = await Editor.make()
  .config(nord)
  .use(commonmark)
  .use(history)
  .create()

history 플러그인을 추가하면 사용자가 입력한 내용을 되돌리거나 다시 실행할 수 있습니다.

문서 작성 화면에서는 실행 취소 기능이 거의 필수이기 때문에 기본적으로 추가하는 것이 좋습니다.

16. 리스너 플러그인으로 자동 저장 구현하기

@milkdown/kit을 직접 사용할 때는 listener 플러그인을 붙여 내용 변경을 감지할 수 있습니다.

import { Editor, rootCtx } from '@milkdown/kit/core'
import { commonmark } from '@milkdown/kit/preset/commonmark'
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener'

const editor = await Editor.make()
  .config((ctx) => {
    ctx.set(rootCtx, document.getElementById('app'))

    ctx.get(listenerCtx).markdownUpdated((ctx, markdown) => {
      console.log('변경된 마크다운:', markdown)
      localStorage.setItem('draft', markdown)
    })
  })
  .use(commonmark)
  .use(listener)
  .create()

이 구조를 활용하면 사용자가 글을 작성하는 동안 내용을 자동으로 임시 저장할 수 있습니다.

관리자 공지사항, 매뉴얼 작성, 블로그 작성처럼 긴 글을 작성하는 화면에서는 자동 저장 기능을 넣는 것이 좋습니다.

17. 코드 하이라이팅 적용하기

기술 블로그나 개발 문서에서는 코드 블록 하이라이팅이 중요합니다.

Milkdown에서는 코드 블록 하이라이팅을 위한 플러그인을 사용할 수 있습니다.

설치 예시는 다음과 같습니다.

npm install @milkdown/plugin-highlight

Shiki를 사용하는 예시는 다음과 같습니다.

import { Editor } from '@milkdown/core'
import { commonmark } from '@milkdown/preset-commonmark'
import { highlight, highlightPluginConfig } from '@milkdown/plugin-highlight'
import { createParser } from '@milkdown/plugin-highlight/shiki'

async function createEditor() {
  const parser = await createParser({
    theme: 'github-light',
    langs: ['javascript', 'typescript', 'python', 'html', 'css', 'json'],
  })

  const editor = await Editor.make()
    .config((ctx) => {
      ctx.set(highlightPluginConfig.key, {
        parser,
      })
    })
    .use(commonmark)
    .use(highlight)
    .create()

  return editor
}

이렇게 설정하면 다음과 같은 코드 블록에 문법 강조가 적용됩니다.

```typescript
const message: string = 'Hello Milkdown'

console.log(message)
```

개발 블로그나 기술 문서 관리 시스템을 만든다면 코드 하이라이팅 기능은 꼭 고려하는 것이 좋습니다.

18. 명령어 사용하기

Milkdown은 명령어 시스템을 제공합니다.

명령어를 사용하면 버튼 클릭 시 선택한 텍스트를 굵게 처리하거나, 제목으로 변경하거나, 목록을 삽입하는 기능을 만들 수 있습니다.

예를 들어 강조 명령을 실행하는 코드는 다음과 같습니다.

import { Editor, commandsCtx } from '@milkdown/kit/core'
import {
  commonmark,
  toggleEmphasisCommand,
} from '@milkdown/kit/preset/commonmark'

const editor = await Editor.make()
  .use(commonmark)
  .create()

const toggleItalic = () => {
  editor.action((ctx) => {
    const commandManager = ctx.get(commandsCtx)

    commandManager.call(toggleEmphasisCommand.key)
  })
}

버튼과 연결하면 다음과 같이 사용할 수 있습니다.

<button id="italicButton">기울임</button>

document.getElementById('italicButton')?.addEventListener('click', () => {
  toggleItalic()
})

이 구조를 활용하면 프로젝트에 맞는 커스텀 툴바를 만들 수 있습니다.

예를 들어 관리자 화면에서 제목, 굵게, 목록, 코드 블록, 이미지 삽입 버튼만 제공하는 간단한 툴바를 직접 구성할 수 있습니다.

19. 매크로 사용하기

Milkdown에는 에디터를 쉽게 조작할 수 있는 매크로가 있습니다.

자주 사용하는 매크로는 다음과 같습니다.

19-1. 현재 커서 위치에 내용 삽입하기

import { insert } from '@milkdown/kit/utils'

editor.action(insert('## 새 제목'))

현재 커서 위치에 원하는 마크다운 내용을 삽입할 수 있습니다.

19-2. 전체 내용 교체하기

import { replaceAll } from '@milkdown/kit/utils'

editor.action(replaceAll('# 새 문서\n\n내용을 다시 작성합니다.'))

기존 내용을 모두 지우고 새로운 마크다운 내용으로 교체할 수 있습니다.

19-3. 현재 내용을 마크다운으로 가져오기

import { getMarkdown } from '@milkdown/kit/utils'

const markdown = editor.action(getMarkdown())

현재 에디터 내용을 마크다운 문자열로 가져올 수 있습니다.

19-4. 현재 내용을 HTML로 가져오기

import { getHTML } from '@milkdown/kit/utils'

const html = editor.action(getHTML())

현재 에디터 내용을 HTML 문자열로 가져올 수 있습니다.

다만 HTML을 화면에 출력할 때는 XSS 보안 처리를 반드시 고려해야 합니다.

20. 이미지 업로드 처리 방법

실제 게시판이나 CMS에서는 이미지 업로드 기능이 필요합니다.

Milkdown은 에디터 프레임워크이기 때문에 이미지 업로드 정책은 프로젝트에 맞게 별도로 설계해야 합니다.

일반적인 이미지 업로드 흐름은 다음과 같습니다.

  1. 사용자가 이미지를 선택하거나 드래그 앤 드롭합니다.
  2. 프론트엔드에서 이미지 파일을 서버 업로드 API로 전송합니다.
  3. 서버는 파일을 저장하고 접근 가능한 URL을 반환합니다.
  4. 프론트엔드는 반환받은 이미지 URL을 에디터에 삽입합니다.
  5. 에디터에는 마크다운 이미지 문법으로 이미지가 표시됩니다.

예를 들어 서버에서 /uploads/sample.png라는 URL을 반환했다면 다음과 같은 마크다운을 에디터에 넣을 수 있습니다.

![이미지 설명](/uploads/sample.png)

삽입 코드는 다음과 같이 작성할 수 있습니다.

import { insert } from '@milkdown/kit/utils'

const imageUrl = '/uploads/sample.png'

editor.action(insert(`![이미지 설명](${imageUrl})`))

관리자 페이지에서 이미지 업로드 기능을 구현할 때는 다음 항목을 함께 고려해야 합니다.

  • 파일 확장자 제한이 필요합니다.

  • 파일 크기 제한이 필요합니다.

  • 이미지 MIME 타입 검증이 필요합니다.

  • 저장 경로 관리가 필요합니다.

  • 원본 파일명과 저장 파일명을 분리해야 합니다.

  • XSS 방지를 위한 URL 검증이 필요합니다.

  • 게시글 삭제 시 첨부 이미지 정리 정책이 필요합니다.

  • 사용되지 않는 고아 파일 정리 정책이 필요합니다.

이미지 업로드는 단순히 파일을 올리는 기능이 아니라, 저장 정책과 보안 정책까지 함께 설계해야 하는 기능입니다.

21. DB에는 무엇을 저장해야 하나요?

Milkdown을 게시판에 적용한다면 보통 DB에는 마크다운 원문을 저장합니다.

예를 들어 게시글 테이블은 다음과 같이 구성할 수 있습니다.

CREATE TABLE board_post (
    post_id BIGSERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    content_markdown TEXT NOT NULL,
    content_html TEXT,
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

여기서 핵심 컬럼은 content_markdown입니다.

Milkdown에서 가져온 마크다운 원문을 content_markdown에 저장합니다.
content_html은 선택 사항입니다.

HTML을 매번 렌더링할 때 변환해도 되고, 조회 성능이 중요하다면 저장 시점에 HTML로 변환해서 함께 저장할 수도 있습니다.

다만 HTML을 저장하거나 화면에 출력할 때는 반드시 XSS 처리를 고려해야 합니다.

개인적으로는 원본 데이터는 마크다운으로 저장하고, 화면 출력 시 필요한 경우에만 HTML로 변환하는 방식을 추천합니다.

22. Spring Boot API 예시

프론트에서 Milkdown으로 작성한 마크다운을 Spring Boot API로 저장하는 예시는 다음과 같습니다.

@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public ResponseEntity<Long> createPost(@RequestBody PostCreateRequest request) {
        Long postId = postService.createPost(request);
        return ResponseEntity.ok(postId);
    }
}

요청 DTO는 다음과 같이 작성할 수 있습니다.

@Getter
@Setter
public class PostCreateRequest {
    private String title;
    private String contentMarkdown;
}

서비스에서는 제목과 내용을 검증한 뒤 저장합니다.

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostMapper postMapper;

    @Transactional
    public Long createPost(PostCreateRequest request) {
        if (request.getTitle() == null || request.getTitle().isBlank()) {
            throw new IllegalArgumentException("제목은 필수입니다.");
        }

        if (request.getContentMarkdown() == null || request.getContentMarkdown().isBlank()) {
            throw new IllegalArgumentException("내용은 필수입니다.");
        }

        Post post = new Post();
        post.setTitle(request.getTitle());
        post.setContentMarkdown(request.getContentMarkdown());

        postMapper.insertPost(post);

        return post.getPostId();
    }
}

프론트에서는 다음과 같이 전송할 수 있습니다.

const markdown = crepe.getMarkdown()

await fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    title: titleInput.value,
    contentMarkdown: markdown,
  }),
})

이 구조는 일반적인 게시글 등록, 공지사항 등록, 매뉴얼 등록 화면에 적용할 수 있습니다.

23. 게시글 상세보기에서는 어떻게 보여주나요?

게시글 상세보기에서는 두 가지 방식으로 내용을 보여줄 수 있습니다.

첫 번째 방식은 Milkdown을 읽기 전용 모드로 사용하는 방식입니다.

const crepe = new Crepe({
  root: '#viewer',
  defaultValue: markdownFromServer,
})

await crepe.create()

crepe.setReadonly(true)

이 방식은 에디터에서 작성한 형태와 상세보기 화면의 표현을 최대한 비슷하게 유지할 수 있다는 장점이 있습니다.

두 번째 방식은 마크다운을 HTML로 변환해서 보여주는 방식입니다.

블로그나 문서 화면처럼 단순 조회가 많은 화면에서는 마크다운을 HTML로 변환해서 출력하는 방식도 괜찮습니다.

다만 HTML을 직접 출력할 경우 XSS 처리가 반드시 필요합니다.

관리자 화면의 상세보기나 수정 화면에서는 Milkdown의 readonly 모드를 사용하는 방식이 편리합니다.
일반 사용자에게 공개되는 블로그 화면에서는 마크다운을 HTML로 변환해서 보여주는 방식이 더 가볍게 동작할 수 있습니다.

참고 자료