[북잡 프로젝트] 기존 에디터를 Tiptap으로 리팩토링한 이유와 과정

송수빈송수빈
3 min read

이전에 작성한 글에서 분명 Draft.js를 사용한 이유에 대해 작성했으며 겪었던 트러블슈팅 내용까지 적었지만 결국 Tiptap으로 리팩토링을 하게 되었다..😅

그래서 이번 글에서는

  • 변경한 이유

  • 사용 방법

에 대해 적어보려고 한다.

변경한 이유

이전 글에서도 썼지만, Draft.js는 이제 유지보수가 안 되고 있다.
이 부분이 초반에는 문제가 안됐지만, 현재는 이미지 삽입 기능을 구현하는데 어려움이 있구나를 느꼈다.

문서에는 있는 것 같지만 막상 써보면 버그도 많고 커스터마이징도 복잡해서 너무 불편해서,,, ☹️ 결과적으로 tiptap으로 마이그레이션을 결정하게 되었다.


✏️ 사용방법 ( +S3 이미지 업로드)

✨ 흐름

  • 사용자가 이미지를 올리면

  • 클라이언트가 서버에 POST로 파일 정보 보내서 presigned URL과 fileId를 받음

  • 클라이언트가 presigned URL로 직접 S3에 PUT 방식으로 이미지 업로드

  • 서버는 fileId로 DB에 파일 정보를 저장(이미 처리 완료된 상태)

  • 클라이언트는 fileId를 모아두었다가, 글 작성 시 같이 서버에 보내서 어떤 이미지가 글에 포함됐는지 알려줌

  • 에디터에는 S3에 올라간 이미지의 공개 URL을 삽입해서 사용자에게 보여줌

→ presigned URL을 써서 서버 부하 줄이면서 안전하게 S3에 이미지 업로드하는 방법으로 구성 !

🔗 tiptap 대표적인 특징

  • 모듈화된 확장 시스템

    1. 기본적으로 글자 굵게, 기울임, 리스트 같은 기능이 StarterKit이라는 확장으로 들어있고

    2. 필요에 따라 이미지, 테이블, 링크 등 다양한 확장을 추가해서 기능을 커스터마이징 가능

🚀 사용방법

  • 일단 나는 세가지 확장프로그램을 사용했다.

    1. StarterKit

      • 기본적인 텍스트 편집 기능을 제공 (굵게, 기울임, 리스트, 제목 등)
    2. Image

      • 이미지를 에디터에 삽입할 수 있게 해주는 확장
    3. Placeholder

☝🏻 적용해 보았습니다

  • useEditorTiptap 에디터 인스턴스를 만드는 훅
const editor = useEditor({
  extensions: [
    StarterKit,
    Image,
    Placeholder.configure({
      placeholder: '내용을 입력하세요...',
    }),
  ],
  content: initialContent,
  onUpdate: ({ editor }) => {
    onChange?.(editor.getHTML())
  },
  editorProps: {
    attributes: {
      class:
        'tiptap prose prose-lg min-h-[300px] border border-gray-300 rounded-md p-4 focus:outline-none bg-white',
    },
    handlePaste: async (view, event) => { ... }, // 이미지 붙여넣기 처리
  },
})

useEditor를 사용해 Tiptap 에디터 인스턴스를 생성하고 기본적인 텍스트 기능, 이미지 삽입, 플레이스홀더를 확장으로 설정했다. 에디터에서 변경이 발생하면 onUpdate를 통해 HTML 형태로 부모 컴포넌트에 전달한다.

  • MenuBar에서 에디터 조작(툴바)
    <div className='mb-2 flex gap-2'>
      <button
        className='px-2 py-1 border rounded hover:bg-gray-100 font-bold'
        onClick={() => editor.chain().focus().toggleBold().run()}
        style={{ fontWeight: editor.isActive('bold') ? 'bold' : 'normal' }}
      >
        Bold
      </button>
      <button
        className='px-2 py-1 border rounded hover:bg-gray-100 italic'
        onClick={() => editor.chain().focus().toggleItalic().run()}
        style={{ fontStyle: editor.isActive('italic') ? 'italic' : 'normal' }}
      >
        Italic
      </button>
      <button
        className='px-2 py-1 border rounded hover:bg-gray-100'
        onClick={() => document.querySelector('#file-input').click()}
      >
        Image
      </button>
    </div>
  • Bold: 텍스트 굵게 (toggleBold)

  • Italic: 텍스트 기울임 (toggleItalic)

  • Image: 이미지 업로드를 위한 파일 선택창 열기

버튼의 스타일은 editor.isActive()를 활용하여 현재 적용 상태에 따라 동적으로 바뀐다.

  • BubbleMenu : 컨텍스트 기반 툴바

사용자가 이미지를 선택했을 때 선택한 이미지 위에 cancel(X) 버튼이 포함된 버블 메뉴가 나타나도록 해서 이미지를 직접 삭제할 수 있게 구현했다.

import { BubbleMenu } from '@tiptap/react'
import { FaTimes } from 'react-icons/fa'

const ImageBubbleMenu = ({ editor }) => {
  if (!editor) return null

  return (
    <BubbleMenu
      editor={editor}
      shouldShow={({ editor }) => editor.isActive('image')}
      tippyOptions={{
        duration: 100,
        placement: 'top',
        offset: [0, -10],
      }}
    >
      <button
        type='button'
        onClick={(e) => {
          e.preventDefault()
          editor.chain().focus().deleteSelection().run()
        }}
        className='bg-white text-red-500 hover:text-red-700 border border-gray-300 rounded-full w-6 h-6 flex items-center justify-center shadow-md'
        title='이미지 삭제'
      >
        <FaTimes size={12} />
      </button>
    </BubbleMenu>
  )
}

export default ImageBubbleMenu

완료 👍🏻 ( TMI: 요즘 오죠갱 너무 좋다..)

🐥 배운 점 & 느낀 점

이전 프로젝트에서는 백엔드가 PUT/POST를 직접 관리했는데 지금은 백엔드가 presigned URL만 생성해주고 클라이언트가 직접 S3에 PUT 요청하는 방식으로 바껴서 .. 신기했다 ✍🏻

백엔드 분이 이 방식이 더 많이 쓰인다고 피드백을 해줬는데.. 그 이유에 대해서 생각해봤다.. 😎

  • 백엔드 서버 부담↓ (파일 전송/처리 안 해도 됨)

  • 네트워크 비용↓ (백엔드→S3 전송 불필요)

  • 보안↑ (백엔드 서버가 직접 파일 취급 안 함)

  • 클라이언트가 직접 S3에 업로드하니 속도도 더 빠름

덕분에 클라이언트와 서버 구조, 비용과 보안 측면에서 효율적인 방식을 배울 수 있었다.

0
Subscribe to my newsletter

Read articles from 송수빈 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

송수빈
송수빈