Lexical Editor을 선택하고 느낀 점들

0

안녕하세요. 오늘은 최근 프로젝트에서 에디터를 도입하며 겪었던 고민의 과정을 이야기해볼까 합니다.

단순히 Meta가 만들어서, 혹은 최신 라이브러리라서 선택한 건 아닙니다. 직접 사용해보니 '리액트 개발자가 가장 리액트스러운 방식으로 사고할 수 있는 에디터'였기 때문입니다.

이번 글에서는 Lexical의 아키텍처 구조부터 실전 코드 구현, 그리고 성능 최적화의 원리까지. 개발 과정에서 제가 느꼈던 기술적인 경험들을 차분하게 공유해 드리겠습니다.


1. 도입: 다양한 에디터 시대, 왜 Lexical인가?

1.1. contentEditable의 한계와 리액트와의 동기화

웹 에디터 개발은 본질적으로 브라우저의 contentEditable="true" 속성을 다루는 과정입니다. 분명 HTML 표준 스펙이지만, 브라우저마다 이를 해석하고 처리하는 방식은 미묘하게 다릅니다.

가령 텍스트를 입력하고 엔터를 눌렀을 때, Chrome은 <div>를 생성하지만 Safari나 구형 브라우저는 <br>을 삽입하거나 <p> 태그로 감싸는 등 일관성이 부족합니다. 여기에 한글이나 일본어 같은 CJK 언어권 특유의 문자 조합(Composition) 이슈까지 더해지면 문제는 더 복잡해집니다. composition 이벤트가 예상치 못한 타이밍에 발생해 글자가 중복되거나 사라지는 현상은 에디터 개발자를 꽤나 괴롭히는 요소 중 하나죠.

과거 Draft.js 같은 라이브러리들은 이런 DOM의 파편화를 해결하기 위해 무거운 추상화 계층을 씌우는 방식을 택했습니다. 하지만 리액트가 18 버전으로 넘어오며 동시성 모드(Concurrency)를 도입하자, 상황이 달라졌습니다.

구형 라이브러리의 데이터 구조나 동기적인 이벤트 처리 방식이 리액트의 새로운 비동기 렌더링 주기와 맞물리지 않게 된 것이죠. 이로 인해 원인을 알 수 없는 '커서 튐' 현상이나 상태 불일치 같은 UX 문제가 발생하기 시작했고, 이는 저희 팀이 새로운 대안을 찾게 된 결정적인 계기가 되었습니다.

1.2. Lexical의 철학: DOM은 결과물일 뿐

Lexical은 이러한 배경에서 탄생했습니다. Meta 엔지니어들은 기존 Draft.js의 구조적 한계를 넘어서기 위해 새로운 아키텍처를 설계했고, 그 중심에는 명확한 철학이 있습니다.

"DOM은 결과물일 뿐, 진실(Source of Truth)이 아니다."

Lexical은 DOM을 데이터의 원천으로 보지 않습니다. 브라우저가 렌더링한 DOM을 역추적해 상태를 파악하는 방식은 브라우저 간 차이로 인해 데이터 불일치를 유발하기 쉽기 때문입니다. 대신 EditorState라는 자체적인 인메모리 모델을 통해 상태를 관리하고, 이를 실제 DOM에 Reconciliation하는 방식을 택했습니다.

이러한 접근은 리액트가 Virtual DOM을 사용하는 방식과 매우 닮아 있습니다. 덕분에 리액트 개발자에게는 Lexical의 구조가 꽤 직관적으로 다가옵니다. 마치 useStateuseReducer로 애플리케이션 상태를 관리하듯, 에디터 내부의 복잡한 상태도 예측 가능한 범위 안에서 제어할 수 있다는 점이 인상적이었습니다.


2. Lexical이 다른 에디터와 차별화되는 이유

시중에는 이미 훌륭한 에디터들이 많습니다. ProseMirror는 강력한 스키마 시스템을, Slate.js는 유연함을 무기로 내세웁니다. 하지만 제가 Lexical을 선택하게 된 결정적인 이유는 극단적인 모듈화상태 관리의 안정성 두 가지였습니다.

