글자수를 세는 방법
프로그램을 개발하다보면 글자수를 세야하는 요구사항이 생길 때가 있다. UI 구성이 깨지지 않고, 서버 혹은 외부 환경의 스펙을 대응하기 위해 이를 정확하게 세는 것이 중요하다. 특히 현재 개발하고 있는 ZEPETO 와 같이 글로벌 서비스인 경우에는 다양한 언어의 글자수를 정확히 셀 수 있어야 한다.
그리고 종종 이러한 글자수 스펙과 관련된 이슈가 생길 때가 있는데, 해결책은 단순할지라도 그것의 바탕이 되는 내용을 이해하기는 쉽지 않다. 이는 문자 체계 전반에 대한 배경지식이 요구되기 때문이다.
문자는 가장 자주 접하고 사용하는 데이터 유형인 만큼 이번 기회에 제대로 공부해보면 좋겠다 싶어서 파보기 시작했다. 문자에 대한 Leaky Abstraction 을 제거해보자!
간단한 퀴즈를 풀어보자. 다음 문자들 중 글자수가 1 인 것은 무엇일까?
- 가
- 👨👩👧👦
- 𓀀
- â
정답은..
글자수의 단위를 어떻게 정의 하느냐에 따라 다르다.
왜냐면 해당 글자들은 일견 단순해보이지만, 내부적으로는 꽤나 복잡하게 구성되어 있기 때문에 글자의 단위를 어떻게 정의하느냐에 따라 큰 편차가 있기 때문이다.
글자 단위에 대한 다양한 정의를 내리기 위해서는 우선 컴퓨터 상에서 문자라는 것을 어떻게 정의하는지 부터 이해해야한다.
Unicode code point
세상에는 무수히 많은 문자가 존재하며, 다양한 외형을 가지고 있기에 공통적인 특징을 추출하기 어렵다. 즉, 0 과 1의 논리로 이루어진 컴퓨터 입장에서 문자는 너무나 추상적인 대상이다. 따라서 컴퓨터가 구별할 수 있도록 문자를 좀 더 구체적인 값으로 매핑이 필요하다. 매핑 방법은 다양하지만, 현재 전세계적으로 널리 받아들여지는 표준은 Unicode 다.
해당 글 역시 Unicode 체계를 중심으로 진행될 것이다.
Unicode 는 다음과 같이 모든 문자에 16진수법을 따르는 고유한 매직 넘버를 매핑 했으며, 이러한 매직넘버를 Code Point 라고 부른다.
Unicode 가 최초로 제안되었을 때는 16비트(U+0000 ~ U+FFFF)로 모든 문자를 표현하고자 했는데 65,000 개로도 모든 문자를 표현하기 충분할 것이라고 판단했던 것 같다.
그러나 실제로 이는 턱없이 부족한 숫자였다. 조합형 문자인 한글과 한자만 해도 65000 개를 대부분 채울 수 있기 때문이다.
이로 인해 범위를 21 비트까지(U+0000 ~ U+10FFFF) 확장하여 약 1,100,000 개의 문자를 정의할 수 있도록 수정이 되었고, 이는 현재까지 변하지 않고 있다.
그런데 1,100,000 개 가까이 되는 문자를 어떠한 규칙도 없이 마구잡이로 정의 한다면 유지 보수가 불가능해진다. 그래서 비슷한 속성을 가진 문자끼리 그룹화하여 관리를 하기 위해 Unicode block 이 정의되었다. 가령 “1 ~ 100 까지는 로마자, 101~ 1000 까지는 한글을 정의하는 code point 로 사용할게” 와 같은 약속을 정한 것이다. Unicode block 은 약 270 개 정도 존재한다.
그리고 이러한 블록의 상위 논리 집합 Plane(평면) 도 정의가 되어 있다. 현재까지 총 17개의 Plane 이 존재한다. 이 중 가장 우리에게 친숙한 문자들이 포함되어 있는 Plane 0, 1 을 간단히 살펴보자.
Unicode 가 최초로 설계 될 당시에는 16 bit 내에 문자를 매핑했다고 이야기 했다. 그리고 최초 설계 당시 정의되었던 문자들이 현재는 Plane 0 으로 불리는 범위의 숫자에 정의가 되어있다. 해당 평면은 BMP (Basic Multilingual Plane, 기본 다국어 평면) 라고도 불린다.
Plane 0 이 다국어 평면이 된 것은 사실 자연스러운 일이다. 최초에 문자를 매핑하고자 할 때는 사람들이 가장 많이 사용하는 문자들 부터 고려를 했을 것이고, 이는 보통 자국의 언어이기 때문이다. 곰곰히 생각해보아도 우리가 평소에 작성하는 거의 대부분의 문자는 BMP 에 정의된 문자에서 크게 벗어나지 않는다.
Plane 1 는 음악기호, 수학기호 등 좀 더 특수한 기호들 그리고 우리가 잘 아는 이모지😎 등이 포함되어 있다.
그외 Plane 들은 몇개를 제외하고 대부분은 아직 아무것도 매핑되지 않았으며, 백업용으로 남겨둔 상황이다.
여기까지 읽었다면 Code point 에 숨겨진 비밀을 이해할 수 있다.
Code point 는 최대 6개의 16진수로 표현이 되는데, 상위 2개의 16진수가 해당 Code point 의 평면을 의미한다. 알파벳 A 와 이모지 🌸 의 Plane이 각각 0 과 1임을 확인할 수 있다.
Character encoding
하지만 Code point 역시 아직 컴퓨터에게는 추상적인 대상이다. 따라서 이를 비트로 변환하는 과정이 한번 더 필요하며, 이 과정을 문자 인코딩(Character encoding) 이라고 부른다.
문자 인코딩은 문자 체계에 의존적이다. 따라서 문자 체계별로 고유한 인코딩 방식이 존재하며, Unicode 의 경우 대표적으로 UTF-8 과 UTF-16 (& UTF-32)이 있다.
UTF-8 과 UTF-16 의 차이를 간단하게 표현하면, 몇개의 비트를 사용하여 Code point 를 표현할 것이냐? 이다.
0054 라는 Code point 가 있을 때
- UTF-8 은 최소 8 bit단위로 표현이 가능하기 때문에 1 Byte 가 필요하지만,
- UTF-16 은 최소 16 비트 단위로 표현이 가능하기 때문에, 실제로는 1 Byte 만 필요함에도 불구하고 2 Byte 가 필요하다.
- UTF-32 은 최소 32 비트 단위로 표현이 가능하기 때문에, 실제로는 1 Byte 만 필요함에도 불구하고 4 Byte 가 필요하다.
- 여기서 필요한 최소 bit 단위를 Code unit 이라고 한다. 즉, UTF 뒤의 숫자는 Code Unit 단위를 의미한다.
따라서 일반적인 경우에는 UTF-8 가 좀 더 경제적으로 문자를 표현할 수 있다.
또한 UTF-8 은 가변 폭 인코딩 방식이기 때문에 Code point 에 따라 사용되는 Byte 를 늘릴 수 있다. 다음과 같이 8 bit 로 표현이 불가능한 숫자라면, 표현이 가능할 때까지 8 bit 씩 더하게 된다.
위 예시에서 알 수 있듯 문자열 인코딩 방식은명확한 트레이드 오프가 존재한다.
Code unit 의 bit 수가 많을 수록 Code unit 을 추가하지 않고도 표현할 수 있는 Code point 가 많지만, 반대로 메모리의 낭비가 생길 수 있다. 또한 앞서 얘기한 것처럼 우리는 대부분 BMP 에 위치한 문자를 사용하고 있기에, 적은 bit 로도 표현할 수 있는 Code point 가 많기 때문에 Code unit 이 크다고해서 좋은 것은 아니다.
이쯤 되면 눈치를 챘을 것이다. Code unit 의 bit 수가 가장 작으면서 가변적으로 대응이 가능한 UTF-8 을 사용하지 않을 이유가 없다. 실제로 현재(2024.07.23) 기준으로 약 98% 의 web site 는 UTF-8 을 사용하고 있다.
UTF-16 에 대해서는 재밌는 히스토리가 하나 있다.
UTF-32 는 code unit 이 32 bit 라서 굉장히 비효율적이니까 거의 사용이 되지 않는다고 치더라도, 그 사이에 위치한 UTF-16 은 굉장히 쓰임새가 애매하다. 그럼에도 불구하고 Java 같이 설계된지 오래된 개발언어는 문자의 단위를 UTF-16, 즉 16 bit 단위로 정의하고 있는데 이유가 무엇일까?
이에 대한 가장 유력한 설은 앞서 얘기한 것처럼 Unicode 가 초기에는 16bit 로 모든 문자를 대응하고자 했고 Java 역시 비슷한 시기에 설계가 되었기에 생기게 된 레거시라는 것이다. (뒤에서 살펴보겠지만 비교적 신생언어인 swift 는 이러한 제약 사항이 없다.)
그리고 사실 UTF-16 이 메모리 측면에서 효율적인 경우도 존재한다.
가 라는 문자는 AC00 Code point 로 매핑되는데 이를 표현하기 위해 UTF-8 은 3 byte 가 필요한 반면, UTF-16 은 2 byte 만 요구된다.
이러한 차이가 나는 이유는 UTF-8, UTF-16 의 고유한 비트 매핑 규칙 때문이다.
UTF-8 은 표현하고자 하는 숫자의 범위가 8 bit 이상인 경우, 특수한 규칙으로 변형하여 저장을 하게 된다. 위 예시인 AC00 의 경우 위 규칙 중 3번째 케이스에 속하므로 3 Byte 가 요구된다.
각 자리수 별 1:1 로 매핑해주면 될텐데 왜 별도 변환 과정이 필요한 것일까? 왜냐하면 컴퓨터 입장에서는 0 혹은 1 로 밖에 값을 읽을 수 없으므로, 미리 예약된 masking bit 가 없으면 8 bit 이상의 숫자는 명확하게 구별할 수 없기 때문이다.
예를들어 0000001 0000001 이라는 bit 가 주어졌을 때 이것이 [1], [1] 2개의 숫자를 의미하는 것인지, [11] 하나의 숫자를 의미하는 것인지 구분이 불가능하다. 따라서 예약된 bit 를 사용하여 자리수를 구분하기 위해 위와 같은 변형과정을 거친다.
UTF-16 역시 동일한 목적을 달성하기 위해 Surrogate pair 를 이용하여 변형과정 을 거치게 된다.
UTF-32 의 경우 4 byte 를 사용하기에 항상 모든 Code point 를 표현할 수 있으므로 위와 같은 과정이 필요없다.
여기서 알 수 있는 점은 모든 글자에서 항상 UTF-8 이 효율적으로 메모리를 사용하지 않는 다는 점이다. 모든 것은 트레이드 오프!
- 물론 대부분의 케이스에서는 UTF-8 이 더 효율적인 것이 맞다. 사용빈도가 가장 높은 문자는 BMP 평면에 속해있고, 해당 평면의 숫자 범위는 (0x0000 ~ 0xFFFF) 는 1 Byte 혹은 2 Byte 로 표현이 가능하기 때문이다. 또한 ASCII 와도 호환이 가능하므로 팔방미인이다.
지금까지 살펴본 내용을 다시 정리해보면,
- Unicode 는 문자를 Code Point 라는 숫자로 매핑 했다.
- Code point 는 Plane 과 Code block 으로 그룹화되어 관리된다.
- Code point 는 Character encoding 을 거쳐 bit 로 변환된다.
- Character Encoding 의 Code unit 에 따라 사용되는 bit 수가 달라질 수 있으며, 가장 효율적이고 범용적으로 사용되는 것은 UTF-8 이다.
글자수 세기 실전
이제 드디어 본 주제를 다룰 수 있게 되었다.
서두에 문자의 글자수가 몇 인지 판단하기 위해서는 먼저 글자수를 어떤 단위로 정의할 것인지에 대한 논의가 선행되어야 한다고 이야기 했다.
현재까지 살펴본 내용을 바탕으로는 글자수에 대한 단위를 다음과 같이 정할 수 있다.
- Code Point
- Byte
이 중 논리적으로 좀 더 유의미해 보이는 단위는 Code point 이다. 한 글자당 하나의 Code point 가 매핑되기 때문이다.
그럼 정말 Code point 혹은 Byte 를 활용하여 우리가 의도한 단위로 글자수를 셀 수 있는지 살펴보도록 하자.
한글 가 는 1개의 Code point, 2 혹은 3 byte 로 표현할 수 있다. 아직까지는 Code point 를 기준으로 글자수를 세보는게 유효해보인다.
이모지는 Plane 1 에 위치하고 있기 때문에, Code point 의 수가 커서 표현에 요구되는 바이트 수도 많다.
아마 이모지의 글자수가 제대로 카운트 되지 않는 이슈를 겪었다면 여기서 힌트를 얻을 수 있다.
Java 를 예시로 들어보면, 다음과 같은 결과가 나온다.
"🔥".length == 2
위에 나온 내용들을 모두 이해했다면 2 가 리턴되는 이유를 알 수 있다. Java 는 글자의 최소 단위로 16 bit 를 사용하고, UTF-16 의 매핑 규칙에 따라 해당 Code point 를 표현하기 위해서는 4 byte, 즉 2개의 Char 가 필요하다. length 는 Char 의 개수를 반환하므로 결과적으로 2 가 리턴된다. Java 외의 나머지 언어도 비슷한 이유일 것이라고 생각한다.
한편 여기까지의 예제만 본다면, 글자수 = Code point 수로 판단하면 되는것 아닌가? 라는 생각이 들 수 있다. 하지만 이에 대한 명확한 반례가 존재한다.
해당 이모지는 조합형으로서 하나의 문자에 하나의 Code point 만 할당된다는 명제를 깨고 있다. 만약 UTF-16 기준으로 위 이모지의 length 를 구할 경우, 11 이라는 값이 반환되기 때문에 글자수 제한이 10자인 스펙이 있다면 해당 이모지는 아예 입력이 불가능한 상황이 발생할수도 있다.
일부 언어는 문자를 조합하여 표현하는 것이 가능하다. 이 특징을 활용하여 아주 예외스러운 상황을 연출할 수 있는데 대표적으로 ZALGO Text 가 있다. 언어의 발음기호를 이용하여 위 아래로 글자를 쌓아올리는 것이다.
해당 글자 뭉치는 UI 가 깨지는 것 뿐만 아니라, 글자수를 세는 것도 쉽지 않다. 아무리 글자수에 대한 정의가 다양하다고 할지라도, 위 문자열의 글자수를 19 로 세고 싶은 사람은 없을 것이기 때문이다.
즉, 글자수에 대한 새로운 정의가 필요한 시점이다.
지금까지 살펴보았던 예시들의 공통점은 비록 내부적으로는 복잡한 구조일지라도 우리는 대부분 자연스럽게 1글자로 인식한다는 점이다.
Unicode 에서는 이렇게 자연스럽게 인지하는 문자의 단위를 Grapheme cluster 라고 정의한다.
Grapheme cluster
의미를 가지는 최소 문자 단위
사실 우리는 grapheme cluster 라는 용어를 잘 모를지라도, 이 단위는 굉장히 익숙하다. 왜냐하면 대부분의 텍스트 편집기나 문서 프로그램 그리고 마우스등 에서 문자의 범위를 지정하거나 커서를 움직이는 단위를 grapheme cluster 로 기준 삼고 있기 때문이다.
Code point 로 기준을 잡으면 글자수를 균일하게 구할 수 없는 반면,
grapheme cluster 적용 후에는우리가 기대한 것과 똑같은 글자수를 반환하게 된다.
(사실 이해를 돕기 위해 예시에는 넣었지만 실제로 ZALGO Text 의 grapheme cluster 를 구하면 6으로 반환되지 않는다. 왜냐하면 ZALGO 텍스트는 의미있는 문자 집합으로 구성된 문자열이 아니기 때문에 grapheme cluster 단위로 판단되지 않는다.)
결국 이 모든 것은 grapheme cluster 라는 개념을 알기 위한 여정에 가까웠다. 그런데 grapheme cluster 를 활용할 때 몇가지 주의해야할 점이 있다.
- 각 국가는 고유한 언어 체계를 가지고 있다는 것을 기억해야 한다.
물리적으로는 2글자로 분리되어 보이지만 실제로 해당 문화권에서는 1 글자처럼 쓰는 경우가 아주 드물게 있다. 즉, 의미를 가진 1글자로 인식하는 것은 각 나라의 문화권마다 다르기 때문에 물리적으로 1글자 영역에 있다고 하여 grapheme cluster 가 1이라고 판단해서는 안된다. - Unicode 버전에 따라 grapheme cluster 계산이 달라질 수 있다.
grapheme cluster 는 Legacy 와 extended 로 나뉘는데, extended 는 legacy 를 커버하지만 Legacy 는 최근에 제정된 문자 규칙이나 추가된 문자에 대해서는 올바르게 동작하지 않는 경우가 있다. 따라서 grapheme cluster 와 관련된 솔루션을 활용 할때, 해당 솔루션이 extended grapheme cluster 가 적용이 되었는지 확인해보는게 좋다.
언어별 솔루션
각 개발 언어에서 각 글자수 단위를 어떻게 구하는지에 대해 간략하게 살펴보자.
Java 혹은 Kotlin 의 경우 문자열의 길이를 의미하는 Code unit 이나 Code point 는 기본 String 클래스에서 지원되어 바로 구할 수 있다. Grapheme Cluster 의 경우에는 비교적 최근에 나온 개념이기 때문에 String 에서는 지원되지 않고 BreakIterator 를 이용하여 구할 수 있다.
- Extended grahpheme cluster 는 Java 20 부터 지원을 하는데, Android 프로젝트의 경우 Java 버전이 낮을 수 있다. 이 경우 Java 15 이상부터는 정규식을 활용하여 구하는 것이 가능하다.
Swift 의 경우 비교적 최근에 나온 언어이다보니 이러한 점이 설계 시점에 고려가 되어있다고 느꼈느데, 하나의 문자가 extended grahpheme cluster 단위로 대응 되어 있기 때문이다. 따라서 별도 라이브러리 없이, 모든 글자수 단위를 구하는 것이 가능했다.
유니티에서 주로 사용되는 C# 의 경우, 문자열 길이와 Code point 는 기본 제공이 되었고 grahpheme cluster 는 Java 와 마찬가지로 별도 클래스를 활용해야 했다.
Java script 의 경우 주로 사용하는 언어가 아니고, 파편화된 솔루션이 많아서 혼동을 줄 수 있을 것 같아 따로 정리하지 않았다.
글자수를 세고자 할 때 고려해야할 것들
- 글의 서두에 던진 것처럼 글자수를 정의할 때 다양한 관점이 있다는 것을 고려해야 한다.
- 글자수 관련 기능 구현 시 어떤 맥락에서 글자수를 세야하는지를 먼저 파악하자.
물론 대부분의 요구사항은 Grapheme cluster 로 대응이 가능하고, 실제로도 사람이 생각하는 ‘문자’ 단위에 가장 가깝다고 할 수 있지만 모든 상황에 적용할 수 있는 선택지는 아니다. 가령, 트위터처럼 140자 제한을 두되 하나의 Grapheme cluster가 너무 많은 Byte 를 차지할 수 있는 상황을 막고 싶다면 Code point 로 기준을 세우는게 유용할 것이고, 서버쪽 DB 데이터 크기에 명확히 제약을 두고자 할때는 Byte 단위로 세는게 적절할 것이다. - 실제 글자수와 관련된 기능을 사용했을 때 이슈가 있다면, 지원하고 있는 Unicode 버전을 먼저 확인하면 원인을 좁혀나가는데 수월하다.
- Unicode 는 그 자체로 굉장히 복잡한 체계를 가지고 있고, 이는 모든 개발 플랫폼에서 일관된 결과를 도출하기 어렵다는 것을 의미한다.
따라서 글자수에 대해 정말 엄격하게 대응을 해야하는 영역이라면 개발 과정에서 충분히 소통하여 가장 리소스가 적게드는 방향으로 지원범위를 맞춰나가는게 좋다고 생각한다. (Server driven, 일부 엣지케이스는 drop 하는 등..)
앞으로 누군가 이 문자열은 몇 글자라고 묻는다면,
글자수의 정의에 따라 다르다고 이야기 할 수 있는 글이 되었길 바란다.