원활한 콘텐츠 작성을 위한 에디터 개발기
오늘의집의 집들이, 노하우 에디터의 개발 과정을 소개합니다.
2020년 9월 22일 Web Frontend Developer 끼로

안녕하세요, 오늘의집 웹 프론트 개발자로 일하고 있는 끼로라고 합니다. 이 글에서는 오늘의집의 집들이, 노하우와 같은 콘텐츠를 작성할 수 있는 에디터를 어떻게 만들었는지, 또 전반적인 구조를 소개해보고자 합니다.

오늘의집에서는 다양한 콘텐츠를 제공하는 만큼 그 콘텐츠를 작성하기 위한 에디터도 중요한데요. 기존에 jQuery로 되어 있던 레거시 에디터가 있었지만 기능이 많지 않아 사용하기 다소 어려웠습니다.

새로 에디터를 개발하며 '글을 작성해주시는 에디터분들, 유저분들이 불편함 없이 작성할 수 있도록 일반적인 문서 작성 프로그램과 비슷한 환경을 제공하자'를 목표로 삼았습니다. 클립보드나 래그&드롭, 실행 취소 등의 기능들을 고자 했는데요. 이 기능을 만드는 과정이 상당히 복잡했습니다. 일반적으로 WYSIWYG 에디터를 만들 때에는 상용으로 나와있는 에디터를 사용하거나, contentEditable을 사용하게 되지만 여러 이유로 인해 에디터를 뼈대부터 직접 만들게 되었습니다.

먼저, 오늘의집에서 사용하는 문서 구조는 블럭 기반입니다. 즉 HTML으로 글이 서술되어 있는게 아니라, 오늘의집에서 사용하는 메타 정보 (상품 태그, 사진 ID, 장소 등)와 함께 배열 형식으로 글이 나열되어 있습니다. HTML을 그대로 사용하는 것에 비해 이렇게 한다면 메타 정보를 다루기가 쉽고, HTML 파서를 통해서 DOM으로 바꾸지 않아도 문서 아웃라인을 그릴 수 있다는 장점이 있습니다.

HTML에서는 <h1><p><img><h1> 과 같이 나열되어 있다면, 이걸 JSON 배열 안에 제목, 글, 사진, 제목과 같은 순서로 넣는 방식입니다.

아래와 같은 HTML이....

<h1>안녕하세요!</h1> <p>만나서 반갑습니다.</p> <img src="http://example.com"> <h1>내용</h1>

아래처럼 표현됩니다.

[ { type: 'h1', content: '안녕하세요!' }, { type: 'p', content: '만나서 반갑습니다.' }, { type: 'img', src: 'http://example.com' }, { type: 'h1', content: '내용' }, ]

오늘의집 문서 구조 자체가 블럭 기반이기 때문에 에디터 또한 블럭 기반으로 만들고자 했습니다. 처음에는 에디터 프레임워크 등을 찾아보기도 했는데요, 공개되어 있는 에디터나, 에디터 프레임워크는 블럭 기반인게 거의 없고, 한글 입력이나 드래그, 실행 취소 등에서 이슈가 발생하는 경우가 굉장히 많았습니다.

안녕, contentEditable

일반적으로 웹에서 에디터를 만든다고 한다면, 'contentEditable'을 사용하는게 일반적입니다. contentEditable을 사용하면 브라우저가 자체적으로 클립보드, 드래그&드롭, 실행 취소, 서식과 같은 기능을 전부 제공해줍니다. contentEditable을 간단하게 던져 넣고, execCommand()로 굵기, 이탤릭, 링크 버튼같은 것만 넣는다면 HTML 에디터가 완성됩니다!

... 정말 그럴까요? 안타깝게도 contentEditable에는 문제가 너무 많습니다.

<div contentEditable id="edit">여기에 내용을 입력하세요</div> <button onClick="document.execCommand('bold')">굵게</button> <button onClick="document.execCommand('italic')">이탤릭</button> <button onClick="document.execCommand('undo')">실행 취소</button>

