Logo

Prolip's Blog

tiptap으로 에디터 구현하기
  • #Editor
  • #Project
  • #ModuReview

tiptap으로 에디터 구현하기

Prolip

2025-06-16

Headless 라이브러리인 Tiptap으로 직접 에디터 구현하기, 내가 react-quill을 선택하지 않은 이유

시작하며

이번에 커뮤니티형 프로젝트를 진행하면서 게시글 작성에 꼭 필요한 에디터를 구현하게 되었는데요. titap 라이브러리는 무엇이고 어째서 이 라이브러리를 사용해 에디터를 구현했는지부터 시작해보려고 합니다.

선정 이유

짧은 시간 안에 에디터를 구현하려면 이미 잘 만들어진 라이브러리를 사용하는게 맞다고 생각합니다. 바퀴를 재발명할 필요는 없으니까요.

먼저 에디터 구현에 앞서 라이브러리부터 검색해봤는데요. 총 3개의 라이브러리를 비교했습니다.

1. Toast UI: 선택 X

NHN에서 개발된 라이브러리로 Markdown 모드와 WYSIWYG 모드를 모두 지원하는 에디터 라이브러리입니다. 작성한 글을 바로 우측에서 보여주며 사용 방법도 그다지 어렵지 않아보였는데요.

하지만 선정하지 않은 치명적인 이유가 있었는데요.

깃허브를 확인해보면 마지막 업데이트는 2년 전이며, 이슈는 500개가 넘지만 이에 대한 후속 조치가 없어 사실상 버려진 상태로 볼 수 있었습니다. 더군다나 리액트 18버전과의 호환성 문제도 존재하는듯 보였습니다.

서비스를 개발하며 문제 발생의 위험성을 가진 라이브러리를 선택할 수 없어 다른 라이브러리를 알아보게 되었습니다.

2. react-quill: 선택 X

해당 라이브러리는 빠르게 선정하지 않은 이유부터 나열해보겠습니다.

  1. 업데이트 주기 문제: 깃허브 저장소를 확인해보니 마지막 업데이트가 2년 전이었는데요. 위의 Toast UI와 마찬가지로 관리가 이루어지지 않는 라이브러리로 판단했습니다.
  2. react-quill-new?: react-quill을 대체하려는 react-quill-new라는 라이브러리도 존재했는데요. 하지만 공식적인 계승 버전이 아니라 단순히 기존 라이브러리를 fork해 업데이트하던 라이브러리였습니다. 마지막 업데이트도 2달 전으로 비교적 느슨하게 업데이트되고 있었으며, 생성된 이슈에 대한 피드백도 딱히 활발하지 않았습니다. 저는 ‘생태계가 잘 구성되어있는가?’를 중요시하는데요. 이에 적합하지 않다고 판단했습니다.
  3. Quill 자체의 제약: react-quill은 근본적으로 Quill.js를 감싸 리액트에서 쉽게 사용 가능하게 만든 라이브러리라고 볼 수 있습니다. 그래서 차라리 Quill을 사용해볼까 했는데요. 하지만 공식 문서를 살펴본 결과 리액트나 Next.js 환경에서의 활용 가이드가 적혀있지 않았습니다. 프레임워크 특성에 맞는 문서화가 부족하면 결국 초기에 러닝커브가 불필요하게 커질 수 있어 적합하지 않다고 판단했습니다.

3. tiptap: 선택 O

