안정성 · 호환성
이메일이 monospace로 발송되던 문제 — TipTap codeBlock 스키마
영업 이메일을 보내면 본문이 고정폭(monospace) 글씨로 바뀌고, 본문 속 링크가 클릭 안 되는 평문으로 나갔다. 처음엔 폰트 CSS 문제인 줄 알았다. 파고 들어가 보니 범인은 에디터 스키마였고, 그것도 우리가 켜둔 적도 없는데 기본값으로 켜져 있던 확장 하나였다. 증상은 둘, 원인은 스키마 속성 한 줄.
증상 — "왜 갑자기 코드처럼 보이지"
사용자가 영업 메일을 작성해 보냈는데 받는 쪽에서 이렇게 보였다.
- 본문 전체가 Fixed-width(monospace)로 발송 → Gmail에서 가독성·신뢰도가 뚝 떨어짐
- 서명 끝의 이메일 주소·전화번호 같은 링크가
<a>태그로 안 바뀌고 평문으로 발송 → 클릭 안 됨
예를 들어 이런 서명 블록이 통째로 코드 박스 안에 갇힌 모양으로 갔다.
Best,
Jane Doe
ACME Auto Sales
sales@example.com ← 클릭 안 됨, 그냥 평문
+1 555-000-0000처음엔 "발송 HTML에 monospace 인라인 스타일이 박혔나" 하고 폰트부터 의심했다. 부모 <div>에는
분명 font-family: Arial이 들어가 있었다. 그런데도 고정폭으로 보였다. 여기서 한 칸 의심이 어긋났다.
트리거는 둘, 원인은 하나
재현을 잡아 보니 발생 경로가 두 갈래였다.
- 외부 메일 클라이언트(Gmail forward 화면 등)에서 본문을 복사해 에디터에 paste
- 에디터에 백틱 세 개 + 스페이스(markdown 코드블록 단축키)를 직접 입력
전혀 달라 보이는 두 입력이 똑같은 증상을 냈다. 이 시점에 "두 경로가 만나는 한 지점이 있다"는 가설이 섰고, 그게 진단의 방향을 결정했다.
진단 — 발송 HTML을 직접 까보다
가장 먼저 한 건 실제 발송 HTML을 그대로 들여다보는 일이었다. 화면 렌더가 아니라 직렬화된 결과물을 봐야 했다. 그랬더니 본문이 이렇게 직렬화돼 있었다.
<div data-deallo-authored="true" style="font-family:Arial,Helvetica,sans-serif">
<pre><code>Best,
Jane Doe
sales@example.com ← Link mark 못 박힘, plain text
</code></pre>
</div><pre><code>. 부모 <div>의 font-family: Arial은 <pre>의 user-agent 기본 monospace에
그냥 덮였다. CSS를 아무리 만져도 안 풀릴 만했다. 폰트가 아니라 노드 자체가 코드블록이었던 것이다.
여기서 진짜 의문은 두 번째 증상이었다. 왜 링크가 안 박히지? 코드블록 안 텍스트를 선택하고 툴바의 링크 삽입을 눌러도 시각적으로 아무 변화가 없었다. 에러도 안 났다. 조용히 무시됐다.
답은 TipTap이 쓰는 code_block 노드의 스키마에 있었다.
// @tiptap/extension-code-block 의 스키마
content: "text*" // 텍스트 노드만 허용
marks: "" // mark 일체 거부
code: true
defining: true
parseHTML: [{ tag: "pre", preserveWhitespace: "full" }]marks: "". ProseMirror는 mark를 적용하는 transaction을 받으면 스키마로 검증하는데, marks가 빈
문자열인 노드 안에서는 어떤 mark도 추가될 수 없다. Link는 mark다. 그래서 setLink 체인이
silently rejected됐고, DOM에 <a>가 안 만들어졌고, 글로벌 CSS .ProseMirror a가 걸릴 대상이
없으니 사용자 눈엔 "아무 일도 안 일어남"으로 보였다.
이걸로 두 증상이 한 원인으로 묶였다. code_block 노드 하나가 (1) 직렬화 시 <pre>로 나가
monospace를 강제하고, (2) 자기 스키마의 marks: ""로 Link mark를 막는다. monospace와 링크
실패는 같은 노드의 앞뒷면이었다.
그럼 두 트리거가 어떻게 같은 code_block 노드로 수렴하는가. 이것도 확인했다.
- paste 경로: 외부 메일 HTML에 들어 있던
<pre>가 codeBlock 확장의parseHTML(tag: "pre")에 매칭돼code_block노드로 들어옴 - 백틱 입력 경로: StarterKit가 등록한 input rule이 백틱+스페이스를
code_block으로 변환
요컨대 StarterKit의 codeBlock 확장이 기본값으로 켜져 있던 게 두 경로의 공통 입구였다.
한 줄 원인: TipTap StarterKit의
codeBlock이 활성화돼 있어<pre>·백틱이 모두code_block노드로 변환됐고, 그 노드 스키마의marks: ""가 Link mark 적용까지 막았다.
진단을 도운 단서들
곧장 원인으로 못 가고, 다음 단서들을 하나씩 밟으면서 좁혔다.
- 본문 HTML에
\r\n라인 엔딩이 섞여 있었다 → Windows 환경에서 외부 paste된 흔적이고,preserveWhitespace: "full"이 그 줄바꿈을 고스란히 보존하고 있었다. paste 경로를 의심하게 된 첫 실마리 - paste된
<pre><code>안에 원래 있던<a>는 살아 있는데, 내가 새로 추가한 링크만 안 박혔다 → parse와 mark 적용은 다른 경로다. 기존<a>는 paste 시점에 raw로 보존된 거고, 새 mark transaction은 스키마 검증에서 reject된다. 이걸 구분 못 했으면 "어떤 링크는 되고 어떤 링크는 안 된다"는 엉뚱한 가설로 샜을 것 - Gmail 받은 쪽에서 일부 링크는 클릭됐다 → 이게 제일 헷갈렸다. 알고 보니 Gmail이 렌더 시점에
텍스트의 URL/이메일 패턴을 자동 링크화(auto-linkification)해 준 거였다. HTML 자체엔
<a>가 없다. Outlook·Apple Mail·모바일에선 평문 그대로 노출된다. "Gmail에선 되는데?"를 그대로 믿었으면 버그가 아니라고 결론 냈을 함정이었다. 발송 HTML에<a>가 없다는 걸 직접 확인하고서야 진짜 버그로 인지했다
의사결정 — 어디서 막을 것인가
원인을 잡고 나니 막을 지점이 여러 군데였다. 세 후보를 두고 따졌다.
| 후보 | 평가 |
|---|---|
A. transformPastedHTML로 paste 단계에서 <pre>를 strip | paste 경로만 막힌다. 백틱+스페이스 input rule은 그대로 살아 있어 반쪽짜리 |
B. 스키마 차원에서 codeBlock 확장 자체를 끔 (채택) | paste와 input rule을 한 번에 차단. 영업 메일에 코드블록은 애초에 불필요. 변경은 한 줄 |
C. transformPastedHTML + input rule 비활성 조합 | 두 군데를 따로 다뤄야 함. 코드만 늘고 결과는 B와 같음 |
paste만 막는 A는 매력적으로 보였지만, 두 트리거가 같은 노드로 수렴한다는 걸 진단에서 이미 봤기 때문에 입구 하나(codeBlock 확장)를 끄는 게 가장 깔끔했다. B로 갔다.
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
code: false, // 인라인 백틱 mark 도 비활성 — 이메일엔 불필요
codeBlock: false, // <pre> paste + 백틱 입력 양쪽 차단
})codeBlock만이 아니라 인라인 code도 같이 껐다. 영업 메일 본문에 인라인 monospace를 박을 일이
없는데, 켜둘 이유도 없었다.
이게 왜 동작하는지도 못 박았다.
codeBlock: false→ CodeBlock 확장이 아예 등록 안 됨 →<pre>를 잡던parseHTML매칭이 사라지고, 백틱 input rule도 등록 안 됨- 그러면 paste된
<pre>는 ProseMirror가 모르는 element가 되어 paragraph로 fallback - paragraph 안에서는 Link 확장의
autolink: true,linkOnPaste: true가 모두 정상 작동 → 링크가 제대로<a>로 박힘
monospace도, 링크 차단도 같은 한 줄로 함께 사라졌다. 증상이 둘이어도 원인이 하나면 해결도 하나라는 게 이렇게 떨어졌다.
적용 — 단일 소스 + 독립 인스턴스
이메일 에디터 설정은 컴포저와 서명 폼이 공유하는 단일 소스(createEmailEditorConfig)에 살고
있었다. 거기를 고치니 컴포저·서명 두 곳이 자동으로 같이 고쳐졌다. 한편 자동화(automation) 도메인의
본문 에디터는 확장셋이 달라 별도 StarterKit 인스턴스를 쓰고 있었는데, 같은 함정에 빠질 수 있어
거기에도 동일하게 code: false / codeBlock: false를 박았다.
// 이메일 에디터 단일 소스 (컴포저 + 서명 공유)
- StarterKit.configure({ heading: { levels: [1, 2, 3, 4, 5, 6] } })
+ StarterKit.configure({
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
+ code: false,
+ codeBlock: false,
+ })
// 자동화 도메인의 본문 에디터 (별도 인스턴스)
StarterKit.configure({
heading: false,
horizontalRule: false,
link: false,
+ code: false,
+ codeBlock: false,
})검증 — paste는 jsdom으로 안 잡혀서
회귀 테스트로 "<pre><code>를 넣으면 paragraph로 fallback된다"를 못 박았다.
it('<pre><code> 포함 HTML을 setContent하면 paragraph로 fallback된다', () => {
editor.commands.setContent('<pre><code>Hi\nhello@example.com</code></pre>')
expect(editor.getHTML()).not.toContain('<pre>')
expect(editor.getHTML()).toContain('<p>')
})여기서 한계가 하나 있었다. 진짜 버그는 ClipboardEvent로 paste되는 순간 일어나는데, jsdom에선
실제 클립보드 paste 이벤트를 제대로 못 흉내 낸다. 그래서 setContent로 같은 파싱 경로를 태워
functional equivalence를 확보하는 선에서 끊었다. 실제 paste의 시각 확인은 테스트가 아니라 다음의
수동 비교로 메웠다.
production이 아닐 때만 뜨는 dev 비교 페이지를 만들어, 코드블록을 켠 에디터(LEGACY)와 끈 에디터(FIXED)를 좌우로 띄웠다. 세 시나리오를 직접 돌려 봤다.
- 백틱 세 개 + 스페이스 입력 → LEGACY는 코드블록 진입(monospace) / FIXED는 평문 유지
- 샘플 HTML paste → LEGACY는
<pre><code>보존 / FIXED는 paragraph fallback - 코드블록 안 텍스트 선택 → 링크 삽입 → LEGACY는 시각 변화 없음(mark 거부) / FIXED는 정상
직렬화된 HTML 패널을 같이 열어 두고 실제 발송 HTML이 어떻게 달라지는지를 눈으로 봤다. "Gmail에선 되더라" 같은 착시에 다시 안 속으려면, 발송 HTML 그 자체를 보는 게 결국 가장 확실했다.
하위 호환 — 기존 draft의 <pre>는?
기존에 저장된 발송 메일 HTML은 그대로 둔다(변경 없음). 다만 이미 <pre>가 들어가 있던 draft를
다시 열면 paragraph로 재해석되는데, 텍스트 콘텐츠는 그대로 보존되고 포맷 플래그만 바뀐다. 데이터
손실이 없다. 게다가 그 <pre>는 애초에 paste 버그가 만들어 낸 산물이라, paragraph로 풀리는 게
오히려 의도된 복구다. 잃는 게 아니라 정상으로 되돌리는 쪽이었다.
진단이 남긴 원칙
- 에디터 라이브러리의 기본 확장(StarterKit)을 그대로 쓰면, 기능에 안 맞는 노드가 콘텐츠를 오염시킨다. 우리가 codeBlock을 켠 적은 없지만 StarterKit가 기본으로 켜 줬다. "안 켰으니 괜찮다"가 아니라, 도메인에 안 쓰는 확장은 명시적으로 꺼야 한다
- 증상이 둘(monospace + 링크 실패)이어도 스키마 한 곳(
code_block의marks: "")이 공통 원인일 수 있다. 증상 수만큼 원인이 있다고 가정하면 두 군데를 따로 고치다 엇박이 난다 - "받는 쪽 Gmail에선 멀쩡한데?"는 버그를 덮는 착시일 수 있다. 렌더 결과가 아니라 직렬화된 발송 HTML을 직접 보는 게, 이 도메인에선 가장 빠른 진단 경로였다
- 막을 지점이 여러 곳일 땐, 두 트리거가 만나는 공통 입구 하나를 끄는 게 paste·input rule을 따로 막는 것보다 적은 코드로 둘 다 잡는다