Microsoft MVP성태의 닷넷 이야기
닷넷: 2310. .NET의 Rune 타입과 emoji 표현 [링크 복사], [링크+제목 복사],
조회: 5216
글쓴 사람
정성태 (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

비밀번호

댓글 작성자
 




... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...
NoWriterDateCnt.TitleFile(s)
1755정성태9/22/201434279오류 유형: 241. Unity Web Player를 설치해도 여전히 설치하라는 화면이 나오는 경우 [4]
1754정성태9/22/201424639VC++: 80. 내 컴퓨터에서 C++ AMP 코드가 실행이 될까요? [1]
1753정성태9/22/201420610오류 유형: 240. Lync로 세미나 참여 시 소리만 들리지 않는 경우 [1]
1752정성태9/21/201441069Windows: 100. 윈도우 8 - RDP 연결을 이용해 VNC처럼 사용자 로그온 화면을 공유하는 방법 [5]
1751정성태9/20/201438941.NET Framework: 464. 프로세스 간 통신 시 소켓 필요 없이 간단하게 Pipe를 열어 통신하는 방법 [1]파일 다운로드1
1750정성태9/20/201423832.NET Framework: 463. PInvoke 호출을 이용한 비동기 파일 작업파일 다운로드1
1749정성태9/20/201423732.NET Framework: 462. 커널 객체를 위한 null DACL 생성 방법파일 다운로드1
1748정성태9/19/201425384개발 환경 구성: 238. [Synergy] 여러 컴퓨터에서 키보드, 마우스 공유
1747정성태9/19/201428460오류 유형: 239. psexec 실행 오류 - The system cannot find the file specified.
1746정성태9/18/201426103.NET Framework: 461. .NET EXE 파일을 닷넷 프레임워크 버전에 상관없이 실행할 수 있을까요? - 두 번째 이야기 [6]파일 다운로드1
1745정성태9/17/201423035개발 환경 구성: 237. 리눅스 Integration Services 버전 업그레이드 하는 방법 [1]
1744정성태9/17/201431060.NET Framework: 460. GetTickCount / GetTickCount64와 0x7FFE0000 주솟값 [4]파일 다운로드1
1743정성태9/16/201420985오류 유형: 238. 설치 오류 - Failed to get size of pseudo bundle
1742정성태8/27/201426969개발 환경 구성: 236. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 [2]
1741정성태8/26/201421334.NET Framework: 459. GetModuleHandleEx로 알아보는 .NET 메서드의 DLL 모듈 관계파일 다운로드1
1740정성태8/25/201432507.NET Framework: 458. 닷넷 GC가 순환 참조를 해제할 수 있을까요? [2]파일 다운로드1
1739정성태8/24/201426535.NET Framework: 457. 교착상태(Dead-lock) 해결 방법 - Lock Leveling [2]파일 다운로드1
1738정성태8/23/201422046.NET Framework: 456. C# - CAS를 이용한 Lock 래퍼 클래스파일 다운로드1
1737정성태8/20/201419764VS.NET IDE: 93. Visual Studio 2013 동기화 문제
1736정성태8/19/201425575VC++: 79. [부연] CAS Lock 알고리즘은 과연 빠른가? [2]파일 다운로드1
1735정성태8/19/201418194.NET Framework: 455. 닷넷 사용자 정의 예외 클래스의 최소 구현 코드 - 두 번째 이야기
1734정성태8/13/201419855오류 유형: 237. Windows Media Player cannot access the file. The file might be in use, you might not have access to the computer where the file is stored, or your proxy settings might not be correct.
1733정성태8/13/201426362.NET Framework: 454. EmptyWorkingSet Win32 API를 사용하는 C# 예제파일 다운로드1
1732정성태8/13/201434475Windows: 99. INetCache 폴더가 다르게 보이는 이유
1731정성태8/11/201427081개발 환경 구성: 235. 점(.)으로 시작하는 파일명을 탐색기에서 만드는 방법
1730정성태8/11/201422164개발 환경 구성: 234. Royal TS의 터미널(Terminal) 연결에서 한글이 깨지는 현상 해결 방법
... 121  122  123  124  125  126  127  128  129  130  131  [132]  133  134  135  ...