메뉴
HN
Hacker News 15일 전

내가 가장 좋아하는 버그: 잘못된 서로게이트 페어

IMP
8/10
핵심 요약

자바스크립트 기반의 실시간 협업 에디터에서 두 개의 이모지를 나란히 입력하고 그 사이에 다른 문자를 삽입할 때, 내부 CRDT 라이브러리가 서로게이트 페어(Surrogate Pair)를 정확히 반으로 나누는 버그가 발생해 에디터의 동기화가 조용히 멈추는 현상에 대한 디버깅 스토리입니다. 이 문제는 U+FFFF를 넘어서는 다중 바이트 이모지와 특정 바이트 오프셋에서만 발생하기 때문에 원인을 파악하기 매우 어려웠으며, 결국 코드 유닛(Code Unit), 코드 포인트(Code Point), 그래핌 클러스터(Grapheme Cluster)의 차이를 이해함으로써 해결할 수 있었습니다.

번역된 본문

충분히 오랫동안 컴퓨터에서 실행되는 것을 만드는 일을 해왔다면, 언젠가는 자신만의 '최애 버그 스토리'가 생길 것이라고 생각합니다. 이 글은 제가 겪은 짧은 버그 스토리입니다. 이 버그의 핵심적인 개념들을 탐구해 볼 수 있는 인터랙티브 도구도 함께 만들어 보았습니다.

이 버그: 두 이모지가 들어가고, 아무것도 나오지 않는다

저는 팀과 함께 레거시 에디터를 더 협업 친화적인 경험으로 마이그레이션하는 작업을 하고 있었습니다. 상단에는 TipTap(그 자체로 ProseMirror의 래퍼입니다)을 사용하고, 하단에는 Yjs가 실시간 동기화를 위한 CRDT 매직을 처리하는 구조였습니다. 잘 작동했습니다! 대부분은요.

알파/얼리 릴리즈 시절, 주로 내부 사용자나 얼리 억셉터들이 사용하던 시기에 가끔 에디터가 콘텐츠의 저장을 멈추는 일이 발생했습니다. 아무런 알림 없이 말이죠. 계속 타이핑을 하고 모든 것이 정상적으로 보이지만, 편집한 내용은 Yjs 문서에 더 이상 동기화되지 않았습니다. 다음에 페이지를 열었을 때, 실패 시점 이후에 작성한 모든 내용이 사라져 있었습니다. 정말 끔찍했고, 매우 드물게 발생했으며, 우리가 절대 재현할 수 없었기 때문에 원인을 진단하는 것이 거의 불가능했습니다. 우리는 정말 열심히 노력했습니다! 초기의 의심은 주로 불안정한 와이파이 연결과 websocket의 이상한 동작에 쏠려 있었지만, 아무리 네트워크 속도를 제한하거나 와이파이를 켰다 껐다 해도 문제를 재현할 수 없었습니다. 제 기억에 그러한 시나리오에서 시스템은 놀라울 정도로 회복력이 있었습니다. 마치 아무도 보지 않을 때 무작위로 발생하는 것 같았습니다. 콘솔에서 잡힌 명확한 에러도, 스택 트레이스도, 크래시도 없었습니다. 그냥... "어, 내 변경 사항이 저장 안 된 것 같아"라는 말뿐이었습니다.

그러던 어느 날 우리 제품 매니저(PM)가 그 원인을 찾아냈습니다. 이것을 발견하는 것은 결코 사소한 일이 아니었습니다. 그는 누구보다 이 문제를 자주 겪었고(아마도 우리 제품을 가장 열심히 '개발자가 자신의 제품을 직접 사용해 보는(Dogfooding)' 사람이었기 때문일 것입니다), 꼼꼼하게 원인을 좁혀왔습니다. "내가 미친 것 같은데, 특정 문자를 함께 입력하고, 다시 돌아가서 그 사이에 다른 문자를 삽입할 때 이런 것 같아..."

그는 주간 프로젝트 상태 이메일에서 전반적인 상태를 전달하기 위해 🟢(녹색)와 🔴(빨간색) 이모지를 사용하고 있었습니다. 녹색은 계획대로 진행 중, 빨간색은 위험을 의미합니다. 매주 그가 사용하던 템플릿에는 두 이모지가 이미 포함되어 있었고, 그는 단순히 필요 없는 것을 지웠습니다(기쁘게도 대개는 빨간색이었습니다!). 어떤 경우에 그는 녹색 원을 복사해서 빨간색 원 앞에 붙여넣었거나, 그 반대의 작업을 했었습니다. 바로 그 특정 작업, 즉 하나의 다중 바이트 이모지를 다른 이모지 바로 옆에 삽입하는 것이 기본 CRDT 라이브러리에서 splice(잘라내기/붙여넣기) 작업을 트리거했고, 이 과정에서 서로게이트 페어(Surrogate Pair)가 정확히 반으로 나뉘게 된 것입니다.

