react-markdown-editor 기본 사용법 정리

2025년 10월 3일Frontend30분 읽기

react-markdown-editor 기본 사용법 정리

개인적으로 개발공부도 하고 혹시 모를 수입발생도 염두해서 모하지 라는 사이트를 만들고 있다. 이번에 여기에 블로그 글도 남기기 위해서 블로그 사이트를 붙이게 되었다. 구글의 애드센스를 붙이기 위한 자격을 얻기 위한 방편이기도 하다 ㅎㅎ

처음에는 별도로 마크다운 파일을 만든 후, Front Matter로 변경하고, 빌드타임에 컨텐츠를 정적으로 생성하는 방식으로 컨텐츠를 늘려가려고 했지만, 왠지 컨텐츠가 많아지고 또 수정이 필요할 경우에는 사용이 어렵겠다는 생각이 들었다.

또 언제 어디서든 글을 동적으로 작성해서 업로드하면 좋겠다는 생각이 들어서 에디터를 구현하게 되었다.

개발에 사용한 라이브러리는 react-markdown-editor 이라는 라이브러리다. 무료로 사용가능하고 또 이미지를 삽입하기에 무난한 라이브러리라 판단했다. 다른 그럴듯한 마크다운용 에디터는 아직 찾지 못했다.

개발환경

react-router 환경에서 개발했다.

사용된 라이브러리 버전은 다음과 같다.

1"dependencies": {2  "react-router": "^7.9.1",3  "@supabase/ssr": "^0.7.0",4  "@supabase/supabase-js": "^2.57.4"5},6"devDependencies": {7  "@uiw/react-markdown-editor": "^6.1.4",8}

샘플예제

나는 컨텐츠 작성시 이미지를 편하게 업로드 할 수 있는 기능이 필요했다. 그래서 컨텐츠에 이미지 추가시 supabase를 이용해서 이미지를 업로드하는 기능을 추가했다. 샘플코드는 개발했던 모든 코드를 붙여넣기 너무 귀찮아서 관련 코드만 추린다.

에디터 초기화

에디터는 CSR 환경에서 동작하기 때문에 SSR(Server Side Rendering) 환경에서 에디터를 로딩하려면 아래의 코드와 같이 클라이언트가 초기화 된 이후에 호출되도록 해야 한다.

1const MarkdownEditor = lazy(() => import('@uiw/react-markdown-editor')) // 무거운 라이브러리 이므로 필요할 때만 로드 하도록 함2 3export default function BlogEditorPage({ loaderData }: Route.ComponentProps) {4 5  const {6    isInitialized,7    markdownValue,8    previewWidth,9    postMeta,10    setMarkdownValue,11    handleSave,12    handlePaste,13    handleDrop,14    handleMetaFieldChange,15    handleDraftChange,16  } = useBlogEditor({17    blogPost: loaderData.data,18  })19 20  if (loaderData.error) {21    return <EditorLoading text={loaderData.error} redirectUrl="/" />22  }23 24  if (!isInitialized) {25    return <EditorLoading />26  }27 28  if (editorStatus) {29    return <EditorLoading text={editorStatus} />30  }31  32  return (33      <Suspense fallback={<EditorLoading />}>  // lazy()로 로딩된 컴포넌트는 <Suspense fallback={...}> 컴포넌트로 감싸야 함34        <MarkdownEditor35          className="flex-1 pb-30"36          value={markdownValue}37          previewWidth={previewWidth}38          enableScroll={true}39          onChange={setMarkdownValue}40          onPaste={handlePaste} // 뭔가를 복사후 에디터에 붙여넣기 하면 호출된다.41          onDrop={handleDrop}  // 이미지를 에디터에 드래그앤 드롭하면 호출된다.42        />43      </Suspense>44  )45}46 

이미지 삽입하기

블로그 글 작성시 이미지 이미지를 사용하는 경우가 빈번하기 때문에 이미지를 편리하게 업로드하고 싶었다. 마크다운 에디터기 때문에 편집기에서 내가 입력한 이미지를 직접적으로 편집기에 넣을 수는 없었다. 다음과 같은 문법을 사용해서 이미지를 업로드할 수 있다.