2.1. 극단적 모듈화 (The LEGO Mindset)

보통 에디터 라이브러리들은 툴바나 이미지 업로드 같은 기능을 기본적으로 포함하는 '배터리 포함(Batteries Included)' 방식을 취합니다. 초기 설정은 편리할지 몰라도, 커스터마이징 단계에 들어서면 불필요한 기능을 걷어내는 데 더 많은 리소스를 쏟아야 하는 경우가 생기곤 합니다.

반면 Lexical의 코어 패키지(@lexical/core)는 약 22KB(min+gzip) 정도로 매우 가볍습니다. 철저하게 '엔진' 역할만 수행하기 때문입니다.

  • 엔터를 눌러 줄을 바꾸는 기능? → RichTextPlugin
  • 실행 취소(Undo) 기능? → HistoryPlugin
  • 마크다운 단축키? → MarkdownShortcutPlugin

줄 바꿈이나 스타일링 같은 기본적인 기능조차 모두 독립된 플러그인으로 존재합니다. 즉, 필요한 기능만 레고 블록처럼 선택적으로 조립해 사용할 수 있다는 뜻입니다. 덕분에 무거운 모놀리식 구조에서 벗어나 서비스 목적에 딱 맞는 최적의 에디터를 구성할 수 있었습니다.

특히 리액트 환경에서는 이러한 플러그인들이 리액트 컴포넌트 형태로 제공되어 더욱 직관적입니다. 아래 코드를 보시면 그 구조가 명확히 드러납니다.

javascript
// Lexical의 LEGO Mindset 예시: 필요한 기능만 컴포넌트로 끼워 넣습니다.

이러한 선언적(Declarative) 방식은 리액트 개발자에게 매우 익숙하고 효율적입니다. 컴포넌트 트리만 봐도 현재 에디터에 어떤 기능이 활성화되어 있는지 직관적으로 파악할 수 있고, 불필요한 기능은 해당 컴포넌트 라인을 주석 처리하는 것만으로 간단히 제외할 수 있기 때문입니다.

2.2. 상태의 안정성: Double Buffering & Immutability

Lexical을 선택한 또 다른 기술적 이유는 바로 견고한 상태 관리 모델이었습니다. 리액트 개발 시 가장 주의해야 할 '예측 불가능한 변이(Mutation)' 문제를 Lexical은 구조적으로 방지하고 있습니다.

2.2.1. 불변성과 스냅샷

Lexical의 EditorState는 불변(Immutable) 객체입니다. 사용자가 키보드를 눌러 텍스트를 입력하면, 기존 상태를 수정하는 것이 아니라 변경 사항이 반영된 새로운 상태 객체를 생성합니다.

이러한 구조는 상태 관리의 예측 가능성을 높여줍니다. 렌더링 중 데이터 오염이나 비동기 로직 충돌에 대한 우려를 덜 수 있기 때문입니다. 특정 시점의 EditorState는 그 자체로 완벽한 Snapshot이므로, 실행 취소(Undo/Redo) 기능은 단순히 과거의 스냅샷으로 상태를 교체하는 것만으로 구현됩니다. 또한 협업 기능 개발 시 동시성 제어를 위한 데이터 무결성을 확보하기에도 유리한 구조를 갖추고 있습니다.

2.2.2. 더블 버퍼링 (Double Buffering)

Lexical은 상태 관리에 더블 버퍼링 기법을 도입했습니다. 이는 상태를 두 가지로 분리하여 관리하는 방식입니다.

  • Current State: 현재 화면(DOM)에 렌더링 된 최종 상태
  • Pending State: 백그라운드에서 변경 사항을 계산 중인 대기 상태

이는 리액트가 변경 사항을 Virtual DOM에서 계산한 뒤 실제 DOM에 반영(Commit)하는 과정과 유사합니다. Lexical은 키 입력 등의 업데이트가 발생하면 Pending State에서 모든 노드 변환 작업을 마칩니다. 그리고 계산이 완료되는 순간, 단 한 번의 Swap을 통해 PendingCurrent로 변환시킵니다.