contentEditable은 공식적으로 정해진 표준이 아니기 때문에 브라우저마다 동작이 전부 다르다는 문제점이 발생합니다.

  • <strong>태그를 사용하는 브라우저도 있고, <b> 태그를 사용하는 브라우저도 있습니다.
  • 어떤 브라우저는 <br>을 사용하는데, 어떤 브라우저는 <div>를 쓰는 방식으로 줄바꿈을 처리합니다.
  • contentEditable 내부의 내용물을 조작하는 execCommand() 명령어의 동작도 브라우저마다 전부 다릅니다.

이처럼 일관성이 없기에 활용도가 낮습니다.

직접 글 내용 조작하기

execCommand('bold')를 사용한다면 유저가 선택한 영역이 굵어졌다가 일반 텍스트가 되었다를 반복하게 됩니다.

이 함수를 그대로 사용하는 대신, 문서의 DOM 노드들을 직접 조작한다면 선택한 텍스트를 굵게 만들 수 있습니다. 게다가 직접 로직을 작성하기 때문에 브라우저끼리도 동작이 똑같아집니다.

Lorem ipsum <i>dolor sit amet</i> 라는 HTML이 있다고 예시를 들어봅시다. 이걸 트리 구조로 나타내게 되면 아래와 같이 그릴 수 있게 됩니다.

여기서 'ipsum dolor' 부분을 사용자가 선택한 다음, 이 부분을 굵게 만들려면 어떻게 해야 할까요?

bold1

먼저 유저가 선택한 커서 앞 뒤, 즉 경계에 있는 노드들을 자릅니다.

bold2

그 다음, 선택 영역 안에 있는 모든 노드들 ("ipsum", "dolor")를 각각 새로 b 노드를 만들어서 그 안에 넣으면 됩니다.

bold3

최종적으로 Lorem<b>ipsum</b> <i><b>dolor</b> sit amet</i> 가 된 것을 확인할 수 있습니다.

이런 로직을 만들고 나면 모든 브라우저에서 일관적으로 동작하는 '굵게' 명령이 완성됩니다. 하지만 이미 굵음 처리가 되어 있는 노드를 만나는 상황을 고려하거나, 굵음 처리를 취소하고자 하는 로직을 고려하게 된다면 로직이 훨씬 복잡해집니다.

위의 로직을 그대로 적용할 경우 똑같은 동작을 반복하게 되면 'b' 노드가 2번 반복해서 들어가게 됩니다.

bold4

이런식으로 엣지 케이스가 굉장히 많기 때문에 DOM을 직접 수정하는건 충분히 가능하긴 하지만 복잡한 일입니다.

더 큰 문제점은, 이렇게 직접 DOM을 수정해서 기능을 구현하면 더 이상 실행 취소 기능이 동작하지 않는다는 점입니다. execCommand는 브라우저에 내장되어 있는 Ctrl+Z 히스토리를 저장하기 때문에, execCommand를 사용한다면 실행 취소를 따로 구현할 필요 없이 그냥 작동하지만, 이렇게 DOM을 수정하게 되면 브라우저에 내장된 히스토리가 더 이상 동작하지 않게 됩니다.

따라서, 실행 취소를 구현하기 위해서 히스토리를 관리하기 위한 모듈 또한 필요해지게 됩니다. (실행 취소에 관련해서도 이야기 할 내용이 많긴 하지만, 이 글에서는 다루지 않고 다음 글에서 이야기하도록 하겠습니다.)

이렇게 contentEditable에는 문제점이 많아서 해소하기 위해 하나둘씩 기능을 직접 구현하게 되면, 결국 contentEditable은 단순히 유저로부터 편집 명령을 받는 용도로 축소되게 됩니다.

bold5

즉 contentEditable에 현재 문서의 상태를 반영해서 그리게 되고, 편집 명령이 내려지면 유저의 의도를 파악해서 문서 상태를 편집한 뒤 다시 contentEditable 내부에 업데이트된 상태를 그리는 식으로 구현되게 됩니다.

편집 명령 가져오기

