MarkDownEditor 선택

minseok baek·2024년 8월 5일

프로젝트

목록 보기
10/20

board관련한 작업을 진행하는 도중 평문으로만 작성된 게시판이 밋밋하다는 생각이들었다. 평소에 마크다운문법을 즐겨 사용하여 게시글을 작성하는편이어서, 게시판에 마크다운 문법을 적용하면 훨씬 깔끔하고 가독성 좋은 글을 작성할 수 있 것이라고 생각했다.

하지만, 마크다운을 즐겨 사용해보기만 했지 프로젝트에 적용해본 적은 없었다. 평소 같았으면 직접 구현해보는 방향을 선택했겠지만 현재는 리팩토링 과정 중이고, 빠르게 결과물을 만들어내야하는 상황이라 라이브러리를 사용하기로 했다.

고려 사항

  • Next.js와 React 18 버전에서 호환될 것
  • 디자인 컨셉과 맞아야 하기 때문에 커스텀이 가능할 것
  • 문법이 간단할 것

선정된 라이브러리

  • react-simple-markdown
  • uiw/react-md-editor

각각의 장단점은 다음과 같다.

  • react-simple-markdown: 이름처럼 가볍고 React 18 버전을 지원한다. 그러나 세부 사항의 커스텀은 불가능하다.
  • uiw/react-md-editor: React 18 버전을 지원하며, 세부적인 커스텀이 가능하다. 그러나 상대적으로 무겁다.

최종 선택

복잡한 마크다운 기능과 복잡한 커스텀도 필요없고, 테마 색상에 전환만 적용할 수 있으면 충분하기 때문에 react-simple-markdown을 선택했다.

import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import 'simplemde/dist/simplemde.min.css';
import EasyMDE, { Options as EasyMDEOptions } from 'easymde';

// Dynamic import to avoid SSR issues with SimpleMDE
const SimpleMDE = dynamic(() => import('react-simplemde-editor'), { ssr: false });

interface CustomToolbarButton {
  name: string;
  action: string | ((editor: EasyMDE) => void);
  className: string;
  title: string;
}

const MarkdownEditor: React.FC = () => {
  const [content, setContent] = useState('');

  const handleChange = (value: string) => {
    setContent(value);
  };

  const options: EasyMDEOptions = {
    autofocus: true,
    spellChecker: false,
    placeholder: '입력해주세요...',
    toolbar: [
      'bold',
      'italic',
      'heading',
      '|',
      'quote',
      'unordered-list',
      'ordered-list',
      '|',
      'link',
      'image',
      '|',
      'preview',
      'side-by-side',
      'fullscreen',
      '|',
      {
        name: 'guide',
        action: () => {
          window.open('https://simplemde.com/markdown-guide', '_blank');
        },
        className: 'fa fa-question-circle',
        title: 'Markdown Guide',
      } as CustomToolbarButton
    ],
    renderingConfig: {
      codeSyntaxHighlighting: true,
    },
  };

  return (
    <div>
      <SimpleMDE value={content} onChange={handleChange} options={options} />
    </div>
  );
};

export default MarkdownEditor;

shared/component/markdown 관련 컴포넌트로 공용화하여 사용했으며, 나름의 커스텀도 적용했다. 하지만 커스텀 절차가 생각보다 복잡했고, 타입 지정도 복잡하다는 생각이들어 포기했다.

@uiw/react-md-editor로 변경

uiw/react-md-editor로 변경하여 적용한 결과,
오류도 없고 문법도 간편했다. 그러나 편집기를 불러오는 렌더링 속도가 너무 느렸다. 모든 페이지를 불러온 후 한참 뒤에 편집기를 불러오는 속도가 절망적이었다. 어째든 오류도 없고 타입 선정 문제도 없어 이 문제만 해결하면 사용할만 하다 생각하였다.

import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import 'react-md-editor/dist/css/style.css';
import '@uiw/react-markdown-preview/dist/markdown.css';

const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });

const MarkdownEditor: React.FC = () => {
  const [value, setValue] = useState<string>("**Hello world!!!**");

  const handleChange = (newValue?: string) => {
    setValue(newValue || "");
  };

  return (
    <div style={{ padding: '20px' }}>
      <MDEditor value={value} onChange={handleChange} />
    </div>
  );
};

export default MarkdownEditor;

우선 한참 후에 편집기를 불러오는 문제를 해결하기 위해 useEffect를 활용하여 편집기의 로딩이 끝나는 시점에 화면을 렌더링 해주었다.

  const [isEditorLoaded, setIsEditorLoaded] = useState(false);

  useEffect(() => {
    const loadEditor = async () => {
      await import('@uiw/react-md-editor');
      setIsEditorLoaded(true);
    };

    loadEditor();
  }, []);

