Open Source

다중 서식 텍스트를 IME 조합 문자로 교체 시 서식 유실 문제 해결

2026년 2월 23일

Lexical 에디터에서 서로 다른 서식이 적용된 텍스트들을 전체 선택하고 한글(IME)을 입력할 때, 첫 글자 이후의 서식이 초기화되는 회귀(Regression) 버그를 해결한 과정을 공유합니다.

문제 상황

기존에 해결되었던 것으로 보였던 IME 입력 시의 서식 유지 기능이 특정 조건에서 다시 오동작하는 것을 발견했습니다.

재현 단계

  1. 볼드체가 적용된 "안녕" 입력
  2. 서식 없이 "하세요" 입력
  3. 전체 선택 후 "반갑습니다" 입력

결과: 첫 조합 문자인 "반"에는 볼드체가 적용되지만, 이후 입력되는 "갑습니다"부터는 서식이 유실되고 일반 텍스트로 입력됩니다.


원인 분석

배경: 히스토리 관리와 Composition Tag

이전에 COMPOSITION_START_CHAR(너비 0인 공백 문자)가 Undo 시 복구되는 문제를 해결하기 위해 HISTORY_MERGE_TAG를 도입했었습니다. 이후, 조합 중 상태를 더 정밀하게 관리하기 위해 조합 이전의 상태를 저장하는 새로운 태그 로직(#8142)이 추가되었습니다.

문제의 핵심

새롭게 추가된 태그 로직에서 조합 중에는 Selection 상태가 노드의 데이터와 실시간으로 동기화되지 않는 부작용이 발생했습니다. 이로 인해 조합 중 첫 글자가 입력된 후 다음 문자로 넘어가는 과정에서 Selection의 format 정보가 유지되지 못하고 초기화되는 현상이 발생한 것이 원인이었습니다.


해결 방법

1. Selection 서식 유지 로직 보완

조합 중에도 현재 Selection의 formatstyle이 유지되도록 로직을 수정했습니다.

수정 전:

if (firstNode.isComposing()) {
  this.anchor.offset -= text.length;
} else {
  this.format = firstNode.getFormat();
  this.style = firstNode.getStyle();
}

수정 후:

this.format = firstNode.getFormat();
this.style = firstNode.getStyle();
if (firstNode.isComposing()) {
  this.anchor.offset -= text.length;
}

2. E2E 테스트 검증 방식 개선

기존 테스트 코드는 조합된 문자를 한번에 전송하여 최종 결과물만 확인해서, 중간 단계에서 서식이 유실되는 현상을 포착하지 못하고 있었습니다. 이를 각 문자 단위로 조합 이벤트를 발생시켜 중간 상태까지 검증하도록 수정했습니다.

수정 전 (최종 결과만 검증):

await client.send("Input.imeSetComposition", {
  selectionEnd: 1,
  selectionStart: 1,
  text: "ㄱ",
});
await client.send("Input.imeSetComposition", {
  selectionEnd: 1,
  selectionStart: 1,
  text: "가",
});
await client.send("Input.imeSetComposition", {
  selectionEnd: 2,
  selectionStart: 2,
  text: "가ㄴ",
});
await client.send("Input.imeSetComposition", {
  selectionEnd: 2,
  selectionStart: 2,
  text: "가나",
});
await client.send("Input.insertText", { text: "가나" });

수정 후 (음절 단위 검증):

await client.send("Input.imeSetComposition", {
  selectionEnd: 1,
  selectionStart: 1,
  text: "ㄱ",
});
await client.send("Input.imeSetComposition", {
  selectionEnd: 1,
  selectionStart: 1,
  text: "가",
});
await client.send("Input.insertText", { text: "가" });
await client.send("Input.imeSetComposition", {
  selectionEnd: 2,
  selectionStart: 2,
  text: "가ㄴ",
});
await client.send("Input.imeSetComposition", {
  selectionEnd: 2,
  selectionStart: 2,
  text: "가나",
});
await client.send("Input.insertText", { text: "나" });

이렇게 테스트를 세분화함으로써, 각 음절이 입력되는 모든 순간에 서식이 올바르게 유지되는지 엄격하게 검증할 수 있게 되었습니다.

playground


느낀점

한 번 해결했다고 생각한 버그가 다른 기능의 개선 과정에서 의도치 않게 다시 발생하는 '회귀 버그'의 해결했습니다. 오픈소스 기여에 있어 단순히 기능을 구현하는 것 이상으로, 예상치 못한 사이드 이펙트를 방지하기 위한 견고한 테스트 설계가 얼마나 중요한지 다시금 배웠습니다.