들어가며
리치텍스트 에디터를 한 번이라도 직접 만들어본 사람이라면, 십중팔구 contenteditable의 늪에 빠진다. 브라우저가 자동으로 편집을 허용해주는 건 좋은데, 동작이 브라우저마다 미묘하게 다르고, 같은 키 입력도 어떤 환경에서는 <p>로, 다른 환경에서는 <div>로 변한다. 사용자가 키를 누르는 순간 DOM이 그대로 바뀌어버리니, "지금 화면의 DOM이 우리가 원하는 형태가 맞나?" 를 보장할 방법이 사실상 없다.
ProseMirror는 바로 이 문제를 정면으로 푸는 라이브러리다. CodeMirror를 만든 Marijn Haverbeke가 만들었고, NYT·Asana·The Guardian 같은 곳들이 실서비스에 쓴다. 우리가 이름을 들어본 Tiptap, Atlassian의 에디터, Outline 같은 프로덕트도 전부 ProseMirror 위에 얹혀 있다.
오늘은 공식 Reference Manual과 Guide를 따라가며, ProseMirror가 어떤 발상으로 어떤 문제들을 풀고 있는지 핵심 개념을 톺아본다.
1. ProseMirror가 풀려는 문제
공식 사이트가 자기 소개를 한 줄로 이렇게 적어놨다.
"a toolkit for building rich-text editors on the web"
이 한 줄 뒤에 깔린 진짜 메시지는 두 가지다.
- 마크다운/XML처럼 구조가 있는 문서와, 사용자가 보고 만지는 시각적인 편집기 화면 사이의 간극을 메운다
- 사용자가 무슨 짓을 해도 문서가 항상 우리가 정의한 규칙을 만족하는 형태로 유지되도록 보장한다
여기에서 "구조가 있는 문서"라는 말이 핵심이다. 보통 우리가 만지는 HTML은 정말 자유롭다 - <p> 안에 <div>가 들어가도 되고, <h1> 안에 <h3>가 중첩돼도 브라우저는 군말 없이 그려준다. 하지만 위키, 협업 문서 같은 도메인은 그런 자유를 원하지 않는다. 오히려 "문단 안에는 인라인만, 헤딩 안에는 헤딩이 못 들어옴" 같은 규칙이 강하게 지켜지길 원한다. ProseMirror는 그 규칙을 schema라는 형태로 코드화하고, 모든 변경이 그 규칙을 통과한 뒤에만 적용되도록 강제한다.
다른 리치텍스트 라이브러리들과 비교해보면 차이가 더 명확해진다.
- Quill: 입문하기 쉽고 단순하지만, 본인만의 노드 타입을 정의하거나 구조 제약을 거는 일은 어렵다
- Slate: React와 잘 어울리고 데이터 모델도 비슷한 결의 immutable 트리지만, 구조 검증은 ProseMirror만큼 강하지 않다
- Lexical (Meta): 후발주자답게 빠르고 가벼움. 다만 생태계 성숙도는 아직 ProseMirror만큼은 아님
- Tiptap: ProseMirror 위에 친절한 껍데기를 씌운 형태. 결국 깊이 들어가면 ProseMirror를 만나게 된다
ProseMirror는 그냥 갖다 쓰면 되는 완성품 보다는 필요한 부품을 골라 끼워 자기 에디터를 직접 조립하는 키트 에 가깝다. 그래서 처음엔 진입장벽이 높지만, 익숙해지면 거의 모든 종류의 에디터를 깊은 수준에서 통제할 수 있다.
2. 핵심 4축
ProseMirror를 머릿속에 그리려면 우선 4개의 큰 개념을 알면 된다. 이게 사실상 ProseMirror의 골격 전체다.
| 축 | 한 줄 설명 | 패키지 |
|---|---|---|
| Schema | 문서가 어떤 노드/마크로 구성될 수 있는지 정의 | prosemirror-model |
| State | 문서 + 셀렉션 + 활성 플러그인까지 묶은 한 시점의 스냅샷 | prosemirror-state |
| Transaction | 한 State를 다음 State로 바꾸는 변경 묶음 | prosemirror-transform |
| View | State를 화면에 그리고, 사용자 입력을 다시 Transaction으로 바꿔주는 다리 | prosemirror-view |
이 네 개 위에 키 단축키, undo/redo, 기본 편집 명령, 기본 스키마 같은 작은 모듈들이 얹힌다 - prosemirror-keymap, prosemirror-history, prosemirror-commands, prosemirror-schema-basic 등. 필요한 것만 골라 넣어 쓰는 식이라, 한 번에 모든 걸 외울 필요는 없다.
3. Schema - "valid하지 않은 문서는 아예 만들어지지 않는다"
ProseMirror의 가장 강한 약속 중 하나는 이거다.
문서는 항상 schema가 허용한 모양만 가질 수 있다.
여기에서 schema는 한 마디로 "우리 문서엔 어떤 종류의 노드가 있고, 그 노드들이 어떤 식으로 중첩될 수 있는지" 를 정의한 명세서다. 공식 가이드는 schema를 이렇게 표현한다.
"describes the kind of nodes that may occur in the document, and the way they are nested."
schema가 정의하는 건 보통 다음 세 가지다.
- node types - paragraph, heading, blockquote, image처럼 문서에 등장할 수 있는 블록/요소들
- mark types - bold, italic, link, code처럼 텍스트에 입혀지는 스타일들
- content expressions - "어떤 노드 안에는 어떤 자식이, 어떤 순서/개수로 올 수 있는지" 를 정규식 비슷한 문법으로 적은 것
코드로 보면 감이 더 잘 잡힌다.
import { Schema } from "prosemirror-model";
const schema = new Schema({
nodes: {
doc: { content: "block+" }, // 문서엔 block이 1개 이상
paragraph: { group: "block", content: "inline*" }, // 문단 안엔 인라인만
heading: {
group: "block",
content: "inline*",
attrs: { level: { default: 1 } },
},
text: { group: "inline" },
},
marks: {
strong: {},
em: {},
},
});이 스키마에선 다음과 같은 규칙이 자동으로 강제된다.
doc안에는block그룹의 노드만 올 수 있다 (한 개 이상)paragraph나heading안에는 인라인만 올 수 있다 (블록이 못 들어옴)- 사용자가 어떤 입력을 하든, 이 규칙을 깨는 변경은 자동으로 거부된다
이게 일반 contenteditable과의 가장 큰 차이다. 그냥 contenteditable에서는 사용자가 복사해 붙여넣은 HTML이 <p> 안에 <div>를 만들 수도 있고, 그걸 막으려면 우리가 입력 직후에 일일이 정규화 코드를 짜야 한다. ProseMirror에서는 그런 이상한 트리가 애초에 만들어지지 않는다.
4. State와 Transaction - 변경을 한 곳으로 모으는 장치
EditorState - 한 시점의 스냅샷
"includes the document, selection, and stored marks"
State는 문서·셀렉션·플러그인 상태까지 전부 묶은 한 시점의 사진이다. 한 번 만들어진 state는 절대 그 자리에서 바뀌지 않는다. 변경이 필요하면 항상 새 state가 만들어진다. 우리가 React에서 state를 직접 mutate하지 않고 새 객체를 반환하는 것과 같은 결이다.
import { EditorState } from "prosemirror-state";
const state = EditorState.create({
schema,
plugins: [],
});처음 보면 답답해 보일 수 있지만, 이 불변성이 뒤에서 등장할 거의 모든 좋은 성질의 출발점이 된다 - undo가 단순해지고, 협업이 단순해지고, 디버깅이 단순해진다.
Transaction - "변경을 적기만 하고, 적용은 따로 한다"
문서를 바꾸려면 직접 만질 수 없으니, 대신 변경 명세서를 만든다. 그게 transaction이다.
"Every change causes a transaction to be created, which describes the changes that are made to the state."
let tr = state.tr; // 현재 state로부터 빈 transaction 시작
tr.insertText("hello"); // 거기에 변경을 적는다
const newState = state.apply(tr); // 새 state를 만든다흐름이 이렇다 - 현재 state에서 빈 transaction을 받아온 뒤, 거기에 "어떤 변경을 할 것인지" 를 누적해서 적고, 마지막에 state.apply(tr)로 새 state를 만들어낸다.
이 구조 덕분에 따라오는 좋은 점들이 있다.
- 여러 변경을 한 묶음으로 처리 - "텍스트 삽입 + 마크 적용 + 셀렉션 이동" 같은 걸 하나의 atomic한 단위로
- 위치 추적이 자동 - 텍스트가 삽입되면 그 뒤 위치들이 한 칸씩 밀린다. 그 매핑을 transaction이 알아서 들고 있어준다 (이걸 position map이라고 부른다)
- 모든 변경이 한 곳을 통과 - 플러그인은 이 transaction만 관찰하면 모든 편집 활동을 다 볼 수 있다. 검사·취소·추가 작업 전부 가능
이게 contenteditable의 두 번째 큰 함정을 풀어준다. 일반 contenteditable에서는 사용자 키 입력이 즉시 DOM을 바꿔버리니, 우리가 그걸 사후에 검사하고 정규화해야 한다. ProseMirror에서는 모든 편집이 transaction을 거치므로, 사후가 아니라 사전에 통제할 수 있다.
5. View - DOM과 transaction 사이의 다리
EditorView가 하는 두 가지 일
"Shows a given editor state as an editable element in the browser, and handles user interaction with that element."
View는 정확히 두 가지 일만 한다.
- 현재 state에 맞춰 DOM을 그린다
- 사용자가 화면을 만지면 (타이핑, 클릭, 붙여넣기 등) 그걸 받아 transaction으로 바꿔준다
import { EditorView } from "prosemirror-view";
const view = new EditorView(document.body, {
state,
dispatchTransaction(transaction) {
const newState = view.state.apply(transaction);
view.updateState(newState);
},
});여기서 가장 중요한 건 dispatchTransaction 콜백이다. 모든 문서 변경은 예외 없이 이 함수를 통과한다. 그래서 이 콜백은 일종의 편집의 단일 관문 역할을 한다.
이 관문을 가로채면 할 수 있는 일이 많아진다.
- 변경을 그대로 Redux 같은 외부 상태 관리로 보낼 수도 있고
- 협업 서버로 전송할 수도 있고
- 단순히 로깅하거나 분석에 보낼 수도 있다
ProseMirror는 contenteditable을 버리지 않고 그 위에 일관성 레이어를 한 겹 씌우는 접근을 택했다. 그래서 브라우저가 잘 해주는 일들 - 한국어 IME 입력, 모바일 가상 키보드, 드래그 앤 드롭, native 텍스트 셀렉션 같은 - 은 그대로 활용하고, 그 결과로 발생한 DOM 변경만 받아서 transaction으로 정규화한다. 직접 wheel 이벤트 가로채서 fake editor 만드는 방식보다 훨씬 자연스럽다.
6. 데이터 흐름 - 한 방향으로 도는 사이클
위 개념들을 합치면, ProseMirror에서 한 번의 편집은 항상 다음 사이클을 따라간다.
DOM event (사용자가 키를 누름)
↓
EditorView가 이벤트를 받아 transaction을 만듦
↓
dispatchTransaction(tr) 콜백 호출
↓
newState = state.apply(tr)
↓
view.updateState(newState)
↓
View가 DOM을 새 state에 맞춰 갱신
↓
(다시 다음 DOM event 대기)흐름이 한 방향으로만 돈다. Redux 써본 사람이라면 단번에 익숙할 모양이다. 모든 편집이 transaction이라는 한 지점을 반드시 통과한다는 보장이 다음 모든 좋은 성질을 만들어낸다.
- 플러그인이 모든 편집을 빠짐없이 볼 수 있다 (몰래 일어나는 DOM 변형이 없다)
- undo/redo는 transaction 스택만 다루면 된다 - DOM과는 무관
- 협업도 같은 모델로 환원된다 - "내 transaction은 서버로 보내고, 서버에서 받은 남의 transaction은 apply"
7. Plugin 시스템 - 에디터를 확장하는 표준 방법
"Used to extend the behavior of the editor and editor state in various ways."
Plugin은 ProseMirror에서 기능을 덧붙이는 표준 통로다. 그냥 함수가 아니라 여러 종류의 확장 슬롯이 정의된 객체라고 보면 된다. 다음과 같은 것들을 추가할 수 있다.
- 자기만의 state slot - 플러그인이 자체 상태를 editor state 안에 함께 보관할 수 있다 (예: 검색 모드의 현재 검색어)
- DOM 이벤트 핸들러
- decoration - 실제 문서를 바꾸지 않고 시각적인 표시만 덧붙이는 장치 (예: 검색 결과 하이라이트, 다른 사람의 커서 위치 표시)
- transaction filter / appender - 들어오는 transaction을 거부하거나, 거기에 추가 변경을 덧붙이는 훅
- command와 keymap -
Mod-z같은 단축키 바인딩
대표적인 공식 플러그인들도 같은 메커니즘 위에 만들어져 있다.
prosemirror-history- undo/redoprosemirror-keymap- 키 바인딩prosemirror-commands- Enter, Backspace 같은 표준 편집 동작prosemirror-schema-list- 리스트 처리prosemirror-collab- 협업 편집 (OT 기반)
import { history, undo, redo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";
const state = EditorState.create({
schema,
plugins: [
history(),
keymap({ "Mod-z": undo, "Mod-y": redo }),
keymap(baseKeymap),
],
});플러그인이 ProseMirror의 모든 좋은 기능 (undo, 단축키, 협업, 검색 하이라이트 등) 의 만들어지는 자리이고, 우리도 같은 모양으로 자기 기능을 추가할 수 있다.
8. 미니멈 셋업 한 번에 보기
위 개념들을 합치면, 실제로 동작하는 가장 작은 에디터는 이 정도다.
import { schema } from "prosemirror-schema-basic";
import { EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { history, undo, redo } from "prosemirror-history";
import { keymap } from "prosemirror-keymap";
import { baseKeymap } from "prosemirror-commands";
const state = EditorState.create({
schema,
plugins: [
history(),
keymap({ "Mod-z": undo, "Mod-y": redo }),
keymap(baseKeymap),
],
});
const view = new EditorView(document.body, {
state,
dispatchTransaction(tr) {
const newState = view.state.apply(tr);
view.updateState(newState);
},
});이게 전부다. schema, state, view, plugin, transaction - 5개 개념이 단 30줄 안에 다 담겼다. 처음엔 이름이 많아 헷갈려 보이지만, 한 번 이렇게 짜놓고 보면 각 개념이 어디에 자리잡는지 명확해진다.
9. 다른 라이브러리와의 관계
Tiptap
ProseMirror에 친절한 껍데기를 씌운 형태다. 학습 곡선이 훨씬 낮고, "extension"이라는 추상 단위로 schema와 plugin을 한 번에 묶어 쓰게 해준다. 다만 본질은 ProseMirror라서, 어느 정도 깊이 들어가는 순간 ProseMirror의 개념을 직접 알아야 한다.
Slate
React와의 통합이 매끄럽고, 데이터 모델도 비슷한 결의 immutable 트리다. 다만 schema 검증은 ProseMirror만큼 엄격하지 않아서, contenteditable 위에서 발생하는 정합성 이슈를 그렇게 강하게는 막아주지 않는다. 그래서 Slate 기반 프로덕트들이 종종 직접 정규화 코드를 더 많이 가지고 있다.
Lexical (Meta)
비교적 최근에 등장한 후발주자다. 트리 모델은 비슷하고 빠르며, 협업·decoration 같은 상위 기능도 깔끔하게 제공한다. 다만 생태계의 두께(블로그, 사례, 플러그인) 는 아직 ProseMirror가 더 많다.
Quill
가장 단순한 선택지다. 학습 곡선이 짧고 빠르게 띄울 수 있다. 단 "우리 문서엔 이런 노드 타입이 추가로 있어야 해" 같은 요구가 들어오는 순간, 자유도의 한계에 부딪힌다.
10. 언제 ProseMirror를 쓰면 좋고, 언제는 오버킬일까
잘 맞는 경우
- 블록 단위로 동작하는 도메인 특화 에디터 - Notion 스타일의 블록 에디터, 위키, 마크다운 라이브 프리뷰
- 협업 편집이 필요한 경우 -
prosemirror-collab이 OT(operational transform) 기반으로 잘 굴러간다 - schema 제약이 강해야 하는 경우 - 출판 시스템, 의료 기록처럼 "이상한 트리는 절대 안 됨" 이 핵심 요구일 때
- 사용자 입력을 깐깐하게 정규화해야 할 때 - 외부에서 붙여넣은 HTML을 깨끗하게 가공해 받아야 할 때
오버킬일 수 있는 경우
- 단순 댓글창이나 단순 폼 텍스트 - Quill, 또는 그냥 textarea로 충분하다
- 가벼운 마크다운 입력 - 직접 만들거나 Lexical이 더 빠르다
정리
- ProseMirror는 "문서가 항상 schema가 허용한 모양으로 유지된다" 를 강하게 보장하는 리치텍스트 toolkit이다
- 4개의 축 - Schema / State / Transaction / View - 으로 모든 것이 표현되고, 모든 편집은 transaction이라는 한 관문을 반드시 거친다
- contenteditable을 버리지 않고 그 위에 일관성 레이어를 씌우는 접근을 택했다. 그래서 IME·드래그·셀렉션 같은 native 동작은 그대로 살리면서, 결과 DOM 변경만 transaction으로 정규화한다
- Tiptap, Atlassian Editor, NYT, Outline 같은 큰 사용처가 두텁다. Tiptap 쓰는 사람도 결국엔 ProseMirror를 알아야 한다
내가 가장 인상 깊었던 건 모든 편집이 transaction이라는 단 하나의 관문을 통과한다는 설계 결단이다. 이 한 가지가 plugin 가시성, undo, 협업, 로깅을 한 자리에서 풀어버린다. 그동안 contenteditable 위에서 매번 DOM을 사후 정규화하던 시절의 불안감을 이 한 줄짜리 약속이 깔끔하게 정리해주는 느낌이다. 다음엔 prosemirror-collab이 협업 편집(OT)을 어떻게 다루는지 따라가보고 싶다.