추상화 문서 모델과 에디터(feat. TOAST UI Editor 3.0)


TOAST UI Editor는 마크다운을 기반으로 한 에디터이지만, 위지윅 에디터를 통합한 형태의 인터페이스를 제공한다. 이러한 특징 덕분에 마크다운에 익숙하지 않은 사용자도 마크다운 기반의 문서를 손쉽게 편집할 수 있어 개발자와 비 개발자 모두를 위한 에디터로 유용하게 사용될 수 있다. 예를 들어 개발자가 테이블이나 이미지 마크다운 문법을 사용해서 작성한 내용을 비개발자는 복잡한 문법을 사용하지 않고 위지윅 에디터에서 직관적으로 편집할 수 있다.

최근에 릴리즈한 TOAST UI Editor 3.0에서는 이러한 우리의 강점을 더 강화하기 위해, 마크다운 에디터와 위지윅 에디터 간의 구조적 통일성과 호환성을 높이고자 노력하였다. 이 과정에서 우리는 추상화 문서 모델을 에디터에 도입하였는데, 이는 기존 버전이 갖고 있던 많은 문제점을 해결할 뿐만 아니라 앞으로 다양한 확장의 가능성을 열어 줄 아주 중요한 변화이다.

이 글에서는 3.0의 중심에 있는 추상화 문서 모델의 개념에 대해 먼저 짚어보고, 이를 왜 도입하게 되었는지 설명할 것이다. 그리고 TOAST UI Editor가 가졌던 기존의 문제점과 한계점에 대해 알아보고, 3.0에서는 이러한 문제가 어떻게 해결되었는지 살펴볼 것이다.

DOM 기반의 위지윅(WYSIWYG; What You See Is What You Get) 에디터

HTML에서는 contentEditable속성을 사용하여 DOM 노드의 편집을 활성화할 수 있다.

<div contenteditable="true"></div>

images

그리고 이 편집 영역 내에서 셀렉션을 조작하고 명령으로 다양한 노드들을 추가, 삭제할 수 있는 애플리케이션을 우리가 흔히 위지윅(WYSIWYG; What You See Is What You Get) 에디터라고 부른다. 이외에도 위지윅 에디터에서는 편집 중 발생하는 크로스 브라우징, 셀렉션 및 키 이벤트 처리 등과 같은 다양한 이슈를 에디터 내에서 직접 처리한다.

웹 에디터의 초창기에 대부분의 위지윅 에디터는 DOM을 기반으로 동작하였다. 이런 경우 사용자가 편집한 내용이 바로 DOM에 반영된다. 물론 위와 같은 이슈를 해결하기 위해 DOM에 반영하기 전에 보정하는 작업을 수행한다(경우에 따라서는 DOM에 변경 사항이 반영된 후 브라우저 이슈를 위해 후처리로 보정하는 경우도 있다). DOM을 데이터 모델로서 사용하는 이러한 에디터의 대표적인 예로 CKEditor 4, TinyMCE, Squire 등이 있다.

TOAST UI Editor 2.x의 위지윅 에디터도 DOM 기반의 위지윅 에디터 Squire를 기반으로 구현되었다. 하지만 이러한 DOM 기반의 에디터는 아래와 같은 문제점들이 있다.

DOM 콘텐츠와 시각적 콘텐츠 사이의 맵핑이 다를 수 있다.

아래의 문장을 보자.

Powerful Rich Text Editor, TOAST UI Editor 3.0

마지막 단어 TOAST UI Editor 3.0은 아래처럼 여러 DOM 구조로 표현할 수 있다.

<strong><em>TOAST UI Editor 3.0</em></strong>
<em><strong>TOAST UI Editor 3.0</strong></em>
<em><strong>TOAST UI</strong><strong>Editor 3.0</strong></em>
<em><strong>TOAST UI</strong></em><strong><em>Editor 3.0</em></strong>

시각적으로는 볼드, 이탤릭이 적용된 한 문장으로 보이지만 여러 DOM 구조를 가질 수 있는 것이다. 이러한 1 : N 맵핑 관계에서 편집 중인 콘텐츠를 일관성있게 유지하는 것은 거의 불가능하다. 또한 편집 명령(브라우저 API 또는 별도의 구현 함수)을 실행하여 커서의 위치를 변경하거나, 문자를 삽입하는 경우에도 서로 다른 결과를 가져올 수도 있다.

