Claude Code로 리치 텍스트 에디터 구현하기
Claude Code와 Tiptap으로 React 리치 텍스트 에디터, 툴바, JSON/HTML 저장, 보안 처리를 구현합니다.
리치 텍스트 에디터는 단순한 입력 컴포넌트처럼 보이지만, 실제 서비스에서는 저장 형식, 붙여넣기 HTML, XSS, 링크 프로토콜, 이미지 URL, 모바일 선택 영역, 공개 페이지 렌더링까지 모두 설계해야 합니다. contenteditable만 직접 쓰면 빠르게 데모는 만들 수 있지만, 브라우저 차이와 보안 검토를 계속 직접 떠안게 됩니다.
이 글에서는 Claude Code에 막연히 “에디터 만들어줘”라고 요청하지 않습니다. Tiptap을 선택하고, 변경 파일과 보안 조건을 좁힌 뒤, React/TypeScript 컴포넌트로 툴바, JSON/HTML 저장, DOMPurify 기반 sanitization까지 구현합니다. Tiptap 공식 React 설치 문서는 @tiptap/react, @tiptap/pm, @tiptap/starter-kit 설치를 안내합니다. StarterKit은 문단, 제목, bold, italic, list 같은 기본 기능을 빠르게 제공합니다.
왜 Tiptap을 선택하나
Lexical도 좋은 선택입니다. 공식 Lexical 사이트는 가볍고 모듈화된 text editor framework라고 설명하며, list, link, table 같은 기능을 패키지로 추가할 수 있습니다. 다만 첫 CMS 입력 필드에서는 toolbar, 저장 형식, preview, sanitization을 빨리 안정화하는 것이 중요합니다. Tiptap은 JSON과 HTML 출력이 직관적이고, Claude Code에게 요구사항을 전달하기 쉽습니다.
| 기준 | Tiptap | Lexical |
|---|---|---|
| 첫 구현 | StarterKit으로 빠름 | plugin과 command 설계가 더 필요 |
| 저장 | JSON/HTML 흐름이 단순 | EditorState 전략을 정해야 함 |
| 확장 | Extension 중심 | custom node에 강함 |
| Claude Code와의 궁합 | 예제와 공식 문서를 붙이기 쉬움 | 요구사항이 상세할수록 안정적 |
MDN의 contenteditable 문서는 true에서는 붙여넣기 서식이 유지되고 plaintext-only에서는 서식이 제거된다고 설명합니다. 이런 차이를 직접 처리하기보다, 편집 엔진은 Tiptap에 맡기고 애플리케이션은 저장, 검증, 공개 렌더링을 책임지는 편이 안전합니다.
설치와 Claude Code 프롬프트
먼저 의존성을 설치합니다. 협업 편집, 댓글, slash command, mention은 뒤로 미룹니다. 초반 목표는 편집, 저장, 복원, preview, 보안 검토가 한 흐름으로 동작하는 것입니다.
npm install @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image @tiptap/extension-character-count dompurify
Claude Code 공식 문서는 Claude Code가 codebase를 읽고, 파일을 수정하고, 명령을 실행할 수 있다고 설명합니다. 그래서 다음처럼 좁고 검증 가능한 요청을 주는 것이 좋습니다.
Tiptap으로 React/TypeScript 리치 텍스트 에디터를 구현해 주세요.
수정 파일은 src/components/RichTextEditor.tsx 하나로 제한합니다.
bold, italic, H2/H3, bullet list, ordered list, link, image URL을 지원합니다.
저장 payload는 JSON, sanitized HTML, plainText로 구성합니다.
http/https가 아닌 link와 image URL은 거부합니다.
DOMPurify를 사용하고, 마지막에 수동 테스트 체크리스트를 작성해 주세요.
처음 사용하는 프로젝트라면 Claude Code 시작 가이드를 먼저 확인하세요. API 제출과 연결된다면 폼 검증 가이드가 도움이 되고, Markdown 변환이 필요하면 Markdown 처리 가이드와 역할을 나누는 것이 좋습니다.
복사해서 쓰는 React/TypeScript 컴포넌트
아래 코드는 toolbar, URL 검증, character count, sanitized HTML, JSON output, local draft 저장을 포함합니다. Tailwind class를 쓰지만, 스타일만 바꾸면 일반 React 프로젝트에서도 같은 구조로 사용할 수 있습니다.
"use client";
import type { Editor, JSONContent } from "@tiptap/core";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Image from "@tiptap/extension-image";
import CharacterCount from "@tiptap/extension-character-count";
import DOMPurify from "dompurify";
import type { ReactNode } from "react";
import { useState } from "react";
export type SavedEditorContent = {
json: JSONContent;
html: string;
plainText: string;
};
type Props = {
initialContent?: JSONContent | string;
maxCharacters?: number;
onChange?: (content: SavedEditorContent) => void;
};
const allowedTags = ["p", "br", "strong", "em", "s", "h2", "h3", "ul", "ol", "li", "blockquote", "code", "pre", "a", "img"];
const allowedAttrs = ["href", "src", "alt", "title", "target", "rel"];
function normalizeHttpUrl(value: string): string | null {
const trimmed = value.trim();
if (!trimmed) return null;
for (const candidate of [trimmed, `https://${trimmed}`]) {
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") return url.toString();
} catch {
// Try the next candidate.
}
}
return null;
}
export function sanitizeEditorHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
ALLOW_DATA_ATTR: false,
});
}
function buildPayload(editor: Editor): SavedEditorContent {
return {
json: editor.getJSON(),
html: sanitizeEditorHtml(editor.getHTML()),
plainText: editor.getText(),
};
}
export function RichTextEditor({
initialContent = "<p>Start writing...</p>",
maxCharacters = 8000,
onChange,
}: Props) {
const [lastSaved, setLastSaved] = useState<SavedEditorContent | null>(null);
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [2, 3] } }),
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: "noopener noreferrer nofollow", target: "_blank" },
}),
Image.configure({ allowBase64: false }),
CharacterCount.configure({ limit: maxCharacters }),
],
content: initialContent,
immediatelyRender: false,
editorProps: {
attributes: {
class: "min-h-[260px] rounded-b-md border border-t-0 border-slate-300 bg-white p-4 leading-7 outline-none focus:ring-2 focus:ring-sky-500",
"aria-label": "Rich text body",
},
transformPastedHTML(html) {
return sanitizeEditorHtml(html);
},
},
onUpdate({ editor }) {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
},
});
if (!editor) return null;
const characters = editor.storage.characterCount.characters();
const saveDraft = () => {
const payload = buildPayload(editor);
setLastSaved(payload);
onChange?.(payload);
window.localStorage.setItem("article-draft", JSON.stringify(payload));
};
return (
<section className="rounded-md border border-slate-300 bg-slate-50">
<div className="flex flex-wrap gap-1 border-b bg-white p-2" role="toolbar" aria-label="Formatting toolbar">
<ToolButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()}>B</ToolButton>
<ToolButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()}>I</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</ToolButton>
<ToolButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</ToolButton>
<ToolButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()}>List</ToolButton>
<ToolButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()}>1.</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Link URL", "https://");
const href = input ? normalizeHttpUrl(input) : null;
if (!href) return window.alert("Use an http or https URL.");
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
}}>Link</ToolButton>
<ToolButton onClick={() => {
const input = window.prompt("Image URL", "https://");
const src = input ? normalizeHttpUrl(input) : null;
if (!src) return window.alert("Use an http or https image URL.");
editor.chain().focus().setImage({ src, alt: "" }).run();
}}>Image</ToolButton>
</div>
<EditorContent editor={editor} />
<footer className="flex items-center justify-between gap-3 border-t p-3 text-sm">
<span>{characters}/{maxCharacters} characters</span>
<button type="button" onClick={saveDraft} className="rounded bg-slate-900 px-3 py-2 text-white">Save draft</button>
{lastSaved && <span>HTML: {lastSaved.html.length} bytes</span>}
</footer>
</section>
);
}
function ToolButton({ active, onClick, children }: { active?: boolean; onClick: () => void; children: ReactNode }) {
return (
<button type="button" aria-pressed={active} onClick={onClick} className={active ? "rounded border bg-sky-100 px-2 py-1" : "rounded border bg-white px-2 py-1"}>
{children}
</button>
);
}
JSON과 HTML 저장/복원
초기 설계에서는 JSON, sanitized HTML, plain text를 함께 저장하는 것이 실용적입니다. JSON은 재편집용, HTML은 공개 페이지용, plain text는 검색, RSS, meta description, 길이 제한에 씁니다. Tiptap의 JSON/HTML 출력 문서는 변경 이벤트에서 getJSON()을 쓰는 흐름과 static rendering 옵션을 설명합니다.
import { useEffect, useState } from "react";
import { RichTextEditor, type SavedEditorContent } from "./RichTextEditor";
export function ArticleEditorPage() {
const [draft, setDraft] = useState<SavedEditorContent | null>(null);
useEffect(() => {
const raw = window.localStorage.getItem("article-draft");
if (raw) setDraft(JSON.parse(raw) as SavedEditorContent);
}, []);
return (
<main>
<RichTextEditor initialContent={draft?.json ?? "<p>New article</p>"} onChange={setDraft} />
<article dangerouslySetInnerHTML={{ __html: draft?.html ?? "" }} />
</main>
);
}
보안과 구조
DOMPurify 공식 저장소는 DOMPurify를 HTML, MathML, SVG용 XSS sanitizer로 설명합니다. 하지만 sanitizer 하나로 모든 위험이 사라지지는 않습니다. 서버에서도 로그인, 권한, payload 크기, 허용 tag, URL scheme, 이미지 domain을 다시 확인해야 합니다. 특히 script, iframe, inline style, 임의의 data-*는 명확한 제품 요구가 생기기 전까지 허용하지 않는 것이 좋습니다.
flowchart LR
A["Editor UI"] --> B["Tiptap JSON"]
A --> C["Sanitized HTML"]
B --> D["Database draft"]
C --> D
D --> E["Public page"]
E --> F["Search / RSS / CTA"]
세 가지 이상 사용 사례
첫째, 블로그 CMS입니다. JSON으로 재편집하고 HTML로 공개 렌더링하며 plain text로 검색과 SEO 설명을 만듭니다. 둘째, 사내 지식 베이스입니다. 제목, 목록, 코드 블록, 링크만 있어도 대부분의 문서 작성은 충분합니다. 셋째, ecommerce 상품 설명입니다. rich text는 판매 문구를 좋게 만들지만, 상품 페이지는 빠르고 안전해야 하므로 HTML 크기와 이미지 URL을 제한해야 합니다. 넷째, AI 초안 편집 화면입니다. AI가 생성한 HTML도 신뢰하지 말고 같은 sanitization 경로를 지나게 해야 합니다.
흔한 실수
HTML만 저장하면 나중에 목차 생성, Markdown export, 협업 편집, 디자인 시스템 변경이 어려워집니다. 클라이언트 sanitization만 믿는 것도 위험합니다. API는 UI를 거치지 않은 요청을 받을 수 있습니다. 마지막으로 mobile test를 미루면 keyboard, selection, 긴 URL, 이미지 삽입 후 줄바꿈에서 문제가 나옵니다.
수익화 CTA와 테스트 결과
리치 텍스트 에디터는 콘텐츠 수익 경로를 빠르게 개선합니다. SEO 글, 상품 페이지, training material, help center를 빨리 수정할 수 있기 때문입니다. 운영 규칙과 템플릿까지 정리하려면 Claude Code 제품과 템플릿을 보고, 팀 도입이나 실제 구현 상담이 필요하면 Claude Code 교육과 상담을 확인하세요.
이 흐름을 작은 article editor에서 테스트했을 때, toolbar 디자인보다 payload 설계를 먼저 정한 편이 훨씬 안정적이었습니다. Claude Code prompt에 JSON, HTML, plainText, URL validation, manual checklist를 넣으면 리뷰할 diff가 작아지고, 저장 후 복원과 공개 페이지 확인도 쉬워졌습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.