Tiptap 텍스트 에디터 개발 코드와 배포 링크는 아래에서 확인할 수 있습니다.
- 배포 링크: https://text-editor.bio/
- Github 코드: https://github.com/jaqwe2301/text-editor
이전 포스팅에 이어서 아래 세 가지에 관하여 이야기해보고자 한다.
- 라이브러리 없이 텍스트 에디터를 구현하는 것을 포기한 이유
- Tiptap 라이브러리 간단 소개
- 이전에 만든 로직을 어떻게 Tiptap과 결합하였을까
라이브러리 없이 텍스트 에디터를 구현하는 것을 포기한 이유
텍스트 에디터 구현하면서 어려움도 있었지만, 완성까지는 어떻게든 갈 수 있겠다는 생각도 들었다. 하지만, 구현 범위와 리스크를 생각했을 때 ‘라이브러리 없이 끝까지 가는 건 무리’라는 결론에 도달했다.
1. 끝 없이 필요한 로직과 예외처리
텍스트 에디터를 구현한다는 것은 단순히 UI를 그리고 API를 사용하는 것과 차원이 달랐다.
글을 작성하다가 Enter를 누르면 당연히 줄이 바뀐다. 그런데 이 당연한 동작도 에디터를 직접 만들기 시작하면 전부 코드로 구현해야 한다. 이러한 과정에서 필요한 예외처리가 상당한데, 뿐만 아니라 ctrl + V(붙여넣기), 문장의 첫 부분에서 backspace를 눌렀을 때의 처리 등 글을 작성하는 과정에서 일어나는 거의 모든 동작을 직접 코드로 처리해야 해서, 구현 부담이 매우 컸다.
2. DOM 제어에 대한 위험성
React에서는 직접 DOM을 제어하는 것을 엄격히 위험하다고 이야기한다.
React에서 UI는 state와 props를 토대로 DOM을 업데이트 한다. 하지만, 에디터를 직접 만들려면 직접 DOM을 변경해야 하고,
React가 기억하는 화면(가상 DOM)과 브라우저의 실제 DOM이 서로 다른 상태가 된다.
결국, 상태 관리는 React가, 화면 변경은 직접 DOM 조작으로 나눠서 처리하기 시작하면, 나중에 어디에서 버그가 터지는지 추적하기가 굉장히 어려워진다.
위와 같은 두 가지 이유를 가지고 다시 라이브러리를 찾게 되었다.
처음에는 러닝커브가 부담스러워 Tiptap 도입을 망설였다.
하지만 GPT 도움을 받아 구조를 빠르게 파악했고, 기존에 짜 둔 로직도 extension으로 옮겨올 수 있다는 걸 확인하면서 Tiptap을 도입했다.
Tiptap 라이브러리

