에디터 아키텍처

리치 텍스트 에디터의 기본 아키텍처 이해하기: 모델-뷰 분리, 문서 모델 설계, 뷰 레이어 책임, 상태 관리.

개요

현대적인 리치 텍스트 에디터들은 문서 모델과 시각적 표현을 분리하는 일관된 아키텍처 패턴을 따릅니다. 이 분리는 유지보수 가능하고 확장 가능한 에디터의 기초입니다.

모델-뷰 분리

에디터 아키텍처의 핵심 원칙은 문서 모델(내부 표현)과 (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용 데코레이터 시스템