브라우저들은 이런 사용을 쉽게 하도록 하기 위해서 contentEditable에 "input", "beforeinput" 이벤트를 추가해서, 정확히 "유저가 어떤 걸 하려고 했는지"를 이벤트 상으로 나타내서 에디터 구현이 쉽도록 했습니다. 예를 들자면 글자를 추가할 때에는 inputType이 "insertText"이고, 글자를 삭제할 때에는 inputType이 "deleteWordBackward"인 식으로 어떤 행동을 하고 있는지를 명확하게 나타내고 있습니다. 에디터들은 "beforeinput"이 발생되면 브라우저 기본 동작을 취소하고, 대신 에디터 코드들이 자유롭게 DOM을 유저의 의도에 맞게 편집하게 됩니다.

이렇게 하면 외부에 의해서 DOM이 변경되지 않고, 계속해서 "유저가 어떤 행동을 하고자 했는지"를 받게 되기 때문에, 의도에 맞게 상태를 바꿔주기만 하면 React와 같은 상태 라이브러리를 아주 매끄럽게 사용할 수 있게 됩니다. slate.js와 같은 라이브러리가 이런 방식을 사용하고 있다고 알고 있습니다.

  1. div에서 beforeinput 이벤트가 오는데 3번째 노드의 4번째 위치에 "a"라는 텍스트를 넣고자 한다는 내용입니다.
  2. 에디터 로직은 해당 이벤트의 기본 동작을 취소시킵니다.
  3. 내부 상태에서 3번째 노드에 대응하는 항목을 찾고, 4번째 위치에 "a"를 추가하도록 상태를 업데이트합니다.
  4. 상태가 업데이트되면 React는 에디터를 그 상태에 맞게 업데이트합니다.
  5. 유저가 원하는 대로 내부 상태가 바뀌었고, UI 또한 업데이트되었습니다.

하지만 "beforeinput"을 지원하지 않는 경우도 많기 때문에 (IE, 파이어폭스) beforeinput을 사용하기는 아직 어렵습니다. 대신 키보드 이벤트나 마우스 이벤트 등을 가로채서 유저의 의도를 유추해서 구현하는 경우 또한 존재합니다. 예를 들자면, 엔터가 눌려지면 해당 이벤트를 취소하고, 에디터 코드가 브라우저 대신 새 줄을 추가하는 식입니다.

문제는 beforeinput을 사용하거나, 다른 이벤트들을 가로채서 사용하는 경우에도 잔버그가 많습니다. 특히 IME와의 문제가 굉장히 많은데요, 한글 입력이 일부 OS / 일부 브라우저에서만 안된다거나, 모바일에서 작성이 불가능하다거나 등의 이슈가 굉장히 많았습니다.

위에서 말했듯이 이런 구현은 "브라우저의 기본 동작을 취소하고" 에디터가 직접 DOM을 건드리게 됩니다. 하지만 한글 입력기나, 모바일 키보드처럼 IME가 동작하는 환경에서는 IME의 상태 또한 같이 관리가 되어야 하는데요, DOM을 맘대로 수정하게 되면 상태가 꼬이게 됩니다. 물론 여러가지 workaround를 통해 고칠 수 있긴 하지만 브라우저들이 IME 이벤트를 정상적으로 처리하지 않는 등 이슈가 많기 때문에 모든 브라우저에서 잘 돌아가도록 만들기는 어렵습니다.

bold6

반면 contentEditable을 브라우저가 수정하는 그대로 사용하게 된다면 이런 이슈는 발생하지 않습니다.

이벤트들을 가로채서 유저의 의도를 파악할 수 있게 한다면 에디터의 구조가 전반적으로 깔끔해지긴 하지만, 한글을 정상적으로 작성할 수 있어야 한다는게 매우 중요했으므로 오늘의집 에디터를 개발할 때에는 이런 방법을 사용하지 않는 접근을 시도해야 했습니다.

