전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

TipTap을 플랫폼에 가두기 — FieldEditor와 revision coalescing

에디터 라이브러리는 언젠가 갈아탑니다. 그래서 blog-* 어디에도 @tiptap import가 없어요. 저장 버튼 없는 저장(250ms 유휴 flush)과 이력 스팸을 막는 coalescing까지, 실제 상태 기계로 뜯어봤습니다.

2026. 07. 14. 12:25:168분 읽기프로젝트/arcaC/기술 노트
#TipTap#rich-text#저작 버퍼#플러그인 아키텍처#revision
조회 42
에디터 가두기 썸네일

에디터 라이브러리는 언젠가 갈아탑니다. 지금 것이 나빠서가 아니라, 5년 뒤에도 이게 최선일 리가 없어서요.

그래서 이 플랫폼에서는 규칙을 하나 정했어요. 블로그 코드 어디에도 에디터 라이브러리를 import하지 않는다.

실제로 blog-* 패키지 전체에서 @tiptap을 찾으면 0건이 나옵니다. 에디터를 쓰는데 에디터를 모르는 거예요.

왜 이렇게까지 하냐면

리치 텍스트 에디터는 의존성 중에서도 성격이 특이해요.

보통 라이브러리리치 텍스트 에디터
호출하는 쪽이 주도에디터가 상태를 소유
데이터가 내 것문서 모델이 라이브러리 것
빼면 그 기능만 사라짐빼면 저장된 문서를 못 읽음

세 번째 줄이 무서운 부분이에요. 에디터의 문서 형식을 그대로 DB에 저장하면, 그 라이브러리를 영원히 못 버립니다. 과거 글을 읽을 수가 없거든요.

그래서 두 겹으로 막았습니다. 저장 형식을 우리 것으로 정의하고, 에디터는 그 형식과 화면 사이의 번역기로만 씁니다.

경계를 어디에 그을 것인가

"에디터를 가둔다"는 말은 쉬운데, 실제로는 선을 어디에 그을지가 전부예요. 세 가지 후보가 있었습니다.

경계 위치블로그가 아는 것라이브러리를 바꿀 때
에디터를 그대로 노출에디터 인스턴스 · 확장 · 트랜잭션블로그 코드를 전부 고쳐야 함
얇은 래퍼에디터 개념이 래퍼 타입에 남음타입이 바뀌면 결국 번짐
필드 편집기 + 자체 문서 형식문서 값과 변경 콜백플랫폼 안에서 끝남

두 번째가 함정이에요. 래퍼를 만들었으니 가둔 것 같은데, 그 래퍼의 props가 에디터 타입을 그대로 노출하면 아무것도 안 가둔 겁니다. 이름만 바꾼 거죠.

그래서 판정 기준을 하나 뒀어요. 라이브러리를 지웠을 때 타입 에러가 어디까지 번지는가. blog 패키지에서 한 건도 안 나야 통과입니다.

# 이 검사가 통과해야 경계가 살아 있는 겁니다
$ grep -r "@tiptap" packages/blog-*/src | wc -l
0

# 그리고 이건 문서가 아니라 CI 검사로 돌립니다.
# 문서로만 두면 반년 안에 한 줄이 들어와요.

첫 번째 벽 — 문서 형식은 우리 것

DB에 들어가는 건 우리가 정의한 형식이에요.

{
  "schema": "platform.rich-text.document/v1",
  "blocks": [
    { "type": "paragraph", "children": [ { "text": "안녕하세요" } ] },
    { "type": "heading", "level": 2, "children": [ { "text": "소제목" } ] },
    { "type": "codeBlock", "code": "SELECT 1;" },
    { "type": "image", "src": "...", "alt": "...", "caption": [ { "text": "..." } ] }
  ]
}

스키마 이름에 버전이 박혀 있죠. 형식을 바꿔야 할 때 옛 문서를 알아볼 수 있게요.

