에디터 문서 모델

내 이 세상 도처에서 쉴 곳을 찾아보았으나, 마침내 찾아낸, 컴퓨터가 있는 구석방보다 나은 곳은 없더라.

에디터 문서 모델

복잡한 소프트웨어를 만들 때 여러 번의 갈림길에 서게 된다. 나중에는 쉽게 알 수 있는 사실이라도 선택할 당시에는 명확하지 않아 어느 쪽이 옳은지 판단하기가 쉽지 않은 경우가 많다. 에디터에서 문서 모델을 설계할 때도 마찬가지다. 문서 모델은 편집하는 문서를 어떤 구조로 저장하고 조작할지를 결정하는 에디터의 핵심 데이터 구조다. 문서 모델의 구조에 따라 이후 개발할 편집 기능의 구현 방향이 갈릴 것이다. 문서 모델이 잘못되어 있다면 기능 구현은 매우 피곤하고 힘든 과정이 될 수 있다.

입력기 또는 뷰에서 발생한 이벤트를 받아 모델을 조작하고, 수정된 모델을 기반으로 뷰를 업데이트하는 것이 에디터의 기본 동작 흐름이다. 키보드 이벤트는 입력기를 통해 발생하지만 마우스 이벤트는 뷰에서 발생한다. 따라서 에디터가 잘 동작하려면 모델과 뷰의 매핑이 중요하다. 에디터를 만들면서 빈번하게 발생하는 문제의 대부분이 모델-뷰 매핑 오류에서 비롯된다.

에디터를 만들면서 내가 생각할 수 있는 문서 모델 구조는 다음 세 가지다. 에디터의 기본 동작 중에서도 가장 기본인 글자 입력/삭제, 단락 추가/분리/삭제 동작을 각 모델에서 어떻게 구현할 수 있을지, 단락 스타일, 글꼴 스타일을 어떻게 표현할 수 있을지 생각해보면 어느 모델을 사용하는 것이 유리할 지 판단하는 데 도움이 된다.

DOM

HTML DOM을 그대로 문서 모델로 사용하는 방법이다. 물론 문서 모델 DOM은 브라우저 화면에 표시될 HTML의 DOM과는 다른 모습이 될 수 있다. 문서 모델의 DOM 트리는 내가 정의한 구조를 유지하도록 할 수 있다. 예를 들어 텍스트 런(run)의 중첩을 허용하지 않는 단순한 구조로 문서 모델을 제한할 수 있다. 이렇게 하면 여러 편집 동작을 구현하는 게 단순해진다.

예를 들어 문서를 다음과 같이 나타낼 수 있다. 편의상 문서 모델의 루트를 <doc>으로, 단락은 <p>로, 런은 <r>로, 볼드체가 적용된 런은 <b>로, 이탤릭체가 적용된 런은 <i>로 나타냈다.

<doc>
  <p><r>김수한무 </r><b>거북이</b><r></r><i>두루미</i></p>
</doc>

모델이 HTML 문자열이 아니라 DOM 트리라는 점을 이해하는 것이 중요하다. 실제 HTML에는 <r> 태그가 없지만 이렇게 써도 DOM 트리를 구성하고 조작하는 데는 문제가 되지 않는다. DOM을 쓸 때의 장점은 문서를 조작할 때 jQuery나 브라우저에서 제공하는 Range 객체를 사용할 수 있다는 점이다. 반면 뷰 구조에 따라 모델과 뷰를 매핑하는 작업이 까다로워질 수 있다. 예를 들어 에디터에서 페이지 모양의 뷰를 제공하기로 했다면 뷰의 DOM 구조와 모델의 DOM 구조는 완전히 달라지는데 이때 모델과 뷰의 요소를 매핑하는 작업은 단순하지 않다.

JSON

문서 모델을 JSON 형식으로 가지고 있으면 JavaScript로 조작하기가 쉬워 보인다. JSON을 읽어 뷰를 만들어 내는 것도 어렵지 않아 보인다. 문서를 단락(paragraph)의 배열로, 위치는 단락과 단락 처음부터의 오프셋으로 나타낼 수 있다. 단락 내 글꼴 스타일은 해당 스타일이 적용될 오프셋 범위로 표현할 수 있다.

var doc = [
  { text: "김수한무 거북이와 두루미",
    b: { start: 5, end: 8},
    i: { start: 10, end: 12}
  }
  ...
]

