RINDA · 근본원인 분석 리포트

시퀀스 메일 초안 재로딩 시 본문 행·열 깨짐
근본원인 · 원인 코드 · 페이지 경로별 · HTML SSOT 통일

박준영 보고 (6/12) — “메일 초안 저장 후 재로딩 시 행과 열이 모두 변경되어 보임”

분석일 2026-06-15 · beta 실데이터 399,469 active rows · 코드 전수조사 + DB 전수통계

핵심 요약 (TL;DR)

증상은 하나지만 원인은 두 개의 독립 트랙이다. 진짜 코드 버그는 P1이며, 실데이터의 대량 이상치는 P2(별도 데이터 이벤트)로 코드 버그가 아니다.

8,665
표 포함 본문 <table>
재로딩 시 깨짐 (P1)
296×
표 본문 급증
4월 29 → 5월 8,576건
48,457
| 포함 text
GFM이 표로 오인
61,306
P2 단일 이벤트
코드 무관 (데이터)
원인 코드 한 줄: CreateCampaignStep2.tsx:178-181 — 보존된 emailBodyHtml을 버리고 emailBodyTextmarkdownToHtml로 재파싱. 해결 = HTML을 콘텐츠 SSOT로 통일.

1어떤 코드가 원인인가 — 파일·라인 정밀

① 직접 원인 — 재로딩 매핑이 HTML을 버린다

admin/src/pages/sequences/CreateCampaignStep2.tsx:176-200
// 서버 step → 에디터 state 매핑 (초기 로딩) const serverSteps = serverStepsData.map((step) => { let bodyText = step.emailBodyText || "" // ← plain text (정규화됨) if (bodyText && !isHtmlContent(bodyText)) { bodyText = markdownToHtml(bodyText) // ← GFM 재파싱: | → 표, 들여쓰기 → pre } return { emailBodyText: bodyText, // ← 에디터 value 로 주입됨 (깨진 값) emailBodyHtml: step.emailBodyHtml || "", // ← 원본 HTML, 매핑만 되고 에디터엔 안 씀! } })

에디터(TiptapEditor)는 emailBodyText를 value로 받는다 — EmailEditorPanel.tsx:373 value={currentStep?.emailBodyText}. 즉 멀쩡한 emailBodyHtml(190행)이 손에 있는데도 쓰지 않고, 재파싱된 값을 보여준다.

② 재파싱 엔진 — 평문을 구조로 오인