이러한 방식은 미완성된 중간 상태가 화면에 노출되는 것을 구조적으로 차단합니다. 덕분에 빠른 타이핑 속도에서도 화면 깜빡임이나 불필요한 레이아웃 재계산없이 안정적인 UI를 유지할 수 있습니다.

2.3. 성능과 안정성의 근거

새로운 기술 스택을 도입할 때 가장 중요한 고려 사항 중 하나는 '안정성'입니다. 이 점에 있어 Lexical은 신뢰할 수 있는 배경을 가지고 있습니다.

현재 Facebook, Messenger, WhatsApp, Instagram의 웹 에디터가 모두 Lexical 기반으로 운영되고 있습니다.

수많은 사용자가 매일 이용하는, 특히 텍스트 입력이 핵심인 메신저 서비스들에 적용되어 있다는 점은 시사하는 바가 큽니다. 다양한 네트워크 환경과 저사양 기기에서의 퍼포먼스, 입력 지연 문제를 해결해야 하기 때문입니다. Meta가 자사 서비스의 핵심 엔진을 기존 Draft.js에서 Lexical로 전면 교체했다는 사실은, 이 라이브러리가 성능 최적화와 수많은 엣지 케이스 처리에 있어 이미 실전 검증을 마쳤음을 의미합니다.


3. 리액트와의 통합 (Reconciliation)

많은 에디터 라이브러리들이 리액트용 래퍼(Wrapper)를 제공하지만, 내부를 들여다보면 useRef를 통해 DOM에 직접 접근하거나 명령형(Imperative) 코드로 조작하는 경우가 많습니다. 이는 리액트의 선언형 패러다임과 충돌할 수 있으며, 복잡한 UI 구현 시 상태 동기화 문제를 일으키기도 합니다.

반면 Lexical은 태생부터 리액트의 재조정(Reconciliation) 프로세스와 구조를 같이 하도록 설계되었습니다.

3.1. DOM Reconciler와 역할의 분리

Lexical은 자체적인 DOM Reconciler를 내장하고 있습니다. EditorState가 변경되면, 변경 전후의 상태 트리를 비교(Diffing)하여 실제 DOM에 필요한 최소한의 변경 사항(Mutation)만을 적용합니다.

여기서 주목할 점은 이 과정이 리액트의 가상 DOM(Virtual DOM)과 충돌하지 않도록 명확히 분리되어 있다는 것입니다. 텍스트 노드나 기본적인 블록 요소(p, div, span)는 Lexical 엔진이 직접 효율적으로 업데이트하고, 버튼이나 카드 같은 복잡한 인터랙티브 컴포넌트는 리액트 포털(React Portal)이나 DecoratorNode를 통해 리액트 렌더링 트리로 위임합니다.

즉, "텍스트 처리와 기본 구조는 Lexical 엔진이, 복잡한 뷰(View) 렌더링은 리액트가" 담당하는 역할 분담이 이루어집니다. 덕분에 에디터 내부 요소를 개발할 때도 기존 리액트 컴포넌트를 개발하던 방식 그대로 접근할 수 있습니다.

3.2. Batching과 Microtasks를 이용한 최적화

성능 최적화의 핵심은 배치(Batching) 처리입니다. 사용자가 빠르게 키보드를 입력할 때마다 매번 렌더링 사이클이 돌아간다면, 레이아웃 계산(Reflow) 비용으로 인해 브라우저 성능 저하가 발생할 수 있습니다.

Lexical은 상태 업데이트를 queueMicrotask를 사용해 비동기적으로 스케줄링하여 이 문제를 해결합니다.

  1. 사용자 입력 발생 (Event)
  2. Lexical은 즉시 DOM을 수정하지 않고, 변경 사항을 메모리상의 Pending State에 기록합니다.
  3. 동시다발적인 여러 업데이트(입력, 플러그인 동작, 포맷팅 변경 등)를 **마이크로태스크 큐(Microtask Queue)**에 모읍니다.
  4. 브라우저가 다음 페인팅(Painting)을 수행하기 직전, 모아둔 변경 사항을 단 한 번의 프로세스로 병합(Batch)하여 DOM에 반영(Reconciliation)합니다.

