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

비밀번호

댓글 작성자
 




... 136  137  138  139  140  141  142  143  144  145  146  147  148  149  [150]  ...
NoWriterDateCnt.TitleFile(s)
1303정성태6/26/201227397개발 환경 구성: 152. sysnet DB를 SQL Azure 데이터베이스로 마이그레이션
1302정성태6/25/201229397개발 환경 구성: 151. Azure 웹 사이트에 사용자 도메인 네임 연결하는 방법
1301정성태6/20/201225763오류 유형: 156. KB2667402 윈도우 업데이트 실패 및 마이크로소프트 Answers 웹 사이트 대응
1300정성태6/20/201231762.NET Framework: 329. C# - Rabin-Miller 소수 생성방법을 이용하여 RSACryptoServiceProvider의 개인키를 직접 채워보자 [1]파일 다운로드2
1299정성태6/18/201232878제니퍼 .NET: 21. 제니퍼 닷넷 - Ninject DI 프레임워크의 성능 분석 [2]파일 다운로드2
1298정성태6/14/201234402VS.NET IDE: 72. Visual Studio에서 pfx 파일로 서명한 경우, 암호는 어디에 저장될까? [2]
1297정성태6/12/201231046VC++: 63. 다른 프로세스에 환경 변수 설정하는 방법파일 다운로드1
1296정성태6/5/201227671.NET Framework: 328. 해당 DLL이 Managed인지 / Unmanaged인지 확인하는 방법 - 두 번째 이야기 [4]파일 다운로드1
1295정성태6/5/201225077.NET Framework: 327. RSAParameters와 System.Numerics.BigInteger 이야기파일 다운로드1
1294정성태5/27/201248520.NET Framework: 326. 유니코드와 한글 - 유니코드와 닷넷을 이용한 한글 처리 [7]파일 다운로드2
1293정성태5/24/201229772.NET Framework: 325. System.Drawing.Bitmap 데이터를 Parallel.For로 처리하는 방법 [2]파일 다운로드1
1292정성태5/24/201223751.NET Framework: 324. First-chance exception에 대해 조건에 따라 디버거가 멈추게 할 수는 없을까? [1]파일 다운로드1
1291정성태5/23/201230272VC++: 62. 배열 초기화를 위한 기계어 코드 확인 [2]
1290정성태5/18/201235078.NET Framework: 323. 관리자 권한이 필요한 작업을 COM+에 대행 [7]파일 다운로드1
1289정성태5/17/201239237.NET Framework: 322. regsvcs.exe로 어셈블리 등록 시 시스템 변경 사항 [5]파일 다운로드2
1288정성태5/17/201226461.NET Framework: 321. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (3) - Type Library파일 다운로드1
1287정성태5/17/201229294.NET Framework: 320. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (2) - .NET 4.0 + .NET 2.0 [2]
1286정성태5/17/201238217.NET Framework: 319. regasm.exe로 어셈블리 등록 시 시스템 변경 사항 (1) - .NET 2.0 + x86/x64/AnyCPU [5]
1285정성태5/16/201233264.NET Framework: 318. gacutil.exe로 어셈블리 등록 시 시스템 변경 사항파일 다운로드1
1284정성태5/15/201225690오류 유형: 155. Windows Phone 연결 상태에서 DRIVER POWER STATE FAILURE 블루 스크린 뜨는 현상
1283정성태5/12/201233306.NET Framework: 317. C# 관점에서의 Observer 패턴 구현 [1]파일 다운로드1
1282정성태5/12/201226105Phone: 6. Windows Phone 7 Silverlight에서 Google Map 사용하는 방법 [3]파일 다운로드1
1281정성태5/9/201233187.NET Framework: 316. WPF/Silverlight의 그래픽 단위와 Anti-aliasing 처리를 이해하자 [1]파일 다운로드1
1280정성태5/9/201226153오류 유형: 154. Could not load type 'System.ServiceModel.Activation.HttpModule' from assembly 'System.ServiceModel, ...'.
1279정성태5/9/201224916.NET Framework: 315. 해당 DLL이 Managed인지 / Unmanaged인지 확인하는 방법 [1]파일 다운로드1
1278정성태5/8/201226145오류 유형: 153. Visual Studio 디버깅 - Unable to break execution. This process is not currently executing the type of code that you selected to debug.
... 136  137  138  139  140  141  142  143  144  145  146  147  148  149  [150]  ...