잘못된 서러게이트 페어의 위험: 조용한 동기화 실패 이야기
소프트웨어를 충분히 오래 개발하면, 코딩 오류라기보다 매트릭스의 결함 같은 버그를 마주하게 됩니다. 한 엔지니어링 팀에게는 협업 편집기가 콘텐츠 저장을 멈추는 조용하고 간헐적인 실패로 나타났습니다. 콘솔 오류도, 스택 트레이스도, 명백한 네트워크 오류도 없었고, 단지 데이터가 공허 속으로 사라졌습니다.
문제의 원인은 현대 컴퓨팅에서 가장 교묘한 측면 중 하나인 잘못된 서러게이트 페어를 처리하는 JavaScript의 Unicode 문자열 처리 방식이었습니다.
버그: 두 개의 이모지는 들어오고, 하나도 나오지 않는다
이 문제는 레거시 편집기를 TipTap(프로세미러 래퍼)과 Yjs를 사용한 CRDT 기반 실시간 동기화 협업 경험으로 마이그레이션하는 과정에서 드러났습니다. 대부분의 사용자는 시스템이 완벽히 동작했지만, 일부 사용자는 편집 내용이 가끔 동기화되지 않는다고 보고했습니다. 편집기는 로컬에서는 반응했지만 페이지를 새로 고치면 실패 시점 이후의 모든 변경 사항이 사라졌습니다.
제품 매니저가 특정 패턴을 발견하면서 돌파구가 열렸습니다: 다른 다중 바이트 문자 사이에 특정 문자를 삽입할 때 버그가 발생했습니다. 여기서는 🟢(초록 원)와 🔴(빨강 원) 이모지를 사용했습니다.
사용자가 "splice" 작업(특정 바이트 오프셋에서 문자 삽입 또는 삭제)을 수행하면, 기본 CRDT 라이브러리가 가끔 서러게이트 페어를 중간에서 잘라 버립니다. 이로 인해 고아 서러게이트가 포함된 문자열이 생성되고, 동기화 프로세스가 충돌합니다.
Unicode 계층 구조 이해하기
단순 이모지가 동기화 엔진을 충돌시킬 수 있는 이유를 이해하려면, 흔히 "문자"라고 혼동되는 세 가지 개념을 구분해야 합니다:
1. 코드 유닛
JavaScript는 문자열을 내부적으로 UTF-16으로 저장합니다. 코드 유닛은 16비트 원시 값입니다. JavaScript에서 .length나 .slice()를 호출하면, 인간이 인식하는 문자와는 무관하게 코드 유닛을 기준으로 동작합니다.
2. 코드 포인트
코드 포인트는 Unicode 표준이 정의한 실제 원자 단위입니다. 많은 문자는 단일 16비트 코드 유닛에 들어가지만, 대부분의 이모지처럼 큰 문자는 그렇지 않습니다. 이러한 경우는 서러게이트 페어(하이 서러게이트와 로우 서러게이트)로 저장됩니다.
3. 그래프힘 클러스터
그래프힘 클러스터는 인간이 하나의 문자로 인식하는 단위입니다. 일부 이모지는 여러 코드 포인트의 조합으로 이루어집니다. 예를 들어, 여성 우주비행사(👩🚀)는 여성 이모지, 제로폭합자, 로켓 이모지로 구성됩니다.
| 문자 | 코드 유닛 | 코드 포인트 | 그래프힘 |
|---|---|---|---|
| A | 1 | 1 | 1 |
| 🤠 | 2 | 1 | 1 |
| 👩🚀 | 5 | 3 | 1 |
| 👨👨👧👧 | 11 | 7 | 1 |
.slice()가 시스템을 깨뜨리는 이유
JavaScript의 .slice()는 코드 유닛을 기준으로 동작하므로, 서러게이트 페어를 중간에 자르는지 여부를 전혀 고려하지 않습니다. 카우보이 이모지(🤠)를 중간에 슬라이스하면 두 조각이 생깁니다:
"🤠".slice(0, 1); // → '\uD83E' (고립된 하이 서러게이트)
"🤠".slice(1, 2); // → '\uDD20' (고립된 로우 서러게이트)
이 조각들은 유효하지 않은 Unicode입니다. 브라우저에서는 대체 문자()로 표시될 수 있지만, 특정 API에 전달될 때 치명적인 오류를 일으킵니다. 이 경우 고아 서러게이트가 encodeURIComponent에 전달되어 URIError: URI malformed를 발생시켰습니다. 이 오류가 잡히지 않아 Yjs 동기화 루프가 조용히 죽었고, 사용자는 로컬 편집만 가능한 상태에 빠졌습니다.
완화 및 해결 방안
이 문제를 해결하려면 "핵" 해킹에서 아키텍처 변경까지 다단계 접근이 필요했습니다:
즉각적인 해킹
버그가 상위 의존성(lib0)에 존재했기 때문에 팀은 처음에 두 가지 방어책을 구현했습니다:
- 오프라인 지원: 오프라인 모드를 활성화해 CRDT가 로컬에서 계속 업데이트되도록 했습니다. 페이지를 다시 로드하거나 오류가 해소되면 상태를 다시 병합할 수 있었습니다.
- 전역 오류 캐치:
window.addEventListener("error", ...)전역 리스너를 추가해URIError: URI malformed를 특별히 잡았습니다. 감지되면 앱은 모달을 띄워 사용자가 페이지를 새로 고치도록 유도해 조용한 데이터 손실을 방지했습니다.
영구적인 수정
근본 원인은 두 가지 주요 방법으로 해결되었습니다:
- 상위 패치:
lib0라이브러리를 업데이트해 슬라이스 중 고아 서러게이트를 감지하고 Unicode 대체 문자(U+FFFD)로 교체하도록 했습니다. 이렇게 하면URIError가 발생하지 않습니다. - 원자 노드 타입: ProseMirror/TipTap에서 이모지를 자체 "원자 노드 타입"으로 정의했습니다. 이를 통해 편집기가 각 이모지를 분할할 수 없는 단위로 취급해 커서나 splice 작업이 서러게이트 페어 내부에 들어가는 것을 방지했습니다.
현대 개발을 위한 교훈
이 버그는 str[0]이나 .slice(0, 1)이 "첫 번째 문자"를 반환한다는 가정이 비ASCII 입력을 지원하는 모든 애플리케이션에서 위험한 가정임을 일깨워 줍니다. 사용자 이니셜을 생성하는 도구(firstName[0] + lastName[0] 등)에서도 사용자의 이름이 이모지로 시작하면 충돌하거나 쓰레기 값을 출력할 수 있습니다.
현대적 해결책: Intl.Segmenter
오늘날 JavaScript에서 문자열 조작을 수행하는 개발자는 그래프힘 클러스터를 인식하는 Intl.Segmenter API를 사용하는 것이 올바른 접근법입니다:
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = [...seg.segment("👩🚀A👍")].map((s) => s.segment);
// Result: ['👩🚀', 'A', '👍']
코드 유닛이 아니라 그래프힘 단위로 분할함으로써 고아 서러게이트 가능성을 없애고, 애플리케이션이 인간이 보는 방식대로 텍스트를 처리하도록 보장합니다.
커뮤니티 관점
이 문제에 대한 기술 토론은 여러 언어에 걸친 시스템적 문제임을 강조합니다. 일부 개발자는 Dart와 같은 언어에서도 유사한 "반으로 자른" 이모지 문제가 발생했다고 언급했습니다. 또 다른 사람들은 property-based testing이 다양한 Unicode 문자를 함수에 제공함으로써 이러한 엣지 케이스를 발견하는 가장 효과적인 방법 중 하나라고 제안했습니다.
몇몇은 UTF-16의 복잡성을 컴퓨팅 역사상의 "불필요한 오류"라고 주장하는 반면, 다른 이들은 인코딩 방식과 관계없이 여러 스칼라가 하나의 논리적 단위(그래프힘)를 형성한다는 개념은 언제나 불가피했다고 지적합니다.