최종적으로 해결한 방법은 contentEditable 자체의 동작을 절대 가로막지 않되, 편집이 일어날 때마다 내부 상태를 업데이트 하는 방법이었습니다. diff를 떠서 내부 상태를 contentEditable과 일치시키는 방법, 즉, 거꾸로 Virtual DOM을 구현하는 방식으로 구현해야 하기 때문에 내부 구현은 다소 복잡하긴 하지만, 이렇게 해서 한글 입력이 잘 되지 않는 문제를 완전히 피해갈 수 있었습니다. 이에 대한 방법은 후술합니다.

에디터 내부 문서 포맷

블럭 기반 문서의 데이터를 내부적으로 처리하려면 DOM에 모든걸 맡기지 않고 에디터 자체적으로 상태를 관리하는게 필요했습니다. 즉... 에디터의 기능들인 서식, 실행 취소, 드래그&드롭, 선택, ... 등을 직접 모두 구현하고, 이를 표현할 수 있는 상태를 설계해야 합니다. 이 부분에서 특히 HTML을 어떻게 표현할 것인지에 대한 시행착오가 굉장히 많았습니다.

물론 DOM과 동일한 구조를 그대로 사용할 수도 있었지만, 실행 취소나 선택과 같은 부분은 "노드의 주소"를 부여해서 정확히 어느 부분이 바뀌었는지, 어느 부분이 선택되었는지 알 수 있어야 합니다. DOM의 경우에는 노드를 직접 들고 있고 "노드의 주소"에 대한 처리는 많이 까다롭기 때문에 처리하기 어려웠습니다.

반면, JSON을 통해 문서를 나타낸다면 contents[0].contents[3] 과 같이 간단하게 주소를 나타낼 수 있기 때문에 이런 부분에서 처리가 쉬워집니다. 또, 직렬화 또한 많이 간단해집니다.

bold7

오늘의집 에디터는 블럭 기반이기 때문에, p, h1같은 '블럭 태그'들은 그대로 각 블럭으로 나타내면 되기 때문에 고민이 그렇게 많지 않았습니다. 하지만, <b>, <i> 와 같은 인라인 태그들은 어떻게 처리할 지 고민이 좀 많았습니다.

HTML 표현 시행 착오

위에서 contents[0].contents[3] 같이 주소를 나타내는걸 예시로 들었는데요, 만약 인라인 태그들을 1차원 배열 안에 모두 표현할 수 있다면 선택이나 삽입같은 부분이 굉장히 간단해지기 때문에 인라인 태그를 포함해서 글 전부 1차원 배열에 담는걸 목표로 했습니다.

Hello, <b>world<i>!</i></b>

처음에는 HTML과 동일하게, <b>, <i>, </i>, </b>와 같이 '시작 마커', '종료 마커' 둘을 넣는 방법으로 처리하고자 했습니다.

bold8

이렇게 하면 HTML과 완전히 동일한 구조를 1차원으로 표현할 수 있지만, 노드가 겹치는 상황에 대한 처리가 굉장히 복잡해집니다. 노드가 합쳐질 수도 있고, b와 i의 순서가 서로 다를 수도 있고, 노드가 쪼개져야 하는 상황도 있는 등 여러가지 어려운 상황이 발생합니다.

bold9

근본적으로는 위의 그림처럼 HTML으로 표현할 수 없는 상황이 가능해지기 때문에 이를 풀어내는 로직이 필요하게 됩니다.

이런 데이터를 풀어내면서 서식을 적용하는 코드를 짜려면 너무 복잡한 로직이 필요했습니다. 단순히 서식 하나를 적용하는데 몇백줄이 필요하게 되면서 도저히 안되겠다는 판단을 내렸고 다른 방법을 고민해보기로 했습니다.

만약 'bold', 'italic'을 각각 태그로 부여하지 말고, 글자를 여러 단위("세그먼트")로 나눠서 각 단위마다 속성을 부여할 수 있게 된다면 이런 중첩이나, 순서에 신경쓰지 않고 세그먼트 단위로만 텍스트를 묶으면 됩니다.

bold10

이렇게 하면 서식을 너무 쉽게 적용할 수 있게 되기 때문에 (커서 지점에서 세그먼트를 쪼갠 뒤, 사이에 있는 세그먼트들에 모두 속성을 부여해주면 끝) 이 방법을 사용하기로 했습니다.

