너비 확인 작업이 한자를 깨뜨린 사건

터미널 테이블에 이름을 입력했더니 깨진 채로 출력되었습니다. 성씨는 𠮷田이었습니다.

첫 번째 글자는 흔히 쓰이는 吉이 아닙니다. 𠮷 (U+20BB7)입니다. 이는 실제 일본 성씨에서 사용되는 희귀한 형태입니다. 테이블이 열 너비에 맞추기 위해 셀을 잘라냈는데(truncate), 이름 대신 깨진 문자가 출력되었습니다. 한자가 반으로 쪼개진 것입니다.

버그는 한 줄짜리 지름길 코드(shortcut)에 있었습니다. 코드는 실제로 문자열을 자르기 전에 인덱스 기준으로 잘라도 안전한지 먼저 판단했습니다. 이 로직은 JavaScript의 문자열 처리 방식 때문에 실패했습니다.

JavaScript 문자열에는 세 가지 다른 길이가 있습니다:

  • 코드 유닛 길이(Code unit length): "𠮷".length는 2입니다. 이는 UTF-16 유닛의 개수를 셉니다.
  • 코드 포인트 개수(Code point count): [..."𠮷"].length는 1입니다. 이는 실제 문자의 개수를 셉니다.
  • 표시 너비(Display width): 터미널에서 차지하는 열(column)의 수는 2입니다.

일반적인 영어 텍스트의 경우, 이 숫자들은 모두 같습니다. "abc"는 3개의 유닛, 3개의 포인트, 3개의 열을 가집니다. 대부분의 코드는 이러한 우연이 규칙이라고 가정합니다.

𠮷 문자는 그 규칙을 깨뜨립니다. 이 문자는 2개의 코드 유닛과 2개의 열을 가집니다. 숫자는 일치하지만, 그 이유는 서로 다릅니다. 코드는 2와 2가 같다는 것을 보고 인덱스 기준으로 문자열을 자르는 빠른 경로(fast path)를 사용했습니다.

인덱스 3에서 문자열을 잘랐을 때, 첫 번째 문자는 온전히 가져왔지만 두 번째 문자는 절반만 가져왔습니다. 이로 인해 고립된 서로게이트(surrogate)가 남게 되었습니다. 터미널은 이를 깨진 상자 모양으로 표시합니다.

漢과 같은 일반적인 일본어 문자는 안전합니다. 이들은 1개의 코드 유닛과 2개의 열을 가집니다. 1과 2가 같지 않기 때문에 코드는 문제가 되는 지름길 코드를 피하게 됩니다. 이 버그는 희귀한 문자나 이모지에서만 발생합니다.

이를 해결하려면 다음을 수행해야 합니다:

  • 하이 서로게이트(high surrogates)가 포함된 문자열을 거부하도록 빠른 경로(fast path)를 방어해야 합니다.
  • 코드 유닛 대신 전체 코드 포인트 단위로 잘라야 합니다.

Array.from(str)을 사용하면 코드 포인트 단위로 반복(iterate)하기 때문에 이 문제가 해결됩니다. 문자를 하나의 온전한 단위로 취급하기 때문입니다.

교훈은 간단합니다. 한 가지 단위로 측정하고 다른 단위로 자르지 마십시오. 표시 너비를 측정하면서 코드 유닛 인덱스로 자른다면, 사용자의 데이터를 깨뜨리게 될 것입니다.

희귀한 CJK 문자나 이모지로 코드를 테스트하십시오. ASCII로는 이러한 오류를 발견할 수 없습니다. 코드가 두려워하는 입력을 직접 제공해야 합니다.

Source: https://dev.to/greymothjp/a-width-check-said-the-string-was-safe-to-cut-it-split-a-kanji-in-half-4hjk

Optional learning community: https://greymoth-jp.github.io/cjk-failure-corpus/