그리고 이 형식에는 에디터 개념이 하나도 없어요. 커서도, 선택 영역도, 트랜잭션도 없습니다. 그냥 문서죠.

(참고로 지금 읽고 계신 이 글도 저 형식으로 저장돼 있어요. 코드 블록도 위에 보이는 그 모양 그대로고요.)

문서 형식을 우리 것으로 정의했을 때 실제로 얻는 게 뭔지도 적어 둘게요.

상황에디터 형식을 그대로 저장했다면자체 형식이라면
라이브러리 교체과거 글을 못 읽음번역기만 새로 씀
서버에서 렌더에디터를 서버에서 돌려야 함그냥 JSON 순회
검색 색인에디터 내부 구조를 알아야 함블록 타입만 보면 됨
형식 변경마이그레이션 경로가 없음스키마 버전으로 분기

두 번째 줄이 이 블로그에서 실제로 값을 했어요. 공개 페이지는 서버가 다 그려서 보내는데, 그게 가능한 이유가 문서 형식이 그냥 데이터라서입니다. 에디터 형식이었으면 서버에서 에디터를 띄워야 했을 거예요.

스키마 이름에 버전을 박아 둔 것도 마지막 줄 때문이고요. 언젠가 v2가 나오면, v1 문서를 읽을 때 무엇을 해야 하는지가 이름만 보고 정해집니다.

두 번째 벽 — 에디터는 플랫폼 안에만 있습니다

블로그 플러그인이 쓰는 건 에디터가 아니라 필드 편집기예요.

// 블로그 쪽이 아는 것은 이 정도
<FieldEditor
  field="body"
  value={document}
  onChange={queuePatch}
/>

이 안에서 무엇이 도는지는 블로그가 몰라요. 오늘은 TipTap이고 내일은 다른 것일 수 있습니다. 바뀌어도 블로그 코드는 안 고쳐요.

이 경계 덕에 나중에 실제로 이득을 봤어요. 링크 기능과 영상 블록을 추가할 때, 블로그 쪽이 아니라 플랫폼 안쪽에서만 작업이 끝났거든요. 그러니까 다이어리도 같은 기능을 공짜로 얻었습니다.

저장 버튼을 없앴어요

여기서 다른 문제가 시작됩니다. 저장 버튼이 없으면 언제 저장하죠?

제일 단순한 답은 "타이핑할 때마다"인데, 그러면 서버가 초당 몇 번씩 맞습니다. 반대로 너무 늦으면 사용자가 창을 닫았을 때 글이 날아가고요.

그래서 상태 기계를 하나 만들었어요.

type AuthoringBufferState =
  | { kind: "idle" }      // 바뀐 게 없음
  | { kind: "dirty" }     // 바뀌었고, 타이머 돌는 중
  | { kind: "saving" }    // 전송 중
  | { kind: "saved" }     // 저장됨
  | { kind: "conflict" }  // 다른 곳에서 먼저 바뀜
  | { kind: "invalid" };  // 서버가 거절함

타이핑하면 dirty가 되고, 손을 멈춘 지 250ms가 지나면 저장합니다. 다시 타이핑하면 타이머가 리셋되고요.

상태 기계는 여섯 가지예요. 저장 버튼 하나가 사라진 대가로 상태가 여섯 개 생긴 셈이죠.

type AuthoringBufferState =
  | { kind: "idle" }        // 바뀐 게 없음
  | { kind: "dirty" }       // 바뀌었고 아직 안 보냄
  | { kind: "saving" }      // 보내는 중
  | { kind: "saved" }       // 저장됨
  | { kind: "conflict" }    // 다른 곳에서 먼저 바꿨음
  | { kind: "invalid" };    // 서버가 거부했거나 요청이 실패
상태사용자에게 보이는 것다음에 할 일
dirty아무것도 (조용함)250ms 뒤 저장
saving조용함 — 깜빡이지 않음응답 대기
saved옅은 "저장됨"idle 로
conflict명시적 안내 + 선택지사용자가 결정
invalid명시적 오류재시도 또는 수정

