사이트픽 블로그
← 목록으로

개발일지 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 → RichTextEditor
  • admin/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만 남겼다.

전체 빌드/타입체크 결과

앱tscbuild
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

  • 첫 번째 댓글을 남겨보세요.