DOM 셀렉션과 시각적 셀렉션 사이의 맵핑이 다를 수 있다.

셀렉션은 콘텐츠보다 더 복잡하다.

Powerful Rich Text Editor <strong><em>TOAST UI Editor 3.0</em></strong>

위의 문장에서 TOAST UI Editor 3.0 앞에 커서를 두는 것은 세 가지 경우의 수가 있다.

  • <strong> 시작 태그 앞
  • <strong> 시작 태그와 <em> 시작 태그 사이
  • <em> 시작 태그 뒤

그리고 이 위치에서 글자를 입력한다면 볼드일까? 이탤릭일까? 일반 텍스트일까? 명확하게 확신할 수 없다. 또한 커서 위치가 동일하지만 시각적으로 보이는 위치가 다를 수도 있다.

Powerful Rich Text Editor TOAST UI-
Editor 3.0

위의 문장에서 첫 번째 라인의 마지막 위치와, 두 번째 라인의 첫 번째 위치는 커서 위치가 동일하지만 보이는 위치가 다르다. 이처럼 셀렉션은 N : N 맵핑 관계를 가지며, 이를 각 브라우저에서 일관성있게 지정하는 것은 굉장히 머리 아픈 일일 것이다.

각 브라우저의 contentEditable 동작이 다를 수 있으므로, 일관적인 편집 결과를 얻기 어렵다.

크롬, 엣지, 파이어 폭스 등 브라우저는 각자 다른 엔진을 가지고 있으며, 동일한 명세를 구현하였더라도 동작이 다를 수 있다. contentEditable 역시 동작이 다를 수 있고, 여기서 또 위지윅 에디터 별로 생성되는 HTML이 다를 수 있다.

만약 파이어폭스에서 복사한 다음 사파리로 붙여넣으면 어떻게 될까? 이런 경우 콘텐츠가 깨진다면 버그를 수정하기 굉장히 어렵다. 하지만 사용자들은 다른 브라우저나 에디터에서 작성된 콘텐츠를 위지윅 에디터에 붙여넣어도 제대로 동작하길 기대할 것이다.

결과적으로 위의 이슈들은 편집의 결과를 예측하기 어렵게 만든다. 그리고 기존의 TOAST UI Editor의 위지윅 에디터 역시 동일한 문제를 가진다.

하지만 추후 이러한 문제를 해결하고 편집 결과를 예측 가능한 경우의 수로 제한하는 방법이 등장하게 된다. 바로 추상화 문서 모델이다.

추상화 문서 모델

추상화 문서 모델은 말 그대로 DOM에서 필요한 정보를 투영하여 유사하게 만들어진 문서 구조를 의미한다. 이러한 추상화 문서 모델을 에디터에 적용한다면 어떠한 장점이 있을까?

추상화 문서 모델이 있다면 에디터는 자체적인 모델 구조를 사용하여 변경 사항을 제어할 수 있으며 이를 DOM에 맵핑하여 반영할 수 있다. 즉 추상화 문서 모델을 사용함으로써 에디터는 사용자가 편집한 변경 사항을 일관성있는 동작으로 변환하여 DOM에 적용할 수 있는 것이다. 그렇기 때문에 편집 결과의 경우의 수를 제어하여 에디터의 콘텐츠를 훨씬 편리하게 관리할 수 있다.

앞에서 보았던 문장을 다시 예시로 보겠다.

Powerful Rich Text Editor, TOAST UI Editor 3.0

DOM을 모델로 사용한다면 에디터에서 위의 문장이 여러 DOM 구조로 표현될 수 있음을 보았다. 이런 경우 추상화 문서 모델을 사용한다면 사용자가 볼드 → 이탤릭, 이탤릭 → 볼드 또는 문장을 나누어 노드를 편집한다고 해도 아래처럼 일관된 형태의 DOM 구조로 렌더링할 수 있다. 이것이 가능한 이유는 DOM에 반영되기 전에 먼저 추상화 문서 모델을 통해 일관된 렌더링 형태로 변환할 수 있기 때문이다.

Powerful Rich Text Editor, <strong><em>TOAST UI Editor 3.0</em></strong>

이외에도 외부 콘텐츠를 붙여넣는 동작이나 셀렉션, 키 입력 이벤트를 캡처하여 에디터의 내부 모델에 맞게 가공할 수 있다.

