이 글에서 다루는 것
Notion·Google Docs처럼 블록이 쌓인 문서를 웹에서 편집할 때, "에디터 하나에 문서 전체" 대신 host(문서 주인) / guest(포커스 블록 손님) 로 나누는 패턴과, 그 위에서 생긴 네 가지 문제를 정리한다.
가장 단순한 구현은 "문서 전체 = 리치텍스트 에디터 하나"다. 타이핑은 편하지만 곧 막힌다 — 권한·구독·저장이 "에디터가 들고 있는 트리"에 묶이고, 블록마다 다른 UI(임베드, 위젯)를 붙이기 어렵고, 블록이 수백 개여도 에디터 하나가 전부 살아 있어야 한다. 그래서 역할을 나눈다: Host는 블록 목록·패치·권한·저장을 쥔 문서의 진짜 주인(SoT), Guest는 지금 커서가 있는 한 블록만 편집하는 손님 에디터다. 포커스가 없는 블록은 읽기용 HTML로 그리고, 클릭해서 포커스가 들어가면 그때만 guest가 마운트되고, 포커스가 빠지면 guest가 내용을 host에 돌려주고(commit) 사라진다.
하루 요약
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| Host/Guest | 문서 변경을 패치 관문으로만 — guest는 포커스 블록만 | inject → commit → keymap 위임; 구조 연산 전 commit-before-delegate | stale commit이 바뀐 host 상태를 덮어쓰지 않음 |
| External store | React 밖 상태를 UI에 붙일 때 flake 없이 리렌더 | 스냅샷에 단조 revision (iso#n) 부여 | 같은 ms mutation에서도 리렌더가 드롭되지 않음 |
| Undo | 전체 스냅샷 복사 대신 do/inverse 2-스택 | { undo, redo } 2-스택; 재생 중 record·normalizer 상호 배제 | 메모리 절약 + redo 붕괴 방지 |
| DnD·복사 | guest 한 블록 한계를 브라우저 네이티브로 보완 | HTML5 DnD + host 패치; 핸들 글리프는 ::before pseudo로 복사 경로에서 제외 | 크로스블록 이동 가능, 장식 글리프가 복사에 안 섞임 |
1. Host / Guest — 문서는 패치가 소유한다
배경지식 (개념)
- Source of Truth (SoT): "진짜 데이터가 어디에 있는가". 문서 구조·패치는 host가 소유한다.
- Guest 에디터: ProseMirror/TipTap 등 리치텍스트 엔진이 지금 포커스된 한 블록만 편집한다.
어떤 상황이었나
Guest(리치텍스트 엔진)가 "문서 전체"처럼 행동하면, 블록 분할·타입 변경·권한 검사가 guest 내부 상태에 종속된다. 나중에 "이 블록만 읽기 전용", "이 블록만 실시간 구독" 같은 요구가 오면 에디터 API와 싸워야 한다. 문서 변경을 패치 관문으로만 받고 싶었다.
핵심 작업
- Host에 패치 apply 관문을 둔다. 문서 변경은 전부
[{ op: "update", id, content }, …]같은 패치로만 들어간다. - Guest는 포커스된 블록의
content만 받는다: inject(포커스 시 host가 현재 내용을 guest에 넣음) → commit(포커스 해제·퇴거 시 guest가 내용을 host에 반납) → keymap 위임("제목으로 바꾸기" 같은 구조 키는 guest가 처리하지 않고 host 콜백으로 넘김). - 키도 층으로 나눈다 — 텍스트·마크(굵게, 글자 입력)는 Guest, 구조(블록 타입 변경·이동)는 Host, 무포커스(여러 블록 선택 모드)는 Host selection 모드.
- stale commit 방지: 구조 연산 직후 예전 guest가 늦게
blurcommit을 내면 이미 바뀐 host 상태를 예전 내용으로 덮어쓴다. 그래서 구조 연산 전에는 항상 지금 guest 내용을 먼저 commit한 뒤(commit-before-delegate) host 패치를 적용하고, 그 lease는 폐기(retire)한다.
교훈
- 문서는 host 패치가 SoT이고, 에디터는 포커스 블록의 guest일 뿐이다.
2. useSyncExternalStore — "바뀌었는데도 같다고 판단"하는 함정
배경지식 (개념)
useSyncExternalStore: React 밖 스토어를 UI에 붙이는 공식 훅.
어떤 상황이었나
포커스 중인 블록 id, 레이아웃 상태처럼 React 밖에 있는 스토어를 UI에 붙일 때 useSyncExternalStore를 쓴다. React는 스토어가 "바뀌었다"고 알려도, getSnapshot()이 돌려준 값이 이전과 Object.is로 같으면 리렌더를 안 한다. 스냅샷을 new Date().toISOString()처럼 밀리초 타임스탬프만 넣으면, 같은 1ms 안에 "마운트 + 바로 update"가 연속될 때 두 스냅샷 문자열이 동일해 React가 "변경 없음"으로 보고 UI를 안 그린다. flake 없이 리렌더하고 싶었다.
핵심 작업
스냅샷에 항상 증가하는 revision을 붙인다.
SSR을 타는 컴포넌트는 getServerSnapshot도 반드시 넘긴다.
교훈
subscribe가 호출됐다 ≠ 스냅샷 값이 달라졌다 — timestamp만 쓰지 말고 revision을 쓴다.
→ useSyncExternalStore 스냅샷 동일성 · SSR hydration mismatch
3. Undo/Redo — 스냅샷 대신 "앞으로/뒤로" 쌍
배경지식 (개념)
- do/inverse 2-스택: 편집이 이미 "패치"로 표현된다면, 그 패치의 반대(inverse)만 있으면 되돌릴 수 있다. undo·redo 두 스택으로 관리한다.
어떤 상황이었나
문서 전체를 스택에 복사해 두면 메모리가 크고, 구조 공유·메모이제이션도 깨지기 쉽다. 전체 스냅샷 복사 대신 do/inverse 2-스택으로 가고 싶었다.
핵심 작업
엔트리 하나 = { undo: inverse패치, redo: 원래패치 }.
- 사용자가 편집 → apply 관문에서 do와 inverse를 같이 계산 → undo 스택에 push, redo 스택은 비움.
- Undo → undo 스택 pop →
undo적용 → redo 스택에 push. - Redo → redo 스택 pop →
redo적용 → undo 스택에 push.
undo/redo 재생 중에는 다시 히스토리에 기록하지 않고, 자동 normalizer도 끈다 — 불변식이 redo 상태와 어긋나 redo가 붕괴하기 때문이다.
교훈
- Undo UI보다 먼저 단일 apply 관문이 있어야 inverse를 믿을 수 있다.
4. 드래그·복사 — 브라우저와 싸우지 않기
배경지식 (개념)
- HTML5 Drag and Drop: guest가 포커스된 한 블록에만 살아 있을 때, 블록을 넘는 이동을 브라우저 네이티브 DnD로 푼다.
- Pseudo content: 장식 글리프를 DOM 텍스트가 아니라
::before { content }로 두면Selection.toString()·복사에 안 섞인다.
어떤 상황이었나
Guest는 포커스된 한 블록에만 살아 있어 블록을 넘어 텍스트를 옮기기 어렵다(문제 A). 또 핸들 글리프 ⠿를 DOM 텍스트로 두면 복사에 섞인다(문제 B). guest 한 블록 한계를 브라우저 네이티브로 보완하고 싶었다.
핵심 작업
- 문제 A (크로스블록 이동): 컨테이너 전체에서 HTML5 Drag and Drop을 쓰고, 드롭 결과는 host 패치로 반영한다.
dragover에서preventDefault()필수, 드래그 중 DOM 갈아끼우면 세션 끊김이라 드래그 시작 시 guest 정리, E2E는DragEvent직접 디스패치로 경로 검증. - 문제 B (핸들 글리프):
⠿를::before { content: "⠿" }pseudo content로 두어 복사 경로에서 제외한다.
교훈
- 브라우저가 잘하는 일(선택·드래그)을 재구현하지 말고, 부족한 구멍만 host 패치로 메운다.
- 장식 글리프는 DOM 텍스트가 아니라 pseudo content —
Selection.toString()에 섞이지 않게.
→ HTML5 drag and drop · Selection.toString() and pseudo content