그가 이 사실을 저와 협업 에디터 전환 작업을 하느라 고생하던 제 직속 부하직원에게 보여주는 전화 회의에 참여했던 기억이 납니다. 저는 아마 너무 흥분했을 것입니다. 저는 난해한 버그를 정말 좋아하거든요. 제 직속 부하직원은 "부장님이 이 버그에 흥분하신 것 같아요"라고 말했습니다. 그의 말이 틀린 것은 아니었습니다.

재미있는 점은 모든 이모지가 이 문제를 일으키는 것은 아니라는 것입니다. 서로게이트 페어가 필요한 U+FFFF 이상의 이모지들만 문제를 일으켰습니다. 그리고 모든 편집이 문제를 일으킨 것도 아니며, 정확히 잘못된 바이트 오프셋에서 잘라내기(splice)를 유발하는 작업만이 문제를 일으켰습니다. 무슨 일이 일어나고 있는지 알기 전까지는 디버깅하기가 정말 미칠 노릇이었습니다.

코드 유닛(Code Units), 코드 포인트(Code Points), 그리고 그래핌 클러스터(Grapheme Clusters)

도대체 무슨 일이 일어난 걸까요? 지난 단락에서 말한 'U+FFFF 이상'이라는 것은 무슨 뜻일까요? 어떤 바이트 오프셋이라는 걸까요? 이 버그를 이해하려면 세 가지 용어를 알아야 합니다: 코드 유닛 → 코드 포인트 → 그래핌 클러스터.

코드 유닛은 JavaScript가 내부적으로 문자열을 저장하는 데 사용하는 기본 16비트 값(UTF-16)입니다. 이것이 .length가 세는 단위입니다. .slice().charCodeAt()이 작동하는 단위이기도 합니다. JavaScript는 기본적으로 코드 유닛 수준에서 동작합니다.

코드 포인트는 유니코드(Unicode)가 실제로 하나의 문자로 정의하는 단위입니다. U+1F920(🤠)과 같은 코드 포인트는 유니코드 관점에서 하나의 문자지만, 하나의 16비트 코드 유닛에 담기에는 너무 큽니다. 그래서 UTF-16은 이를 서로게이트 페어(Surrogate Pair)라 불리는 두 개의 코드 유닛, 즉 상위 서로게이트(High Surrogate)와 하위 서로게이트(Low Surrogate)로 나눕니다. 단순한 ASCII 문자와 많은 일반적인 기호들은 하나의 코드 유닛에 들어가므로 구분이 중요하지 않습니다. 하지만 이모지는 어떨까요? 거의 항상 두 개의 코드 유닛을 차지합니다.

그래핌 클러스터는 인간이 (글자를 인식하는) 단위입니다...

