Search

ProseMirror로 에디터를 구성할 때 꼭 알아야 할 핵심 구조

subtitle
prosemirror 기반의 에디터를 사용할 때 알아야하는 기본 구조
Tags
front-end
Created
2025/03/31
2 more properties

왜 알아야 할까?

ProseMirror는 완성품이 아니라 레고 박스에 가깝다. ProseMirror가 아닌 Quill.js나 Tiptap을 사용하면 되는 것 아닌가하는데 Quill은 ‘완성품’에 가깝기 때문에 커스터마이징이 어렵다. 한편, Tiptap은 ProseMirror 기반이지만, tiptap에서도 커스터마이징이 필요한 순간에는 ProseMirror에 대한 지식이 반드시 수반된다.
하지만 ProseMirror는 직접 다뤄보면 개념이 많고, 구조도 복잡하다. 이 글은 ProseMirror의 공식 문서를 직접 읽고 정리한 내용이고, 핵심 구조와 개념을 빠르게 파악할 수 있도록 돕는다.
ProseMirror가 내부적으로 문서를 어떻게 표현하고 관리하는지 이해할 수 있다.
트랜잭션, Transform, Plugin 같은 중요한 개념을 사례와 함께 파악할 수 있다.
position, indexing 등을 통해 원하는 위치의 콘텐츠를 마음대로 컨트롤할 수 있게 된다.

ProseMirror의 문서 구조: doc, fragment, node

ProseMirror에서 에디터 콘텐츠는 document → fragment → node 구조로 표현된다.
document: 에디터 전체 콘텐츠를 나타내는 최상위 객체
fragment: 여러 노드를 묶어 놓은 것
node: 실제 콘텐츠 단위 (텍스트, 이미지 등)
특히 leaf node는 콘텐츠를 가질 수 없는 노드로, 예를 들어 이미지, 가로줄, 동영상 등이 여기에 해당한다.

왜 ProseMirror의 노드 구조는 플랫할까?

ProseMirror는 주로 텍스트 편집에 특화되어 있다. 그래서 DOM처럼 중첩된 트리 구조가 아닌, 플랫한 구조를 사용한다. 이렇게 하면 특정 위치를 offset 기반으로 바로 찾을 수 있고, 마크(mark)를 통한 스타일 조작도 훨씬 단순해진다.
또한 인접한 노드의 마크는 자동으로 결합되기 때문에, 스타일 변경이 더 직관적이다.

Position, Token, Slice란?

position: 문서에서 노드가 차지하는 위치 (숫자 인덱스로 관리)
token: 텍스트 콘텐츠를 분해한 최소 단위 (예: helloh, e, l, l, o)
slice: 복사/붙여넣기, 드래그앤드롭 시 사용되는 콘텐츠 덩어리. openStart, openEnd를 통해 어느 정도 깊이에서 잘린 것인지 알 수 있다.

문서 변경은 Transaction과 Transform을 통해 일어난다

ProseMirror는 상태 불변성을 유지하기 위해 직접 문서를 수정하지 않는다. 대신 transaction을 통해 변경을 누적하고, 이 안에서 여러 step들이 문서를 점진적으로 바꾼다.
let tr = state.tr tr.insertText("hello") // selection 자리에 hello 삽입 let newState = state.apply(tr)
JavaScript
복사
Transform은 실제 DOM 변경이 아닌, 변경 내역(step)을 쌓는 구조다. 이를 통해 협업 편집과 undo/redo 와 같은 기능을 구현할 수 있다.

Step과 Mapping: 포지션을 잃지 않고 따라가기

문서가 바뀌면 기존에 알고 있던 position이 무의미해질 수 있다. 이걸 해결하는 게 step.getMap() 이다. 삽입/삭제 등의 변화가 있어도, 이전 포지션을 새로운 포지션으로 변환할 수 있게 해준다.
let map = step.getMap() map.map(10) // 문서 변경 전 position 10 → 변경 후 몇 번으로 이동했는지 찾는다.
JavaScript
복사
이 기능 덕분에 selection도 정확하게 추적 가능하다.

에디터 상태(state)의 구성

ProseMirror의 EditorState는 다음 세 가지로 구성된다:
doc: 문서 자체
selection: 현재 선택 범위
storedMarks: 다음 입력될 텍스트에 적용할 마크 정보
추가로 undo 히스토리나 플러그인 상태도 함께 저장된다. 이 모든 것은 불변 상태로 유지된다.

Plugin: 에디터 기능의 핵심 확장 포인트

Plugin을 통해 에디터 기능을 확장할 수 있다. 키 이벤트 처리, 커스텀 상태 관리, 데코레이션 등 다양한 방식으로 사용된다.
let myPlugin = new Plugin({ props: { handleKeyDown(view, event) { console.log("key pressed") return false } } })
JavaScript
복사
상태를 가지는 플러그인도 만들 수 있다:
state: { init() { return 0 }, apply(tr, value) { return value + 1 } }
JavaScript
복사

Decorations: 문서 외관을 꾸미는 법

문서 내용을 바꾸지 않고 하이라이팅, 스타일링, 위젯 삽입 등을 하고 싶을 때는 Decoration을 사용한다.
Decoration.inline(0, 5, {style: "background: yellow"})
JavaScript
복사
많은 데코레이션이 있을 경우, 상태로 저장해서 tr.mapping으로 업데이트하는 것이 성능상 유리하다.

NodeView: 노드마다 독립적인 UI 컴포넌트

특정 노드에 대해 완전히 독립적인 UI를 만들고 싶다면 NodeView를 활용할 수 있다. 이미지, 비디오, 코드블록 등에 자주 사용된다.
class ImageView { constructor(node, view, getPos) { this.dom = document.createElement("img") this.dom.src = node.attrs.src this.dom.addEventListener("click", () => { const alt = prompt("새 alt 입력:") if (alt) view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { src: node.attrs.src, alt })) }) } }
JavaScript
복사

Commands: 명령어 인터페이스로 편집 동작 정의

Command는 사용자의 편집 행동(삭제, 삽입 등)을 코드로 정의한 함수다. 상태와 dispatch를 받아 실행 가능 여부를 판단하고, 실행한다.
function deleteSelection(state, dispatch) { if (state.selection.empty) return false if (dispatch) dispatch(state.tr.deleteSelection()) return true }
JavaScript
복사
여러 command를 묶어 순차적으로 실행할 수도 있다:
chainCommands(deleteSelection, joinBackward, selectNodeBackward)
JavaScript
복사

협업 편집

ProseMirror는 step 기반의 트랜잭션을 통해 충돌 없는 협업 편집을 구현할 수 있다. collab 플러그인을 사용하면 로컬 변경을 step으로 관리하고, 중앙 서버(authority)와 주고받는다.

결론

Tiptap과 같이 훌륭한 에디터가 있다고 해도, 까다로운 요구사항들이 있을 경우 커스터마이징이 필요하다. 그런 경우에는 ProseMirror에 대한 기본 지식이 꼭 필요하다. 그래야 올바른 방식으로 에디터를 컨트롤 할 수 있다. 이처럼 ProseMirror는 학습 곡선이 있는 편이지만, 내부 구조를 이해하면 아주 강력한 에디터를 만들 수 있다.