두 번째 줄에 "깜빡이지 않음"이라고 적은 게 실제 결정이에요. 저장 중 표시를 넣었더니 타이핑할 때마다 화면 구석이 계속 깜빡였거든요. 그게 생각보다 거슬립니다.

idleFlushMs: options.idleFlushMs ?? 250,

그리고 저장 중에 또 타이핑하면요? 새 저장을 또 보내지 않고 최신 내용만 들고 기다립니다. 진행 중인 저장이 끝나면 그때 보내요.

그래서 요청이 겹치지 않습니다. 이게 없으면 늦게 출발한 요청이 먼저 도착해서 옛날 내용이 최신을 덮어쓰는 사고가 납니다.

저장이 겹치지 않게 만드는 부분이 이 버퍼에서 제일 까다로웠어요.

const flush = async () => {
  cancelTimer();

  // 이미 저장 중이면, 그게 끝날 때까지 기다렸다 다시 시도합니다
  if (activeFlush) {
    await activeFlush;
    return flush();
  }

  const patch = latestPatch;
  if (patch === undefined) return;
  latestPatch = undefined;      // 들고 있던 것을 비웁니다

  setState({ kind: "saving" });
  // ... 전송 ...
};

// 타이핑할 때마다 최신 것만 덮어씁니다
const queue = (patch) => {
  latestPatch = { ...latestPatch, ...patch };
  setState({ kind: "dirty" });
  cancelTimer();
  timer = setTimeout(() => void flush(), idleFlushMs);
};

`latestPatch`가 하나뿐인 게 핵심이에요. 저장 대기 중인 변경이 큐로 쌓이지 않고 항상 최신 하나로 합쳐집니다. 3초 동안 타이핑해도 보낼 건 결국 하나예요.

그리고 저장에 실패하면 비웠던 patch를 다시 넣습니다. 그 사이 새로 타이핑한 게 있으면 그쪽이 이기게요. 실패했다고 사용자가 방금 친 글자를 되돌리면 안 되니까요.

// 실패하면 되돌려 넣되, 그 사이 새로 친 게 우선입니다
latestPatch = {
  ...patch,            // 실패한 것
  ...(latestPatch ?? {}),  // 그 사이 새로 친 것 — 이쪽이 덮어씀
};

그런데 이력이 스팸이 됐어요

여기서 예상 못 한 문제가 왔습니다. 250ms마다 저장하니까 수정 이력이 이렇게 쌓인 거예요.

revision 41"안녕하"
revision 42"안녕하세"
revision 43"안녕하세요"
revision 44"안녕하세요."
...

이력을 만든 이유는 "옛날로 돌아가기" 위해서인데, 이러면 돌아갈 지점을 고를 수가 없어요. 이력이 있으나 마나입니다.

그래서 짧은 시간 안의 편집은 같은 revision을 덮어쓰게 했어요. 이걸 coalescing이라고 부릅니다.

options(id: string | undefined) {
  // 같은 편집 세션이면 같은 키를 준다
  return { coalesceKey: `authoring:${normalized}` };
}

편집 세션 id가 같으면 같은 revision을 갱신하고, 세션이 바뀌면 새 revision을 만듭니다. 그래서 이력에는 "한 번 앉아서 고친 결과"가 한 줄로 남아요.

coalescing 키는 이렇게 정합니다.

키가 같으면키가 달라지는 때
같은 revision 을 덮어씀편집 화면을 새로 열었을 때
이력에 줄이 안 늘어남다른 사람이 편집을 시작했을 때
일정 시간이 지나 세션이 만료됐을 때

그래서 "한 번 앉아서 고친 결과"가 이력에 한 줄로 남아요. 점심 먹고 와서 다시 고치면 새 줄이 생기고요. 사람이 기억하는 "작업 단위"와 이력의 단위가 맞아떨어집니다.

