개발일지 2026-06-11
게시물 리치텍스트 에디터 도입 개발일지 (2026-06-11)
개요
피아노 학원 플랫폼의 공지/FAQ/게시판 "내용"은 그동안 전부 plain text였다.
admin에서는 <TextField multiline>으로 입력하고, 표시측(brand/student)은
<Typography>{content}</Typography>로 그냥 텍스트로 렌더했다. 줄바꿈만 겨우 보이고
굵게·목록·링크 같은 서식은 전혀 안 됐다.
이번 작업은 게시물 내용 입력에 Tiptap 리치텍스트 에디터를 도입하고, 저장 포맷을 HTML 문자열로 통일한 뒤, 표시측은 DOMPurify로 sanitize해서 안전하게 렌더하도록 바꾼 것이다. 적용 범위는 공지 + FAQ + 게시판 전부(admin 입력 + brand/student 표시).
핵심 결정:
- 에디터 = Tiptap v3 (커스터마이징 자유도 중시). CRA+TS4.9 환경 빌드가 깨지면 react-quill-new로 전환할 계획이었으나, 빌드 게이트를 통과해 Tiptap 그대로 채택.
- 저장 포맷 = HTML 문자열 → API 스키마(
content/answer: string)와 백엔드 변경 0. - XSS 방어는 표시 시점에 DOMPurify로 수행(저장값을 믿지 않는다).
- 게시판(Boards)은 더미데이터라 저장 API가 없어 에디터만 교체, 저장은 미연결 유지.
수정/생성 파일 목록
admin (입력측 + 공통 컴포넌트)
admin/src/components/common/RichTextEditor.tsx(신규) — Tiptap v3 + MUI 툴바 에디터admin/src/components/common/HtmlContent.tsx(신규) — admin 표시/미리보기용 sanitize 렌더admin/src/components/common/index.ts(수정) — 두 컴포넌트 export 추가admin/src/pages/Notices.tsx(수정) — 공지 내용TextField→RichTextEditoradmin/src/pages/Faqs.tsx(수정) — FAQ 답변TextField→RichTextEditor(질문은 단문이라 유지)admin/src/pages/Boards.tsx(수정) — 게시판 내용TextField→RichTextEditor(저장 미연결 주석)admin/package.json(수정) — Tiptap 3종 + extension-link + dompurify
brand (표시측)
brand/src/components/HtmlContent.tsx(신규) — DOMPurify sanitize 렌더brand/src/pages/Notice.tsx(수정) — 카드 미리보기/상세 다이얼로그를HtmlContent로 교체brand/package.json(수정) — dompurify
student (표시측)
student/src/components/HtmlContent.tsx(신규) — brand와 동일 로직student/src/pages/MorePage.tsx(수정) — 공지·FAQ 표시를HtmlContent로 교체student/package.json(수정) — dompurify
academy 앱은 공지/FAQ 표시를 사용하지 않아 이번 범위에서 제외했다.
파일별 상세
1. RichTextEditor (admin 공통)
Tiptap v3의 useEditor로 에디터 인스턴스를 만들고, MUI 아이콘 버튼으로 툴바를 구성했다.
StarterKit(굵게/기울임/취소선/제목/목록/인용 등)에 Link 익스텐션을 더했다.
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}),
],
content: value || '',
editable: !disabled,
onUpdate: ({ editor }) => {
const html = editor.getHTML();
// 빈 에디터는 <p></p> 를 반환하므로 빈 문자열로 정규화
onChange(html === '<p></p>' ? '' : html);
},
});
가장 신경 쓴 부분은 controlled 동기화 가드다. 수정 다이얼로그를 재오픈하면 외부
value가 바뀌므로 에디터 내용도 갱신해야 한다. 하지만 사용자 타이핑(onUpdate→onChange→value)
으로 value가 같아진 경우까지 setContent를 호출하면 커서가 튀고 루프가 생긴다. 그래서
현재 에디터 HTML과 외부 value가 다를 때만 setContent를 호출한다.
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
const next = value || '';
const normalizedCurrent = current === '<p></p>' ? '' : current;
if (next !== normalizedCurrent) {
editor.commands.setContent(next, { emitUpdate: false });
}
}, [value, editor]);
외관은 MUI Box로 테두리(1px solid divider)를 두르고 :focus-within에서 primary.main
색으로 바꿔 기존 TextField와 통일했다.
2. HtmlContent (표시 공통 — admin/brand/student 각각 배치)
앱 간 공유 빌드가 없어 동일 로직을 admin/brand/student에 각각 뒀다. 핵심은 두 가지:
(a) 하위호환 — 기존 plain text 데이터를 마이그레이션 없이 호환. HTML 태그가 없으면
white-space: pre-wrap으로 plain 렌더해서 줄바꿈을 보존하고, 태그가 있으면 sanitize 후
innerHTML.
const looksLikeHtml = /<[a-z][\s\S]*>/i.test(html || '');
(b) XSS 방어 — DOMPurify 화이트리스트로 허용 태그/속성만 남기고, 모든 <a>에
target=_blank + rel=noopener noreferrer를 강제한다.
const ALLOWED_TAGS = ['p','br','strong','em','u','s','h2','h3','ul','ol','li','a','blockquote','code','span'];
const ALLOWED_ATTR = ['href','target','rel'];
const clean = DOMPurify.sanitize(html || '', { ALLOWED_TAGS, ALLOWED_ATTR });
const tmp = document.createElement('div');
tmp.innerHTML = clean;
tmp.querySelectorAll('a').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
3. 입력측 교체 (Notices / Faqs / Boards)
세 페이지 모두 TextField multiline 한 줄을 RichTextEditor로 바꾸고, onChange가
이벤트 대신 HTML 문자열을 직접 넘기도록 시그니처만 맞췄다.
// 예: Notices.tsx
<RichTextEditor
label="내용"
value={formData.content || ''}
onChange={(html) => setFormData({ ...formData, content: html })}
placeholder="공지 내용을 입력하세요"
/>
Boards는 더미데이터(boardPosts)라 저장 버튼이 setFormOpen(false)만 하고 영속화되지
않는다. 혼동을 막으려 코드에 명시 주석을 달았다.
{/* TODO: boards 저장 API 미연결 (더미). 에디터만 적용 — 새로고침 시 입력 사라짐. */}
4. 표시측 교체 (brand Notice / student MorePage)
brand Notice.tsx는 카드 미리보기(3줄 클램프)와 상세 다이얼로그 두 군데를 교체했다.
// 카드
<HtmlContent html={notice.content} clamp={3} variant="body2"
sx={{ color: 'text.secondary', lineHeight: 1.7 }} />
// 상세
<HtmlContent html={selectedNotice.content} variant="body1" sx={{ lineHeight: 2, py: 2 }} />
student MorePage.tsx는 공지 본문과 FAQ 답변을 교체했다. FAQ는 "A." 접두를 별도 Box로
빼고 본문만 HtmlContent로 렌더해서 레이아웃을 유지했다.
검증 & 트러블슈팅
빌드 게이트 (Phase 0)
이번 작업의 가장 큰 리스크는 CRA(react-scripts 5) + TypeScript 4.9 + Tiptap v3 조합의 빌드 실패였다. Tiptap v2는 peer dependency가 React 17/18이라 React 19와 충돌하고, React 19 정식 지원은 v3부터다. 그런데 v3 + CRA에서 빌드가 깨졌다는 리포트가 있어서, 제일 먼저 에디터를 실제 import한 상태로 프로덕션 빌드를 돌려 통과 여부를 확인했다.
cd admin && npx tsc --noEmit && GENERATE_SOURCEMAP=false CI=false npm run build
# → Compiled successfully. (빌드 통과 → fallback 불필요)
다행히 통과해서 react-quill-new fallback 없이 Tiptap을 그대로 갔다. @tiptap/pm
(ProseMirror 런타임)은 반드시 별도 설치해야 한다는 점도 챙겼다.
의존성 메모
dompurify 3.x는 자체 타입을 제공해서 @types/dompurify가 불필요했다. 설치 시
"This is a stub types definition... you do not need this installed" 경고가 떠서
타입 패키지는 빼고 dompurify만 남겼다.
전체 빌드/타입체크 결과
| 앱 | tsc | build |
|---|---|---|
| admin | 통과 | 통과 |
| brand | 통과 | 통과 |
| student | 통과 | 통과 |
| academy(변경 없음) | 통과 | — |
XSS 스모크 테스트
jsdom 환경에서 DOMPurify 화이트리스트가 실제로 악성 태그를 거르는지 확인했다.
dirty = '<p>안녕<strong>굵게</strong></p><img src=x onerror=alert(1)><script>alert(2)</script><a href="https://x.com">링크</a>'
# OUT: <p>안녕<strong>굵게</strong></p><a href="https://x.com">링크</a>
<img onerror>와 <script>가 모두 제거되고 허용 태그만 보존됐다.
결론 / 배운 점
- 저장 포맷을 HTML 문자열로 고정하니 백엔드/API 스키마를 한 줄도 안 건드리고 끝났다. 에디터를 Tiptap에서 Quill로 바꾸더라도 저장 계약이 동일해 표시측은 영향이 없다.
- XSS 방어 지점을 표시측 한 곳(HtmlContent)으로 집약한 게 깔끔했다. 입력 검증을 믿지 않고 출력 시점에 sanitize하는 게 방어적으로 안전하다.
- 하위호환 분기(
looksLikeHtml) 덕분에 기존 plain text 공지도 마이그레이션 없이 줄바꿈이 보존된다. - Tiptap의 controlled 동기화는 직접 가드를 짜야 한다. "외부 value ≠ 현재 에디터 HTML일 때만 setContent"가 커서 튐/무한 루프를 막는 핵심.
- 리스크 큰 의존성(여기선 Tiptap v3 + CRA)은 제일 먼저 빌드 게이트로 검증하고 시작하면 나중에 갈아엎는 비용을 줄일 수 있다.
남은 과제
- 게시판(Boards) 실제 저장은
admin/src/api/boards.ts+ 백엔드 엔드포인트 선행 필요 → 별도 과제. - dev 서버 실데이터 왕복 검증(저장→재오픈→표시)은 백엔드(EC2) 기동 후 진행 예정.
댓글 0
- 첫 번째 댓글을 남겨보세요.