LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


글자수를 세는 7가지 방법

안녕하세요. 라인플러스 개발실에서 일하고 있는 박상진입니다.

이 블로그에서는 글자 수를 세는 방법에 대해서 얘기해보고자 합니다. 라인 서비스에서는 프로필이름, 그룹이름, 상태메시지 등 여러 곳에서 글자 수를 세게 되는데요. 글이 화면에 부족하거나 넘치지 않게 하고, 스토리지 용량을 정확하게 할당하기 위해서는 글자 수를 정확히 세는 것은 중요한 일입니다. 특히 라인은 전세계에서 사용하는 서비스인만큼 다른 언어들의 글자 수도 정확히 셀 수 있어야 합니다. 어느 날 BTS(Bug Tracking System)의 프로필 이름에 emoji를 입력하면 1자가 2자로 표시되는, 글자 수가 정확히 카운트되지 않는다는 이슈가 올라왔습니다. emoji란 일본에서 처음 쓰이기 시작한 것으로 지금은 Unicode 표준에 포함되어 세계적으로 널리 쓰이고 있는 그림문자 세트인데요. 처음에는 단순히 Surrogate를 제대로 카운트하지 못하는 문제라고 짐작하고 분석을 시작했습니다. Surrogate란 쉽게 이야기해서 UTF-16 인코딩을 16 비트 이상으로 확장해 주는 문자세트인데, emoji 중에는 Surrogate를 통해 표현되는 문자들이 있기 때문입니다.

하지만, 자세히 살펴보니 실제로는 Surrogate와 무관한 2글자가 입력되는 것이 확인되었습니다. 그렇다면 emoji 문자 뒤에 공통으로 추가되는 character가 있는 게 아닐까 하여 규칙을 찾아서 예외처리하려고 생각하고 있었는데, 또 다른 이슈가 또 올라왔습니다.

"태국어글자가 정확히 count되지 않아요"

조사를 해보니 태국 문자뿐 아니라 아랍 문자, 인도 문자에서도 비슷한 현상이 발견되었습니다. 태국은 라인 사용자가 많은 중요한 국가 중 하나이고 인구 2위의 인도도 중요한 국가이며, 아랍지역도 이란을 비롯해 라인 사용자가 많은 중요한 국가가 많기에 이런 문제를 근본적으로 해결할 방법을 찾아보기로 했습니다. 가장 먼저 찾아낸 것은 이들 태국 문자(ภาษาไทย), 아랍 문자(العربية), 데바나가리 문자(देवनागरी, 힌두어)의 공통점이 조합형 문자라는 것입니다. 그렇게 조합형 문자의 Unicode 표준 등에 대해 공부하면서 알게 된 사실은 글자 수를 세는 간단한 일조차 글로벌 서비스에서는 생각만큼 간단하지 않다는 것이었습니다.

구체적인 내용

질문: ""은 몇 글자인가요? 답변: "글자"의 정의에 따라 다릅니다.

  1. Bytes : 8비트. 메모리 또는 스토리지 상에서 Unicode string이 몇 바이트를 차지하는지는 인코딩에 따라 달라집니다.
  2. Code Units : text 인코딩에 있어서 처리를 위한, 한 단위를 표현할 수 있는 최소한의 비트 조합. 예를 들어, 1 code unit은 UTF-8은 1 바이트, UTF-16은 2 바이트, UTF-32는 4 바이트입니다.
  3. Code Points : Unicode character. Unicode space 상의 한 integer 값으로, 현재는 U+0000~U+10FFFF 사이의 값 중 하나입니다.
  4. Grapheme clusters : 사용자가 인지하는 하나의 글자. 1 grapheme cluster는 여러 개의 code point로 이루어질 수 있습니다.

‘글자 수’의 정의와 카운트를 구하는 방법

  • Grapheme
    • 사용자가 인식하는 글자. 문자 체계에서 표현할 수 있는 글자의 최소 단위. 1 Grapheme은 N code point로 이루어져 있다.
    • 예 : A 각 image2015-1-27 12_35_56 image2015-1-27 1_41_28
    • 카운트 구하는 방법