세밀한 이력이 필요하면 어쩌냐고요? 필요한 사람이 없었어요. 글자 단위 이력이 필요한 건 협업 편집이고, 이 블로그는 혼자 씁니다. 요구가 생기면 그때 키를 잘게 쪼개면 되고요.

여기서 앞 편의 잠금과 만납니다

그런데 덮어쓰기에는 위험한 경우가 있어요. 그 revision이 이미 발행돼 있다면요.

공개된 글의 내용이 소리 없이 바뀌면 "불변 릴리스"가 거짓말이 됩니다. 그래서 덮어쓰기 SQL 안에 잠금 조건이 들어 있어요.

UPDATE content_revision
SET payload = $6, ...
WHERE ...
  AND NOT EXISTS (
    SELECT 1 FROM release_content_projection
    WHERE ... AND release_content_projection.revision = content_revision.revision
  );

참조 중이면 0행이 고쳐지고, 호출한 쪽이 "새 revision을 만들어야겠구나"로 갑니다. 자세한 이야기는 읽기 모델을 둘로 가른 편에 있어요.

(두 기능이 각자 만들어졌는데 한 SQL 조건에서 만났어요. 이런 게 설계가 맞아떨어지는 순간이더라고요.)

세 번째 줄이 특히 그래요. 이 두 기능은 시점도 목적도 달랐습니다.

기능언제 만들었나원래 목적
revision coalescing자동 저장 이력이 스팸이 됐을 때이력을 읽을 수 있게
발행된 revision 잠금릴리스를 불변으로 만들 때공개된 글이 안 바뀌게

둘이 만난 자리가 하나의 SQL 조건이에요. 덮어쓰려는 revision이 어떤 릴리스에도 참조되지 않았을 때만 UPDATE가 걸립니다.

그리고 이게 잘 맞아떨어진 이유는 우연이 아니라, 둘 다 같은 원칙에서 나왔기 때문이에요. 공개되기 전은 마음껏 흔들려도 되고, 공개된 뒤는 잠긴다. 이 한 문장이 두 기능의 공통 조상입니다.

실패를 어떻게 보여주나

저장 버튼이 없으면 실패도 안 보입니다. 이게 진짜 위험해요 — 사용자는 저장된 줄 알고 창을 닫거든요.

상태사용자에게 보이는 것
saving조용함매번 알리면 소음
saved"변경사항은 자동으로 저장돼요"안심시키는 정도만
conflict명시적 안내 + 선택지다른 곳에서 먼저 바뀜 — 덮어쓸지 물어야 함
invalid무엇이 문제인지고칠 수 있게

성공은 조용하고 실패는 시끄럽게. 반대로 만들면 사용자가 성공 알림에 익숙해져서 실패 알림도 넘겨버려요.

치른 값

불편해진 것실제로 겪은 일
에디터 기능 추가 경로가 김새 마크 하나 넣으려면 문서 형식 → 에디터 → 렌더러 → 저장까지 4곳
에디터의 고급 기능을 다 못 씀라이브러리가 주는 걸 우리 형식으로 번역할 수 있어야만 씀
디버깅할 곳이 늘어남화면이 이상할 때 형식인지 번역인지 렌더러인지 봐야 함

첫 줄은 실제로 매번 아팠어요. 마크 하나 추가하는 데 네 곳을 지나야 하거든요.

그래도 이 값을 치를 만하다고 본 이유는 하나예요. 이 블로그의 글은 제가 없어져도 읽을 수 있어야 하거든요. 문서가 특정 라이브러리에 인질로 잡혀 있으면 그게 안 됩니다.


다음 편은 새벽 3시에 발행 버튼을 누르는 손가락 이야기예요. 예약 발행의 실체는 타이머가 아니라 권한 모델이었습니다.

시리즈 이어읽기

쓰고, 발행하기

2 / 4

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.