추상화된 문서 모델을 사용하는 에디터는 일반적으로 아래와 같은 특징을 가진다.

  • contentEditable을 입력 이벤트 처리 용도로만 사용한다.
  • 기본 셀렉션, 키 입력 이벤트를 캡처하여 동작을 직접 제어한다.
  • 별도의 추상화된 문서 모델을 사용하여 DOM에 반영한다.
  • 외부 컨텐츠를 붙여넣을 경우 내부 문서 모델로 변환하여 적용한다.

추상화 문서 모델이 적용된 위지윅 에디터의 구조를 아주 간단하게 표현한다면 아래와 같을 것이다.

images

이러한 에디터의 종류로는 ProseMirror, Quill, Draft.js 등이 있다.

TOAST UI Editor + ProseMirror

우리는 앞서 살펴본 추상화 문서 모델을 TOAST UI Editor에 적용한다면 현재 우리의 에디터가 가진 많은 한계점을 개선할 수 있으리라 판단하였다. 또한 마크다운 에디터의 편집기와 위지윅 에디터가 같은 모델 구조를 가짐으로써 구조적으로도 통합하기 좋을 것이라 생각하였다.

초기에는 문서 모델과 공용의 에디터 뷰 모두 자체적으로 만드는 것도 고려해보았지만, 개발자 2명이서 코어 구조를 다시 만들고 이 코어 구조를 기반으로 한 마크다운 에디터와 위지윅 에디터까지 개발하는 것은 리소스 상 무리가 있다고 판단되었다. 그래서 우리는 여러 오픈소스를 대상으로 몇 주간의 리서치와 프로토타이핑 기간을 가진 후 ProseMirror를 최종적으로 선택하였다. ProseMirror는 문서 모델을 구성하는 스키마를 커스터마이징 하는 방법을 제공하기 때문에 마크다운, 위지윅 에디터의 뷰를 모두 변경하려는 우리의 목적에 부합하였다.

변경된 3.0의 구조는 아래처럼 구조를 요약할 수 있다.(비교를 위해 2.x의 구조도 추가하였다.)

2.x의 구조

images

3.0의 구조

images

3.0에서 ProseMirror는 기존 마크다운의 편집기 역할을 하던 CodeMirror와 위지윅 에디터의 코어 라이브러리 Squire를 대체한다. 그리고 여기서 우리가 정의한 Markdown Abstract model, WYSIWYG Abstract model은 각각 아래와 같은 역할을 수행한다.

  • Markdown Abstract model 마크다운 편집 영역의 노드를 추상화한 모델로서 Toastmark가 생성한 AST와 편집 위치, 편집 내용과 같은 정보를 주고 받는다. 이 정보는 구문 강조 및 마크다운의 실시간 미리보기 영역 렌더링에 사용된다.(내부적으로 이 둘 사이의 위치 보정을 위해 계산 연산이 실행된다.) 이를 그림으로 표현하면 아래와 같다.

image

참고로 마크다운 에디터는 위지윅 에디터와는 달리 단순 텍스트로 구성되기 때문에 마크다운 편집 영역의 추상화 모델 역시 텍스트 작성을 위한 paragraph 노드만을 가지도록 설계하였다.

  • WYSIWYG Abstract model 위지윅 추상화 모델은 에디터 뷰에서 사용할 각각의 노드의 형태와 동작을 제어한다.

그리고 우리는 이 구조를 적용하여 다음과 같은 목적을 달성할 수 있었다.

통일성

위의 2.x 구조에서 볼 수 있듯이 기존 TOAST UI Editor는 CodeMirror와 Squire라는 완전히 다른 구조로 마크다운, 위지윅 에디터의 콘텐츠를 편집하고 관리하였다. 각각의 에디터를 제어하는 구조가 서로 완전히 달랐기 때문에 내부적인 코드 유지 보수에 많은 어려움을 겪었다. 동일한 기능을 추가하기 위해 완전히 다른 구조에서 별도로 개발해야 했고 이로 인해 이해하기 어려운 코드들이 양산되었다.

하지만 추상화 모델을 적용하여 각 에디터의 구조와 오퍼레이션을 일관성있게 통일할 수 있었다. 예를 들어 에디터 노드의 구조는 아래처럼 추상화 모델을 구성하는 스키마와 커맨드, 키맵을 정의하는 클래스 구조로 모두 통일되었다. 이 구조는 마크다운과 위지윅 양쪽 모두 동일한 추상화 문서 모델 구조를 사용하지 않았다면 불가능한 일이었을 것이다.