최종적으로 titap이라는 라이브러리를 선택하게 되었는데요. 먼저 tiptap에 대해 잠시 영업해보자면

  1. Headless 라이브러리입니다.
  • 공식 홈페이지 입구부터 광고하고 있는 문구인데요. tiptap은 기본 UI가 없는 Headless 라이브러리입니다. 이게 기본 UI가 없는게 단점으로 보일 수 있는데요. 오히려 프로젝트 상황에 맞게 필요한 기능만 선택해서 UI를 설계할 수 있어 저에겐 장점으로 보였습니다. 사용하지 않는 불필요한 UI를 억지로 끼울 필요도 없고 원하는 기능을 자유롭게 확장할 수 있어 꽤 매력적이었습니다.
  1. 패키지 크기가 무진장 작습니다.
  • npm 사이트에서 확인해볼 수 있었는데요. Quill의 Unpacked Size는 3.04MB, titap은 323 kB에 제가 사용하는 익스텐션 4개의 크기 244.8KB를 합쳐 567.8KB로 약 6배 정도 차이난다는 점에서 선택하게 되었습니다. 확실히 필요한 기능만 골라서 사용할 수 있으니 패키지 크기부터 차이가 꽤 나는 거 같았습니다.
  1. 문서화와 생태계
  • tiptap은 공식 문서가 정말 제대로 정리되어 있는데요. 리액트뿐만 아니라 Next.js 환경에서 사용하는 가이드도 모두 제공하고 있습니다. 제가 위에서 중시한다고 했던 생태계 기준에서도 합격이고, 실제로 많은 개발자들이 tiptap으로 에디터를 구현하며 남겨둔 레퍼런스가 풍부해 참고할 자료도 많았습니다.

에디터 표시하기

먼저 tiptap을 프로젝트에 설치하면 되는데요.

pnpm i @tiptap/react @tiptap/pm @tiptap/starter-kit

저는 pnpm을 쓰고 있는데 사용하는 패키지 매니저에 맞게 변경해주시면 됩니다. tiptap/pm은 뭔가 싶으실텐데요. 알아보니 tiptap을 실행하는 데 필요한 모든 ProseMirror 패키지가 담겨있다고 합니다. starter-kit의 경우 기본적으로 에디터에서 지원하는 옵션들이 담긴 패키지로 헤딩1, 헤딩2, 헤딩3, 리스트 등 아주 기본적인 옵션들이 담긴 패키지입니다.

'use client' import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' const Editor = () => { const editor = useEditor({ extensions: [StarterKit], content: '<p>Hello World! 🌎️</p>', immediatelyRender: false, }) if (!editor) { return <p>에디터 정보를 불러오고 있어요..</p> } return <EditorContent editor={editor} /> } export function Home() { return <Editor /> }

사용 방법은 간단한데요. useEditor를 호출해 필요한 옵션들을 지정해 editor 객체를 만듭니다.

  • extensions 속성은 배열을 통해 확장하고 싶은 옵션들을 나열할 수 있습니다.
  • content 속성은 처음 에디터가 렌더링될 때의 초기값으로 아무것도 적지 않을 경우 빈 에디터가 표시되며 위와 같이 작성할 경우 Hello World가 출력됩니다.
  • immediatelyRender 옵션은 tiptap 2.5.0 버전부터 추가된 옵션인데요. 기존에 tiptap은 SSR 프레임워크와 호환되지 않는 문제(하이드레이션 미스매치)가 있었는데요. 이 옵션이 추가되면서 Next.js에서 사용이 가능해졌어요.

하지만 이렇게 만든 에디터를 실행하면 아래와 같이 textarea 태그에 입력하듯 단순히 글만 작성할 수 있는데요. headless 라이브러리로 사용자가 필요한 기능을 직접 확장하고 구현해줘야 하기 때문입니다.

image

StarterKit에는 에디터에서 지원하는 기본적인 텍스트 옵션들이 포함된다고 말씀 드렸는데요. 마크다운 형식과 동일하게 #을 사용한 헤딩 적용, - 기호를 사용한 un-ordered list, 등 옵션이 적용되기는 합니다.

하지만 저희 프로젝트는 tailwindcss를 사용하고 있기 때문에 모든 html 기본 스타일이 초기화되어 확인이 불가능했는데요.

pnpm add @tailwindcss/typography // tailwindcss.config.ts plugins: [require("@tailwindcss/typography")], // Editor.ts const Editor = () => { const editor = useEditor({ extensions: [StarterKit], content: '<p>Hello World! 🌎️</p>', immediatelyRender: false, editorProps: { attributes: { class: "prose lg:prose-lg focus:outline-none", }, }, }) if (!editor) { return <p>에디터 정보를 불러오고 있어요..</p> } return <EditorContent editor={editor} /> }