하지만 이렇게 하면 텍스트의 위치를 1차원으로 표현할 수 없기 때문에 유저의 선택 위치를 쉽게 표현할 수 없습니다. 저희가 최종적으로 채택한 방법은 "세그먼트 구분자"를 넣어놓고, 해당 구분자 뒤에 있는 문자열에 서식을 적용하는 방법입니다. 이렇게 하면 세그먼트 단위로 글자를 구분해서 서식을 쉽게 적용하도록 할 수도 있고, 선택 위치 또한 쉽게 비교할 수 있게 됩니다.

bold11

이렇게 문자열 서식을 처리하는 로직이 간단해질 수 있기 때문에 오늘의집 에디터는 세그먼트를 사용하게 되었습니다.

contentEditable과 엮기

위에서 설명했듯이 편집 이벤트를 모두 가로채서 개발하는 방법은 IME 상태가 꼬일 여지가 굉장히 많습니다. IME 상태를 정상적으로 관리하지 못한다면 한글 입력이 정상적으로 되지 않습니다.

오늘의집 에디터를 개발할 때에는 한글 입력이 무엇보다 중요했기 때문에 IME 상태가 꼬일 여지가 없도록, 브라우저가 바꾼 데이터를 내부 상태와 비교해서 내부 상태를 거기에 맞게 바꾸도록 구현하게 되었습니다.

React와 같은 라이브러리는 Virtual DOM을 구현하는데요, 상태를 이용해 나중에 그릴 가상 DOM을 만들어 놓고, 실제 DOM과 비교해가면서 변경이 필요한 노드를 업데이트해가는 식으로 가상 DOM을 만들어서 주기만 하면 바꿀 요소를 빠르게 파악해서 업데이트하는 방식입니다.

하지만, 이 상황에서는 거꾸로 DOM을 상태로 바꾸고, 이전 / 다음 상태를 비교해서 어떤 부분에서 변경이 필요한지 파악하는 로직이 필요했습니다. 즉, "거꾸로 Virtual DOM"? 을 만들게 되었습니다.

처음 아이디어는 "거꾸로 Virtual DOM"이라는 이름에 맞게 변경된 실제 DOM과, 렌더링된 가상 DOM 두 개를 비교해서 상태의 변경점을 만드는 것이었지만 이렇게 하는건 너무 복잡했습니다. 대신 DOM을 상태로 바꾼 뒤 비교했더니 훨씬 간단하게 구현할 수 있었습니다.

bold12

정방향 / 역방향 둘 다 구현하려면 아래의 3가지 구성요소가 필요합니다.

  • 내부 상태를 DOM으로 바꿔주는 "텍스트 렌더러"
  • DOM을 내부 상태 포맷으로 바꿔주는 "텍스트 변환기"
  • 내부 상태 둘을 비교해서 변경점을 찾아주는 "diff"

처음에는 텍스트 렌더러의 경우에는 React를 그대로 사용하고자 했는데요, contentEditable의 내용물이 이미 바뀐 상황에서는 React가 정상적으로 작동하지 못하고 노드들이 이상하게 수정되는 모습을 보여서 사용하기 어려웠습니다.

대신, React의 JSX를 사용하는 대신 hyperscript와 동일한 문법으로 텍스트 렌더러를 구현하고, 직접 가상 DOM 렌더러를 구현하는 방법으로 브라우저가 DOM 노드를 직접 수정하게 되더라도 정상적으로 동작하는 렌더러를 따로 만들었습니다.

일반적인 텍스트 diff의 경우에는 최적의 diff를 얻으려면 LCS 문제를 빠르게 풀어내는 알고리즘을 작성해야 하는데요, 이 경우에는 대부분의 경우 글자 하나씩만 바뀌기 때문에 같은 글자가 나타나면 동일한 섹션이라고 인식하는 방법으로 간단해도 만족스러운 결과를 얻을 수 있었습니다.

React 상태에 넣기 애매해진 상태들