그러나 JSON을 사용하면 각 스타일의 오프셋을 관리해야 하는 부담이 생긴다. 단락의 맨 앞에 글자를 입력한다고 생각해보자. bi의 시작/끝 오프셋을 모두 업데이트 해야 한다. '거북이와' 바로 다음 위치에서 엔터 키를 눌러 단락을 분리한다고 생각해보자. 새로운 단락 객체를 만들어 배열에 추가하고 i의 인덱스를 업데이트해줘야 한다. 이런 작업이 불가능하지는 않겠지만 DOM을 써서 조작할 때보다 복잡하고 생각하기 어려워 질 것 같다.

단락은 런(run)의 배열로 표현하면 어떨까? 이렇게 하면 매 입력마다 각 런의 오프셋을 업데이트 해야 하는 문제는 없어진다.

var doc = [
  { pid: "p1",
    runs: [
      { rid: "p1r1",
        text: "김수한무 "},
      { rid: "p1r2"
        style: "bold"
        text: "거북이와 "},
      { rid: "p1r3"
        style: "italic"
        text: "두루미"}
    ]}
  ...
]

단락을 나누는 경우를 생각해보자. '거북이와' 바로 다음 위치에서 엔터 키를 눌러 단락을 분리하는 경우 새로운 단락을 만들어 런을 옮겨주면 된다. 그러나 현재 런으로부터 부모 단락을 얻고, 현재 런 이후의 형제 노드를 얻는 방법이 없다. 런에서 부모 단락을 얻기 위해 각 런에 부모 단락 아이디(pid)를 추가하는 것을 고려할 수 있다. 관리해야 할 속성이 하나 늘어났다. 현재 런 이후의 형제 노드는 어떻게 얻어야 할까? 간단한 방법은 떠오르지 않는다. DOM에서는 모두 쉽게 얻을 수 있는 정보다.

물론 방법이 있을 것이다. 부모-자식을 쉽게 참조할 수 있도록 속성을 추가하고, 자신의 이전, 이후 런을 쉽게 알 수 있도록 런 인덱스 같은 것을 추가하는 것도 고려할 수 있겠다. 이런 속성을 하나씩 추가할 때마다 각 속성이 올바른 상태를 유지하도록 제어하는 코드가 필요할 것이고 복잡도는 증가할 것이다.

DOM을 문서 모델로 사용할 때와 마찬가지로, 뷰-모델 매핑 또한 여전히 풀어야 문제다. JSON을 사용한다고 이 문제가 사라지지는 않는다.

View as Model

모델과 뷰의 구조가 동일하다면 뷰를 모델 그대로 사용할 수 없을까? 불가능해 보이지는 않는다. 이 방법은 뷰와 모델을 하나로 가져가는 것이며 뷰는 HTML로 표현할 수밖에 없으므로 결국 모델/뷰는 동일한 HTML DOM으로 표현될 것이다. 편집이 발생해 모델을 수정하면 뷰도 함께 수정되어 별도의 렌더링 단계를 거칠 필요가 없어진다.

언듯 보면 괜찮은 방안으로 보이지만 제약사항이 있다. 에디터 화면에서 표현해야 하는 기능은 결국 뷰의 마크업으로 나타내야 하는데 이 모델에서는 뷰와 모델이 동일하므로 뷰의 구조가 모델의 구조를 결정하게 된다. 뷰에 나타나는 구조가 데이터를 표현하는 최선의 방법이 아닌 경우 문제가 드러난다.

간단한 에디터를 만들 때 고려할 수 있는 방안 중 하나라 생각하지만 다양하고 복잡한 기능을 제공하는 에디터를 만들 생각이라면 뷰와 모델을 분리하는 방향을 선택하는 것이 좋다.

결론

각 접근법마다 나름의 장점과 제약사항이 있다. DOM을 문서 모델로 사용하는 에디터는 만들어 봤지만 다른 방식의 모델로 만들어 보지는 않았기 때문에 직접 해보면 다른 특성을 알게 될지도 모르겠다. 현재로선 문서 모델로 DOM을 선택하는 것이 가장 좋아 보인다. 페이지 보기, 다양한 글머리 기호, 다단계 번호매기기 등 HTML의 기본 기능을 넘어선 다양한 기능을 구현하고 싶다면 모델과 뷰를 분리해야 한다.