typography 라이브러리를 설치해 에디터 객체를 생성할 때 prose 옵션을 적용해주면 각 헤딩 옵션이나 리스트 등의 스타일이 적용되어 아래와 같이 에디터 내에 스타일이 적용되는 모습을 확인할 수 있습니다.

image

하지만 일반적인 사용자들은 이런 옵션들을 키맵핑으로 사용하는게 익숙하지 않을 수 있습니다. 그래서 툴바를 통해 시각적으로 사용자에게 옵션을 제공해줘야 하는데요.

툴바 제공하기

우선 툴바 구현 이전에 키맵핑이 아닌 코드를 통해 마크업 옵션을 적용 및 해제할 수 있어야 하는데요.

commands

editor.commands.toggleHeading({ level: 1 })

heading 옵션의 경우 공식문서 상으로 위와 같이 안내하고 있습니다. 하지만 이 방식으로 구현하면, 버튼을 클릭하면 사용자가 입력하던 지점에서 포커스가 사라져버리는 문제가 발생해요.

버튼을 클릭하고 사라진 포커스 때문에 직접 에디터 내 입력 영역을 클릭해야만 하는 번거로운 방식입니다.

heading-commands.gif

chain

editor.chain().focus().toggleHeading({ level: 1 }).run();

이 방법은 에디터 객체의 chain 함수를 사용하는 방법인데요. chain 메서드를 사용하면, 말 그대로 뒤에 여러 함수를 체인을 통해 묶듯 동작을 연결할 수 있습니다.

  1. focus() - 가장 먼저 실행될 함수로 에디터 외부의 H1 버튼을 클릭하면서 사라진 포커스를 다시 에디터 내에 복원하는 함수인데요. 기본적으로 마지막 입력 지점으로 복원됩니다.
  2. toggleHeading({ level }) - 지정된 레벨에 따라 헤딩 옵션을 지정합니다.
  3. run() - 연결된 모든 함수를 실행합니다.

위 동작이 모두 연결되며 아래와 같이 동작하게 됩니다.

heading-chain.gif

Nodes extensions | Tiptap Editor Docs 공식 문서를 확인하면 사용하고 싶은 마크업 옵션을 어떻게 적용할 수 있는지 나와있습니다. 적용할 때 필요한 기능들을 골라 적용하면 됩니다.

현재 특정 마킁업 옵션이 적용된 상태를 시각화하기 위해서 editor 객체의 isActive 함수를 사용할 수 있는데요. 반환 타입은 boolean 타입으로 이 값을 이용해 스타일링하면 적용된 옵션을 시각화할 수 있게 됩니다.

editor.isActive('heading', {level: 1}) <button className={`font-bold p-1 ${ editor.isActive("heading", { level: 1 }) ? "bg-gray-200" : ""}`} onClick={toggleHeading1} > H1 </button>

image.png

이제 어떻게 옵션을 적용할 수 있는지 확인했으니 툴바 구현체를 어떻게 구현했는지 기록해보겠습니다.

const toggleHeading1 = () => { editor.chain().focus().toggleHeading({ level: 1 }).run(); }; <button onClick={toggleHeading1}>H1</button>

먼저 모든 옵션 버튼마다 위와 같이 함수와 html 태그를 직접 하드코딩할 수는 없었습니다.

toolbarConfig.ts

type ActiveStateKey = keyof EditorActiveState; export type ToolbarConfig = { icon: keyof typeof icons; action: (editor: Editor) => void; stateKey: ActiveStateKey; text: string; }; const headingOptions: ToolbarConfig[] = [ { icon: 'Heading1', action: editor => editor.chain().focus().toggleHeading({level: 1}).run(), stateKey: 'isHeading1', text: '제목1', }, { icon: 'Heading2', action: editor => editor.chain().focus().toggleHeading({level: 2}).run(), stateKey: 'isHeading2', text: '제목2', }, { icon: 'Heading3', action: editor => editor.chain().focus().toggleHeading({level: 3}).run(), stateKey: 'isHeading3', text: '제목3', }, ]; const markOptions: ToolbarConfig[] = [ { icon: 'Bold', action: editor => editor.chain().focus().toggleBold().run(), stateKey: 'isBold', text: '굵게', }, { icon: 'Italic', action: editor => editor.chain().focus().toggleItalic().run(), stateKey: 'isItalic', text: '기울임', }, { icon: 'Strikethrough', action: editor => editor.chain().focus().toggleStrike().run(), stateKey: 'isStrike', text: '취소선', }, ]; // ... // 그 외 옵션들