export class ListItem extends NodeSchema {
  get name() {
    return 'listItem';
  }

  get schema() {
    return {
      content: 'paragraph listGroup*',
      attrs: {
        task: { default: false },
        checked: { default: false },
        rawHTML: { default: null },
      },
      defining: true,
      parseDOM: [
        // ...
      ],
      toDOM({ attrs }: ProsemirrorNode): DOMOutputSpecArray {
        // ...
      },
    };
  }

  commands(): EditorCommand {
    // ...
  }

  keymaps() {
    return {
      Enter: this.commands()(),
    };
  }
}

또한 각 에디터의 편집 동작을 제어하거나 이벤트를 제어하기 위한 내부 플러그인도 동일한 형태로 가져갈 수 있었다.

return new EditorView(this.el, {
  state,
  handleKeyDown: (_, ev) => {/* ... */},
  handleDOMEvents: {
    copy: (_, ev) => this.captureCopy(ev),
    cut: (_, ev) => this.captureCopy(ev, EVENT_TYPE),
    scroll: () => {/* ... */},
    keyup: (_, ev: KeyboardEvent) => {/* ... */},
  },
  // ...
});

코드 베이스에서 통일된 구조와 일관성있는 코드 덕분에 그 동안 내부적으로 작성된 수많은 스파게티 코드들을 깔끔하게 정리할 수 있었다.

호환성

기존의 에디터는 마크다운 콘텐츠를 위지윅 콘텐츠로 변환할 때 특정 케이스에 따라 DOM 또는 HTML 문자열을 전, 후처리하는 코드가 다수 존재하였다. 앞서 언급했듯이 2.x의 위지윅 에디터는 DOM을 모델로 사용하기 때문이다.

convertor라는 변환 모듈에서 이런 전, 후처리를 하였고, 아래에서 일부 복잡한 처리 코드를 확인할 수 있다.


// 2.x의 에디터 간 변환 코드 일부
toMarkdown(html, toMarkOptions) {
  const result = [];

  html = this.eventManager.emitReduce('convertorBeforeHtmlToMarkdownConverted', html);
  html = this._appendAttributeForBrIfNeed(html);
  // ...
}

_appendAttributeForBrIfNeed(html) {
  // ...
  html = html.replace(
    FIND_PASSING_AND_NORMAL_BR_RX,
    '<br data-tomark-pass /><br data-tomark-pass />$1'
  );
  html = html.replace(FIND_FIRST_TWO_BRS_RX, '$1<br /><br />');

  // Preserve <br> when there is only one empty line before or after a block element.
  html = html.replace(
    /(.)<br \/><br \/>(<h[1-6]>|<pre>|<table>|<ul>|<ol>|<blockquote>)/g,
    '$1<br /><br data-tomark-pass />$2'
  );
  html = html.replace(
    /(<\/h[1-6]>|<\/pre>|<\/table>|<\/ul>|<\/ol>|<\/blockquote>)<br \/>(.)/g,
    '$1<br data-tomark-pass />$2'
  );

  return html;
}

toHTML(markdown) {
  let html = this._markdownToHtml(markdown);

  html = this.eventManager.emitReduce('convertorAfterMarkdownToHtmlConverted', html);
  html = this._removeBrToMarkPassAttributeInCode(html);

  return html;
}

만약 기존 2.x 에서 새로운 노드 형태를 추가하거나 변환 코드의 동작을 바꾼다면 또 다시 DOM 전, 후처리 코드가 양산될 것이다.

3.0에서 이러한 코드는 모두 제거되었고, Toastmark의 AST와 위지윅 문서 모델 간의 구조를 적절히 맵핑하여 명확하게 변경할 수 있게 되었다. 새로운 구조에서는 문서 모델의 규격에 맞게 필요한 노드와 동작만 정의하면, 깔끔하게 두 에디터의 변환 동작을 호환할 수 있다.

확장성

추상화 모델을 통해 앞서 언급한 구조적 통일성, 에디터 간의 호환성을 높이는 목적을 달성하였고, 이는 자연스럽게 다양한 기능으로 확장할 수 있는 초석이 되었다. 문서 모델이 있다면, 원하는 형태의 노드를 추가하여 기능을 확장하거나, 반대로 추가되면 안되는 노드를 제어하는 것도 간단하게 할 수 있다. 이러한 장점을 이용하여 우리는 플러그인 시스템을 대폭 개선할 수 있었고, 마크다운 커스텀 블록과 같은 기능 지원도 가능하게 되었다.