public static int getGraphemeLength(String value) {
    BreakIterator it = BreakIterator.getCharacterInstance(); 
    it.setText(value); 
    int count = 0; 
    while (it.next() != BreakIterator.DONE) { 
        count++; 
    }
    return count;
}
  • Code Point
    • Unicode codespace 상에서 0~10FFFF 사이의 값을 가진 문자.
    • 예 : U+AC01
    • 카운트 구하는 방법
String.codePointCount()
  • UTF-16BE
    • 각 code point를 2 또는 4 바이트(Big Endian)로 표현하는 멀티바이트 인코딩 방식. Java의 primitive 'char'와 1:1로 대치가 가능하다. Code point를 U+10000~U+10FFFF로 인코딩하기 위해서는 4 바이트(2 code unit)의 high/low surrogate가 필요하다.
    • 예 : 0xAC01
    • 카운트 구하는 방법
String.length()
(code unit count)
  • UTF-8
    • 각 code point를 1에서 4 바이트 길이의 부호 없는 바이트 시퀀스에 할당하는 Unicode 인코딩 방식.
    • 예 : 0xEA,0xB0,0x81,0xF0,0x9F,0x85,0xB1
    • 카운트 구하는 방법
String.getBytes().length 
String.getBytes("UTF-8").length 
(byte count) 
  • CESU-8
    • 각 code point를 1, 2, 3 또는 6 바이트 길이의 부호 없는 바이트 시퀀스에 할당하는 Unicode 인코딩 방식. UTF-8과 마찬가지로 범위는 U+0000~U+FFFF지만, U+10000~U+10FFFF 사이의 code point는 4 바이트 대신 3(high surrogate)+3(low surrogate)=6 바이트로 인코딩이 되어 있다.
    • 예 : 0xED,0xA0,0xBC,0xED,0xB5,0xB1
    • 카운트 구하는 방법
public static int getCESU8Length(String str) {
    int strlen = str.length(), utflen = 0, c = 0;
    for (int i = 0; i < strlen; i++) {
        c = str.charAt(i); 
        if ((c >= 0x0000) && (c <= 0x007F)) utflen++;
        else if (c > 0x07FF) utflen += 3;
        else utflen += 2; 
    } 
    return utflen; 
} 
  • Modified UTF-8
    • 변형된 UTF-8은 CESU-8 인코딩 방식으로, null(U+0000)을 0xC0, 0x80로 인코딩하는 추가적인 규칙이 있다. (Java serialization, class file 등에서만 사용됨)
    • 예 : 0xED,0xA0,0xBC,0xED,0xB5,0xB1,0XC0,0x80
    • 카운트 구하는 방법
public static int getModifiedUTF8Length(String str) { 
    int strlen = str.length(), utflen = 0, c = 0; 
    for (int i = 0; i < strlen; i++) { 
        c = str.charAt(i); 
        if ((c >= 0x0001) && (c <= 0x007F)) utflen++;
        else if (c > 0x07FF) utflen += 3;
        else utflen += 2; 
    } 
    return utflen; 
} 

사례

GEMINI는 code point로 U+264A이고 UTF-8으로 3 바이트로 인코딩됩니다.

GEMINI character를 실제로 iPhone 등에서 입력하면 emoji / text style을 선택하는 Variation-Selector character(VS15)가 뒤에 붙어서 2 code point로 표현됩니다.

또 다른 emoji입니다. base character인 U+1F171는 16 비트를 초과하는 영역에 정의되어 있어서 UTF-16로 인코딩 시
high/low surrogate의 4 바이트로 인코딩되고 UTF-8으로 4 바이트, CESU-8으로 6 바이트로 인코딩됩니다.

위와 같이 3 code point로 표현되는 emoji도 있습니다.

데바나가리 문자의 경우 한 글자가 4 code point로 표현되는 경우도 있습니다.

아랍 문자, 태국 문자 등도 보통 여러 code point가 한 글자를 표현합니다.

한글이나 일부 라틴계 문자 등도 조합형 표현식이 있습니다. (한글자모, 발음 구별 부호 등) 예를 들어 '각'(U+AC01)은 'ㄱㅏㄱ'(U+1100, U+1161, U+11A8)으로도 표현될 수 있는데, 각각을 NFC(각), NFD(ㄱㅏㄱ) 형태라 부릅니다. Unicode를 다루는 프로그램은 두 문자를 동일하게 취급해야 하며 이러한 형태 간의 변환을 위해 Unicode normalization을 하게 됩니다.