코드의 일부만 표시하겠습니다. 이렇게 표시할 옵션들에 대한 설정 값들을 배열로 관리하는 방법을 선택했는데요. 비슷한 성격을 가진 마크업 옵션 기준으로 분리했습니다.

배열 내의 각 객체들은 표시될 아이콘, 눌렀을 때 실행될 함수(마크업 옵션을 적용합니다.), 활성 상태 조회를 위한 stateKey, 마우스 커서를 올렸을 때 표시할 툴팁에 표시되는 텍스트를 포함합니다.

ToolbarGroup.tsx

type Props = { children: React.ReactNode; }; export default function ToolbarGroup({children}: Props) { return <article className="flex flex-wrap items-center">{children}</article>; }

그룹별로 분리된 마크업 옵션 배열들을 실제로 렌더링할 때도 그룹별 묶어주기 위해 ToobalGroup 컴포넌트를 구현했습니다.

ToolbarLine.tsx

export default function ToolbarLine() { return <div className="w-[1.5px] h-[25px] bg-gray-300" />; }

에디터를 보면 마크업 버튼 그룹 옆에 선이 있는데요. 해당 선을 표시하기 위한 컴포넌트를 구현했습니다.

ToolbarButton.tsx

const variants = cva('w-[35px] h-[35px] flex items-center justify-center hover:bg-gray-100 cursor-pointer', { variants: { iconType: { node: 'w-[22px] h-[22px]', marks: 'w-[18px] h-[18px]', image: 'w-[20px] h-[20px]', }, active: { true: 'bg-gray-200', false: 'bg-white', }, }, }); type Props = VariantProps<typeof variants> & React.ButtonHTMLAttributes<HTMLButtonElement> & { icon: keyof typeof icons; ref?: RefObject<HTMLButtonElement>; }; export default function ToolbarButton({icon, iconType, active, ref, ...props}: Props) { return ( <button ref={ref} className={variants({active})} {...props} > <LucideIcon name={icon} className={variants({iconType})} /> </button> ); }

toolbarConfig에 정의된 마크업 옵션 배열을 순회할 때, 배열 내의 객체를 사용해 버튼을 표시해야 하는데요. 활성 상태에 따라 시각화해야 하기 때문에 active 값을 인자로 받아 스타일링을 동적으로 변경해줍니다.

따로 cva 사용 방법에 대한 설명은 다루지 않겠습니다.

Toolbar.ts

type Props = { editor: Editor; editorState: EditorActiveState; }; export default function Toolbar({editor, editorState}: Props) { return ( <section className="border-b py-2 md:pb-3 w-full flex flex-wrap justify-center items-center gap-2"> {/** 헤딩 그룹 **/} <ToolbarGroup> {headingOptions.map(({icon, action, stateKey, text}) => ( <ToolbarButton icon={icon} onClick={() => action(editor)} iconType="node" active={editorState[stateKey]} /> ))} </ToolbarGroup> <ToolbarLine /> {/** 마크업 그룹 **/} <ToolbarGroup> {markOptions.map(({icon, action, stateKey, text}) => ( <ToolbarButton icon={icon} onClick={() => action(editor)} iconType="marks" active={editorState[stateKey]} /> ))} </ToolbarGroup> <ToolbarLine /> {/** 위와 동일하게 그 외 필요한 옵션 객체를 순회하며 렌더링 **/} </section> ) }

이렇게 Toolbar 컴포넌트에선 각 영역, 각 쓰임새에 맞게 만들어진 컴포넌트들을 조합해 버튼을 렌더링하는데요.

마크업 옵션을 적용하기 위해선 editor 객체가 필요하기 때문에 컴포넌트의 인자로 editor 객체를 받아 마크업 옵션 배열의 각 객체의 action 함수로 전달합니다.