3.0에서 개선된 점

추상화 문서 모델의 장점과 이로 인해 우리가 달성한 목적을 충분히 설명하였다. 그렇다면 이제 기존보다 TOAST UI Editor 3.0의 어떠한 점이 개선되었는지 알아보자.

더 정확해진 에디터 간 변환(호환성 향상)

두 에디터 간의 변환은 Toastmark의 AST(마크다운 노드의 정보)와 추상화 문서 모델(위지윅 노드의 정보)를 순회하며 상호 변환하는 방식으로 변경되었다. 변환에 필요한 정보들을 내부 모델에 모두 저장하고 있기 때문에 더 정확한 변환이 가능해진 것이다.

CommonMark 스펙의 ATX 헤딩Setext 헤딩을 예시로 보자.

마크다운에서 헤딩 노드는 ATX 헤딩과 Setext 헤딩 두 가지 형태로 작성할 수 있다. 자세한 스펙은 위의 링크를 참조 바란다.

2.x

images

3.0

images

2.x에서는 변환 시 두 헤딩 노드의 정보를 유지하지 못한다. 반면 3.0에서는 두 노드의 정보를 그대로 보존하여 정확하게 변환되는 것을 볼 수 있다.

커스텀 문법 지원(기능 확장)

3.0에서는 TOAST UI Editor만의 커스텀 마크다운 문법을 사용하여 원하는 노드를 렌더링할 수 있다. 아래 그림과 같이 커스텀 마크다운 문법을 사용하여 KaText 형태의 수식을 표현할 수 있다.

image

이러한 기능 지원이 가능한 것은 위지윅 에디터의 추상화 모델에서 해당 노드에 대한 정보를 가지고 편집할 수 있기 때문이다. 기존 2.x의 위지윅 에디터였다면 DOM에서 아래와 같은 다양한 형태의 노드를 추가하고 제어해야만 하는데 이것은 굉장히 고통스러운 일이 되었을 것이다.

image

편집 노드의 일관성

사용자가 호출한 편집 명령의 순서나 텍스트 영역에 상관없이 일관된 형태로 문서 구조를 만들 수 있다. 아래 문장을 세 가지 형태의 편집 명령을 실행하여 각각 2.x, 3.0에서 어떠한 결과로 보이는지 살펴보자.

Powerful Rich Text Editor TOAST UI Editor 3.0

2.x

images

3.0

images

2.x의 위지윅 에디터에서는 볼드, 이탤릭 명령을 어떤 순서로 실행하였는지, 어떻게 텍스트를 나누어 변경하였는지에 따라 노드의 편집 결과(DOM 구조)가 모두 다르다. 반면, 3.0에서는 항상 일관성 있게 예측 가능한 편집 결과를 보여준다.

맺음말

지금까지 TOAST UI Editor 3.0을 만들게 된 배경과 목적에 대해 살펴보았다. 그리고 이 중심에는 추상화 문서 모델이 있었다. 우리는 추상화 문서 모델을 이용하여 많은 한계점을 극복할 수 있었고, 에디터 간의 호환성과 기능 확장성에서 많은 개선을 이루어냈다. 이러한 변화는 앞으로도 TOC, Suggestion Popup, 커스텀 인라인 문법 지원 등 다양한 기능의 확장으로 우리를 이끌 수 있을 것이다.

더 나아간다면 오탈자 체크를 위한 애플리케이션과 연동을 하거나 동시 편집 기능을 지원하여, 그야말로 Rich한 에디터로 나아갈 수 있을 것이다. 하지만 이러한 기능들을 위해서는 많은 연구와 프로토타이핑이 필요할 것이며, 당장은 논하기 어려울 것이다.

새롭게 릴리즈된 TOAST UI Editor 3.0은 많은 사용자의 도움 덕분에 빠르게 안정화되고 있으며, 앞으로도 TOAST UI Editor는 꾸준히 발전해 나갈 것이니 지켜봐 주기 바란다.

이 글이 추상화 에디터(추상화 문서 모델 + 에디터)가 무엇인지 이해하는 데 조금이나마 도움이 되었길 바라고, 다음에 기회가 된다면 이러한 문서 모델을 어떻게 구성하여 에디터로 만들 수 있는지 다루어보겠다.

참고 링크