이러한 기술 덕분에 대량의 텍스트가 있는 문서에서도 입력 지연 없이 안정적인 성능을 유지합니다. 이는 리액트 18의 Automatic Batching 개념과도 정확히 맞닿아 있는 부분으로, Lexical이 리액트의 성능 철학을 공유하고 있음을 보여줍니다.



4. 실전 구현: DecoratorNode로 X Card(트윗) 구현하기

앞서 살펴본 이론들이 실제 코드에서는 어떻게 적용되는지, '에디터 내부에 인터랙티브한 트윗 카드를 삽입하는 기능'을 통해 확인해 보겠습니다.

Lexical의 DecoratorNode를 활용하면 단순히 정적인 이미지가 아니라, 데이터 통신이나 이벤트 처리가 가능한 '살아있는 리액트 컴포넌트'를 에디터 흐름 속에 자연스럽게 배치할 수 있습니다.

4.1. 개념: Node(Model)와 Component(View)의 분리

커스텀 노드를 구현할 때는 MVC 패턴과 유사하게 두 가지 역할을 분리하여 접근해야 합니다.

  • Node (Model): 데이터(트윗 ID 등)를 저장하고 직렬화(JSON 변환)를 담당하는 Lexical 클래스입니다.
  • Component (View): 실제 화면에 렌더링 될 리액트 컴포넌트입니다.

4.2. TweetNode 구현 (The Model)

먼저 DecoratorNode를 상속받아 TweetNode를 정의합니다. 이 클래스는 Lexical 엔진에게 "이 노드는 트윗 데이터를 담고 있으며, 특정 리액트 컴포넌트로 렌더링되어야 한다"는 명세(Schema)를 제공합니다.

javascript
import

4.3. TweetComponent 구현 (The View)

이제 decorate() 메서드가 반환하는 리액트 컴포넌트를 구현합니다. 여기서는 우리가 평소 개발하는 리액트 방식 그대로 접근하면 됩니다. Lexical 훅(useLexicalComposerContext, useLexicalNodeSelection)을 사용하여 에디터와 데이터 및 상태를 동기화합니다.

javascript
import

4.4. 결과물과 기술적 의의

위 코드를 적용하면 에디터의 텍스트 흐름 사이에 인터랙티브한 트윗 카드가 렌더링 됩니다. 이 구현의 핵심은 사용자 경험과 개발 경험의 일치에 있습니다.

  • 사용자 관점: 텍스트와 이질감 없이 자연스럽게 통합된 카드로 인식됩니다. 클릭 시 파란색 테두리(Selection Ring)가 활성화되어 선택 상태임을 알 수 있고, 키보드 방향키로 텍스트와 카드 사이를 자유롭게 오가거나 백스페이스로 카드를 삭제하는 등의 네이티브 동작이 그대로 유지됩니다.
  • 개발자 관점: 복잡한 DOM 조작 로직이 필요하지 않습니다. document.querySelector로 요소를 찾거나, 수동으로 이벤트 리스너를 관리할 필요가 없습니다. 오직 데이터 모델인 TweetNode와 뷰인 <TweetComponent />만 존재할 뿐이며, Lexical이 내부적으로 NodeKey를 통해 이 둘을 완벽하게 동기화합니다.

이것이 바로 Lexical이 제공하는 '리액트다운 확장성'입니다. 명령형(Imperative)으로 DOM을 직접 제어하는 대신, 선언형(Declarative)으로 데이터와 뷰를 정의하면 나머지는 프레임워크가 처리하는 방식. 이것이 제가 Lexical을 선택한 주된 이유이자, 리액트 생태계에서 이 라이브러리가 갖는 가장 큰 강점입니다.


5. 경쟁자들과의 비교 (Comparison)

