board관련한 작업을 진행하는 도중 평문으로만 작성된 게시판이 밋밋하다는 생각이들었다. 평소에 마크다운문법을 즐겨 사용하여 게시글을 작성하는편이어서, 게시판에 마크다운 문법을 적용하면 훨씬 깔끔하고 가독성 좋은 글을 작성할 수 있 것이라고 생각했다.
하지만, 마크다운을 즐겨 사용해보기만 했지 프로젝트에 적용해본 적은 없었다. 평소 같았으면 직접 구현해보는 방향을 선택했겠지만 현재는 리팩토링 과정 중이고, 빠르게 결과물을 만들어내야하는 상황이라 라이브러리를 사용하기로 했다.
각각의 장단점은 다음과 같다.
복잡한 마크다운 기능과 복잡한 커스텀도 필요없고, 테마 색상에 전환만 적용할 수 있으면 충분하기 때문에 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로 변경하여 적용한 결과,
오류도 없고 문법도 간편했다. 그러나 편집기를 불러오는 렌더링 속도가 너무 느렸다. 모든 페이지를 불러온 후 한참 뒤에 편집기를 불러오는 속도가 절망적이었다. 어째든 오류도 없고 타입 선정 문제도 없어 이 문제만 해결하면 사용할만 하다 생각하였다.
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라는 편집기에 대해서 알게되었다. 공식 사이트를 참고한 결과, 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};
}
`;