현재 적용된 옵션을 시각화하기 위한 active 상태는 컴포넌트의 인자로 전달된 editorState 객체에 stateKey를 통해 boolean 값을 ToolbarButton 컴포넌트로 전달하게 됩니다.

여기서 editorState에 대한 설명이 필요해 잠시 다른 주제로 넘어가겠습니다.

에디터 최적화

뜬금 없이 툴바 구현 와중에 에디터 최적화가 나왔는데요. 원래 이 내용은 마지막으로 빼려고 했으나 Toolbar 컴포넌트에서 사용하는 객체라 어쩔 수 없이 다루게 되었습니다.

먼저 제가 구현했던 에디터의 리렌더링 빈도를 보여드리자면,

before.gif

타이핑되는 모든 문자마다 리렌더링이 유발되어 모바일의 경우 화면이 뚝뚝 끊기며 사용에 불쾌함을 느낄 정도로 성능이 저하되는 문제가 발생했습니다.

Tiptap 공식 문서에 따르면 useEditor 훅으로 생성된 editor 객체는 모든 트랜잭션마다 컴포넌트를 리렌더링한다고 합니다. Tiptap은 ProseMirror를 기반으로 만들어졌는데, ProseMirror는 상태를 직접 변경하는 것이 아닌 트랜잭션 객체를 통해 상태를 변경합니다.

모든 편집 작업은 글자 입력 및 삭제, 마크업 토글, 포커스 변경, 커서 이동, 이미지 및 링크 삽입 등 모두 하나의 트랜잭션 객체로 표현되고, 그 트랜잭션이 에디터의 상태를 바꾸는 역할을 합니다.

그럼 useEditor로 editor 객체를 만들면 내부적으로 Tiptap은 위에서 설명한 모든 트랜잭션이 발생할 때마다 editor 객체의 상태를 바꾸고 이에 따라 리액트는 컴포넌트 전체를 리렌더링합니다.

쉽게, 타이핑 한 글자마다 트랜잭션이 발생 => 에디터 상태를 업데이트 => 리렌더링 발생 이런 구조입니다.

공식 문서 상으로 useEditor 훅을 사용할 때 shoudRerenderOnTransaction 옵션 값을 false로 설정할 경우(구현 당시 tiptap 2버전이었는데, 지금 3버전으로 업데이트 되었더라구요? 지금은 기본값이 false인 거 같아요.) 모든 트랜잭션이 생길 때마다 리렌더링하던 동작을 중지시킬 수 있다고 나와있습니다.

이 말은 위에서 설명했던 타이핑, 마크업 토글, 포커스 변경, 커서 이동 등 여러 동작에 대해 리렌더링이 일어나지 않는다는 의미입니다.

실제로 설정한 이후 리렌더링이 발생하지 않았으나, 마크업 토글이 일어난 이후 editor.isActive 등으로 활성화 상태를 추적할 수 없어 툴바 버튼의 활성 상태를 추적할 수 없는 문제가 발생했습니다.

결과적으로 에디터 내에 마크업은 실제로 적용은 되지만 툴바 버튼의 활성 상태를 반영할 수 없었습니다.

useEditorState

useEditorState 훅을 사용하면 에디터 상태를 세분화해 구독할 수 있는데요. 구독하고 있는 값들을 이용해 필요한 상황에서만 리렌더링이 일어나게 최적화할 수 있습니다. 그래서 이 훅을 사용해 툴바에서 필요한 editor 상태만 선택적으로 구독하게 되었는데요.

