Microsoft MVP성태의 닷넷 이야기
닷넷: 2310. .NET의 Rune 타입과 emoji 표현 [링크 복사], [링크+제목 복사],
조회: 1075
글쓴 사람
정성태 (seongtaejeong at gmail.com)
홈페이지
첨부 파일
[cs_rune.zip]    

(시리즈 글이 12개 있습니다.)
.NET Framework: 326. 유니코드와 한글 - 유니코드와 닷넷을 이용한 한글 처리
; https://www.sysnet.pe.kr/2/0/1294

.NET Framework: 411. 유니코드의 "compatibility character"가 뭘까요?
; https://www.sysnet.pe.kr/2/0/1607

.NET Framework: 429. C# - 유니코드 한글 문자열을 ks_c_5601-1987로 변환하는 방법
; https://www.sysnet.pe.kr/2/0/1657

개발 환경 구성: 230. 유니코드의 Surrogate Pair, Supplementary Characters가 뭘까요?
; https://www.sysnet.pe.kr/2/0/1710

.NET Framework: 450. 영문 윈도우에서 C# 콘솔 프로그램의 유니코드 출력 방법
; https://www.sysnet.pe.kr/2/0/1712

.NET Framework: 794. C# - 같은 모양, 다른 값의 한글 자음을 비교하는 호환 분해
; https://www.sysnet.pe.kr/2/0/11710

개발 환경 구성: 407. 유니코드와 한글 - "Hangul Compatibility Jamo"
; https://www.sysnet.pe.kr/2/0/11724

Windows: 176. Raymond Chen이 한글날에 밝히는 윈도우의 한글 자모 분리 현상
; https://www.sysnet.pe.kr/2/0/12369

닷넷: 2307. C# - 윈도우에서 한글(및 유니코드)을 포함한 콘솔 프로그램을 컴파일 및 실행하는 방법
; https://www.sysnet.pe.kr/2/0/13794

개발 환경 구성: 731. 유니코드 - 출력 예시 및 폰트 찾기
; https://www.sysnet.pe.kr/2/0/13798

개발 환경 구성: 732. 모바일 웹 브라우저에서 유니코드 문자가 표시되지 않는 경우
; https://www.sysnet.pe.kr/2/0/13799

닷넷: 2310. .NET의 Rune 타입과 emoji 표현
; https://www.sysnet.pe.kr/2/0/13813




.NET의 Rune 타입과 emoji 표현

(이 글에 포함된 일부 유니코드 문자는 모바일 웹 브라우저에서는 정상적으로 안 보일 수 있습니다.)




관련한 글이 마침 나왔는데요,

유니코드 문자열 처리
; https://forum.dotnetdev.kr/t/topic/11904

일단 위의 글을 짧게 정리하면, "닷넷은 UTF-16 인코딩을 사용한다"라고 할 수 있습니다. 그리고 덧글을 통해 Rune이 언급되었는데요, 이것을 설명하기에 앞서 왜 Rune이 나올 수밖에 없었는지에 대한 배경 설명을 코드와 함께 먼저 곁들여 보겠습니다. ^^




기본적으로 메모리는 byte로 이뤄져 있습니다. byte는 0~255 범위(28)의 값을 가질 수 있는데요, 따라서 ASCII 코드 체계에서 byte는 정확히 한 개의 '글자'와 매칭합니다. 따라서, 그 시절에는 n 번째 위치의 byte를 읽으면 그것이 곧 n 번째 위치의 '글자'가 되었습니다.

당연히 한/중/일 등의 문자까지 포함한다면 1byte로는 부족하고, 216개의 값을 가질 수 있는 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의 조합"이라고 표현하는 것이 더 낫지 않을까 싶습니다.

암튼, 제가 이 분야로는 전문가가 아니라서 더 정확한 썰을 풀기가 어렵군요. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]







[최초 등록일: ]
[최종 수정일: 11/12/2024]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13838정성태12/4/2024632오류 유형: 935. Windbg - Breakpoint 0's offset expression evaluation failed.
13837정성태12/3/2024698디버깅 기술: 204. Windbg - 윈도우 핸들 테이블 (3) - Windows 10 이상인 경우
13836정성태12/3/2024922디버깅 기술: 203. Windbg - x64 가상 주소를 물리 주소로 변환 (페이지 크기가 2MB인 경우)
13835정성태12/2/2024985오류 유형: 934. Azure - rm: cannot remove '...': Directory not empty
13834정성태11/29/20241073Windows: 275. C# - CUI 애플리케이션과 Console 윈도우 (Windows 10 미만의 Classic Console 모드인 경우)파일 다운로드1
13833정성태11/29/20241077개발 환경 구성: 737. Azure Web App에서 Scale-out으로 늘어난 리눅스 인스턴스에 SSH 접속하는 방법
13832정성태11/27/20241110Windows: 274. Windows 7부터 도입한 conhost.exe
13831정성태11/27/2024977Linux: 111. eBPF - BPF_MAP_TYPE_PERF_EVENT_ARRAY, BPF_MAP_TYPE_RINGBUF에 대한 다양한 용어들
13830정성태11/25/20241069개발 환경 구성: 736. 파이썬 웹 앱을 Azure App Service에 배포하기
13829정성태11/25/20241026스크립트: 67. 파이썬 - Windows 버전에서 함께 설치되는 py.exe
13828정성태11/25/20241047개발 환경 구성: 735. Azure - 압축 파일을 이용한 web app 배포 시 디렉터리 구분이 안 되는 문제파일 다운로드1
13827정성태11/25/20241117Windows: 273. Windows 환경의 파일 압축 방법 (tar, Compress-Archive)
13826정성태11/21/20241174닷넷: 2313. C# - (비밀번호 등의) Console로부터 입력받을 때 문자열 출력 숨기기(echo 끄기)파일 다운로드1
13825정성태11/21/20241144Linux: 110. eBPF / bpf2go - BPF_RINGBUF_OUTPUT / BPF_MAP_TYPE_RINGBUF 사용법
13824정성태11/20/20241083Linux: 109. eBPF / bpf2go - BPF_PERF_OUTPUT / BPF_MAP_TYPE_PERF_EVENT_ARRAY 사용법
13823정성태11/20/20241084개발 환경 구성: 734. Ubuntu에 docker, kubernetes (k3s) 설치
13822정성태11/20/20241044개발 환경 구성: 733. Windbg - VirtualBox VM의 커널 디버거 연결 시 COM 포트가 없는 경우
13821정성태11/18/20241170Linux: 108. Linux와 Windows의 프로세스/스레드 ID 관리 방식
13820정성태11/18/20241127VS.NET IDE: 195. Visual C++ - C# 프로젝트처럼 CopyToOutputDirectory 항목을 추가하는 방법
13819정성태11/15/20241122Linux: 107. eBPF - libbpf CO-RE의 CONFIG_DEBUG_INFO_BTF 빌드 여부에 대한 의존성
13818정성태11/15/20241215Windows: 272. Windows 11 24H2 - sudo 추가
13817정성태11/14/20241100Linux: 106. eBPF / bpf2go - (BPF_MAP_TYPE_HASH) Map을 이용한 전역 변수 구현
13816정성태11/14/20241157닷넷: 2312. C#, C++ - Windows / Linux 환경의 Thread Name 설정파일 다운로드1
13815정성태11/13/20241100Linux: 105. eBPF - bpf2go에서 전역 변수 설정 방법
13814정성태11/13/20241213닷넷: 2311. C# - Windows / Linux 환경에서 Native Thread ID 가져오기파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...