<aside> 📑

</aside>


이 글은 contentEditable 기반 커스텀 에디터가 키보드 입력, 커서 이동, 한글 IME, 소켓 이벤트, 리뷰(댓글) 등 모든 동작을 어떻게 처리하는지 흐름 중심으로 정리한 기술 문서입니다.


1. 왜 contentEditable인가 — 아키텍처 선택

저희 서비스의 기획상, 텍스트 에디터에 리뷰가 달리면 style 추가가 필요합니다. 이 스타일이 단순한 하이라이팅이 아닌, 아래 영역의 opacity와 padding 적용이 들어가기 때문에 일반 textarea나 input 태그를 통한 구현은 어렵습니다.

따라서 텍스트 에디터 라이브러리를 사용하거나, 직접 텍스트 에디터 라이브러리를 구현하는 방법이 존재하였습니다. 간단한 스타일링을 위하여 라이브러리를 도입하는 것은 과한 기술 사용이라는 생각이 들었습니다. 따라서 도입 이전에 직접 구현하여 보다 기본 텍스트 에디터의 동작에서 스타일링만 가능한 방식은 없을지를 먼저 찾아보게 되었습니다. 그 과정에서 알게된 것이 contentEditable 속성이었습니다.

해당 속성은 div 태그를 input 태그나 textarea 태그처럼 입력이 가능하도록 만들어주는 속성이었습니다. 따라서 내부에 스타일을 설정한 children 요소들을 넣으면 쉽게 구현이 될 것 같다는 생각을 했습니다. 해당 아이디어를 빠르게 적용하여 탐색을 해본 결과, 아래의 에러를 만날 수 있었습니다. 에러의 내용은 수정이 가능한 요소 내에 react에서 관리하는 children도 포함되어 있기 때문에 요소의 내용이 예기치 않게 수정되지 않도록 주의해 달라는 내용입니다. 즉, 요소를 수정함으로써 실제 dom과 react가 예측하는 dom의 내용과 달라지기 때문에, dom의 변경 사항 동기화에 문제가 생길 수도 있어 발생하는 경고입니다. 해당 경고를 무시하기 위해서는 suppressContentEditableWarning 속성을 추가해주면 됩니다. 다만 경고와 같이 브라우저의 조작으로 인해 실제 DOM과 React가 예측하고 있는 DOM의 내용이 달라질 수 있기 때문에 주의가 필요합니다.

image.png

image.png

children 요소를 넣지 않고 contentEditable을 사용하는 방법에는 dangerouslysetinnerhtml 속성을 사용하여, DOM 요소의 HTML 콘텐츠를 설정하는 방법이 있습니다. 브라우저 DOM에서 innerHTML을 사용하기 위한 React의 대체 방법으로, 일반적으로 코드에서 HTML을 설정하는 것은 사이트 간 스크립팅(XSS) 공격에 쉽게 노출되기에 위험합니다. 실제로 빠르게 적용해보았을 때, 코드래빗의 XSS 공격에 주의하라는 리뷰를 받을 수 있었습니다.

따라서 저희는 suppressContentEditableWarning 속성을 사용하고, 직접 저희가 실제 DOM과 React가 예측 중인 DOM 사이의 간격을 줄이는 방법을 통해 구현을 시작하게 되었습니다.

브라우저의 기본 contentEditable 동작

앞서 말씀드린 것 처럼 브라우저는 contentEditable="true" 속성을 주면 그 DOM 요소를 즉시 편집 가능 영역으로 만들어 줍니다. 키를 누르면 브라우저가 직접 DOM을 조작해서 텍스트 노드를 추가·제거하고, Backspace면 앞 글자를 지우고, Enter면 <div> 또는 <br>을 삽입합니다. 커서 위치도 브라우저의 Selection API가 자동으로 관리합니다.

이 방식은 간단해 보이지만, 리뷰(하이라이트 + 댓글) 구조처럼 DOM이 복잡한 경우 브라우저가 예상치 못한 DOM 변형을 일으킵니다.

React와의 충돌 — removeChild 에러의 원인

React는 Virtual DOM을 통해 실제 DOM을 관리합니다. 그런데 contentEditable 영역 안에서 브라우저가 DOM을 직접 변경하면, 다음 렌더링 시 React가 가진 Virtual DOM 트리와 실제 DOM 트리가 달라집니다. React는 자신이 만든 노드가 있다고 생각하지만, 브라우저가 이미 그 노드를 제거했기 때문에 removeChild: The node to be removed is not a child of this node 에러가 발생합니다.

React Virtual DOM: [span.chunk][span.chunk][span.review-outer]
실제 DOM (브라우저가 수정 후): [span.chunk][text node][span.review-outer]
→ React가 span.chunk를 removeChild 시도 → 이미 없음 → 에러