Deallo

안정성 · 호환성

이메일이 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이 들어가 있었다. 그런데도 고정폭으로 보였다. 여기서 한 칸 의심이 어긋났다.

트리거는 둘, 원인은 하나

재현을 잡아 보니 발생 경로가 두 갈래였다.

  1. 외부 메일 클라이언트(Gmail forward 화면 등)에서 본문을 복사해 에디터에 paste
  2. 에디터에 백틱 세 개 + 스페이스(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>를 strippaste 경로만 막힌다. 백틱+스페이스 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)를 좌우로 띄웠다. 세 시나리오를 직접 돌려 봤다.

  1. 백틱 세 개 + 스페이스 입력 → LEGACY는 코드블록 진입(monospace) / FIXED는 평문 유지
  2. 샘플 HTML paste → LEGACY는 <pre><code> 보존 / FIXED는 paragraph fallback
  3. 코드블록 안 텍스트 선택 → 링크 삽입 → LEGACY는 시각 변화 없음(mark 거부) / FIXED는 정상

직렬화된 HTML 패널을 같이 열어 두고 실제 발송 HTML이 어떻게 달라지는지를 눈으로 봤다. "Gmail에선 되더라" 같은 착시에 다시 안 속으려면, 발송 HTML 그 자체를 보는 게 결국 가장 확실했다.

하위 호환 — 기존 draft의 <pre>는?

기존에 저장된 발송 메일 HTML은 그대로 둔다(변경 없음). 다만 이미 <pre>가 들어가 있던 draft를 다시 열면 paragraph로 재해석되는데, 텍스트 콘텐츠는 그대로 보존되고 포맷 플래그만 바뀐다. 데이터 손실이 없다. 게다가 그 <pre>는 애초에 paste 버그가 만들어 낸 산물이라, paragraph로 풀리는 게 오히려 의도된 복구다. 잃는 게 아니라 정상으로 되돌리는 쪽이었다.

진단이 남긴 원칙

  • 에디터 라이브러리의 기본 확장(StarterKit)을 그대로 쓰면, 기능에 안 맞는 노드가 콘텐츠를 오염시킨다. 우리가 codeBlock을 켠 적은 없지만 StarterKit가 기본으로 켜 줬다. "안 켰으니 괜찮다"가 아니라, 도메인에 안 쓰는 확장은 명시적으로 꺼야 한다
  • 증상이 둘(monospace + 링크 실패)이어도 스키마 한 곳(code_blockmarks: "")이 공통 원인일 수 있다. 증상 수만큼 원인이 있다고 가정하면 두 군데를 따로 고치다 엇박이 난다
  • "받는 쪽 Gmail에선 멀쩡한데?"는 버그를 덮는 착시일 수 있다. 렌더 결과가 아니라 직렬화된 발송 HTML을 직접 보는 게, 이 도메인에선 가장 빠른 진단 경로였다
  • 막을 지점이 여러 곳일 땐, 두 트리거가 만나는 공통 입구 하나를 끄는 게 paste·input rule을 따로 막는 것보다 적은 코드로 둘 다 잡는다