한글(현대한글)은 NFC로 표현 시 모두 1 code point로 표현되는 반면 한글고어(옛한글), 데바나가리, 아랍 문자, 태국 문자 등은 NFC 형태에서도 여러 개의 code point를 필요로 합니다. (위에서 예로 든 devanagari kshi도 이미 NFC 형태입니다.) OS에 따라 NFC를 사용하기도 하고 NFD를 사용하기도 하는데, MAC OS의 경우 Unicode 파일 경로를 다룰 때 내부적으로 NFD를 사용하기 때문에 압축파일 내의 한글 등으로 된 파일명이 Windows 등에서 부정확하게 표시되는 일도 있습니다. 하지만, 어느 경우에도 Grapheme cluster는 동일하게 1개로 카운트됩니다. 따라서 사용자가 인식하는 글자 수를 모든 경우에 정확히 카운트하려면 code unit이나 code point가 아닌 Grapheme cluster 카운트를 사용해야 합니다.

다른 언어로 grapheme 카운트를 구하는 방법

  • Java
public static int getGraphemeLength(String value) {
    BreakIterator it = BreakIterator.getCharacterInstance();
    it.setText(value);
    int count = 0;
    while (it.next() != BreakIterator.DONE) {
        count++;
    }
    return count;
}
  • C++
int getGraphemeLength(const UnicodeString &str) {
    UErrorCode err = U_ZERO_ERROR;
    std::unique_ptr<BreakIterator> iter(
        BreakIterator::createCharacterInstance(Locale::getDefault(), err));
    assert(U_SUCCESS(err));
    iter->setText(str);
    int count = 0;
    while(iter->next() != BreakIterator::DONE) ++count;
    return count;
}
  • Go
func grLen(s string) int {
    if len(s) == 0 {
        return 0
    }
    gr := 1
    _, s1 := utf8.DecodeRuneInString(s)
    for _, r := range s[s1:] {
        if !unicode.Is(unicode.Mn, r) {
            gr++
        }
    }
    return gr
}
  • Perl
say 'møøse'.graphs;
  • PHP
$length = grapheme_strlen('Hello, world!')
  • Swift
countElements(str)

Java의 primitive 'char'는 어째서 1 grapheme도 1 code point도 아닌 UTF-16 인코딩의 1 code unit에 대응되도록 설계되었을까요? 왜냐하면 Java가 설계될 당시 Unicode는 전체 code point가 16 비트로 정의되어 있었기 때문입니다.

"16 비트로 세상의 모든 문자를 표현한다"는 개념은 Unicode 설계자가 만족스러워했던 Unicode의 설계 원칙이기도 했습니다.(그러나 Java가 발표되고 불과 얼마 지나지 않아 Unicode는 16 비트를 넘어 확장됩니다. 현재 Unicode 7.0에서는 U+10FFFF, 즉 17*65536=1,114,112까지 정의되어 있습니다.)

한편, MySQL이나 Oracle의 'utf8' charset은 실제로는 UTF-8이 아니라 CESU-8이나 마찬가지여서 UTF-8보다 공간을 더 필요로 할 수 있습니다. UTF-8 인코딩을 사용하려면 'AL32UTF8'(oracle) 또는 'utf8mb4'(mysql) charset을 사용해야 합니다. 최신 언어인 Swift의 경우 Character type이 아예 1 Grapheme을 표현하도록 정의되어 있습니다.

1 grapheme을 저장하기 위한 공간은 4 code point, 4 'char' 또는 UTF-8일 경우 12 바이트 정도는 할당해야 상대적으로 안전합니다. 1 grapheme에 최대로 필요한 code point 수는 각 언어별 automata와 writing system까지 관련되어 있는 복잡한 문제입니다. 그래서 Unicode 표준에서도 다루고 있지 않습니다.

다시 처음 질문으로 돌아가면 는 6 grapheme, 13 code point이고 UTF-8로 인코딩하면 36 바이트로 카운트됩니다.