const editorState = useEditorState({ editor, selector: snapshot => { const {editor} = snapshot; if (!editor) return null; return { isHeading1: editor.isActive('heading', {level: 1}), isHeading2: editor.isActive('heading', {level: 2}), isHeading3: editor.isActive('heading', {level: 3}), isBold: editor.isActive('bold'), isItalic: editor.isActive('italic'), isStrike: editor.isActive('strike'), // ...그 외 필요한 옵션들 }; }, });

selector 함수는 에디터의 상태가 변경될 때마다 항상 실행되는 함수입니다. 상태가 변경되는 시점의 스냅샷이 인자로 전달되는데요.

상태 변경 시점의 editor 객체를 사용해 현재 문단 혹은 커서에 적용된 마크업 상태(boolean)를 외부로 반환할 수 있습니다.

const headingOptions: ToolbarConfig[] = [ { icon: 'Heading1', action: editor => editor.chain().focus().toggleHeading({level: 1}).run(), stateKey: 'isHeading1', text: '제목1', }, // ... ]; // Toolbar.tsx <ToolbarGroup> {headingOptions.map(({icon, action, stateKey, text}) => ( <ToolbarButton {...} active={editorState[stateKey]} /> ))} </ToolbarGroup>

아까 설명했던 마크업 옵션 객체를 보면 stateKey 속성이 정의되어 있었는데요. 이 stateKey를 사용해 활성화 상태를 버튼으로 주입해 시각화할 수 있게 됩니다.

이렇게 적용함으로써 에디터의 트랜잭션 리렌더링을 비활성화 하면서도 마크업 상태 추적은 정상적으로 동작하도록 구현할 수 있었는데요. 이 챕터는 최적화 이후 어떻게 개선되었는지를 보여드리고 마무리하겠습니다.

after.gif

툴바 연결하기

위에서 툴바에 대한 설명은 모두 끝냈다고 생각합니다. 이제 이 툴바를 연결해 에디터와 상호작용할 수 있게 만들어줘야 하는데요.

type Props = { initialContent?: string; }; export default function EditorContainer({initialContent}: Props) { const {editor, editorState, editorRef} = useReviewEditor(initialContent); if (!editor || !editorState) { return <LoadingSpinner text="에디터 정보를 불러오고 있어요." />; } return ( <> <Toolbar editor={editor} editorState={editorState} /> <EditorContent spellCheck="false" className="p-5 h-full overflow-auto" ref={editorRef} editor={editor} /> </> ); }

이렇게 에디터 객체를 만드는 컴포넌트에서 툴바와 tiptap의 EditorContent으로 만들어진 에디터 객체를 전달하면 됩니다. 에디터 객체를 만드는 데 생각보다 시간이 오래 걸립니다. 0.5~1초? 잘 보면 useEditor 훅의 반환 값은 null | Editor로 생성되지 않을 경우 null이 반환됩니다.

그래서 위와 같이 editor 객체나 활성 상태 추적을 위한 editorState 객체가 만들어지지 않은 경우 로딩 스피너를 표시하도록 구현했습니다.

image.png

제가 만든 에디터는 위와 같습니다. 예브죠?

작성된 에디터 값 조회하기

에디터를 구현했다면 당연히 작성한 글을 조회할 수 있어야 합니다. 다행히 아주 쉽게 지금까지 작성된 값을 조회할 수 있는데요.

tiptap은 에디터에 작성된 글을 json, html 형식으로 빼낼 수 있습니다.

editor.getHTML(); // html 형식 editor.getJSON(); // json 형식

3버전부터 마크다운으로도 빼낼 수 있는 거 같은데 프로만 지원하는건지 appId, token을 필요로 하더군요. 자세한 내용은 Markdown | Tiptap Conversion 여기를 참조하면 되겠습니다.

getHTML (미리보기 구현하기)

html 형식으로 현재 작성된 게시글을 조회한다면 아래와 같이 값이 반환됩니다.

안녕하세요! html 형식으로 조회해볼게요. "<p style=\"text-align: left\">안녕하세요! html 형식으로 조회해볼게요.</p>"

사용자가 작성한 글이 저장 이후에 어떻게 표시될지 미리보기를 지원해주면 사용자 경험에 아주 좋을 거 같은데요.

변환된 html 값을 이용하면 미리보기를 아주 쉽게 구현할 수 있습니다.

type Props = { content: string; } export default function Viewer({content}: Props) { const clean = DOMPurify.sanitize(content); return ( <section> <div className="prose prose-p:my-3 prose-h1:font-bold lg:prose-lg focus:outline-none" dangerouslySetInnerHTML={{__html: clean}} /> </section> ); }

위 코드에서 사용된 dangerouslySetInnerHTML은 리액트에서 html 문자열을 DOM에 직접 삽입할 때 사용하는 특수한 속성인데요. 기본적으로 리액트는 XSS(Cross-Site Scripting) 공격을 방지하기 위해 html 문자열을 그대로 렌더링하지 않고 이스케이프 처리합니다.

하지만 dangerouslySetInnerHTML를 사용하면 이 제한을 우회해서 문자열 형태의 html을 DOM 요소로 변환해 렌더링할 수 있어요. 누가 에디터에 script 태그를 이용해 악의적인 공격 스크립트를 작성하면 이게 저장되고, 이후에 조회할 때 실행될 수도 있는 거예요.

진짜 이름 그대로 ‘너 이거 위험한 거 알지? 알면서도 감수하고 직접 삽입하겠다는 거지?’ 이정도로 특별한 속성이라고 생각하면 됩니다.

<img src="실패할 무언가" onerror="alert('안녕하세요!!!')">

이미지 태그는 불러오지 못하면 실행되는 onerror 콜백이 존재하는데요. 위와 같이 이미지 실패를 의도적으로 유발하고 악의적인 스크립트를 실행하는 공격도 가능해집니다.

이를 방지하고자 DOMPurify를 이용하게 되는데요. 그냥 쉽게 전달된 html 문자열을 파싱하고 필터링하고 다시 직렬화해서 소독해준다고 생각하면 됩니다. 저도 내부적으로 어떻게 동작하는지는 모르겠지만, 적어도 허용된 태그나 속성, 안전하지 않은 프로토콜에 연관된 모든 것들을 제거해준다는 것은 알고 있습니다.

이렇게 인자로 전달된 content를 한 번 소독하고 dangerouslySetInnerHTML에 전달해주면 됩니다.

preview.gif

그럼 이렇게 멋있는 미리보기를 구현할 수 있습니다. 그런데 미리보기인데 굳이 DOMPurify를 사용할 필요가 있을까요? 라고 생각하실 수 있습니다. 물론 그렇습니다. 어차피 본인이 쓴 글이니 공격당할 일도 없으니까요.

하지만 저는 이 Viewer 컴포넌트를 미리보기와 게시글 조회에 모두 재사용하다보니 어쩔 수 없었습니다.

getJSON

html 형식으로 현재 작성된 게시글을 조회한다면 아래와 같이 값이 반환됩니다.

안녕하세요! 이번엔 JSON 형식으로 조회해볼게요. { "type": "doc", "content": [ { "type": "paragraph", "attrs": { "textAlign": "left" }, "content": [ { "type": "text", "text": "안녕하세요! 이번엔 JSON 형식으로 조회해볼게요." } ] } ] }

백엔드가 달라는 값으로 주자

JSON 형식으로 변환하면 줄바꿈만 생겨도 객체의 depth나 속성이 슬라임처럼 증식하는데요. 저희 프로젝트의 경우 AI가 작성한 글을 요약해주는 기능을 지원합니다.

JSON 형식으로 반환된 글은 AI에게 제공하기에 어려움이 있어 html 형식으로 반환하고 있습니다.

마치며

정말 간단하게? tiptap을 사용해 에디터를 어떻게 구현했는지 작성해봤습니다. 굳이 저장이나 미리보기 훅 같은 코드는 보여드릴 필요가 있을까? 싶어서 그냥 UI 상으로 표시되는 코드만 보여드렸습니다.

사실 이미지 업로드 기능이 생각보다 많이 힘들었는데요. 그 내용을 여기에 모두 담아내기엔 글이 너무 길어질 거 같아서 다음 게시글로 정리해보려고 합니다.

혹시 에디터 코드에 대해 궁금하신 분들이 계시다면 modu-client/src/features/review/editor at main · modu-review/modu-client 이 곳에서 확인하실 수 있습니다!

코드에 대해 궁금한 점이 있으시다면 문의 남겨주세요!

zustand 코드 까보기

zustand 코드 까보기

에디터에 이미지 업로드 구현하기

에디터에 이미지 업로드 구현하기