원문 보기
원문 보기 (영어)
If you're in the business of building things that run on computers long enough, I think you will eventually acquire a favorite bug story. This is a short story about mine. I've also built an interactive tool where you can explore the concepts underpinning the heart of this bug. The bug: two emoji enter, none leave I was working on migrating a legacy editor to a more collaborative experience with my team. TipTap on top (itself a wrapper around ProseMirror ), Yjs underneath handling the CRDT magic for real-time syncing. It worked well! Mostly. In our alpha/early release days, when it was still mostly internal and/or early rollout users, sometimes the editor would just stop saving your content. Silently. You'd keep typing and everything looked fine, but your edits stopped syncing to the Yjs document. The next time you opened the page, everything you'd written after the failure point was gone. It was utterly terrifying, very rare and almost impossible to diagnose because we could never recreate it. We really tried! My early suspicions generally revolved around shaky wifi connections and wonky websocket behaviors, but no amount of throttling or turning my wifi on and off seemed to recreate the issue. The experience was surprisingly resilient in those scenarios, in my memory. It felt like it happened randomly, never when anyone was looking. No obvious errors picked up in the console, no stack trace, no crash. Just... "Hey, I think my changes didn't save." Then one day our product manager cracked it. This was not a trivial thing to find. He'd been experiencing it more than anyone else (probably because he was the best at dogfooding our product) and had been methodically narrowing it down. "I feel like I'm going crazy, but I think it's when I type specific characters together, go back and insert a character between them..." He'd been using 🟢 and 🔴 in his weekly project status emails to communicate general health. Green for on-track, red for at-risk. Every week the template he was using had both characters already present and he would simply remove the one he didn't need (Generally the red one, I am happy to say!). On this occasion he'd copied the green circle and pasted it in front of the red one at some point, or maybe vice versa. That specific operation— inserting one multi-byte emoji adjacent to another— was triggering a splice in the underlying CRDT library, which split a surrogate pair down the middle. I remember being on the call when he showed this to me and one of my direct reports who'd been toiling away at the collaborative editing transition. I must've gotten a little too excited—I live for esoteric bugs—"I feel like you got energized by this," he said. He wasn't wrong. Adding to the fun, not every emoji triggered it. Only the ones above U+FFFF that required surrogate pairs. And not all edits resulted in the problem either—only the ones that caused a splice at exactly the wrong byte offset. It was a wild one to debug before we knew what was going on. Code units, code points, and grapheme clusters So what was going on? What does "ones above U+FFFF " in that last paragraph even mean? What byte offsets? To understand this bug we need to introduce three pieces of vocabulary: Code Units → Code Points → Grapheme Clusters Code units are the raw 16-bit values that JavaScript uses to store strings internally (UTF-16). This is what .length counts. This is what .slice() and .charCodeAt() operate on as well. JavaScript operates at the code unit level by default Code points are what Unicode actually defines as a single character. A code point like U+1F920 (🤠) is one character in Unicode's view, but it's too big to fit in a single 16-bit code unit. So UTF-16 splits it into two code units called a surrogate pair : a high surrogate and a low surrogate. Simple ASCII characters and a lot of common symbols fit in one code unit, so the distinction doesn't matter for them. Emoji, though? Almost always two. Grapheme clusters are what a human perceives as "one character." The female astronaut 👩‍🚀 looks like one character but is actually three code points glued together: 👩 (woman) + a zero-width joiner + 🚀 (rocket). Five code units, three code points, one grapheme. The deceptively simple 👨‍👨‍👧‍👧 (Family: Man, Man, Girl, Girl) emoji is an impressive eleven! The enigmatic ☃ is 1. Here's how those numbers diverge: Code units Code points Graphemes A 1 1 1 🤠 2 1 1 👩‍🚀 5 3 1 👨‍👨‍👧‍👧 11 7 1 I will pause to once again plugin the interactive surrogate explorer I alluded to at the top. You can type any emoji and see this breakdown yourself! How .slice() breaks things The cowboy 🤠 is one code point stored as two code units (a surrogate pair). If you slice between them: "🤠".slice(0, 1); // → '\uD83E' (lone high surrogate) "🤠".slice(1, 2); // → '\uDD20' (lone low surrogate) Those fragments aren't valid characters. They're half a pair with no partner. On their own they render as replacement characters (�) or get silently swallowed. But the real problem comes when you try to encode one: encodeURIComponent("🤠".slice(0, 1)); // URIError: URI malformed That's what was crashing our tool. What was actually happening Yjs depends on a utility library called lib0. The lib0 splice method used JavaScript's .slice() internally. When a CRDT operation happened to land between the two halves of an emoji's surrogate pair, lib0 would produce a string with an orphaned surrogate. That string would eventually get passed to encodeURIComponent during sync, which threw an uncaught URIError . The error was uncaught. Nothing in the Yjs or TipTap error handling caught it. So sync just... stopped. The editor kept working locally, giving you every indication that things were fine, while your changes silently went nowhere. It only showed up on pathological edits: replacing one emoji with another, or inserting a character right between two emoji. The hack we shipped We couldn't fix lib0—though I'm happy to report it did eventually get fixed ! We couldn't patch Yjs. We needed to ship something. So we did two things: Although we didn't initially care about offline support for our product, adding it was pretty trivial. Our thinking was it could save us in a future situation should the user get disconnected and keep typing. We would continue to update the CRDT locally, and the next time they came back to the document their changes would be updated and merged with the current state of things. This was a hedge and leaned into what CRDTs are actually good at and designed for. An embarrassingly nuclear option (my call, with my fingerprints all over): we attached a global window.addEventListener("error", ...) listener that regex-matched for URIError: URI malformed . When it caught one, it logged the event for tracking and set a piece of state that our editor would check. If we saw the error, we'd throw up a modal telling the user something went wrong and asked them to reload the page. I watched this metric like a hawk and was relieved with how rare it ended up being. We weren't the only ones. The upstream issues ( yjs#303 , tiptap#3020 ) had other editors reporting the same problem with similar workarounds. The real fix Two things eventually fixed it for real: lib0 got patched. The upstream fix was to detect if the first character of a sliced string was a high surrogate without a matching low surrogate, and replace it with U+FFFD (the Unicode replacement character, �). Not perfect, but it stopped the URIError from happening and prevented sync from dying. We made emoji an atomic node type. In ProseMirror (and by extension TipTap), you can define custom node types. We setup an extension that made emoji their own node, which meant the editor treated each one as an indivisible unit. Cursor movements and editing operations couldn't split an emoji in half. This didn't fix the lib0 bug, and there were some other side-effects here that were challenging, but it eliminated most of the editing patterns that tr