Tiptap은 *ProseMirror 기반의 *Headless 텍스트 에디터 라이브러리이다.
- *ProseMirror: 브라우저 화면에 보이는 글·이미지·문단을 트리 구조(문서 모델)로 관리하는 라이브러리다. 커서·선택 영역·되돌리기/다시하기·bold/italic 같은 편집 명령도 안전하게 처리해 준다.
- *Headless: 버튼·툴바 같은 UI를 강제하지 않고, 기능만 제공하는 방식을 의미한다. 보통 에디터 라이브러리는 툴바, 버튼까지 함께 제공해서 화면 구조도 어느 정도 정해져 있다. 이에 반에 Tiptap 같은 Headless 에디터는 기본적인 편집 기능만 제공하고, 개발자가 UI를 직접 정의할 수 있다.
Tiptap에서 대표적인 특징 중 하나는 extension 구조다. Tiptap에서는 밑줄, 정렬, 폰트 크기 같은 스타일 기능이 기본으로 강제되지 않고, 개발자가 필요한 기능만 extension으로 선택해서 붙이는 방식을 사용한다. 덕분에 꼭 필요한 기능만 구성했을 때, 대략 60kB 안팎(gzip 기준)의 비교적 작은 번들 크기로 에디터를 운용할 수 있다.
또한 extension은 기본적으로 제공하는 기능을 선택하는 것에 그치지 않고, 직접 설계한 기능을 에디터에 통합할 수 있는 커스텀 확장 역할을 한다. 이번 프로젝트에서는 네이버 블로그 구조를 참고하면서 직접 구현해 둔 스타일/이미지/링크 처리 로직들이 있었는데, 이 로직들을 전부 갈아엎기보다는 Tiptap extension으로 감싸서 다시 조립하는 방식을 선택했다.
이전에 만든 로직을 어떻게 Tiptap과 결합하였을까
Tiptap은 내부적으로 문서를 세 가지 레벨로 나눠서 다룬다.
- Node: 문단, 이미지, 링크 카드 같은 블록/구조
- Mark: 볼드, 이탤릭, 폰트, 폰트 크기 같은 문자 단위 스타일
- Extension: 붙여넣기, 드롭, 글자 수 제한처럼 에디터 전체의 동작과 정책
이전에 직접 구현했을 때는
DOM에서 <span>을 잘라내고 className / style을 직접 수정하면서 bold, font-size, font-family 같은 스타일을 브라우저 DOM 레벨에서 다루고 있었다.
Tiptap으로 옮기면서는 이 로직들을 Node / Mark / Extension 단위로 나눠, 브라우저 DOM이 아니라 문서 모델 레이어에서 관리하도록 재구성할 수 있었다.
- 이미지 리사이즈 + 대표 이미지 선택 → Node + NodeView로 분리
- 폰트 패밀리, 폰트 크기 → Mark로 분리
- URL 붙여넣기, 블록 드롭 가드, 글자 수 제한 → Extension으로 분리
아래에서는 각각을 Node / Mark / Extension 관점에서 어떻게 나누고 해결하였는지를 정리해 보겠다.
1. [ Node / NodeView ]
이전 구현에서는 이미지마다 직접 DOM에 핸들을 붙이고 이벤트를 관리하다 보니, 리사이즈 후 상태를 어디에 저장할지 계속 고민하게 되는 문제가 있었다.
Tiptap에서는 width/height 같은 값은 Node attribute로 올리고, 실제 리사이즈 UI와 마우스 이벤트만 NodeView 쪽에 몰아주면서 이 경계가 훨씬 명확해졌다.
Node는 문서 안에 이미지, 문단, 코드블록 같은 블록 요소가 어떤 속성을 가지는지를 정의하는 쪽이고, NodeView는 그 노드를 실제 DOM으로 어떻게 보여줄지, 어떤 인터랙션을 붙일지를 담당한다. 코드상에서는 대략 아래처럼 사용하면 된다.
import { Node, mergeAttributes } from "@tiptap/core";
export const MyImage = Node.create({
name: "image",
group: "block",
addAttributes() {
return {
src: { default: null },
};
},
renderHTML({ HTMLAttributes }) {
return ["img", mergeAttributes(HTMLAttributes)];
},
addNodeView() {
return () => {
const img = document.createElement("img");
img.style.maxWidth = "100%";
return { dom: img };
};
},
});
여기서 Node.create 안의 addAttributes / renderHTML까지는 문서 모델 정의이고, addNodeView 안이 실제 화면에서 어떻게 보이고 어떻게 동작할지를 커스텀하는 부분이다.
이러한 패턴으로 CustomImage 라는 이름의 이미지 핸들러를 만들었다. Node 쪽에는 src, width, height, id와 같이 이미지가 가져야 할 상태만 올려두고, NodeView 안에서는 그 상태를 기반으로 실제 <img> DOM, 네 모서리에 붙는 리사이즈 핸들이 보이도록 만들었다.
요약하면, 이미지라는 블록 자체는 Node로, 그 위에 올라가는 UI/동작은 NodeView 쪽으로 몰아서 관리하는 구조로 정리한 셈이다.
2. [ Mark ]
이전에는 <span>을 직접 쪼개다 보니 bold + font-size 같이 겹치는 스타일에서 일어나는 버그나 예외처리가 많았는데, Mark로 바꾸면서 발생하던 문제들이 어느 정도 정리되었다.
Mark는 Node처럼 덩어리 단위가 아니라, 텍스트 범위에 얹히는 스타일을 표현할 때 쓴다. 굵게/기울임/밑줄 같은 것도 전부 Mark이고, 커스텀으로 폰트 패밀리나 폰트 크기도 Mark로 만드는 게 자연스럽다.
const Highlight = Mark.create({
name: "highlight",
addAttributes() {
return {
color: {
default: "yellow",
renderHTML: attrs => ({ style: `background:${attrs.color}` }),
},
};
},
renderHTML({ HTMLAttributes }) {
return ["span", mergeAttributes(HTMLAttributes), 0];
},
addCommands() {
return {
setHighlight:
color =>
({ chain }) =>
chain().setMark(this.name, { color }).run(),
};
},
});
이렇게 Mark를 정의해 두면, 실제로는 editor.commands.setHighlight("pink") 같은 식으로 호출해서 현재 선택 텍스트에 스타일을 입힐 수 있다.
개발한 에디터에서는 이 패턴을 그대로 가져와서 FontFamilyClass와 FontSize를 구현했다. FontFamilyClass는 class="font-pretendard"처럼 폰트 패밀리 클래스를 Mark attribute로 들고 있다가 DOM으로 나갈 때 class에 얹어 주는 식이고, FontSize는 fontSize: "18px" 같은 값을 받아서 style="font-size:18px"로 렌더링 한다.
editor.commands.setFontFamilyClass("font-pretendard");
editor.commands.setFontSize("18px");
툴바 쪽에서는 위 코드처럼만 호출해 주면 된다.
그 이후에
- 어느 범위까지 같은 스타일로 묶을지(예: 선택을 늘리거나 줄였을 때),
- 겹치는 스타일을 어떻게 합칠지 / 나눌지
- undo / redo를 눌렀을 때 스타일 변경을 어떻게 되돌릴지
같은 복잡한 부분은 ProseMirror가 내부에서 알아서 처리해 준다.
예전처럼 직접 <span>을 잘게 쪼개고 style.fontSize를 조정하던 때와 비교하면, 텍스트 스타일은 Mark로 표현하고, 툴바에서는 editor.commands.XXX() 한 줄로 구현할 수 있어서 전체 코드가 훨씬 단순해졌다.
3. [ Extension ]
마지막으로 Extension은 Node/Mark처럼 “무언가를 그리는 것” 보다는, 에디터 전체의 행동·규칙을 주입하는 훅에 가깝다. 코드에서는 Extension.create 안에서 ProseMirror Plugin을 추가하고, 그 안의 filterTransaction, handlePaste, handleDrop 같은 훅을 이용해 입력 흐름을 가로채면 된다. 예를 들어 아주 단순한 글자 수 제한은 이렇게 적을 수 있다.
const MaxLength = Extension.create({
addProseMirrorPlugins() {
return [
new Plugin({
filterTransaction(tr, state) {
if (!tr.docChanged) return true;
const text = tr.doc.textContent || "";
return text.length <= 3000;
},
}),
];
},
});
개발한 에디터에서는 이 Extension을 조금 더 다양한 목적에 썼다.
MaxLength에서는 위처럼 filterTransaction으로 모든 입력/붙여넣기 트랜잭션을 검사해서 3,000자를 넘으면 아예 트랜잭션을 막아 버렸다.
또 https로 시작하는 URL을 붙여넣으면, 글자 그대로 들어가게 두지 않고 붙여넣기 이벤트를 가로챈 뒤, 썸네일·제목·주소가 함께 보이는 링크 미리보기 박스로 바꿔 넣도록 만들었다. (Tiptap 관점에서는 이 박스를 하나의 Node, 즉 블록 요소로 본다.)
const editor = useEditor({
extensions: [
StarterKit,
CustomImage, // 이전 포스트에서 다뤘던 이미지 NodeView
FontFamilyClass, // 폰트 패밀리 마크
FontSize, // 폰트 크기 마크
LinkPreview, // 링크 카드 블록 노드
LinkPastePreview, // URL 붙여넣기 → 링크 카드
MaxLength, // 글자 수 제한
BlockDropGuard, // 이미지/링크 프리뷰 드롭 제어
],
});
최종적으로는 Tiptap의 useEditor에 우리가 만든 확장들을 그대로 넘겨주면 된다.
에디터 입장에서는 여러 확장이 붙어 있는 하나의 텍스트 에디터일 뿐이고, 각 기능은 extensions 안에서 독립적으로 동작한다.
직접 에디터를 구현할 때는 코드가 중구난방이라 구조를 잡기가 쉽지 않았는데, Tiptap의 extension 단위로 나누고 나니 기능별로 파일을 분리하고 관리하기가 훨씬 수월해졌다.
텍스트 에디터를 만들고 나니 뿌듯함과 아쉬움이 동시에 남았다.
언젠가는 한 번쯤 만들어 보고 싶었던 기능을 외주 프로젝트로 완성도 있게 구현했다는 점은 기쁘지만, 단발성으로 끝난 작업이라 Tiptap을 더 깊게 파고들지 못했다는 아쉬움도 있다.
그래도 여러 로직을 직접 만들고 부딪혀 보면서 확실히 성장했다는 느낌을 받았다. 다음에는 이렇게 시행착오를 줄일 수 있도록, 초기 설계 단계에서 라이브러리 선택과 사전 조사를 더 탄탄히 가져가 보고 싶다.
참고 자료:
'문제 해결 (개발)' 카테고리의 다른 글
| [React] 텍스트 에디터 제작기 1 (DOM을 이용한 에디터 직접 제작) (0) | 2025.12.04 |
|---|---|
| Next.js 에서 NICE 본인인증 구현하기2 (Route Handlers) (0) | 2025.11.18 |
| Next.js 에서 NICE 본인인증 구현하기 (iOS, 카카오톡 인앱 브라우저 환경에서는 왜 안될까) (0) | 2025.11.18 |
| AWS 서버 비용 줄여 보자! (EC2) (1) | 2024.04.25 |