이번 글에서는 Rich Text Editor(RTE 또는 WYSIWYG 에디터)에 대해서 알아보도록 하겠습니다. RTE는 사용자가 텍스트를 서식 있게 편집할 수 있도록 도와주는 도구입니다. 일반적인 textarea와 달리 bold, italic, underline, 폰트 사이즈, 글 정렬, 리스트, 이미지 삽입, 링크 등 스타일을 실시간으로 적용할 수 있습니다. Google Docs, Notion, MS Word에서 글을 작성할 때 경험하는 대부분의 기능은 모두 RTE를 통해 제공됩니다.
RTE는 보통 WYSIWYG(What You See Is What You Get) 방식으로 동작합니다. 사용자가 화면에서 보고 있는 결과가 곧 최종 결과물이라는 개념으로, 텍스트를 굵게 표시하면 화면에서도 즉시 굵게 보이고, 저장된 결과 역시 동일한 형태로 유지됩니다. 이 덕분에 사용자는 HTML이나 마크업 문법을 직접 다루지 않아도 직관적으로 문서를 작성할 수 있습니다.
이러한 특성 때문에 RTE는 블로그, 게시판, 문서 편집기, 협업 툴 등 콘텐츠 작성이 중요한 서비스에서 필수적인 요소로 사용됩니다. 단순한 텍스트 입력을 넘어서, 문단 구조와 스타일을 함께 관리할 수 있어 보다 풍부한 표현이 가능해집니다. 다양한 RTE 중에서도 Tiptap 에디터를 기준으로, RTE가 어떤 구조로 동작하는지와 실제로 어떻게 구현하는지에 대해 정리해보겠습니다.
Tiptap
Tiptap은 ProseMirror 기반 Headless Rich Text Editor 라이브러리입니다. UI를 직접 제공하지 않고, 에디터의 상태와 동작만을 관리하기 때문에 개발자가 원하는 형태로 자유롭게 커스터마이징 할 수 있다는 장점이 있죠. 즉 Tiptap은 에디터 UI 컴포넌트보다는 에디터를 만들기 위한 엔진에 가깝습니다. 툴바, 버튼, 단축키, 스타일 등 모두 직접 구현해야 하지만 그만큼 서비스 요구사항에 맞는 세밀한 제어가 가능합니다.
Tiptap은 하나의 라이브러리로 모든 기능을 제공한다기 보다는, 역할에 따라 여러 패키지로 나뉘어 있습니다. 필요한 기능만 선택해서 사용하시면 됩니다. 그 중에서 필수로 설치해야 하거나, 설치하면 좋은 라이브러리를 먼저 설치해줍시다.
Get started | Tiptap Editor Docs
Build a custom rich text editor with Tiptap, a customizable and headless editor framework. Learn more about Tiptap in the docs.
tiptap.dev
npm install @tiptap/react @tiptap/core @tiptap/starter-kit
@tiptap/react 는 React 환경에서 Tiptap 에디터를 사용하기 위한 패키지입니다. 에디터 인스턴스를 생성하기 위한 useEditor 훅과 실제 편집 영역을 렌더링하는 EditorContent 컴포넌트를 제공합니다. React 프로젝트에서 Tiptap 사용 시, 가장 기본이 되는 패키지입니다.
import { useEditor, EditorContent } from "@tiptap/react";
@tiptap/core 는 Tiptap의 핵심 로직이 담긴 패키지입니다. Extension, Command, Node, Mark 등의 기반이 되는 모듈들이 포함되어 있으며, 커스텀 extension을 만들 때 필수적으로 사용됩니다. 일반적인 에디터 구성에서는 직접 사용할 일이 많지는 않지만, 폰트 사이즈나 커스텀 스타일처럼 에디터 동작을 확장해야 하는 경우 반드시 필요합니다.
@tiptap/starter-kit 은 문서 편집에 자주 사용되는 기본 extension들을 한 번에 제공하는 패키지입니다. paragraph, heading, bold, italic, strike, list 등의 기능이 기본으로 포함되어 있습니다. 대부분의 경우 StarterKit 하나만으로도 기본적인 RTE를 구성할 수 있으며, 필요에 따라 특정 기능을 비활성화하거나 설정을 커스터마이징 할 수 있습니다.
추가로, 밑줄, 정렬, 텍스트 스타일을 위해서 아래의 3가지 라이브러리를 추가로 설치했습니다.
npm install @tiptap/extension-underline
npm install @tiptap/extension-text-align
npm install @tiptap/extension-text-style
Tiptap 에디터 기본 흐름
설치된 Tiptap 에디터는 useEditor 훅을 통해 생성됩니다. 어떤 기능을 사용할지 extensions 배열로 정의하고, 초기 컨텐츠와 에디터 옵션을 함께 설정해줍시다.
const editor = useEditor({
extensions: [
StarterKit.configure({
paragraph: {
HTMLAttributes: {
class: "leading-relaxed",
},
},
}),
Underline,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
TextStyle,
],
content: htmlContent,
editorProps: {
attributes: {
class: "w-full h-full focus:outline-none prose max-w-none min-h-full",
style: "font-size: 20px; line-height: 1.8;",
},
},
});
위와 같이 설정하면 Tiptap의 가장 기본적인 RTE 형태를 구성할 수 있습니다. StarterKit을 통해 문단, 제목, 굵기 등의 기본 편집 기능을 사용할 수 있고, 여기에 Underline과 TextAlign, TextStyle extension을 추가해 밑룾ㅇ과 정렬, 텍스트 스타일을 처리했습니다. content를 통해 초기 문서를 로드하고, editorProps를 통해 에디터 영역의 기본 스타일과 레이아웃을 설정했습니다. 이제 생성된 editor 인스턴스를 EditorContent 프롬프트에 전달하면 실제 편집 가능한 영역이 화면에 렌더링됩니다.
<EditorContent editor={editor} />
Tiptap은 기본적으로 폰트 사이즈 변경 기능을 제공하지 않기 때문에, 글자 크기를 제어하기 위해 직접 커스텀 extension을 만들어줍시다. 아래 FontSize는 textStyle mark를 기반으로 font-size 값을 관리합니다. 이제 생성한 FontSize도 위 extension 배열에 추가해주면 됩니다.
const FontSize = Extension.create({
name: "fontSize",
addOptions() {
return {
types: ["textStyle"],
};
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
fontSize: {
default: null,
parseHTML: (element) => {
const size = element.style.fontSize;
return size ? size.replace("px", "") : null;
},
renderHTML: (attributes) => {
if (!attributes.fontSize) {
return {};
}
return {
style: `font-size: ${attributes.fontSize}px`,
};
},
},
},
},
];
},
addCommands() {
return {
setFontSize:
(fontSize: string) =>
({ commands }) => {
return commands.setMark("textStyle", { fontSize });
},
unsetFontSize:
() =>
({ chain }) => {
return chain()
.setMark("textStyle", { fontSize: null })
.removeEmptyTextStyle()
.run();
},
};
},
});
const setFontSize = (px: string) => {
if (!editor) return;
editor.chain().focus().setFontSize(px).run();
};
const getCurrentFontSize = (): string => {
if (!editor) return "20";
const { fontSize } = editor.getAttributes("textStyle");
return fontSize || "20";
};
뷰어 모드와 에디터 모드
읽기 전용 뷰어 모드와 수정 가능한 에디터 모드를 isUpdate 상태값으로 분리해줬습니다. 문서 편집이 항상 활성화 된 상태가 아니라, 필요할 때만 에디터와 툴바를 노출하는 방식입니다.
HTML 뷰어 모드
수정 중이 아닐 때는 Tiptap을 렌더링하지 않고, 저장된 HTML 문자열을 그대로 출력하기 위해 dangerouslySetInnerHTML을 사용했습니다.
<div
className="prose max-w-none leading-relaxed"
style={{ fontSize: "20px", lineHeight: "1.6" }}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
뷰어 에디터 전환
뷰어에서 에디터로 전환 시, 기존 문서 내용을 유지하고 초기 포커스 위치를 제어해줍시다.
useEffect(() => {
if (editor && isUpdate) {
editor.commands.setContent(htmlContent, { emitUpdate: false });
editor.commands.focus("start");
updateToolbarState();
}
}, [isUpdate, editor]);
에디터 뷰어 전환
에디터에서 뷰어로 전환 시(수정 완료 시) 에디터 내용을 HTML로 변환하여 저장해줍니다.
const saveEdit = () => {
if (editor) {
setHtmlContent(editor.getHTML());
}
setIsUpdate(false);
};
동기화
툴바와 에디터 동기화를 시켜줍시다. toolbarKey로 상태를 관리하며, 변경될 때마다 리렌더링을 시켜줍니다.
const [toolbarKey, setToolbarKey] = useState(0);
const updateToolbarState = () => {
if (!editor) return;
setCurrentFontSize(getCurrentFontSize());
setToolbarKey((prev) => prev + 1);
};
마지막으로 설정한 에디터에 키보드 입력 시(ctrl + B = 볼드 처리) 이벤트를 달아줍니다. 아래는 전체 에디터 코드입니다.
const editor = useEditor({
extensions: [
StarterKit.configure({
paragraph: {
HTMLAttributes: {
class: "leading-relaxed",
},
},
}),
Underline,
TextAlign.configure({
types: ["heading", "paragraph"],
}),
FontSize,
TextStyle,
],
content: htmlContent,
editorProps: {
attributes: {
class: "w-full h-full focus:outline-none prose max-w-none min-h-full",
style: "font-size: 20px; line-height: 1.8;",
},
handleKeyDown: (view, event) => {
if (event.ctrlKey || event.metaKey) {
if (event.key === "b" || event.key === "B") {
editor.chain().focus().toggleBold().run();
updateToolbarState();
event.preventDefault();
return true;
}
if (event.key === "i" || event.key === "I") {
editor.chain().focus().toggleItalic().run();
updateToolbarState();
event.preventDefault();
return true;
}
if (event.key === "u" || event.key === "U") {
editor.chain().focus().toggleUnderline().run();
updateToolbarState();
event.preventDefault();
return true;
}
}
return false;
},
},
onUpdate: () => {
updateToolbarState();
},
onSelectionUpdate: () => {
updateToolbarState();
},
onFocus: () => {
updateToolbarState();
},
});
이제 에디터 영역이 끝나고, 툴바에 함수를 달아주면 끝입니다. 볼드처리, 이탤릭, 밑줄, 폰트 사이즈 변경, 그리고 정렬은 아래와 같습니다.
editor.chain().focus().toggleBold().run();
editor.chain().focus().toggleItalic().run();
editor.chain().focus().toggleUnderline().run();
editor.chain().focus().setFontSize(px).run();
editor.chain().focus().setTextAlign("left").run()
editor.chain().focus().setTextAlign("center").run()
editor.chain().focus().setTextAlign("right").run()
위 코드들은 Tiptap에서 제공하는 Command API를 사용해 에디터 상태를 제어하는 예시입니다. 툴바 버튼 클릭이나 단축키 입력 시, 내부적으로는 이러한 command들이 실행되어 텍스트 서식이 변경됩니다. chain()을 사용하면 여러 명령을 하나의 흐름으로 연결할 수 있고, focus()를 통해 에디터에 포커스를 준 뒤 원하는 서식을 적용할 수 있습니다. 마지막으로 run()을 호출해야 실제로 명령이 실행됩니다.
지금까지 Rich Text Editor의 개념부터, Tiptap 에디터의 구조와 기본 설정, 그리고 커스텀 extension과 command를 활용한 서식 제어까지 살펴보았습니다. Tiptap은 기본 기능만 사용해도 충분히 강력하지만, extension과 command를 조합하면 서비스 요구사항에 맞는 에디터를 유연하게 구현할 수 있다는 점이 가장 큰 장점입니다. 이 글에서 소개한 구조를 기반으로 툴바 UI, 단축키, 커스텀 스타일 등을 하나씩 추가해 나간다면, 보다 완성도 높은 Rich Text Editor를 구현할 수 있을 것입니다.
언제나처럼 ㅡ 시작은 삽질이지만, 끝은 지식입니다.
'기능 > 기타' 카테고리의 다른 글
| [RTE] React Tiptap 에디터 적용하기 2 (7) | 2026.01.18 |
|---|---|
| [SMTP] 구글 이메일 발송 (3) | 2025.08.08 |
| [기타] 엑셀 다운로드 (3) | 2025.08.08 |