이 방법으로 페이지를 불러오는 문제를 해결했지만, 따로 모듈을 불러오는 문제를 해결했을뿐 절망적인 속도는 개선될 수 없었다.
다시 서칭한 결과 next_config에서 사용하지 않는 모듈을 import하는 것을 커스텀할 수 있다고 공식문서에서 설명하지만 그절차도 복잡하고 여기에 많은 시간을 쏟고 싶진 않았다.

최종 선택: react-markdown-editor-lite

react-markdown-editor-lite라는 편집기에 대해서 알게되었다. 공식 사이트를 참고한 결과, Next.js에 대한 적용 방법이 잘 설명되어 있었고, 가벼운 커스텀도 가능하며 문법도 간단했다. 적용한 결과, 이름처럼 매우 가볍고 원하는 기능을 모두 포함하고 있어 충분하다고 판단했다. 스타일 커스텀에 대해서는 개발자 도구에서 CSS속성을 찾아 대입하는 과정을 거쳐 쉽게 해결할 수 있었다.

최적화

  • 공용 컴포넌트처럼 사용하여 값을 넘겨 받으면 마크다운의 글을 작성할 때마다 무한 업데이트가 발생했다. 이를 해결하기 위해 컴포넌트 내부에서 텍스트 상태를 관리하고 디바운스를 적용하여 사용자의 상호작용이 끊기는 시점에 업데이트 하도록 했다.

XSS 공격

미리보기 기능의 경우 markdownit파서를 사용하여 변환했는데 해당 과정에서 XSS공격의 위험성이 있을거라고 생각했다. 서칭 해본 결과 markdownit은 파서로써 역할만 수행할뿐 xss 공격에 대해서는 보장되어있지 않았다. 이를 방지하기 위해 DOMPurify 라이브러리를 사용하여 마크다운 문법을 정제하여 render하는 방식을 선택하여 XSS공격으로부터 자유로워졌다.

import dynamic from 'next/dynamic';

import { memo, useEffect, useState } from 'react';

import styled from 'styled-components';

import DOMPurify from 'dompurify';

import MarkdownIt from 'markdown-it';
import 'react-markdown-editor-lite/lib/index.css';

import { useDebounce } from '@/src/shared/hooks';

const MdEditor = dynamic(() => import('react-markdown-editor-lite'), {
  ssr: false,
});

const mdParser = new MarkdownIt();

type Props = {
  placeholder?: string;
  onChange: (text: string) => void;
};

function MarkdownEditor({
  onChange,
  placeholder = '값을 입력해주세요.',
}: Props) {
  const [value, setValue] = useState('');
  const debouceValue = useDebounce(value);

  useEffect(() => {
    onChange(value);
  }, [debouceValue]);

  const handleEditorChange = ({ text }: { text: string }) => {
    setValue(text);
  };

  return (
    <Container>
      <MdEditor
        value={value}
        renderHTML={(text) => DOMPurify.sanitize(mdParser.render(text))}
        onChange={handleEditorChange}
        placeholder={placeholder}
        view={{
          menu: true,
          md: true,
          html: false,
        }}
        plugins={[
          'header',
          'font-bold',
          'font-italic',
          'list-ul',
          'block-code-inline',
          'block-code-block',
          'block-quote',
          'mode-toggle',
        ]}
      />
    </Container>
  );
}

export default memo(MarkdownEditor);

const Container = styled.div`
  width: 100%;
  height: 100%;
  margin-block: 2rem;

  // 전체 테두리
  .rc-md-editor {
    background-color: ${(props) => props.theme.colors.textArea};
    border: 0.1em solid ${(props) => props.theme.colors.mainLine};
    height: 100%;
  }

  // 툴바
  .rc-md-editor .rc-md-navigation {
    background-color: ${(props) => props.theme.colors.textArea};
    border-bottom: 0.1em solid ${(props) => props.theme.colors.mainLine};
  }

  // md배경 관련
  .rc-md-editor .editor-container .input {
    background-color: ${(props) => props.theme.colors.textArea};
    color: ${(props) => props.theme.colors.text};
  }

  .rc-md-editor .editor-container > .section {
    border-right: 0.1em solid ${(props) => props.theme.colors.mainLine};
  }

  // html 렌더링 스타일 ?
  .custom-html-style {
    color: ${(props) => props.theme.colors.text};
  }
`;
profile
성장은 점진적 과부하, 매주 회고를 목표로 시작했지만 그때 그때 컨셉이 달라요. 시행착오를 통해 저만의 방식을 찾아가는중입니다.

0개의 댓글