admin/src/lib/utils/markdown.ts:6-18
export function markdownToHtml(markdown) { marked.setOptions({ breaks: true, gfm: true }) // GFM: | 줄을 <table>로, return marked(markdown) // 줄바꿈을 <br>로, 4칸을 <pre>로 }

Tiptap은 표를 지원하는 HTML 에디터다 — tiptap-editor.tsx:1043 content: value, :1045 onUpdate: getHTML(), ExtendedTable 확장 보유. 따라서 emailBodyHtml을 그대로 넣으면 무손실인데, 굳이 plain을 GFM으로 재구성해 깨뜨린다.

③ 같은 결함의 다른 형태 — Form/Launch는 표를 아예 버린다

admin/src/pages/sequences/utils/step-edit-body.ts:60-92
function htmlToStepMarkdown(html) { // <table>/<tr>/<td> 변환 규칙이 전혀 없음 ↓ .replace(/<[^>]*>/g, "") // 모든 잔여 태그 제거 → 표 구조 통째 소실 .replace(/[ \t]+/g, " ") // 들여쓰기·정렬 붕괴 } export function deriveStepEditorBodyValue(step) { if (step.emailBodyHtml?.trim()) return htmlToStepMarkdown(step.emailBodyHtml) // HTML→md 다운컨버트 return step.emailBodyText ?? "" }

SequenceStepForm(:107,133,740)과 LaunchModalStepEditor는 MDEditor(markdown)라, HTML 본문을 markdown으로 깎아 넣는다. 표 변환 규칙이 없어 <table>이 평문으로 무너진다.

④ 백엔드 — 정상 경로에선 안 타지만 이중파싱 여지

elysia-server/src/services/sequence-step.service.ts:261-266 · 547-552
const emailBodyHtml = data.emailBodyHtml != null ? data.emailBodyHtml // FE가 HTML 보내면 그대로 ✅ : data.emailBodyText ? markdownToHtml(data.emailBodyText) : null // 폴백(방어가드 없음)

FE가 항상 emailBodyHtml을 보내므로 정상 경로에선 폴백이 안 탄다. 다만 emailBodyText가 이미 HTML일 때 markdownToHtml로 또 파싱하면 깨지므로 isHtmlContent 가드 추가가 안전하다.

왜 “미리보기는 정상, 편집화면만 깨짐”인가: 미리보기·테스트발송·뷰어는 모두 emailBodyHtml 우선(EmailPreviewDialog.tsx:89, StepBodyPreview.tsx:19). 오직 에디터 주입 경로(①③)만 emailBodyText를 재파싱한다.

2근본 원인 — 2개 트랙 분리

P1 · 코드 버그 (박준영 보고의 실체)

저장 Tiptap getHTML() ─→ emailBodyHtml = 원본 HTML (표·정렬 보존) emailBodyText = htmlToPlainText(HTML) (lossy) DB body_html = 원본 ✅ body_text = plain 로딩 ✗ emailBodyHtml 무시 → emailBodyText(plain)을 markdownToHtml 재파싱 └─ | → <table> · 줄바꿈 → <br> · 4칸 → <pre> ← 행·열 붕괴 미리보기 emailBodyHtml 우선 → 정상 ✅

P2 · 데이터 이벤트 (코드 버그 아님)

body_html이 빈 row 61,330건을 전수 추적 → 단일 시각 2026-06-02 02:49:10, 1개 워크스페이스·1개 시퀀스에 61,306건 일괄 insert. 일회성 스크립트 작업이며 body_text는 plain(평균 943자, 표 6건)이라 P1과 무관.

분리 결론: P2는 코드가 아니라 데이터 백필 대상. 해당 시퀀스의 body_html을 1회 채우면 해소. P1 코드 수정과 섞지 않는다.

3페이지 경로별 분석 — 에디터가 2개 family

경로 / 컴포넌트에디터표현로딩 우선순위상태
Step2 CreateCampaignStep2 → EmailEditorPanelTiptap (HTML)HTML지원emailBodyText 재파싱P1 버그
Form SequenceStepFormMDEditor (md)markdown미지원html→md 다운컨버트표 소실
Launch LaunchModalStepEditorMDEditor (md)markdown미지원raw body_text표 소실
EmailPreviewDialog / TestSendDialogiframe·sanitizeHTML보존emailBodyHtml 우선정상
StepBodyPreview / StepPreviewDialog / Exploreriframe·sanitizeHTML보존emailBodyHtml 우선정상
AI 출력은 이미 HTML을 제공(bodyHtml = convertTextToHtml(bodyText)). 그런데 Form/Launch는 이를 htmlToStepMarkdown으로 깎아 표를 버린다. Step2(ManualMode)만 emailBodyHtml 우선의 정답 패턴.

4실데이터 통계 — beta 399,469 active rows

source별 분포 — html 보존 품질

ai
315,259 (79%)
manual
62,488 (16%)
template
21,722 (5%)
sourcerowshtml 비어있음text 손상판정
ai315,259188,55330,853html 정상
manual62,48861,312112198% empty=P2
template21,722000정상

월별 추이 — 표 본문 급증 & P2 스파이크

표 포함 본문html 비어있음(P2)
2026-02
표 0
2026-03
표 0
2026-04
표 29
2026-05
표 8,576
2026-06
empty 61,306
핵심 신호: 표 본문이 2026-05에 8,576건으로 296배 폭증(AI 대량 생성). 이 모수가 step2 재로딩에서 행·열이 깨지는 P1의 실제 피해 대상. 6월 빨간 막대는 P2 단일 이벤트(별도 트랙).

주별 html_empty 추이 — P2 스파이크 격리

주(week)생성emptyempty %비고
~ 2026-05-25 (15주)17,000±0~12≈0.0%정상 운영
2026-06-01102,47261,30659.8%P2 단일 insert 02:49
2026-06-0826,42900.0%정상 복귀
2026-06-1516,37400.0%정상

15주 연속 ≈0% → 단 한 주만 59.8% → 즉시 복귀. 코드 회귀가 아니라 일회성 데이터 주입임을 날짜 통계가 입증.

5최적 해결법 — HTML SSOT (실데이터 기반)

설계 원칙 (2026)

  • HTML = 콘텐츠 SSOT. Tiptap HTML이 권위. plain은 발송용 파생.
  • Lossy 재파싱 제거. 정상 데이터에 markdownToHtml·htmlToStepMarkdown 금지.
  • 레거시 폴백 격리. body_html이 빈 과거 row에만 1회 변환.
  • 발송 text 단방향 파생. htmlToPlainText(html)로만.

“하나로 통일” 가능성

  • 가능 스토리지는 이미 단일 테이블.
  • 가능 세 에디터를 Tiptap+emailBodyHtml로 통일.
  • 잔존 read-side markdownToHtml 폴백(레거시 15%).
  • 잔존 MDEditor·markdown.ts는 서명·템플릿이 사용→삭제 불가.

단계적 실행 계획

단계내용효과위험
N1 · 최소 즉시CreateCampaignStep2.tsx:178 로딩을 emailBodyHtml 1순위로박준영 버그(표 8,665) 즉시 해소최소
N2 · Form/LaunchTiptap+HTML 직접화, htmlToStepMarkdown 제거, 저장 htmlToPlainText, AI는 emailBodyHtml두 경로 표·정렬 보존 + AI 다운컨버트 제거중 (발송 text·서명 주입 검증)
N3 · 정리죽은 유틸 제거SSOT 단일화낮음
P2 · 백필 데이터2026-06-02 단일 시퀀스 61,306건 body_html 채움해당 시퀀스 정상화낮음
권장: N1을 즉시 alpha+beta 배포(저위험·고효과)로 박준영 버그를 닫고, N2/N3는 서명 주입 HTML 호환 검증 후 후속 PR로 분리. P2는 데이터 백필 1회.