오늘의집 에디터를 개발하면서 "문서 상태"뿐만 아니라 유저의 현재 선택 상태나 어느 노드가 포커스되어 있는지 여부, 클립보드 등 실제 DOM이 바뀌지는 않아서 React 상태에 넣기에는 조금 애매한데 내부 상태에서는 계속 관리해야 하는 데이터가 너무 많았습니다.

일반적으로 이런 상태들은 useRef를 통해서 인스턴스를 하나 만들고 안에 집어넣는 방법으로 처리가 가능합니다. React에서는 공통된 상태를 위로 올리는 걸 권장하기 때문에, 의존성이 없는 것부터 시작해서 선택 상태, 포커스 여부 등을 각 컴포넌트로 나눠서 context로 넣어주는 방법을 처음에 고민했습니다.

하지만, 에디터의 특성상 순환 참조같은 것도 굉장히 많이 일어납니다. 유저의 선택 상태에 따라서 포커스가 초기화되어야 하고, 클립보드를 사용하려면 유저의 선택 상태를 가져와서 HTML로 컨버전하는 등 마치 거미줄처럼 각 구성요소가 얽히게 됩니다. Redux와 같은 것들을 사용하는 것도 고려해 보았지만, 이런 상태들은 React 상태에서 처리할 수 있는 것들이 아니기 때문에 사용하기가 많이 곤란했습니다. 또, 메모리를 아끼기 위해서 대부분의 상태를 mutable하게 수정한다는 선택을 하게 되어 React의 상태를 그대로 사용하기도 곤란했습니다.

이런 상황에서는 React의 통상적인 구조보다는 MVC 구조와 비슷하게 따라가는게 더 효율적이었고, 따라서 내부 상태를 아예 MVC 패턴으로 관리하게 되었습니다.

  • Model에서는 문서 상태, 더 나아가서 유저의 커서 상태, 드래그&드롭 상태, 포커스, 히스토리 등 다양한 상태들을 보관합니다.
  • Controller에서는 유저의 입력을 받아서 여러 상태를 수정하고, API 통신 등의 동작을 수행합니다.
  • View는 React로 구성된 부분으로, Model이 바뀔 때마다 화면에 DOM을 그리게 됩니다.

bold13

Model이 바뀔 때마다 View를 업데이트 해주어야 하는데요, 이를 해결하기 위해 시그널 패턴을 도입하게 되었습니다.

  1. Model마다 관심사별로 시그널을 만들어 놓습니다.
  2. React의 컴포넌트들은 바뀔 때마다 업데이트되어야 하는 관심사에 해당하는 시그널에 콜백을 등록합니다.
  3. Controller가 Model을 수정할 때마다 컴포넌트들이 등록한 콜백이 호출됩니다.
  4. 컴포넌트들은 새로운 상태를 가져와서 렌더링합니다.

bold14

시그널을 직접 만들지 않고, @observable 같은 어노테이션을 통해서 이런 시그널 처리를 자동화시킬 수 있는데요, 이렇게 하면 MobX와 동일하게 됩니다. 아마 다시 에디터를 만들게 된다면 MobX를 사용하는걸 고려해보게 될 것 같습니다.

마치며

에디터 프로젝트를 진행하면서 이렇게 복잡한걸 진행해 본 적이 없어서 시행착오가 굉장히 많았는데요. 그래도 완성하여 오늘의집에 배포, 서비스되고 있는 걸 보니 굉장히 뿌듯했습니다.

이상 오늘의집 에디터의 전반적인 구조를 한번 살펴보았습니다. 이런 뼈대에서 여러가지 기능을 구현하기 위해서 고민했던 것들, 예를 들자면 선택이나 히스토리를 어떻게 구현했는지 등에 대해 하고 싶은 이야기가 많은데요.

이런 내용은 다음 글로 찾아뵈도록 하겠습니다. 읽어주셔서 감사합니다.

오늘의집에서 당신을 찾고 있습니다!
오늘의집에서는 현재 다양한 직군을 채용하고 있습니다. 오늘의집과 함께 세상의 공간을 바꿔나갈 분들을 기다립니다. 자세한 내용은 채용 페이지에서 확인해주세요.
목록으로 돌아가기