.NET의 Rune 타입과 emoji 표현
(이 글에 포함된 일부 유니코드 문자는 모바일 웹 브라우저에서는 정상적으로 안 보일 수 있습니다.)
관련한 글이 마침 나왔는데요,
유니코드 문자열 처리
; https://forum.dotnetdev.kr/t/topic/11904
일단 위의 글을 짧게 정리하면, "
닷넷은 UTF-16 인코딩을 사용한다"라고 할 수 있습니다. 그리고 덧글을 통해 Rune이 언급되었는데요, 이것을 설명하기에 앞서 왜 Rune이 나올 수밖에 없었는지에 대한 배경 설명을 코드와 함께 먼저 곁들여 보겠습니다. ^^
기본적으로 메모리는 byte로 이뤄져 있습니다. byte는 0~255 범위(2
8)의 값을 가질 수 있는데요, 따라서 ASCII 코드 체계에서 byte는 정확히 한 개의 '글자'와 매칭합니다. 따라서, 그 시절에는 n 번째 위치의 byte를 읽으면 그것이 곧 n 번째 위치의 '글자'가 되었습니다.
당연히 한/중/일 등의 문자까지 포함한다면 1byte로는 부족하고, 2
16개의 값을 가질 수 있는 2바이트가 필요했는데요, 문제는 "없이 살던 그 시절"에 2바이트로 모든 문자를 표현하기에는 메모리가 너무 비싼 자원이었다는 점입니다.
그래서, 영문은 1바이트로, 한글은 2바이트로 표현하는
DBCS(Double Byte Character Set)가 등장했는데요, 이때부터 메모리의 특정 byte에 위치한 값이 몇 번째의 '글자'인지 판단할 수 없는 상황이 발생했습니다.
예를 들어, 다음과 같이 텍스트를 표현하면,
// C# 코드 (.NET Core/5+)
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
{
Encoding enc = Encoding.GetEncoding("ks_c_5601-1987");
byte[] buffer = enc.GetBytes("a한b글");
Console.WriteLine(BitConverter.ToString(buffer));
}
// 출력 결과: 61-C7-D1-62-B1-DB
// C/C++ 코드
#include <iostream>
// /execution-charset:ks_c_5601-1987
int main()
{
const char* text = "a한b글";
std::cout << text << std::endl;
}
buffer 또는 text의 메모리에는 다음과 같은 바이트 배열이 들어가게 됩니다.
61 c7 d1 62 b1 db 00
text[0] 61: a
text[1:2] c7 d1: 한
text[3] 62: b
text[4:5] b1 db: 글
text[6] 00: NULL
보는 바와 같이 바이트 메모리에 위치한 값이 몇 번째 글자인지 판단하려면 맨 처음부터 바이트를 읽어가면서
해당 위치의 글자가 영문인지 한글인지 판단해 글자 수를 세어 가야 합니다.
딱 봐도 성능에 문제가 있어 보이는데요, 이런 상황을 해결하려면 그냥 한글 및 영문을 모두 2바이트로 표현하면 됩니다. 실제로 이런 시도가
Windows NT의 초기 시절에 있었는데요, 그래서 2바이트 wchar_t 타입이 윈도우에서 UCS-2를 표현했었습니다.
#include <iostream>
#include <io.h> // _setmode
#include <fcntl.h> // _O_U16TEXT
int main()
{
_setmode(_fileno(stdout), _O_U16TEXT);
const wchar_t* text = L"a한b글"; // (현재는 UTF-16이지만!)
std::wcout << text << std::endl;
}
61 00 5c d5 62 00 00 ae 00 00
text[0:1] 61 00: a
text[2:3] 5c d5: 한
text[4:5] 62 00: b
text[6:7] 00 ae: 글
text[8:9] 00 00: NULL
결국 n 번째의 글자를 찾으려면 2만 곱하면 되는 간단한 연산으로 해결할 수 있는... 행복한 시절이 잠시 있었고, 이후 Unicode가 확장하면서 2바이트로도 감당이 안 돼 결국 고정 폭의 UCS-2가 아닌 가변 폭의 UTF-16 인코딩으로 바뀌게 됐습니다.
C#은 UTF-16 인코딩을 따릅니다. UTF-16은 U+0000 ~ U+FFFF 사이의 문자, 즉 BMP 영역에 속한 2바이트 글자에 대해서는 UTF-16도 동일하게 2바이트로 표현하므로, 위의 코드를 C#으로 옮겨도 출력 결과는 동일합니다.
string text = "a한b글";
byte [] buffer = Encoding.Unicode.GetBytes(text);
Console.WriteLine(BitConverter.ToString(buffer));
// 출력 결과: 61-00-5C-D5-62-00-00-AE
반면, U+010000 ~ U+10FFFF 범위의 문자는 2바이트의 범위를 넘어서므로, (UTF-16 surrogate 영역으로 비워두었던) 0xD800–0xDFFF 범위의 특별한 코드 유닛을 사용해 인코딩을 하므로,
// https://en.wikipedia.org/wiki/UTF-16
U' = yyyyyyyyyyxxxxxxxxxx // U - 0x10000 (y == 10 bits, x == 10 bits)
W1 = 110110yyyyyyyyyy // 0xD800 + yyyyyyyyyy (y == 10 bits)
W2 = 110111xxxxxxxxxx // 0xDC00 + xxxxxxxxxx (x == 10 bits)
20비트 범위의 문자를 추가하게 됐습니다.
현재 유니코드는 4바이트를 전부 쓰지 않고 BMP 영역에 65,536개, Supplementary Plane에 1,048,576개의 문자 영역을 확보해 총 1,114,112개의 문자를 매핑할 수 있는 공간이 정의돼 있습니다.
UTF-16의 경우에도 2바이트로 65,536개, Surrogate 인코딩으로 확보한 20비트로 1,048,576개의 문자를 표현할 수 있으므로, 현재 수준의 유니코드는 모두 표현할 수 있습니다.
어쨌든, UTF-16 인코딩도 U+010000 ~ U+10FFFF 범위에 정의된 글자로 인해 가변 폭의 인코딩을 하므로 n 번째 글자를 바이트로부터 찾기 위해서는 문자열을 처음부터 읽어가면서 글자 수를 세어 가야 합니다.
그렇긴 하지만, 현실적으로는 대부분의 경우 BMP 영역에 속한다고 가정할 수 있으므로 2바이트로 표현하는 것을 감안해 상수 시간에 n 번째 글자를 찾을 수는 있습니다. 게다가 C#의 char는 2바이트여서 string의 인덱서가 반환하는 char로 n 번째 글자를 쉽게 구할 수 있습니다.
string text = "a한b글";
for (int i = 0; i < text.Length; i ++)
{
Console.WriteLine("Character at index {0} is '{1}'", i, text[i]);
}
/* 출력 결과
Character at index 0 is 'a'
Character at index 1 is '한'
Character at index 2 is 'b'
Character at index 3 is '글'
*/
문제는, 위와 같은 char 2바이트로는 표현할 수 없는 U+010000 ~ U+10FFFF 범위의 글자를 만나게 되었을 때 발생합니다.
자, 이제서야 (.NET Core 3.0부터 제공하는) Rune 타입에 대해 이야기를 할 수 있게 됐군요. ^^
Rune Struct
; https://learn.microsoft.com/en-us/dotnet/api/system.text.rune
Rune이 4바이트라는 것에서 설명이 이미 된 것이나 다름없습니다. 그러니까, 2바이트 char 타입이 UTF-16 인코딩으로 인해 2개의 char가 필요한 것에 반해, Rune의 4바이트라는 특성은 Unicode 내의 U+010000 ~ U+10FFFF 문자를 단 하나의 Rune 인스턴스로 표현할 수 있게 해줍니다.
예를 들기 위해, U+010000 ~ U+10FFFF 범위의 글자 중에서
지난번에도 사용했던 '𐀀' 문자(U+10000:
LINEAR B SYLLABLE B008 A) 글자의 경우, UTF-16 인코딩에서 4바이트로 표현하므로 (2바이트인) char는 해당 글자를 표현할 수 없습니다.
string text = "a한𐀀";
for (int i = 0; i < text.Length; i ++)
{
Console.WriteLine("Character at index {0} is '{1}'", i, text[i]);
}
/* 출력 결과
Character at index 0 is 'a'
Character at index 1 is '한'
Character at index 2 is '�'
Character at index 3 is '�'
*/
즉, 위에서 index 2와 3에 해당하는 2개의 문자를 하나로 다룰 수 있어야 하는데요, 바로 이런 경우에 4바이트 Rune이 대안이 될 수 있습니다.
string text = "a한𐀀b";
foreach (Rune rune in text.EnumerateRunes())
{
Console.WriteLine($"{rune} (0x{rune.Value:x})");
}
/* 출력 결과
a (0x61)
한 (0xd55c)
𐀀 (0x10000)
b (0x62)
*/
또는, 직접 유니코드 글자마다 열거하면서 가변 인코딩에 대한 처리를 다루는 것도 가능합니다.
// 뒤늦게 나온 Rune이 Span을 내부적으로 활용하는 덕분에 GC 힙을 사용하지 않는 아름다운 코드 ^^
ReadOnlySpan<char> chars = text.AsSpan();
while (Rune.DecodeFromUtf16(chars, out Rune result, out int charsConsumed)
== System.Buffers.OperationStatus.Done)
{
if (charsConsumed == 0)
{
break;
}
chars = chars.Slice(charsConsumed);
Console.WriteLine($"{result} (0x{result.Value:x})");
}
/* 출력 결과: (text.EnumerateRunes와 동일) */
하지만, Rune으로도 (정상적으로) 표현할 수 없는 영역이 있습니다. 바로 갖가지 유니코드 문자들이 조합을 이뤄가면서 표현되는 emoji가 그것입니다.
역시 Rune의 한계를 느낄 수 있는 경우를 코드로 직접 예를 들어 볼까요? ^^
이러한 좋은 예로, "family emoji"에 해당하는 "👨👩👧👦" 문자가 있습니다. (emoji 문자의 입력은 Windows Key + '.'를 눌러 나오는 'Emoji Panel'을 사용하면 됩니다.)
// https://stackoverflow.com/questions/9533258/what-is-the-maximum-number-of-bytes-for-a-utf-8-encoded-character
string text = "a👨👩👧👦b"; // 또 다른 사례로 "Zalg: H̸͍͖̖̎̂́͠e̷̩̻̦̽̆̐͑̊́l̷̛̖̜̇̌̚͠l̷͖̮̮̞͂͊͋̃͆͜͝o̸̞̻̗͋͂̍̂͝."
foreach (Rune rune in text.EnumerateRunes())
{
Console.WriteLine($"{rune} (0x{rune.Value:x})");
}
/* 출력 결과:
a (0x61)
👨 (0x1f468)
(0x200d)
👩 (0x1f469)
(0x200d)
👧 (0x1f467)
(0x200d)
👦 (0x1f466)
b (0x62)
*/
보는 바와 같이 family emoji는 그것을 구성하는 4개의 유니코드 문자와, 각각의 문자를 조합하는 0x200d 구분자가 함께 사용돼 총 22바이트가 소요됩니다.
U+1F468 (Man), U+200D (Zero Width Joiner),
U+1F469 (Woman), U+200D
U+1F467 (Girl), U+200D
U+1F466 (Boy)
이런 식으로 구성되는 것을 "Extended Grapheme Clusters"라고 한다고. ^^
즉, 유니코드의 코드 포인트로 명시되지 않은 이러한 문자는 4바이트 Rune으로는 담을 수 없어 표현을 할 수 없습니다. 즉, 2바이트 char가 surrogate pair로 구성한 4바이트 문자를 표현할 수 없었던 것과 동일한 한계가 있는 것입니다.
바로 이런 문제를 해결할 수 있는 클래스가
StringInfo 타입으로, 아래의 코드는 그 차이를 보여줍니다.
string text = "a👨👩👧👦b";
TextElementEnumerator charEnum = StringInfo.GetTextElementEnumerator(text);
while (charEnum.MoveNext())
{
Console.WriteLine(
"Character at index {0} is '{1}'",
charEnum.ElementIndex, charEnum.GetTextElement());
}
/* 출력 결과
Character at index 0 is 'a'
Character at index 1 is '👨👩👧👦'
Character at index 12 is 'b'
*/
보는 바와 같이 4개의 유니코드가 모인 emoji까지도 하나의 문자처럼 다룰 수 있게 합니다. 이 정도면, char/Rune/StringInfo의 차이가 어느 정도 이해가 되셨을까요? ^^
(
첨부 파일은 이 글의 예제 코드를 포함합니다.)
참고로 .NET의 Rune은 Go 언어의 Rune과 유사합니다.
Golang - (문자가 아닌) 바이트 위치를 반환하는 strings.IndexRune 함수
; https://www.sysnet.pe.kr/2/0/12890
그러니까, 다른 언어들도 저렇게 가변 인코딩으로 인한 어려움을 겪으며 나름의 방법으로 헤쳐나가고 있는 것입니다.
시간 되시면 윈도우의 emoji 이력도 살펴보시고. ^^
Shortcake on Microsoft Windows 11 23H2 June 2024 Update
; https://emojipedia.org/microsoft/windows-11-23h2-june-2024-update/shortcake
Segoe UI Emoji font family
; https://learn.microsoft.com/en-us/typography/font-list/segoe-ui-emoji
Windows\Fonts\seguiemj.ttf
Announcing Windows 10 Insider Preview Build 21277
; https://blogs.windows.com/windows-insider/2020/12/10/announcing-windows-10-insider-preview-build-21277/
그건 그렇고, "
유니코드 문자열 처리" 덧글에서 Rune에 대한 질문이 나오는데요, 이에 대한 답변이 약간 수긍이 안 됩니다.
음소는 Rune으로 표현..., 음절은 Rune의 조합
음소와 음절에 대한 이해는 다음의 글을 참고하시고,
음소(Phonemes)와 음절(Syllables)
; https://m.cafe.daum.net/splended/D9TK/685
사실 위의 2가지 개념은 한글과 영문의 구조적인 차이로 인해 프로그래밍의 자료형과 딱 맞아떨어지는 것은 없습니다. 우선, 음절을 볼까요? 한글의 경우 'ㄱ', 'ㅏ', 'ㅇ'이라는 음소 3개가 모여 '강'이라는 음절을 나타내는데요, 따라서 어찌 보면 프로그래밍 세계에서의 char는 음절이라고 볼 수도 있습니다. 그런데, 영문의 경우라면,
음절(Syllable)이 무엇인가요?
; https://www.koreanenglish.org/english/english-howto/pronunciation/202-what-a-syllable-is
가령 "Water"의 경우 이것은 "Wa", "ter" 2개의 음절로 이뤄진 것입니다. 즉, char와 같은 개념이 아닙니다.
그렇다면, 음소는 어떨까요? 영문이라면 a, b, c, d, ...는 1개의 char로 표현할 수 있어 이번엔 오히려 char 타입을 음소라고 볼 수도 있습니다. 하지만, 한글의 경우라면 'ㄱ', 'ㄴ', ... 등을 분해해서 char로 가지고 있지 않다는 것에서 차이가 있습니다.
따라서, "음소는 Rune으로 표현..., 음절은 Rune의 조합"이라고 하는 구분은 타당하지 않습니다. 그냥 음소/음절을 떠나서 "Unicode의 코드 포인트는 Rune으로, emoji가 Rune의 조합"이라고 표현하는 것이 더 낫지 않을까 싶습니다.
암튼, 제가 이 분야로는 전문가가 아니라서 더 정확한 썰을 풀기가 어렵군요. ^^
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]