1![대체 텍스트](이미지_URL "이미지 제목")

문제는 내 로컬이미지를 올리기 위해서는 어딘가에 이미지를 올리고 그 이미지의 url을 복사해서 넣는 과정이 불편하다는 것이다. 현재 나는 supabase 를 사용하고 있는데 storage 를 무료로 사용할 수 있다. 그래서 에디터에 이미지를 붙여넣거나 이미지를 편집기에 드롭하면 드롭한 이미지를 supabase 에 업로드 후, 반환된 public url 을 에디터에 삽입하는 기능을 추가했다. 이 라이브러리의 사용법을 몰라서 몇일 헤맸기 때문에 샘플 코드를 기록해 두게 되었다.

샘플코드는 다음과 같다.

1type UseBlogEditorResult = {2  isInitialized: boolean3  editorStatus: string | null4  markdownValue: string5  previewWidth: string6  setMarkdownValue: (value: string) => void7  handleSave: (event: React.FormEvent<HTMLFormElement>) => void8  handlePaste: (event: React.ClipboardEvent<HTMLDivElement>) => void9  handleDrop: (event: React.DragEvent<HTMLDivElement>) => void10}11 12const PREVIEW_BREAKPOINT = '(max-width: 768px)'13const DEFAULT_MARKDOWN = ''14 15export const useBlogEditor = (): UseBlogEditorResult => {16  const [isInitialized, setIsInitialized] = useState(false) // 에디터 초기화 확인용. false 일 경우 로딩중.. 이라는 문구 표시17  const imageUploaderFetcher = useFetcher()  // 이미지 업로드용 fetcher 18  const [markdownValue, setMarkdownValue] = useState(DEFAULT_MARKDOWN)19  const [previewWidth, setPreviewWidth] = useState('50%')20 21  useEffect(() => {22    setIsInitialized(true) // 에디터가 초기화되면 23  }, [])24 25  // 화면이 작으면 미리보기 화면이 화면전체로 표시되게 한다. 26  useEffect(() => {27    if (!isInitialized || typeof window === 'undefined') return28 29    const mediaQuery = window.matchMedia(PREVIEW_BREAKPOINT)30    const updatePreviewWidth = (matches: boolean) => {31      setPreviewWidth(matches ? '100%' : '50%')32    }33 34    updatePreviewWidth(mediaQuery.matches)35 36    const listener = (event: MediaQueryListEvent) => {37      updatePreviewWidth(event.matches)38    }39 40    if (typeof mediaQuery.addEventListener === 'function') {41      mediaQuery.addEventListener('change', listener)42      return () => {43        mediaQuery.removeEventListener('change', listener)44      }45    }46 47    mediaQuery.onchange = listener48    return () => {49      mediaQuery.onchange = null50    }51  }, [isInitialized])52 53  // 에디터 내부의 뷰를 꺼내온다. 이 뷰를 이용해서 컨텐츠를 조작할 수 있다.54  const getCodeMirrorView = useCallback((): EditorView | null => {55    if (typeof document === 'undefined') return null56 57    const cmContent = document.querySelector('.cm-content') as58      | (HTMLElement & { cmView?: { view?: EditorView } })59      | null60 61    return cmContent?.cmView?.view ?? null62  }, [])63 64  65  const createTempImageMarkdown = useCallback((imageName: string) => {66    return `![이미지 업로드 중...](uploading-${imageName})`67  }, [])68 69  // 요청받은 커서의 위치에 전달받은 'placeholder' 를 추가해준다.70  // 이미지가 업로드 되는동안 업로드 중이라는 메시지를 표기하기 위한 용도.71  const insertPlaceholder = useCallback(72    (insertPosition: number, placeholder: string, view: EditorView | null) => {73      if (view) {74        view.dispatch({75          changes: {76            from: insertPosition,77            insert: placeholder,78          },79          selection: {80            anchor: insertPosition + placeholder.length,81          },82        })83 84        setMarkdownValue(view.state.doc.toString())85        return insertPosition + placeholder.length86      }87 88      let nextPosition = insertPosition + placeholder.length89      setMarkdownValue((previous) => {90        const safePosition = Math.min(insertPosition, previous.length)91        nextPosition = safePosition + placeholder.length92        const before = previous.slice(0, safePosition)93        const after = previous.slice(safePosition)94        return `${before}${placeholder}${after}`95      })96 97      return nextPosition98    },99    [],100  )101 102  // 이미지 업로드가 완료되면 실제 이미지 url 로 변경해준다.103  const replacePlaceholderWithImage = useCallback(104    (imageUrl: string, imageName: string, view: EditorView | null) => {105      const tempImageMarkdown = createTempImageMarkdown(imageName)106      const finalImageMarkdown = `![${imageName}](${imageUrl})`107 108      if (view) {109        const docText = view.state.doc.toString()110        const tempImageIndex = docText.indexOf(tempImageMarkdown)111 112        if (tempImageIndex === -1) return113 114        view.dispatch({115          changes: {116            from: tempImageIndex,117            to: tempImageIndex + tempImageMarkdown.length,118            insert: finalImageMarkdown,119          },120        })121 122        setMarkdownValue(view.state.doc.toString())123        return124      }125 126      setMarkdownValue((previous) => {127        if (!previous.includes(tempImageMarkdown)) {128          return previous129        }130 131        return previous.replace(tempImageMarkdown, finalImageMarkdown)132      })133    },134    [createTempImageMarkdown],135  )136 137  // 이미지 파일을 업로드 한다.138  const uploadImage = useCallback(139    (file: File, insertPosition: number, view?: EditorView | null) => {140      const editorView = view ?? getCodeMirrorView()141      const imageName = crypto.randomUUID()142      const placeholder = createTempImageMarkdown(imageName)143      const nextPosition = insertPlaceholder(144        insertPosition,145        placeholder,146        editorView,147      )148 149      const formData = new FormData()150      formData.append('image', file)151      formData.append('imageName', imageName)152 153      imageUploaderFetcher.submit(formData, {154        method: 'post',155        encType: 'multipart/form-data',156        action: '/blog/upload-image',157      })158 159      return nextPosition160    },161    [162      createTempImageMarkdown,163      getCodeMirrorView,164      imageUploaderFetcher,165      insertPlaceholder,166    ],167  )168 169  // 이미지 업로드를 요청 하면 응답은 imageUploaderFetcher.data 를 통해 받게 된다.170  // 응답으로 받은 url 을 이전에 추가했던 이미지 로딩 문구와 교체한다.171  useEffect(() => {172    if (!imageUploaderFetcher.data) return173 174    const { imageUrl, imageName } = imageUploaderFetcher.data as {175      imageUrl?: string176      imageName?: string177    }178 179    if (!imageUrl || !imageName) return180 181    const view = getCodeMirrorView()182    replacePlaceholderWithImage(imageUrl, imageName, view)183  }, [184    getCodeMirrorView,185    imageUploaderFetcher.data,186    replacePlaceholderWithImage,187  ])188 189  // 에디터의 현재 커서 위치를 반환한다.190  const getCursorPosition = useCallback(191    (view: EditorView | null) => {192      return view?.state.selection.main.from ?? markdownValue.length193    },194    [markdownValue],195  )196 197  // 에디터에 붙여넣기 시도시 호출된다. 클립보드의 데이터 타입이 이미지일 경우에만 실행되도록 한다.198  const handlePaste = useCallback(199    (event: React.ClipboardEvent<HTMLDivElement>) => {200      const items = Array.from(event.clipboardData?.items ?? [])201      const imageItems = items.filter((item) => item.type.startsWith('image/'))202 203      if (imageItems.length === 0) return204 205      event.preventDefault()206      const view = getCodeMirrorView()207      let insertPosition = getCursorPosition(view)208 209      imageItems.forEach((item) => {210        const file = item.getAsFile()211        if (file) {212          insertPosition = uploadImage(file, insertPosition, view)213        }214      })215    },216    [getCodeMirrorView, getCursorPosition, uploadImage],217  )218 219  // 에디터에 파일을 드롭 시도시 호출된다. 파일이 이미지일 경우에만 실행되도록 한다.220  const handleDrop = useCallback(221    (event: React.DragEvent<HTMLDivElement>) => {222      event.preventDefault()223 224      const files = Array.from(event.dataTransfer?.files ?? [])225      const imageFiles = files.filter((file) => file.type.startsWith('image/'))226 227      if (imageFiles.length === 0) return228 229      const view = getCodeMirrorView()230      const { clientX, clientY } = event.nativeEvent231      const dropPosition =232        view?.posAtCoords({ x: clientX, y: clientY }) ?? markdownValue.length233      let insertPosition = dropPosition234 235      imageFiles.forEach((file) => {236        insertPosition = uploadImage(file, insertPosition, view)237      })238    },239    [getCodeMirrorView, markdownValue, uploadImage],240  )241 242  return {243    isInitialized,244    markdownValue,245    previewWidth,246    postMeta,247    setMarkdownValue,248    handleSave,249    handlePaste,250    handleDrop,251    handleMetaFieldChange,252    handleDraftChange,253  } 254}255 

위와 같이 에디터를 조작하거나 이벤트를 처리하기 위한 훅을 사용했다. 기본적인 내용들은 코드내 주석으로 작성했다.

react-markdown-editor는 내부적으로 CodeMirror를 사용한다. CodeMirror는 브라우저 기반의 강력한 코드 에디터 라이브러리인데, VSCode, Chrome DevTools 등에서도 사용되는 텍스트 에디터 엔진이다. 구문 강조(syntax highlighting), 자동 완성, 코드 폴딩 등의 기능을 제공한다. 여기서는 텍스트를 삽입하거나 교체하는 용도로 활용했다.

처음에 CodeMirror를 이용해서 컨텐츠를 조작해야 하는지 몰라서 해멨다.

이미지가 에디터에 붙여지는 과정은 다음과 같다.

  1. 이미지를 복사해서 붙여넣기 하거나 이미지 파일을 드래그 앤 드롭해서 에디터에 위치 시킨다.
  2. 붙여넣기 한거나 드롭한 위치에는 ”![이미지 업로드 중…](uploading-${imageName})“라는 문구를 삽입해 둔다.
  3. 문구 삽입 후, 이미지 업로드를 요청한다.
  4. 이미지 업로드가 완료된 후 받은 응답에서 아미지 url 을 가저와서 2단계에서 삽입했던 문구와 교체한다.

다음은 저장용 리액트 라우터 페이지의 loader 샘플코드이다.

이미지 업로드 하기

이미지를 업로드 해주는 페이지 컴포넌트의 코드는 다음과 같다.

1import { redirect } from 'react-router'2import { getUserProfile } from '~/features/users/api/queries'3import { makeSsrClient } from '~/supabase-client'4import type { Route } from './+types/blog-upload-image'5 6export const action = async ({ request }: Route.LoaderArgs) => {7  const { serverSideClient: client } = await makeSsrClient(request)8  const formData = await request.formData()9  const file = formData.get('image') as File10  const imageName = formData.get('imageName') as string11  if (!file) {12    return {13      result: 'no file',14    }15  }16 17  // blog 버킷에 images/{username}/{randomId}.xxx 파일 업로드18  const { data, error } = await client.storage19    .from('blog')20    .upload(21      `images/${username}/${imageName}.${file.type.split('/')[1]}`,22      file,23      {24        contentType: file.type,25        upsert: false,26      },27    )28 29  if (error) {30    console.error('이미지 업로드 실패:', error)31    return {32      result: 'error',33      error: error.message,34    }35  }36 37  const {38    data: { publicUrl },39  } = await client.storage.from('blog').getPublicUrl(data.path)40 41  return {42    result: 'ok',43    imageUrl: publicUrl,44    imageName,45  }46}47 

supabase 를 이용해서 이미지를 업로드하고 업로드된 이미지의 public url 을 반환해준다.