현재 시장의 라이브러리들을 객관적인 수치 지표로 비교해 보았습니다. (데이터 출처: Bundlephobia, NPM Trends, GitHub - 2026년 2월 기준)

비교 항목

Lexical

Slate.js

ProseMirror

Tiptap

번들 사이즈

~22 KB

~46 KB

~62 KB

~125 KB

주간 다운로드

~40만+

~150만+

~250만+

~100만+

GitHub Stars

~21k

~30k

~7.5k

~26k

현재 버전

v0.40

v0.123

v1.41

v3.19

의존성

0개(Zero External Dep)

4~5개(Immer, is-hotkey 등)

~10개(모듈별 분리)

~15개+(ProseMirror 포함)

데이터로 본 Lexical의 위치

  1. 압도적인 경량화 (~22KB): Tiptap은 ProseMirror를 래핑하고 다양한 편의 기능을 제공하는 대신 번들 사이즈가 100KB를 넘어갑니다. 반면 Lexical은 리액트 연동 패키지를 포함해도 Slate.js의 절반, Tiptap의 1/5 수준으로 매우 가볍습니다. 이는 초기 로딩 속도(FCP)가 중요한 서비스에서 큰 강점입니다.
  2. Zero Dependencies: Lexical은 외부 의존성이 '0'입니다. 이는 보안 취약점 이슈나, 의존성 라이브러리의 버전 충돌 문제로부터 자유롭다는 것을 의미합니다. 장기적인 유지보수 관점에서 매우 중요한 지표입니다.
  3. 성장하는 생태계: Slate.js나 ProseMirror에 비해 절대적인 다운로드 수는 적지만, Meta의 프로덕트 적용 이후 가장 가파른 성장세를 보이고 있습니다. 현재 v0.40 버전임에도 불구하고 GitHub Star 수가 빠르게 증가하는 것은 개발자 커뮤니티의 기대감을 반영합니다.

6. 결론: 개발 경험(DX)과 예측 가능성

지금까지 Lexical의 철학부터 아키텍처, 그리고 실전 코드 적용까지 살펴보았습니다.

Lexical은 ProseMirror에 비해 커뮤니티 생태계는 아직 성장 단계이고, 공식 문서가 부족해 소스 코드를 직접 분석해야 하는 경우도 있습니다. 또한 Node, Command, Selection 등 초기에 익혀야 할 고유 개념들로 인해 학습 곡선도 분명 존재합니다.

하지만 리액트 환경에서 프로덕션 레벨의 에디터를 구축해야 한다면, Lexical은 **"예측 가능한 개발 환경"**을 제공한다는 점에서 대체 불가능한 가치를 지닙니다.

  • 데이터의 투명성: EditorState의 불변성 덕분에 데이터 흐름이 명확합니다. 버그가 발생하더라도 상태 변화 시점을 추적하기가 훨씬 수월합니다.
  • 검증된 성능: Meta의 노하우가 집약된 Double BufferingMicrotask Batching 최적화는 대규모 문서에서도 안정적인 입력 경험을 보장합니다.
  • 유연한 확장: DecoratorNode를 통해 리액트 컴포넌트를 에디터의 일부로 자연스럽게 통합할 수 있습니다. 더 이상 DOM을 억지로 조작할 필요가 없습니다.

만약 리액트의 철학을 유지하면서도 성능 타협 없는 에디터를 찾고 계신다면, Lexical은 현시점에서 가장 합리적인 선택지 중 하나입니다. 초기 진입 장벽만 넘어서면, 리액트 개발자에게 가장 자연스럽고 효율적인 방식으로 에디터를 제어할 수 있게 될 것입니다.

시간이 되실 때 Lexical Playground를 직접 경험해 보시길 권합니다. 라이브러리의 완성도를 이해하는 데 더 큰 도움이 될 것입니다.

긴 글 읽어주셔서 감사합니다. 이 글이 여러분의 에디터 선택과 개발 과정에 작은 도움이 되기를 바랍니다.

dinn
dinnAdministrator
Last updated:
이전 글

테스트 글