문자열 길이 측정과 제한에는 UTF-8 바이트, UTF-16 코드 유닛, 유니코드 코드 포인트, 그래핌 클러스터 등 다양한 층위가 얽혀 있습니다. 이 글은 각 방식의 차이와 함정, 일관성 문제, 정규화, 프런트엔드/백엔드 처리, 그리고 실전 권장안(정규화된 유니코드 코드 포인트 기준)을 깊이 있게 다룹니다.
제목: The best – but not good – way to limit string length
문자열의 길이를 구하는 일은 단순해 보이고, 우리는 매일 코드를 쓰며 그것을 합니다. 문자열의 길이를 제한하는 것도 프런트엔드와 백엔드에서 매우 흔합니다. 하지만 이 두 가지—특히 길이 제한—에는 많은 복잡성, 버그 위험, 심지어 취약성 위험까지 숨어 있습니다. 이 글에서는 문자열 길이 제한을 충분히 깊게 파고들어, 우리가 실제로 무엇을 하고 있는지, 어떻게 하는 게 가장 나은지 온전히 이해하도록 돕고… 결국 “최선”도 그리 훌륭하진 않다는 사실을 발견하려 합니다.
TL;DR은 “완전히 이해하기”에는 부족하지만, 모두가 모든 글을 읽을 시간은 없으니 핵심만 추리면 다음과 같습니다.
이제 본격적으로, 익숙한 문자열 길이 함수들을 살펴보면서 시작해 봅시다.
| “a” | “字” | “🔤” | “👨👩👧👦” | “र्स्प” | “x̴͙̹̬̑̓͝͝” | ||
|---|---|---|---|---|---|---|---|
| Go | len(string) | 1 | 3 | 4 | 25 | 15 | 17 |
| JavaScript | String.length | 1 | 1 | 2 | 11 | 5 | 9 |
| Python 3 | len(str) | 1 | 1 | 1 | 7 | 5 | 9 |
| Swift | String.count | 1 | 1 | 1 | 1 | 1 | 1 |
이 네 가지 문자열 길이 측정값은 대부분의 프로그래밍 언어에서 흔한 네 가지 접근을 대표합니다. 즉 UTF-8 바이트, UTF-16 코드 유닛, 유니코드 코드 포인트, 그래핌 클러스터입니다.
다른 곳에도 좋은 설명이 많지만, 여기서는 더 나아가기 위해 필요한 개념만 빠르게 훑어봅시다. (이미 편한 부분은 건너뛰셔도 됩니다.)
먼저 “문자(character)”의 작업 정의: 대부분의 문자 기반 언어1에서 가장 작은 구성 단위에 대한 인간의 개념적 이상입니다. 즉 글자, 이모지, 표의문자, 문장부호, 기호, 그래핌 등. 나중에는 “사용자가 이것을 타이핑하면 ‘문자 수’가 1 증가한다고 기대하는 것”이라는 관점에서도 생각해 보겠습니다. 위 표의 모든 예시는 아마 여러분에게 “문자 하나”처럼 보일 겁니다.
(혼동을 피하고 기술적 정확성을 위해 이 용어를 가볍게 쓰지 않으려 합니다. 그래서 “유니코드 문자”라는 표현은 사용하지 않겠습니다.)
유니코드는 가능한 모든 문자를 열거하려는 인류의 시도이며, 그 외에도 제어 문자, 인쇄 불가 문자, 조합하여 문자를 만들 수 있는 조각 등 아주 많은 것을 포함합니다.
유니코드 공간의 각 항목을 “코드 포인트(code point)”라고 하며 32비트 부호 없는 정수로 표현됩니다. 다만 실제 사용 가능 공간은 2²¹(약 110만 값)이고, 그중 약 15만 개만 할당되어 있습니다. “유니코드 스칼라 값(Unicode scalar units)”이라는 용어도 볼 수 있는데, 이는 사실상 코드 포인트와 같지만 예약된 “서로게이트 쌍” 범위를 제외합니다.
예시(문자: 코드, 10진수)
엄밀히 말해 “유니코드 코드 포인트”는 추상적 개념으로, 각 문자에 수치 값을 부여한 것입니다. 이를 구체적으로 인코딩하는 방식이 보통 3가지 있습니다: UTF-8, UTF-16, UTF-32. “UTF-32”는 32비트로 32비트를 직접 나타내는 방식이며, 엔디안 변형이 있습니다. 명확성을 위해 여기서는 주로 “유니코드 코드 포인트”라고 하겠습니다. UTF-8과 UTF-16은 아래에서 자세히 다룹니다.
참고로 Go에서는 유니코드 코드 포인트를 보통 “룬(rune)”이라고 부릅니다. (Go는 간결함을 위해 이 용어를 도입한 듯합니다2. 좋은 시도지만, 여기서는 보편적인 용어를 쓰겠습니다.)
일부 유니코드 코드 포인트는 결합되어 하나의 시각적 문자로 렌더링될 수 있습니다. 이를 그래핌 클러스터(확장 그래핌 클러스터)라고 합니다.
몇 가지 예시를 보며 복잡성을 들여다봅시다.
이 가족 이모지는 “영폭 조인자(ZWJ) 시퀀스”의 예입니다.
가능한 모든 이모지(또는 일반적으로 코드 포인트)의 조합이 이렇게 하나의 그래핌 클러스터 문자로 합쳐지는 것은 아닙니다. 유니코드 컨소시엄은 정의된 모든 이모지를 공개하며, 여기에 다중 코드 포인트 합성도 포함됩니다.
이모지가 어떻게 렌더링되는지는 여러분이 보는 플랫폼에 따라 달라집니다. 예를 들어, Windows의 Brave 브라우저에서 보이는 가족 이모지는 이렇게 생겼고 –
– Android에서는 이렇게 보입니다 –
.
렌더링은 시간이 지나며 바뀔 수도 있습니다. 2014년 Windows 10에서 Microsoft는 “고양이”와 “닌자” 이모지를 합친 “ninjacat” ZWJ 이모지를 도입했지만 다른 플랫폼에서는 지원되지 않았습니다. 2021년 Microsoft는 이를 제거하여 이제는 두 개의 별도 이모지로 렌더링됩니다.
가족 이모지 자체도 큰 변화를 겪었습니다. (어느 시점엔 iOS에서 제거되었다는 말도 있지만 지금은 잘 렌더링되는 듯합니다.)
또한 ZWJ 없이도 결합되는 “결합 부호(combining marks)”가 있습니다.
이는 하나의 코드 포인트로도 표현 가능한 그래핌 클러스터의 예입니다. U+00E9는 “라틴 소문자 e + Acute”이며 위의 분해형과 시각적으로 동일합니다. (아래 “유니코드 정규화” 참고.)
그래핌 클러스터(정규화로 없애기 어려운 형태)의 사용은 유럽 및 동아시아 문자권에서는 매우 드물지만, 남아시아 문자권에서는 꽤 흔합니다. 예를 들어 힌디어에서는 ~25%의 문자가 결합 부호를 포함합니다.
그리고 잘고(Zalgo) 텍스트처럼 결합 부호를 난폭하게 남용하는 예도 있습니다. 예: c̴͚͉͔̓̑͂͜r̷̙̎̎̿͊a̵̜͍̱̋̕z̷̭̰͉͊̎́͒y̵̺̿̔
제가 아는 한, 하나의 그래핌 클러스터 “문자”에 기여할 수 있는 코드 포인트 수에는 제한이 없습니다. 아래에서 문자열 길이 제한을 생각할 때 이 점을 반드시 염두에 두겠습니다.
(문자열을 그래핌 클러스터 단위로 분할하는 작업을 두고 “세그멘테이션(segmentation)”이라는 말을 종종 씁니다. 일반적으로 문자열을 정의된 단위로 나누는 것을 뜻합니다. 예를 들어 JavaScript의 Intl.Segmenter API는 문자열을 그래핌, 단어, 문장 단위로 분할할 수 있습니다.)
유니코드 정규화에는 합성/분해와 호환성 단순화라는 두 축이 있으며, 이로부터 4가지 표준 모드가 나옵니다. “NF”는 “Normalization Form”, “C”는 “Canonical Composition(정준 합성)”, “D”는 “Canonical Decomposition(정준 분해)”, “K”는 “Compatibility(호환성 단순화)”를 의미합니다. 결과는 다음과 같습니다.
| 단순화 없음 | 단순화 | |
|---|---|---|
| 합성 | NFC | NFKC |
| 분해 | NFD | NFKD |
정준 합성은 “é”(U+0065 + U+0301)처럼 두 코드 포인트로 이루어진 그래핌 클러스터를 단일 코드 포인트 “é”(U+00E9)로 결합합니다. 단일 코드 포인트가 없는 경우(예: 대부분의 잘고 텍스트)에는 아무 일도 하지 않습니다. 정준 분해는 그 반대로, 단일 코드 포인트 문자를 다중 코드 포인트 그래핌 클러스터로 분해합니다(한글 음절을 자모로 분해하는 것도 포함).
호환성 단순화는 일부 화려한 문자를 보다 평범한 문자로 바꿉니다. 예컨대 “ℍ”(U+210D)과 “ℌ”(U+210C)는 평범한 라틴 “H”가 되고, 위 첨자 “²”는 숫자 “2”가 되며, 합자 “ffi”는 “ffi”가 됩니다. 단순화가 없는 형태에서는 이런 치환을 하지 않습니다. (일반적인 발음 부호는 제거되지 않습니다 – “é”는 악센트를 유지.) 정준화와 달리, 단순화는 되돌릴 수 없습니다.
NFC는 문자열을 가능한 한 간결하게 유지하면서 일관성을 높이는 데 좋습니다.
NFKC는 등가 문자를 동일하게 취급하여 검색이나 비교(예: “HELLO”로 “ℍ𝔼𝕃𝕃𝕆”를 찾기)를 하려는 경우에 좋습니다.
분해 형태(NFD/NFKD)는 추가 처리를 통해 모든 악센트를 제거하는 데 유용합니다. 예컨대 검색이나 파일명에 사용할 때 ASCII만 남기고 싶다면 도움이 됩니다.
정규화의 출력은 유니코드 버전에 따라 달라질 수 있습니다. 이와 관련한 위험은 아래 “유니코드 버전” 절에서 논의합니다.
UTF-8은 코드 포인트를 1, 2, 3, 4개의 1바이트 코드 유닛 시퀀스로 인코딩합니다. 대부분의 문자열 데이터에 대해 매우 압축적이라는 장점이 있고, 특히 모든 ASCII 인쇄 가능한 문자는 1바이트에 동일한 ASCII 값으로 들어갑니다(예: UTF-8로 인코딩된 소스 파일을 ASCII 보기로 열어도 어느 정도 읽을 수 있습니다). 최근에는 디스크나 네트워크 전송에서 가장 흔히 사용되는 인코딩입니다.
설계상 오버헤드가 있어, “8×바이트 수”만큼의 비트를 코드 포인트에 온전히 쓰지는 못합니다. 구체적인 구성은 다음과 같습니다.
UTF-16은 디스크나 네트워크 전송에서 자주 쓰이진 않지만, 많은 프로그래밍 언어와 OS가 메모리 내 표현으로 사용합니다. 그 이유는 일부 플랫폼/언어가 원래 2바이트 유니코드 표준인 UCS-2를 지원했기 때문입니다. 유니코드가 4바이트로 확장되자 UTF-16이 만들어졌습니다. UTF-16은 1개 또는 2개의 2바이트 코드 유닛 시퀀스로 코드 포인트를 표현합니다. 단일 코드 유닛 시퀀스는 UCS-2와 동일하여, UCS-2 플랫폼이 비교적 쉽게 전환할 수 있었습니다.
(두 개의 UTF-16 코드 유닛이 하나의 코드 포인트를 이루는 것을 “서로게이트 쌍(surrogate pair)”이라고 합니다. UCS-2 명세에는 두 번째 UTF-16 코드 유닛의 사용을 나타내는 “서로게이트” 예약 영역이 있습니다.)
UTF-16의 장점은 전체 “기본 다국어 평면(BMP)”이 단일 UTF-16 코드 유닛에 들어간다는 점입니다. 이는 “세계 주요 언어에서 사용되는 대부분의 일반 문자”를 의미합니다(단, 이모지는 제외되는 경우가 많습니다). 단점은 ASCII 문자 표현에 두 배의 공간이 든다는 점입니다.
(참고: UTF-8로 BMP 전체를 표현하려면 바이트가 최대 3개 필요합니다.)
코드 페이지, WTF-8, CESU 등은 다루지 않겠습니다. 이 글의 과제와 관련이 없고, 쓸 만한 지식도 없습니다.
이제 문자 인코딩을 이해했으니, 위의 표를 다시 보겠습니다.
| 인코딩 기준 | “a” | “字” | “🔤” | “👨👩👧👦” | “र्स्प” | “x̴͙̹̬̑̓͝͝” |
|---|---|---|---|---|---|---|
| UTF-8 코드 유닛 | 1 | 3 | 4 | 25 | 15 | 17 |
| UTF-16 코드 유닛 | 1 | 1 | 2 | 11 | 5 | 9 |
| 유니코드 코드 포인트 | 1 | 1 | 1 | 7 | 5 | 9 |
| 그래핌 클러스터 | 1 | 1 | 1 | 1 | 1 | 1 |
따라서 프로그래밍 언어(및 언어 내 함수)마다 서로 다른 방식으로 셉니다. 몇 가지 예4:
std::u8string, Haskell Text v2NSString), Haskell Text v1CharString.count, Elixir String.length, Perl 6대부분(아마 전부) 언어가 다른 인코딩으로 변환하고 해당 인코딩의 “길이”를 세는 방법을 제공합니다. 위는 기본값일 뿐입니다. 또한 메모리 내 인코딩과 프로그래밍 인터페이스에서 기본으로 제공하는 접근 방식이 다를 수 있습니다.
우리가 사용하는 언어가 문자열 길이를 어떻게 다루는지 이해하려면, 한 걸음 물러나 “문자열이란 무엇인가”를 생각해 볼 가치가 있습니다. 많은 사람이 “여러 문자의 모음”이라고 정의하겠지만, 이제 보았듯 “문자”는 추상적 의미만 있을 뿐이므로 string 타입을 사용할 때 충분치 않습니다. 우리는 두 가지를 알아야 합니다.
몇 가지 예:
Go의 string 타입은 사실 바이트 배열입니다. 의도상 UTF-8 코드 유닛을 담지만, UTF-8 시퀀스 유효성이 보장되지는 않습니다. len(string)은 바이트 길이를 줍니다. 바이트/코드 유닛 단위로 순회하고 싶다면 문자열을 []byte로 변환해야 합니다. 그냥 문자열을 순회하면 각 스텝이 유니코드 코드 포인트/룬을 줍니다. 유니코드 코드 포인트 개수를 얻으려면 unicode/utf8.RuneCountInString을 씁니다. UTF-16 코드 유닛과 룬 간 변환을 위한 unicode/utf16 패키지가 있습니다. 그래핌 클러스터 분할은 내장 지원이 없습니다.
JavaScript의 string은 UTF-16 코드 유닛들의 집합이며 string.length는 그 코드 유닛 개수를 줍니다. [...string]은 유니코드 코드 포인트 배열을 줍니다. TextEncoder는 UTF-8로 변환합니다. Intl.Segmenter는 그래핌 클러스터 접근을 제공합니다.
Swift의 내부 표현은 예전에는 UTF-16이었지만 2019년부터 UTF-8입니다. Character 타입은 하나의 그래핌 클러스터를 담고, String.count는 그래핌 클러스터 개수를 반환합니다. 인코딩 뷰로 String.UTF8View, String.UTF16View, String.UnicodeScalarView가 있습니다5.
문자열의 내부 동작을 더 깊게 이해하면, 하나의 이모지 길이가 7로 나오는 혼란과 그로 인해 발생하는 버그를 예방할 수 있습니다.
이제 드디어 이 글의 핵심으로!
문자를 인코딩하는 방식이 4가지이므로, 문자열 길이를 세는 방식도 4가지가 있습니다. 세는 방식이 4가지이므로, 문자열 길이를 제한하는 방식도 (최소) 4가지가 있습니다.
이는 아키텍처의 여러 레벨에서 쉽게 불일치를 낳아 버그와 나쁜 사용자 경험을 초래합니다. 특정 문자나 조합이 필요할 수 있어, 테스트에서도 놓치기 쉽습니다.
제가 이 글을 써야겠다고 느끼게 한 길이 제한기 몇 가지를 보죠.
max와 min 제한자는 유니코드 코드 포인트(Go 용어로 rune)를 셉니다.char_length()로 컬럼 제약을 만들면(기본) 유니코드 코드 포인트 기준으로 제한합니다.maxlength 속성은 input/textarea에서 UTF-16 코드 유닛 기준으로 제한합니다. (단… IE는 유니코드 코드 포인트로 제한하는 것을 봤습니다.)TextInput.maxLength는 UTF-16 코드 유닛 기준으로 제한합니다. 이는 iOS에서는 NSString.length를, Android에서는 InputFilter.LengthFilter를 사용하기 때문입니다.푸념: 길이를 세는 방식이 문서에서 바로 드러나지 않는 경우가 많아 정말 성가십니다. 답을 얻기 위해 RN 소스코드를 파고들 필요는 없어야 하죠. (RN만의 문제가 아닙니다.)
불일치는 프런트엔드 클라이언트와 백엔드 API 서버 사이, API와 데이터베이스 사이, 서로 다른 클라이언트 구현 사이, 같은 데이터베이스에 접근하는 서로 다른 서버 사이 등에서 발생할 수 있습니다. 이런 불일치가 초래할 문제를 보겠습니다.
프런트엔드가 백엔드보다 긴 입력을 허용하면(예: 프런트엔드는 100 유니코드 코드 포인트를 허용하지만 백엔드는 100 UTF-8 또는 UTF-16 코드 유닛만 허용), 프런트엔드는 사용자의 입력이 유효하다고 표시한 뒤 백엔드에서 거절될 수 있습니다.
반대로 프런트엔드의 입력 한도가 백엔드보다 짧으면, 사용자는 불필요하게 제한됩니다. 예를 들어 사용자 이름 같은 경우, 일관된 세기 방식의 프런트엔드에서 계정을 만들었지만 더 짧게 세는 클라이언트에서 로그인하려 하면 실패할 수도 있습니다. 다른 너무 긴 데이터는 프런트엔드가 난리를 칠 수도 있겠죠: 어설션 실패, 표시 거부, 사용자에게 변경 강요 등.
유사한 문제는 백엔드와 데이터베이스 사이 등 다른 레벨의 길이 제한에서도 발생합니다.
문자열 길이 제한에서는 의도적이고 일관되게 하십시오. 사용하는 OS나 언어의 기본값을 무심코 따르지 마세요.
현실 점검: 아마도 이런 불일치는 치명적인 버그로 이어지지 않을 겁니다. 아마도 UI를 완전히 못 쓰게 하거나 서버를 크래시시키지 않을 겁니다. 아마도 추한 보안 취약성을 만들지도 않을 겁니다. _대부분_의 사용자는 최대 길이의 사용자 이름을 만들지 않습니다. _대부분_의 사용자는 사용자 이름에 BMP 바깥 문자를 쓰지 않습니다(다만 다른 곳에서는 이모지를 기대하십시오). 경력 내내 이런 점에 전혀 신경 쓰지 않고도 아마 큰 문제를 겪지 않을 수 있습니다. 하지만 a) 당신은 호기심이 있고, b) “대부분”과 “아마”의 여지를 줄이고 싶으니, 더 나은 방법을 찾아봅시다.
이 글을 쓰게 만든 진짜 질문입니다. 문자열 길이 입력을 제한하는 최선의 방법은 무엇인가?
각 인코딩 타입을 다시 후보로 놓고 생각해 봅시다. UI와 API 양쪽 관점에서요. 프런트엔드와 백엔드의 “제한 방식”은 보통 다르다는 점도 유의하세요. 백엔드에서는 입력을 거절(HTTP 400)하는 경우가 많고, 프런트엔드에서는 a) 사용자가 한도를 넘겼음을 보여주고 수정하도록 하거나, b) 아예 입력을 막거나, c) 입력을 잘라냅니다.
또 분명히 합시다. 보통 _개수_를 제한하려는 이유는 _크기_를 제한하려는 이유입니다. 사용자 이름, 채팅 메시지, 책조차 무한정 길 수는 없습니다. 설령 표시하는 데 문제가 없더라도, 처리하고 저장할 수 없습니다. 바이트 수가 아닌 개수로 제한하는 편이 더 인간적일 수 있기에, 이 점을 염두에 두고 접근을 평가하겠습니다.
그래핌 클러스터는 인간이 생각하는 “문자”에 가장 가깝기 때문에, 세기 기준으로 적절해 보입니다. 하지만 실제로는 최악입니다. 앞서 말했듯, 하나의 그래핌 클러스터에 포함될 수 있는 코드 포인트 수에는 제한이 없기 때문입니다. 개수 제한에는 쓸 수 있을지 몰라도, 크기 제한에는 쓸 수 없습니다. (잘고 사이트는 “x”에 360개의 악센트 등을 붙여 총 361 코드 포인트까지 갑니다. Swift의 String.count는 여전히—올바르게—그래핌 클러스터 1개라고 말합니다.)
(아마 클러스터당 코드 포인트 수를 제한할 수도 있겠지만, 유니코드 스펙을 깨는 일이고 아마 좋지 않은 생각일 겁니다. 그와 비슷한 접근은 아래 “하이브리드 세기”에서 봅니다.)
그래핌 클러스터는 추가 처리와 해석이 필요합니다. 이는 주로 렌더링 문제이지만(예: iPhone은 여러 번 충돌 버그가 있었습니다), 입력 검증(잠재적으로 인증 이전)에 복잡한 코드 경로가 들어가는 건 누구에게나 경계 신호입니다6.
그리고 다른 옵션보다 그래핌 클러스터 수준에서 동작하는 것은 유니코드 버전 차이에 민감합니다. 예컨대 새 그래핌 클러스터가 추가되면, 새 분할 알고리즘은 1로 세지만, 오래된 유니코드 버전은 개별 코드 포인트로 셀 수 있습니다.
모든 언어가 그래핌 클러스터 분할을 내장 지원하는 것도 아닙니다. 예를 들어 Go에는 서드파티 패키지가 있지만, 표준 라이브러리나 golang.org/x/text에는 지원이 없습니다.
프런트엔드에서 출발한다면 UTF-16이 기본 선택이 될 가능성이 큽니다. 하지만 좋은 선택이 아닌 이유가 있습니다.
코드 유닛 개수로 길이를 제한할 때의 위험은, 시퀀스의 중간에서 잘라 문자를 깨뜨릴 수 있다는 점입니다. React Native의 iOS 제한기는 최소 5년간 이 문제를 겪다 수정했습니다. RN의 Android 제한기는 경계에서 서로게이트 쌍을 만나면 한 문자 뒤로 물러나는 방식으로 이를 피하는 것처럼 보입니다.
그래핌 클러스터가 아닌 기준으로 세면, “문자” 입력 하나에 대해 카운트가 1보다 더 크게 뛰기도 합니다. 최악은 아니지만, UX 측면에서 바람직하지 않습니다. 그 이유는 두 가지입니다.
첫 번째 문제는 UI에서 매 키 입력마다 NFC 정규화를 적용해 어느 정도 완화할 수 있습니다. 가능한 범위에서 클러스터를 단일 코드 포인트로 줄여주니까요. (타이핑 중 지연은 나쁜 UX입니다! 타이밍과 감각을 꼭 테스트하세요.) 이 점은 아래 유니코드 절에서 더 논의합니다.
두 번째 문제는 UTF-8/UTF-16에서는 완화할 수 없습니다. 설계상 코드 포인트에 여러 코드 유닛이 필요할 수 있기 때문입니다. 다만 이 문제는 UTF-16에서 훨씬 덜 심각합니다. 기본 다국어 평면 전체가 1 코드 유닛에 들어가므로요. 물론 입력 종류에 따라 다릅니다. 이모지에 대해서는 여전히 2씩 증가할 것입니다.
방금 한 말을 다시 강조하겠습니다. 일반적으로 쓰이는 문자 대다수는 BMP에 위치하며, 따라서 1개의 UTF-16 코드 유닛에 들어갑니다. 그래서 흔히 쓰는 문자에 대해서는 UTF-16 코드 유닛으로 세는 것과 유니코드 코드 포인트로 세는 것이 같습니다. 큰 예외는 이모지입니다.
또 다른 큰 요소는 UTF-16이 메모리 내에서는 꽤 흔하지만, 디스크나 네트워크 전송에 직렬화되는 경우는 매우 드물다는 점입니다(UTF-8이 가장 흔하고, 드물게 UTF-32). 우리는 제한할 때 “전송/디스크 상의 크기”를 신경 쓰므로, 직렬화되지 않을 걸 아는 형식으로 세는 건 다소 어색합니다. 하지만 과장하진 맙시다. UTF-16도 직렬화 크기 대비 약 2배 이내에서는 맞춰줍니다.
UTF-16을 쓰면 인코딩 변환을 가장 많이 하게 됩니다.
또한 기본적으로 UTF-8 또는 유니코드 코드 포인트 중심인 언어에서는 UTF-16이 2급 시민 취급인 경우가 있습니다. 예컨대 Go에서 문자열은 UTF-8, 순회 결과는 룬(코드 포인트)이지만, UTF-16 지원은 unicode/utf16 패키지를 써야 합니다. PostgreSQL은 UTF-16을 아예 지원하지 않는 것처럼 보입니다.
정리하자면, UTF-16이 끔찍한 선택은 아니지만 더 나은 방법이 있습니다.
UTF-8은 a) 단순 바이트 카운트이고, b) 전송/디스크 직렬화에 쓸 가능성이 높다는 점에서 매력적입니다. 영어 텍스트에서는 공간 효율도 아주 좋습니다(다른 언어에서는 덜할 수 있음).
하지만 UI에서 UTF-8 바이트로 문자를 세는 것은 혼란을 부릅니다. 순수 영어가 아니라면 카운트가 2, 3, 4씩 자주 증가하고, 인간이 예측하기 어렵게 변합니다.
UI/UX 관점에서 문자열 길이 제한의 중요성은 매우 넓은 스펙트럼을 가집니다. 한쪽 끝에는 표시되는 문자 수 카운트가 있는 아주 짧은 필드(예: 예전 140자 트윗)가 있고, 사용자는 카운트를 보며 의미를 해치지 않는 선에서 축약을 고민합니다. 다른 끝에는 1만 자 메시지 보드 제한처럼 거의 도달하지 않을 한도가 있습니다. 이런 경우 카운트를 아예 보여주지 않고 입력 자체를 하드 제한할 수도 있습니다.
한도가 충분히 높아 건강과 안전을 위한 방어선 정도라면, 문자를 어떻게 세든 큰 상관이 없습니다. 사용자에게 카운트를 보여주지도 않고, 10,000 UTF-32 코드 포인트, 20,000 UTF-16 코드 유닛, 40,000 UTF-8 바이트 중 무엇인들 별 문제 없을 겁니다.
하지만 한도가 낮고 사용자에게 드러난다면… UTF-8 카운트는 이상하게 보일 것입니다.
권위에 기대자면, Google의 API 설계 가이드는 문자열 크기 제한을 반드시 유니코드 코드 포인트로 측정하고, 그 문자열은 NFC 정규화되어야 한다고 합니다.
저는 이것이—API뿐 아니라 UI에서도—대략적으로 _최선_의 접근이라고 봅니다. 다만 완벽하진 않습니다. (“정규화해야 한다”는 권고는 “거의 항상 정규화해야 한다”로 격상하겠습니다.)
유니코드/UTF-32 코드 포인트로 세면, BMP의 모든 문자와 많은 이모지가 카운트 1을 받습니다. 이는 UTF-16(및 UTF-8) 대비 개선입니다. 그래핌 클러스터 같은 해석적 세기도 필요 없습니다.
하지만…
여전히 다음 문제를 겪습니다.
여전히 한 번에 1보다 크게 셀 수 있습니다. 다중 코드 포인트 그래핌 클러스터는 어떤 문자권(예: 10억 명 이상이 쓰는 데바나가리)과 일부 이모지(예: 모든 국기)에서 흔합니다.
그래핌 클러스터 인지 없이 코드 포인트 기준으로 입력을 제한하면, 문자를 잘라 의미 없는 문자나 혼란스러운 결과가 될 수 있습니다. 이건 가정이 아닙니다! 저는 React Native의 Android(아이오스는 아님)가 정확히 이렇게 하는 것을 발견했습니다.
(캐나다 국기 이모지는 “Regional Indicator Symbol Letter C” 뒤에 “Regional Indicator Symbol Letter A”가 오는 클러스터인데, 길이 제한이 “C” 이후에서 잘라 버립니다.)
그럼에도, 유니코드 코드 포인트로 세는 것이 가장 이성적인 선택이라고 생각합니다.
가장 좋은 세기 방법을 새로 만든다면 어떤 모습일까요?
아마: 무한 위험이 없는 그래핌 클러스터.
원하는 바:
아래와 같은 로직으로 합리적으로 구현할 수 있을 듯합니다.
N은 얼마여야 할까요? (아래 답은 주로 이 유익한 StackOverflow 글에 기반합니다.)
제가 찾은, 정의가 잘 된 가장 긴 그래핌 클러스터는 코드 포인트 10개짜리입니다("👨🏻❤️💋👨🏼" – “Kiss - Man: Medium-Light Skin Tone, Man: Medium-Light Skin Tone”). 그래서 10이 적당할 수 있습니다.
유니코드는 Stream-Safe Text Format을 정의하며(NKFD 정규화 이후) 30이라는 제한을 둡니다. “30은 언어학적/기술적 사용에 필요한 것보다 상당히 여유 있는 값입니다. 더 작은 숫자도 가능하지만, 이 값은 매우 넓은 여유를 주면서도 실용 구현의 버퍼 크기 한계 내에 있습니다.” 그래서 30이 한계가 될 수도 있겠죠.
(제가 Stream-Safe Text Process를 제대로 이해했다면, 위에서 제안한 것과 유사한 분해 알고리즘을 제공합니다. 다만 더 관대합니다. 제 방식에서 결합 부호 100개를 가진 잘고 문자는 길이 “71”(N=30 가정)이지만, Stream-Safe에서는 “4”가 될 것입니다. 저는 덜 관대하고 싶지만, 어느 쪽이든 우리는 상한을 두려는 목적을 달성합니다.)
이런 접근이 최적인 듯해도, 완벽하다고 보긴 어렵습니다.
허용 바이트 크기는 길이 한계의 약 100배쯤 되어야 할 겁니다. 그건… 꽤 큰가요? (제 직감은 반반.)
그래핌 클러스터를 쓰는 데서 언급한 많은 문제가 여기에도 적용됩니다. 유니코드 버전 문제 포함.
또, 외부에 제공되는 것(API 등)에 이렇게 비표준 세기 방식을 권하기 어렵습니다. 큰 혼란을 야기할 수 있습니다. 내부적으로조차 프런트엔드/백엔드/DB 전반에서 표준화하려면 버그 위험이 큽니다. 따라서 공통 표준, 공통 레퍼런스, 공통 구현이 있어 이해와 구현의 일관성을 보장할 수 있을 때가 더 실현 가능하겠습니다.
멋진 이름도 필요하죠. “graph length(그래프 길이)” 같은.
부록에 이 알고리즘 구현을 몇 개 포함했습니다.
트위터는 흥미로운 하이브리드 세기 방식을 사용합니다(문서, 코드 (Apache 라이선스)). 몇 가지 예(twttr.txt.getTweetLength() 사용):
문서/로직을 반복하진 않겠지만, 대략적으로:
NFC 유니코드 정규화를 세기 전에 수행합니다. API는 UTF-8 인코딩을 요구합니다.
(이상함: 문서에 따르면 “Ồ”(U+1ED2)는 1이어야 하는데, 저는 2가 나옵니다.)
“단순한 문자는 1, 복잡한 문자는 2”라는 직관적 규칙이 대체로 마음에 듭니다. 하지만:
이 글의 범위를 벗어나지만 중요한 질문/문제들이 있습니다. 여기 적어 두니 염두에 두세요.
이 글은 주로 한도 도달 여부를 판단하기 위한 “세기”에 관한 것이지만, 그다음 “그래서 어떻게?”라는 질문도 중요합니다. 가능한 반응의 두 가지 큰 범주와 프런트엔드/백엔드 고려사항을 봅시다.
가장 단순한 응답은 너무 긴 입력을 거절하는 것입니다. 문자열을 “고치려” 조작하지 않고, 얼마나 초과했는지조차 말하지 않을 수 있습니다. 그냥 “안 됨, 너무 김”이라고 하죠.
백엔드에서는 매우 흔합니다. HTTP 서버는 아마 400으로 응답할 겁니다. 어떤 필드가 너무 길었는지 표시할 수도, 안 할 수도 있습니다. (개별 필드 검토에 앞서 요청 크기 상식선을 잡는 건 반드시 하세요.)
프런트엔드에서 “거절”은 사용자가 데이터를 너무 많이 입력하도록 허용하되, 너무 길다는 오류를 표시하고 제출 버튼을 비활성화하는 형태일 수 있습니다.
프런트엔드에서는 필드에 더 이상의 입력을 막거나, 제출 전에 입력을 능동적으로 잘라낼 수 있습니다. 백엔드에서는 능동적 잘라내기가 될 겁니다. 어느 경우든 문자를 깨뜨릴 위험이 있습니다.
유니코드 코드 포인트는 UTF-8/UTF-16 기준으로 제한하고 코드 유닛 시퀀스 중간에서 자르면 깨질 수 있습니다. 잘못된 인코딩 시퀀스가 됩니다.
그래핌 클러스터는 UTF-8/UTF-16/유니코드 코드 포인트 기준으로 제한하고 클러스터 시퀀스 중간에서 자르면 깨질 수 있습니다. 왜곡되거나 다른 문자/이모지가 될 수 있습니다.
그러므로 잘라내기는 시퀀스 인지 방식으로 해야 합니다. 한도에 도달한 지점이 코드 포인트 또는 클러스터 시퀀스의 중간이라면, 경계까지 물러난 뒤 그곳에서 잘라내야 합니다. (그보다 더 낫게는, 검증된 라이브러리를 사용하세요.)
그리고 반드시 원하는 방식으로 동작하는지 많은 테스트를 하세요. (위에서 언급한 React Native 버그처럼요.)
거절 vs 잘라내기의 UX적 장단은 흥미로운 주제지만 범위를 벗어납니다.
[이 섹션은 aidenn0의 HN 댓글에서 영감을 받았습니다.]
유니코드 명세에는 16개의 주요 버전이 있습니다. 오래된 유니코드 텍스트는 새로운 버전과 호환되지만, 그 역은 반드시 참이 아닙니다. 예컨대 새 버전은 다음을 수행할 수 있습니다.
초래될 수 있는 문제:
등등.
이런 버전 불일치의 실제 사례로, 여기서 “operating system-provided ICU” 검색을 참고하세요. 또한 glibc의 유니코드 버전 차이로 PostgreSQL 인덱스가 손상되는 무서운 논의도 링크됩니다(HN 토론). “놀랍지 않다; 이런 붕괴 이야기를 들을 거라 예상했고, 이렇게 드물게 듣는 게 오히려 놀랍다. 우리는 이런 붕괴를 테스트할 방법도 없다. :-(” 으음.
Jeremy Schneider의 “Did Postgres Lose My Data?”는 유니코드 정렬(대소관계) 변경에 대한 좋은 분석입니다. 저자는 실제 버전 간 차이에 대한 분석도 했습니다. (Unicode Technical Report #10에서: “정렬 순서는 고정되어 있지 않다. 시간 경과에 따라 더 많은 언어 정보가 확보되며 수정이 필요할 수 있고, 언어의 새로운 정부/업계 표준이 변경을 요구할 수 있으며, 유니코드 표준에 새 문자가 추가되어 기존 문자 사이에 끼어들 수 있다. 즉, 정렬은 신중하게 버전관리해야 한다.”)
API(또는 기타) 명세에서 “유니코드 버전”을 요구 조건으로 본 적은 없지만, 엄밀히는 그래야 한다고 생각합니다. 그렇지 않으면 양측이 문자열을 같은 방식으로 이해한다고 확신할 수 없습니다.
한편… 이건 과도한 걱정일 수도 있겠죠?
API가 문자열을 제출 전 유니코드 정규화하도록 요구하고 싶어질 수 있습니다. a) 프런트엔드에서 길이를 세기 위해 이미 정규화하고 있고, b) API는 우리의 것이니 우리가 규칙을 만든다는 생각으로요. 하지만 좋지 않은 생각이라고 봅니다.
우선, API 호출자가 실제로 그렇게 할 거라 신뢰하지 않을 것이므로, 어차피 백엔드에서 다시 정규화를 해야 합니다.
…그리고 정규화된 입력과 원본 입력을 비교하게 됩니다. 하지만 위의 유니코드 버전 문제를 보세요. 백엔드가 유효한 입력을 끝내 받아들이지 않는 병적인 상태에 빠질 수 있습니다.
정규화는 공짜가 아니며, 매우 큰 데이터에서는 비용이 의미 있을 수 있습니다. 예를 들어 Django는 느린 정규화로 인한 최근 DoS 가능 취약성이 있었습니다.
사용하는 언어가 잘못된 UTF-8/UTF-16 인코딩을 어떻게 처리하는지, 그리고 그런 경우 어떻게 대응할지 배우세요.
깨진 시퀀스를 유니코드 대체 문자 U+FFFD로 바꾸는 것이 흔해 보입니다. 예: Go의 json.Unmarshal은 잘못된 UTF-8 코드 유닛 시퀀스를 조용히 U+FFFD로 치환합니다.
유효하지 않은 시퀀스를 감지해야 할까요? 나쁜 입력으로 취급해야 할까요? 아니면 그냥 통과시켜야 할까요?
많은 경우, 문자열이 네이티브로 저장된 인코딩이 아닌 다른 인코딩에 접근(혹은 세기)하는 것은 O(n) 연산입니다. 즉, “이 UTF-8 문자열에는 UTF-16 코드 유닛이 몇 개인가?”, “이 UTF-16 문자열에는 유니코드/UTF-32 코드 포인트가 몇 개인가?”, “그래핌 클러스터가 몇 개인가?” 같은 질문에 답하려면 내부 인코딩에서 대상 인코딩으로 변환하기 위해 문자열을 훑어야 합니다.
대부분 상황, 대부분 길이에서는 유의미한 성능 문제가 아니지만, 큰 루프 안이나 거대 데이터, HPC 문맥에서는 유념하세요.
Swift는 최초 변환 이후 후속 비 UTF-8 접근을 빠르게 하기 위해 “브레드크럼”을 남깁니다. 다른 언어는 모르겠습니다만, 보편적이진 않은 듯합니다.
Henri Sivonen의 “It’s Not Wrong that “🤦🏼♂️”.length == 7”은 훌륭합니다. 여기서 다룬 기초와 겹치는 부분도 있지만, 다른 너디한 주제를 파고듭니다. 예를 들어, 여러 언어에서 문자 수(및 다양한 인코딩) 대비 정보 밀도를 깊게 분석합니다. 이는 인코딩별로 서로 다른 언어에 가해지는 제한의 “공정성” 문제로 이어지는데, 이 글에서는 크게 다루지 않았던 부분입니다. (세계인권선언이라는 하나의 문서의 수많은 번역을 분석하는 영리한 방식입니다.)
Nikita Prokopov의 “The Absolute Minimum Every Software Developer Must Know About Unicode in 2023 (Still No Excuses!)”는 제목 그대로입니다. 이 글을 다 읽고도 한 번 더 훑고 싶다면 가보세요. 또한 같은 코드 포인트라도 로케일에 따라 다르게 처리되는 문제도 다루는데, 알아두면 좋지만 불편한 주제입니다.
Jeremy Schneider의 “Did Postgres Lose My Data?”는 유니코드 버전 변경의 영향을 다룬 좋은 이야기이자 조사입니다. 좋은 자료도 링크되어 있습니다.
이 글을 쓰게 된 계기는, 검토하던 프로젝트에서 문자열 길이 세기와 제한에 사용되는 인코딩이 (명시되지 않은 채) UTF-16이라는 점을 알았기 때문입니다. 이는 제게 이상하게 느껴졌습니다. 코드 유닛 수가 가변적이고, 제가 신경 쓰는 일부 언어에서는 1급이 아니며, 어중간해 보였죠. 또한 maxLength 아래에서 프레임워크가 무엇을 쓰는지 의식적 선택 없이 따라가는 점도 마음에 들지 않았습니다.
그런데 “시니어 개발자” 포즈로 해결하려 들다 보니… 명확한 이유를 갖춘 탄탄한 제안을 가지고 있지 않았습니다. 그래서 블로그 글로 정리하기로 했죠.
놀랍게도—제게는—UTF-16이 사실 나쁜 선택은 아니라는 결론이 나왔습니다. 대부분 언어에서 대부분의 경우, 이모지가 아닌 문자에는 코드 유닛이 하나면 충분하기 때문입니다. 그리고 유니코드 코드 포인트를 쓴다 해서 엄청나게 개선되는 것도 아닙니다. 그래핌 클러스터에 대해서는 결국 1보다 크게 세게 되니까요.
저는 명확한 정답과 튼튼한 근거를 기대했는데, 찾지 못했습니다.
그럼에도, 정규화를 곁들인 유니코드 코드 포인트 기준 세기가 최선의 접근이라고 생각합니다. 제가 7,000자 분량으로 “그냥 Google이 하라는 대로 하자”고 말한 셈인가요? 네, 아마 그렇지만, 이제 저는—그리고 여러분도!—왜 그런지 압니다. 그게 중요합니다. 게다가 매일 당연하게 여기는 것들에 깃든, 흥미롭고 약간은 엽기적인 것들도 배웠죠.
맨 위에 요약(TL;DR)을 덧붙였습니다.
재미 삼아, “하이브리드 세기” 접근의 구현이 어떻게 생길지 봅시다.
JavaScript:
/**
* Counts the number of grapheme clusters in a string, with a sanity limit on the number
* of Unicode code points allowed in the cluster. After 10 code points in a single cluster,
* the remaining code points in the cluster are counted as one each.
* The limit is intended to be larger than the number of code points in in legitimate
* grapheme clusters (as used in emoji and human languages) from less-legitimate uses,
* like Zalgo text.
*
* @param {string} s - the input string
* @returns {number} - the grapheme length
*/
function graphLength(s) {
// A bit of research suggests that the locale arugment is ignored for grapheme segmentation
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
let total = 0;
// Iterate through the grapheme clusters
for (const { segment } of seg.segment(s)) {
// Spread … turns the cluster string into an array of Unicode code points
const n = [...segment].length;
if (n <= 10) {
// Short enough to count as one
total += 1;
} else {
// Too long. The first 10 code points count as 1, and the rest each count as another 1
total += 1 + (n - 10);
}
}
return total;
}
Go(사실상 표준처럼 보이는 서드파티 분할 패키지 사용):
import (
"unicode/utf8"
"github.com/rivo/uniseg"
)
func GraphLength(s string) int {
var total int
graphemes := uniseg.NewGraphemes(s)
for graphemes.Next() {
cluster := graphemes.Str()
n := utf8.RuneCountInString(cluster)
if n <= 10 {
total += 1
} else {
total += 1 + (n - 10)
}
}
return total
}
그리 끔찍해 보이지 않습니다. 저라면 저 코드를 고려해 보겠습니다.
N 값을 10으로 할지 30으로 할지는 “하이브리드 세기” 절을 참고하세요.
어떤 한계를 쓰든, 이를 초과할 때 로그를 남기길 권합니다. 잘고 텍스트라면 괜찮지만, 자주 쓰는 새 문자가 등장했다면 그 사실을 알고 한계를 바꿔야 할지도 모릅니다.
그래프 길이를 구하기 전에 문자열을 NFC 정규화하고 싶을 것입니다.
몇 해 전, 계정 생성 필드의 길이 제한 코드를 작성한 적이 있습니다. 이 글의 요지와는 조금 빗나가지만, 여기서 조사한 관점으로 그 설계를 되돌아보는 것도—적어도 제게는—약간 흥미로울 듯합니다.
Go 백엔드였고, 계정 생성은 웹사이트에서, 로그인은 네이티브 Android와 iOS(그리고 Windows의 IE webview)에서 했습니다.
명세에 길이 요구 사항은 없었지만, 입력이 무한할 수는 없었습니다. 사용자 이름, 특히 비밀번호에 충분한 공간을 주고 싶었고, 문자 유형도 유연하게 허용하고 싶었습니다(PRECIS UsernameCasePreserved 및 OpaqueString). (사용자 이름을 사용하는 방식상 ASCII만으로 제한할 이유가 없었습니다.)
그래서 다음과 같이 설계했습니다.
백엔드:
프런트엔드:
백엔드에서 UTF-8 바이트를 쓴 것은 _크기_를 신경 썼기 때문이니 그럴듯합니다. 프런트엔드에서 유니코드 코드 포인트를 쓴 것도, 백엔드 한도를 안전하게 만족시키는 방법이니 납득됩니다. 대부분 사용자가 한도에 닿지 않을 값으로 설정했습니다.
하지만 즉시 몇 가지 문제가 보입니다.
그래핌 클러스터를 제대로(혹은 전혀) 고려하지 않았습니다. 힌디어 사용자에게는 사용자 이름 길이가 문자 기준으로 10자 정도로 줄어들 수 있습니다. 이는 수용 가능한 수준 아래일 수 있습니다.
PostgreSQL의 유니코드 버전 변경으로 인한 정렬 문제를 알고 나니, 인덱스 필드(예: 사용자 이름)에 ASCII 이외를 허용하기 전에 신중하게 생각했을 겁니다.
프런트엔드와 백엔드의 제한 기준 인코딩이 일치하지 않습니다. 다만 몇 가지로 완화됩니다.
그래서 평범한 사용자는 나쁜 상태에 빠지지 않습니다. 누가 프런트엔드에서 DevTools로 제출 버튼 비활성화를 제거해 50/200 코드 포인트를 넘기는 사용자 이름/비밀번호를 만들더라도, 여전히 로그인할 수 있습니다. 그러면 괜찮은 편이죠.
덜 좋은 점은, 너무 긴 사용자 이름을 가진 사용자가 계정을 _수정_할 수 없다는 겁니다—예를 들어 이메일을 추가하려 할 때, UI가 (변경되지 않은) 사용자 이름을 길이 초과로 표시해 바꾸지 않으면 제출할 수 없게 됩니다. 물론 브라우저 개발자 도구로 장난치면 그 대가를 치르는 거지만, 일관성 부족이 초래하는 불행한 결과의 좋은 예입니다.
Hacker News 토론은 여기에서—있다면—진행됩니다.
제가 이렇게 일반화할 때, 사실상 제가 아는 게 별로 없다는 점을 기억해 주세요. 어떤 언어들에서는 문자를 더 작은 단위로 보는지도 모릅니다. 예를 들어 일본어 한자에서는 부수나 심지어 획이 “원자”일까요? 모릅니다. 이런 가벼운 일반화는 대부분의 독자에게 개념 진행에 필요한 대략적인 느낌을 주려는 것뿐입니다.↩︎
이 글을 읽고 Connor Taffe가 “룬”(및 UTF-8)의 기원에 대해 깊이 파고들었습니다. 코드 고고학은 멋집니다.↩︎
이 오버헤드는 UTF-8의 멋진 성질을 가능하게 합니다. 예: a) 코드 유닛 시퀀스의 첫 바이트만 봐도 전체 시퀀스 길이를 알 수 있고, b) 스트림의 아무 바이트나 봐도 시퀀스의 시작인지 중간인지 알 수 있어, 중단 후 뒤로 물러나거나 다음 시퀀스로 건너뛰기에 유리합니다.↩︎
이들 언어 대부분을 잘 모르거나 아예 모릅니다. 정정 환영합니다.↩︎
저는 이처럼 명시적 뷰를 제공하는 방식을 좋아하지만, 기본을 그래핌 클러스터로 두는 위험은 걱정됩니다. Swift/Perl/Raku/Elixir 프로젝트를 조사해 거대 그래핌 클러스터 공격에 취약한 곳이 얼마나 되는지 알아봐야 합니다.↩︎
곧 입력 검증 중 유니코드 정규화를 추천할 텐데, 이것 역시 분명 “추가 처리와 해석”입니다. 그리고 어떤 인코딩 변환이든 정도의 차이는 있어도 “해석”이 필요합니다.↩︎