Lexical 에디터에서 첫 번째 문자에 포맷이 적용되었을 때 한글(IME) 입력 시 문자가 사라지는 문제를 분석하고, 크로스 브라우저 환경에서의 예외 상황들을 해결한 과정을 공유합니다.
배경 지식
IME Composition이란?
IME(Input Method Editor)는 한국어, 일본어, 중국어처럼 자음과 모음을 조합하여 하나의 글자를 만드는 언어를 입력하기 위한 도구입니다. 글자가 완성되기 전까지의 과정을 Composition이라고 하며, 브라우저는 이 과정에서 compositionstart, compositionupdate, compositionend 이벤트를 발생시켜 조합 상태를 관리합니다.
OS와 브라우저별 처리 차이
IME 처리는 OS와 브라우저 엔진에 따라 동작이 미묘하게 다르며, 이는 에디터 라이브러리 개발 시 가장 까다로운 부분 중 하나입니다.
- 이벤트 발생 순서: 대다수의 브라우저는
keydown이후compositionstart가 발생하지만, Safari는compositionstart가 먼저 발생하거나keydown이 아예 생략되기도 합니다. - Selection 관리: Windows(Chrome)나 Safari는 조합 중인 문자를 포함하는 Selection 범위를 다루는 방식이 macOS(Chrome)와 다릅니다. 이 차이로 인해 특정 환경에서 텍스트가 의도치 않게 드래그된 상태로 오인될 수 있습니다.
- 제어 문자 삽입: 브라우저가 조합 중인 문자를 텍스트 노드로 인식하지 못하는 현상을 방지하기 위해, 에디터 레벨에서 보이지 않는 공백 문자를 삽입하여 기준점을 잡는 처리가 필요할 때가 있습니다.
문제 상황
특정 조건에서 한글을 입력할 때, 입력한 첫 획이 사라지며 조합이 깨지는 현상이 발생했습니다.
재현 단계
- 에디터에 "hello world!" 입력
- 첫 번째 문자('hello')에 포맷(Bold 등) 적용
- "가나다" 입력 시작
결과: 첫 번째 입력 시점에 'ㄱ'이 사라지면서 조합이 깨져 "ㅏ나다"가 입력되었습니다.
환경: macOS(Chrome)에서 발생하며, 타 OS나 브라우저에서는 정상적으로 작동했습니다.
디버깅
1. Selection Offset의 불일치
다중 노드 환경에서 첫 번째 노드에 포맷이 적용되었을 때, IME compositionstart 시점의 selection 상태가 평소와 달랐습니다.
- 포맷팅된 경우: 입력 시
anchoroffset 0,focusoffset 1이 되어, 브라우저는 이를 첫 획이 드래그된 상태로 인식합니다. 이 상태에서 새로운 문자가 들어오면 첫 획을 지우고 덮어쓰게 됩니다. - 일반적인 경우:
anchor와focusoffset이 동일하게 유지되어 정상적인 삽입으로 처리됩니다.
2. 코드 분석: 공백 문자 삽입의 부작용
Lexical은 포맷이 바뀌는 지점에서 입력이 무시되는 것을 방지하기 위해 COMPOSITION_START_CHAR(너비가 0인 공백 문자)를 삽입합니다.
// Lexical 내부 logic ($handleCompositionStart 요약)
if (
event.timeStamp < lastKeyDownTimeStamp + ANDROID_COMPOSITION_LATENCY ||
anchor.type === "element" ||
!selection.isCollapsed() ||
node.getFormat() !== selection.format ||
($isTextNode(node) && node.getStyle() !== selection.style)
) {
// 기준 노드 확보를 위해 제어 문자 삽입
dispatchCommand(
editor,
CONTROLLED_TEXT_INSERTION_COMMAND,
COMPOSITION_START_CHAR,
);
return true;
}
이 제어 문자가 삽입되는 과정에서 insertText가 호출되는데, 이때 anchor가 의도치 않게 움직이면서 selection 범위가 [0, 1]이 되어 드래그 상태를 유발하는 것이 근본적인 원인이었습니다.
해결 과정
1단계: insertText 로직 보완
먼저 COMPOSITION_START_CHAR가 삽입될 때 offset이 강제로 이동하지 않도록 고정하는 로직을 추가했습니다.
if (firstNode.isComposing() && text !== COMPOSITION_START_CHAR) {
// 조합 중일 때는 올바른 범위를 교체하도록 offset 조정
this.anchor.offset -= text.length;
} else {
// 제어 문자 삽입 시에는 포맷과 스타일만 동기화
this.format = firstNode.getFormat();
this.style = firstNode.getStyle();
}
2단계: 근본 원인 수정
테스트 과정에서 드래그된 상태에서 첫 획이 덮어씌워지는 원인을 발견했습니다. Android용으로 작성된 특수 로직이 일반 데스크탑 Chrome에서도 실행되어 덮어졌습니다.
// 수정 전: Safari/iOS가 아니면 모두 실행 (데스크탑 Chrome 포함)
if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT && editor.isComposing()) { ... }
// 수정 후: 오직 Android Chrome에서만 실행되도록 타겟팅
if (IS_ANDROID_CHROME && editor.isComposing()) {
lastKeyDownTimeStamp = 0;
$setCompositionKey(null);
}
이 조건을 IS_ANDROID_CHROME으로 명확히 한정하여, 데스크탑 환경의 Chrome에서 불필요하게 selection이 초기화되는 문제를 해결했습니다.
E2E 테스트 및 예외 처리
로직 수정 후, 크로스 브라우저 환경에서 발생할 수 있는 오류를 해결하기 위해 다음과 같은 예외 처리를 추가했습니다.
1. 다중 노드 환경의 E2E 테스트 추가
기존에는 단일 노드에서의 입력만 검증했으나, 이번 이슈의 핵심인 다중 포맷 노드가 섞여 있는 환경에서의 한국어 입력 테스트 케이스를 새롭게 추가했습니다. 또한, 기존 일본어 히라가나 테스트 중 기대값에 COMPOSITION_START_CHAR가 포함되어 있던 잘못된 테스트 코드를 바로잡았습니다.
2. 잔류 제어 문자 제거 및 UX 개선
수정된 테스트 코드에는 특정 상황에서 보이지 않는 제어 문자가 텍스트 노드에 남아있는 문제가 있었습니다. 이 경우 사용자가 방향키로 커서를 이동할 때, 눈에는 보이지 않는 문자를 지나가기 위해 방향키를 두 번 눌러야 하는 등 UX를 저해하는 문제가 발생했습니다. 이를 해결하기 위해 LexicalUtils.ts에서 DOM 데이터를 읽어올 때, 루프를 통해 텍스트 내의 모든 제어 문자를 완전히 필터링하도록 로직을 강화했습니다.
3. History 관리와 Yjs 협업 대응
Undo(되돌리기) 시 제어 문자가 다시 살아났습니다.
- HISTORY_MERGE_TAG 도입: 제어 문자 삽입 이벤트를 독립된 히스토리로 남기지 않고, 이후 발생하는 실제 문자 입력 이벤트와 하나로 병합되도록
HISTORY_MERGE_TAG를 적용했습니다. - 협업 환경 동기화: 이 처리는 Yjs를 이용한 실시간 협업 환경에서도 중요합니다. 제어 문자가 개별 변경사항으로 전파되지 않도록 하여, 다른 사용자의 화면에서 커서 위치가 튀거나 텍스트가 꼬이는 현상을 방지했습니다.
느낀점
처음 문제를 접했을 때는 금방 해결할 수 있을 거라 생각했지만, IME와 브라우저 엔진이 얽힌 복잡한 생태계를 마주하며 예상보다 깊은 고민이 필요했습니다. 특히 OS와 브라우저마다 제각각인 IME 처리 방식을 조율하는 과정에서, 한 곳을 수정하면 다른 환경에서 예상치 못한 부작용이 발생하는 등 크로스 브라우징 이슈의 어려움을 실감했습니다.
다양한 환경에서 직접 디버깅하고 E2E 테스트를 보완하며 느낀 점은, 단순히 문제를 해결하는 코드를 넘어 '모든 환경에서 지속 가능한 코드'를 작성하는 것이 얼마나 중요한가였습니다. 비록 근본적인 원인이 엉뚱한 조건문 하나 때문이었다는 사실을 깨달았을 땐 다소 허탈하기도 했지만, 그 과정에서 기존 테스트의 허점을 찾아내고 라이브러리를 더 견고하게 다듬을 수 있어 큰 보람을 느꼈습니다.
무엇보다 메인테이너와 긴밀하게 소통하며 문제를 해결해 나가는 과정 자체가 매우 즐거운 경험이었고, 이번 기여를 통해 오픈소스 생태계에 기여하는 즐거움을 다시 한번 체감할 수 있었습니다.