개요
현대적인 리치 텍스트 에디터들은 문서 모델과 시각적 표현을 분리하는 일관된 아키텍처 패턴을 따릅니다. 이 분리는 유지보수 가능하고 확장 가능한 에디터의 기초입니다.
모델-뷰 분리
에디터 아키텍처의 핵심 원칙은 문서 모델(내부 표현)과 뷰(DOM)를 분리하는 것입니다.
문서 모델
- 추상적 표현
- 스키마 검증 구조
- 불변 또는 버전 관리
- 프레임워크 독립적
- 독립적으로 테스트 가능
- 위치 기반 (경로, DOM 아님)
뷰 (DOM)
- HTML 표현
- 사용자에게 보이는 인터페이스
- 변경 가능하고 상호작용 가능
- 브라우저별 특이사항
- 사용자 입력 처리
- DOM 기반 선택
왜 분리해야 하나?
1. DOM은 신뢰할 수 없습니다:
- 브라우저별 동작과 특이사항
- 일관성 없는 HTML 구조
- DOM 변경 후 선택이 무효화될 수 있음
- 테스트와 추론이 어려움
2. 모델은 예측 가능성을 제공합니다:
- 스키마 검증 구조
- 일관된 표현
- 프레임워크 독립적
- 테스트가 더 쉬움
3. 고급 기능을 가능하게 합니다:
- 히스토리를 통한 실행 취소/다시 실행
- 협업 편집
- 다양한 형식으로 직렬화
- 동일한 문서의 여러 뷰
4. 작업을 조합 가능하게 만듭니다:
- 변환을 결합할 수 있음
- 작업이 되돌릴 수 있음
- 적용 전에 검증 가능
모델의 특성
문서 모델은 다음을 만족해야 합니다:
- 추상적: 렌더링 방식과 독립적
- 검증됨: 항상 스키마를 준수
- 불변 또는 버전 관리: 히스토리와 실행 취소/다시 실행 가능
- 위치 기반: 경로/오프셋 사용, DOM 참조 아님
- 직렬화 가능: JSON, HTML, Markdown 등으로 변환 가능
// 문서 모델 예제
{
type: 'document',
children: [
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'world', marks: [{ type: 'bold' }] }
]
},
{
type: 'heading',
level: 1,
children: [{ type: 'text', text: 'Title' }]
}
]
}뷰의 특성
뷰 레이어는 다음을 담당합니다:
- 렌더링: 모델을 DOM으로 변환
- 입력 처리: 사용자 입력을 가로채고 모델 작업으로 변환
- 선택 동기화: DOM 선택을 모델 선택과 동기화 유지
- DOM 업데이트: 변경된 부분만 효율적으로 업데이트
뷰는 모델의 투영이며, 진실의 원천이 아닙니다. 모델이 변경되면 뷰가 업데이트됩니다. 사용자가 뷰와 상호작용하면 모델 작업이 트리거됩니다.
// 뷰 레이어 책임
class View {
// 모델을 DOM으로 렌더링
render(model) {
// 모델 노드를 DOM 요소로 변환
}
// 사용자 입력 처리
handleInput(event) {
// DOM 입력을 모델 작업으로 변환
const operation = this.inputToOperation(event);
this.editor.apply(operation);
}
// 선택 동기화
syncSelection(modelSelection) {
// 모델 선택을 DOM 선택으로 변환
const domSelection = this.modelToDOMSelection(modelSelection);
this.setDOMSelection(domSelection);
}
}문서 모델
문서 모델은 진실의 원천입니다. 렌더링 방식과 독립적으로 문서 구조를 나타냅니다.
모델 구조
문서는 계층적 트리입니다:
- 블록 노드: 단락, 제목, 목록, 코드 블록
- 인라인 노드: 텍스트, 링크, 이미지 (블록 내부)
- 텍스트 노드: 마크가 있는 실제 텍스트 콘텐츠
// 완전한 문서 모델 예제
{
type: 'document',
children: [
{
type: 'heading',
level: 1,
children: [
{ type: 'text', text: 'Introduction' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'This is a ' },
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' and ' },
{ type: 'text', text: 'italic', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' text.' }
]
},
{
type: 'paragraph',
children: [
{ type: 'text', text: 'Visit ' },
{
type: 'link',
attrs: { href: 'https://example.com' },
children: [
{ type: 'text', text: 'example.com' }
]
},
{ type: 'text', text: ' for more.' }
]
}
]
}불변성
많은 에디터가 불변 모델을 사용하는 이유:
- 쉬운 실행 취소/다시 실행 (이전 버전만 저장)
- 시간 여행 디버깅
- 예측 가능한 업데이트
- 프레임워크 통합 (React 등)
// 불변 모델 업데이트
function insertText(model, position, text) {
// 변경하지 않고 새 모델 생성
return {
...model,
children: model.children.map((child, index) => {
if (index === position.block) {
return insertTextInBlock(child, position, text);
}
return child;
})
};
}버전 관리
일부 에디터는 버전 관리와 함께 변경 가능한 모델을 사용합니다:
- 대용량 문서에 더 효율적
- 버전 번호로 변경 추적
- 여전히 실행 취소/다시 실행 구현 가능
- 협업 편집 구현이 더 쉬움
// 버전 관리 모델
class Document {
constructor() {
this.nodes = [];
this.version = 0;
}
insertText(position, text) {
// 모델 변경
this.doInsertText(position, text);
this.version++;
return this.version;
}
getVersion() {
return this.version;
}
}뷰 레이어
뷰 레이어는 모델을 DOM으로 렌더링하고 사용자 상호작용을 처리합니다.
렌더링
렌더링은 모델 노드를 DOM 요소로 변환합니다:
function renderNode(node) {
switch (node.type) {
case 'paragraph':
return createElement('p', renderChildren(node.children));
case 'heading':
return createElement(`h${node.level}`, renderChildren(node.children));
case 'text':
let element = document.createTextNode(node.text);
// 마크 적용
if (node.marks) {
node.marks.forEach(mark => {
element = wrapWithMark(element, mark);
});
}
return element;
case 'link':
const link = createElement('a', { href: node.attrs.href });
link.appendChild(renderChildren(node.children));
return link;
}
}입력 처리
뷰는 사용자 입력을 가로채고 모델 작업으로 변환합니다:
editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'insertText') {
e.preventDefault();
// 모델에서 현재 선택 가져오기
const selection = getModelSelection();
// 작업 생성
const operation = {
type: 'insertText',
position: selection.anchor,
text: e.data
};
// 모델에 적용
editor.applyOperation(operation);
// 모델 변경이 뷰 업데이트를 트리거
}
});선택 동기화
선택은 DOM과 모델 사이에서 동기화되어야 합니다:
// 사용자가 DOM에서 선택을 변경할 때
editor.addEventListener('selectionchange', () => {
const domSelection = window.getSelection();
const modelSelection = domToModelSelection(domSelection);
editor.setSelection(modelSelection);
});
// 모델이 변경될 때
editor.on('modelChange', () => {
const modelSelection = editor.getSelection();
const domSelection = modelToDOMSelection(modelSelection);
setDOMSelection(domSelection);
});상태 관리
에디터 상태에는 다음이 포함됩니다:
- 문서: 현재 문서 모델
- 선택: 모델 좌표의 현재 선택
- 히스토리: 실행 취소/다시 실행 스택
- 스키마: 문서 스키마 정의
- 플러그인: 플러그인 상태
class EditorState {
constructor(schema) {
this.schema = schema;
this.doc = createEmptyDocument(schema);
this.selection = null;
this.history = new History();
this.plugins = new Map();
}
apply(operation) {
// 작업 검증
if (!this.validate(operation)) {
return false;
}
// 히스토리에 저장
this.history.push(this.doc, this.selection);
// 작업 적용
this.doc = this.transform(this.doc, operation);
// 선택 업데이트
this.selection = this.updateSelection(this.selection, operation);
// 플러그인에 알림
this.notifyPlugins('operation', operation);
return true;
}
}아키텍처 패턴
에디터 아키텍처의 일반적인 패턴:
플러그인 시스템
플러그인은 에디터 기능을 확장합니다:
class Plugin {
constructor(editor) {
this.editor = editor;
}
install() {
// 훅 등록
this.editor.on('operation', this.handleOperation);
this.editor.on('render', this.handleRender);
}
uninstall() {
// 정리
this.editor.off('operation', this.handleOperation);
}
handleOperation(operation) {
// 작업을 가로채거나 수정
}
}
// 사용법
editor.use(new HistoryPlugin());
editor.use(new LinkPlugin());
editor.use(new ImagePlugin());명령 시스템
명령은 고수준 작업입니다:
class Command {
constructor(editor) {
this.editor = editor;
}
canExecute() {
// 명령을 실행할 수 있는지 확인
return true;
}
execute() {
// 여러 작업을 조합
const operations = this.getOperations();
operations.forEach(op => this.editor.apply(op));
}
}
class BoldCommand extends Command {
execute() {
const selection = this.editor.getSelection();
if (selection.isCollapsed) {
// 다음 문자에 볼드 토글
this.editor.setPendingMark('bold');
} else {
// 선택 영역에 볼드 적용
this.editor.apply({
type: 'applyMark',
range: selection,
mark: { type: 'bold' }
});
}
}
}변환 시스템
변환은 모델을 수정하는 저수준 작업입니다:
class Transform {
insertText(doc, position, text) {
// 위치에 텍스트 삽입
// 삽입 후 모든 위치 업데이트
return newDocument;
}
deleteRange(doc, range) {
// 범위의 콘텐츠 삭제
// 삭제 후 모든 위치 업데이트
return newDocument;
}
applyMark(doc, range, mark) {
// 범위의 텍스트에 마크 적용
return newDocument;
}
splitNode(doc, position) {
// 위치에서 노드 분할
return newDocument;
}
}다양한 에디터의 접근 방식
다양한 에디터가 이러한 개념을 다르게 구현합니다:
ProseMirror
- 엄격한 스키마 기반 검증
- 불변 문서 모델
- 변환 기반 작업
- 별도의 상태 및 뷰 패키지
Slate
- React 우선 아키텍처
- React 상태와 함께 불변 모델
- 작업 기반 변환
- 정규화 시스템
Lexical
- 프레임워크 독립적 코어
- 조정과 함께 변경 가능한 모델
- 이벤트 기반 업데이트
- UI용 데코레이터 시스템