유니코드 #2 DarkKaiser, 2017년 9월 14일2023년 9월 5일 출처 : http://www.bsidesoft.com/?p=3496& 심화된 인코딩 탐구 저번 포스팅에서는 유니코드에 대한 개요와 인코딩이란 무엇인가에 대한 기초개념을 살펴봤습니다. 다음과 같은 내용이 나왔죠. 코드포인트 – 문자에 할당된 고유한 숫자값 평면 – 코드포인트를 관리하기 위한 그룹범위 코드유닛 – 일정한 크기를 하나의 문자로 바라보는 단위 인코딩 – 코드유닛과 코드포인트의 크기 차이를 처리하기 이번 시간에서는 여러가지 인코딩방식에 따라 실질적인 인코딩을 처리해가면서 구체적인 내용을 살펴볼 예정입니다. 약간만 더 복잡해질거야. …말도 안.. 유니코드 인코딩의 종류 유니코드에서 사용하는 인코딩은 표준으로 지정되어있습니다. UTF8, UTF16, UTF32 라는 세 가지로 각각 뒤에 붙은 숫자는 코드유닛의 비트 크기입니다. UTF8 : 8비트 = 1바이트를 코드유닛으로 사용하는 인코딩. 인터넷에 교환되는 대부분의 파일에 사용됨. UTF16 : 16비트 = 2바이트를 코드유닛으로 사용하는 인코딩. 자바, 윈도우 응용프로그램, 자바스크립트 등의 작동시 사용됨. UTF32 : 32비트 = 4바이트를 코드유닛으로 사용하는 인코딩. 유닉스시스템 일부나 인코딩 이슈를 피하고 싶을 때 사용함. 유니코드 코드포인트의 최대크기는 4바이트입니다. 따라서 UTF32를 쓰는 경우 인코딩, 디코딩 과정이 필요없이 코드포인트를 그래도 코드유닛에 표현할 수 있습니다. 그럼 귀찮은거 없이 다 UTF32를 쓰면 될텐데 왜 UTF8과 UTF16이 있는 걸까요? 앞 시간에 패딩에 대해서 얘기했는데 UTF32를 쓰면 많은 패딩이 발생하여 용량이 크게 불어납니다. 보다 효율적인 메모리를 사용하기 위해 작은 코드포인트는 패딩없이 코드유닛에 쏙 집어넣고 큰 코드포인트만 여러 개의 코드유닛으로 표현하면 메모리 사용시 공간을 절약하게 되기 때문에 인코딩과 디코딩과정이 귀찮지만 UTF8과 UTF16이 존재하는 것입니다. 따라서 용량이 작을수록 유리한 네트웍환경에서는 UTF8이 선호됩니다. 대신 UTF8은 인코딩 디코딩이 너무 자주 발생하기 때문에 중간 타협점이 UTF16입니다. 따라서 프로그램 내부에서 작동할때는 UTF16이 선호되죠. 실제로 자바스크립트 경우도 인터넷에서 파일을 받을 때는 UTF8로 받는 경우가 많지만, 그 받은 파일을 해석하여 메모리에 적재하는 시점에는 UTF16으로 처리합니다. UTF8과 UTF16은 특성이 굉장히 다르기 때문에 차근차근 개별로 살펴보겠습니다. 용량을 아낄 것이냐 귀찮음을 없앨 것이냐의 문제인가.. 보통 메모리와 알고리즘은 교환할 수 있으니까.. ………뭐라고? UTF8 인코딩 이제는 친숙해진 ‘안’ 이라는 한글에 부여된 코드포인트 0xC548을 대상으로 UTF8인코딩 하는 경우를 생각해보죠 ^^ UTF8이란 이름으로 보면 코드유닛이 8비트 즉 1바이트를 다 쓸 수 있을 것 같지만 처음 1비트는 식별기호에 사용되므로 실제로 담을 수 있는 범위는 7비트밖에 되지 않습니다. 따라서 8비트의 절반인 0x7F까지만 담을 수 있습니다. 이 말은 반대로 하면 “코드포인트 0x7F까지는 아무런 인코딩과정없이 코드유닛에 담을 수 있다” 라고 할 수 있습니다. UTF8이 인코딩 과정없이 코드유닛에서 표현할 수 있는 코드포인트는 고작 128개인 셈이죠. 그럼 최초 식별자인 1비트는 무슨 용도일까요? 만약 식별자가 0으로 시작한다면 “이 코드유닛은 하나의 코드유닛으로 코드포인트 하나가 된다” 라는 것을 의미합니다. 이를 2진법으로 시각화하면 0xxxxxxx 로 표현할 수 있습니다. 젤 앞의 0이 식별기호로 사용되고 나머지 7비트에 해당되는 x를 이용하여 값을 표현하기 때문에 2의 7승만큼 표현이 가능하여 0x00 ~ 0x7F(0 ~ 127)까지의 코드포인트를 표현할 수 있게 되는 것입니다. 헌데 우리가 표현하려는 ‘안’의 코드포인트는 무려 0xC548로 0x7F보다 훨씬 큰 값입니다. 이제부터 이 큰 코드포인트를 어떻게 여러개의 코드유닛으로 분리하여 적재하는지를 같이 진행해보려 합니다. 그 전에! UTF인코딩은 기본적으로 2진법의 세계입니다. 따라서 모든 숫자를 2진법으로 생각할 필요가 있습니다. 2진법의 세계에서 1바이트는 8개의 비트로 표시됩니다. 이 점을 처음부터 유의해서 이 후 내용을 보시면 좋습니다. 우선 0xC548을 2진법으로 표현해보죠. 0xC548 = 1100 0101 0100 1000 0xC548은 2바이트(= 16비트)를 완전히 사용하는 큰 숫자입니다. 그럼 어떻게 UTF8의 1바이트 밖에 안되는(식별기호 때문에 더 작지만!) 코드유닛으로 나눌 것인가! 그 방법을 규정한 것이 UTF8이라는 규격의 정체입니다. UTF8에서는 0xC548에 대해 다음과 같이 나누라고 규정되어 있습니다(걍 정해져있는 것입니다. 외워야하는..) 1110xxxx 10xxxxxx 10xxxxxx 뭐 물론 외우는 것이지만 의미를 알고 외우면 조금 낫습니다. 코드유닛 3개를 이용해서 표현하고(위에 1바이트 세 덩어리로 되어있습니다) 첫번째 코드유닛은 1110으로 시작하여 뒤에 xxxx부분만(4bit) 데이터로 사용한다. 두번째 코드유닛은 10으로 시작하고 뒤에 xxxxxx부분만(6bit) 데이터로 사용한다. 세번째 코드유닛도 10으로 시작하고 뒤에 xxxxxx부분만(6bit) 데이터로 사용한다. x부분만 세어보면 총 16비트(4 + 6 + 6)으로 온전히 2바이트를 넣을 수 있게 됩니다. 위의 인코딩 방식에 대해 몇 가지 더 의미를 생각해볼 수 있습니다. 첫번째 코드유닛의 1110은 1이 세 개 있는데, 코드유닛 3개를 동원해서 코드포인트를 나타내겠다는 일종의 헤더입니다. 따라서 디코더는 데이터를 읽어들이다가 1110으로 시작하는 바이트를 만나면 이 코드유닛을 포함하여 세 개의 코드유닛을 한 꺼번에 처리하여 코드포인터를 얻으려고 시도할 것입니다. 두번째 세번째의 코드유닛은 10으로 시작합니다. 10은 이 코드유닛이 코드포인트를 표현하기 위한 연속된 코드유닛 중 하나라는 것을 나타내는 식별기호입니다. 어쨌든 코드유닛3개로 분리하는 위의 방법을 사용하면 총 16비트를 표현할 수 있어 0xC548의 값을 집어넣을 수 있게 됩니다. 이제 차근차근 넣어보죠. 0xC548 1100 0101 0100 1000 인코딩 1110xxxx 10xxxxxx 10xxxxxx x채움 11101100 10010101 10001000 16진수변환 0xEC 0x95 0x88 결국 코드포인트 0xC548은 UTF8 인코딩을 통해 세 개의 코드유닛 0xEC 0x95 0x88 로 변환됩니다. (반대로 코드유닛 0xEC 0x95 0x88 로부터 0xC548를 꺼내서 ‘안’ 이라는 문자를 복원하는 절차는 디코딩에 해당됩니다) …호오, 신기.. UTF8 인코딩 실습하기 위에서 배운 내용을 실제 눈으로 확인하기 위해 실습을 해보죠(윈도우 기준으로 설명합니다) 우선 윈도우메모장을 열어 ‘안’ 이라는 한 글자만 입력한 뒤 저장시에 UTF8옵션을 선택하여 저장합니다. 다음은 저장된 문서를 실제 바이트값으로 확인해야 합니다. 이를 위해서는 바이트로 문서를 볼 수 있는 HEX 에디터가 필요합니다. 무료로 배포되는 버전이 있으니 아래 주소에서 다운로드한 뒤 설치합니다. https://mh-nexus.de/en/hxd/ 설치가 완료되었다면 HxD에서 저장한 문서를 불러옵니다. 다음과 같은 화면을 볼 수 있을 것입니다. 위의 그림에서 앞에 3바이트는 이 문서 UTF8로 인코딩 되어있음을 나타내는 식별기호입니다. 문서가 0xEF 0xBB 0xBF로 시작하면 UTF8 인코딩문서라는 의미가 됩니다. 실제 본문은 4번째 바이트부터인데 이때부터 나오는 4, 5, 6번째의 바이트를 보면 0xEC 0x95 0x88 로 위에서 우리가 직접 인코딩한 결과와 동일하다는 것을 알 수 있습니다. 실제 메모장프로그램은 UTF8문서를 읽어들일 때 이런 바이트를 읽어서 디코딩한 결과로 ‘안’이라는 문자를 보여준 것입니다. 헐! 진짜로 되는 거였다니.. 서로게이트쌍(Surrogate pair) 위에서 최종적으로 계산된 0xEC 0x95 0x88 세 개의 코드유닛은 0xC548 라는 하나의 코드포인트를 나타내기 위해 묶여있는 그룹입니다. 이렇게 하나의 코드포인트를 가리키기 위한 코드유닛의 그룹을 서로게이트쌍(Surrogate pair)라고 부릅니다. surrogate란 뭔가 대리해준다는 뜻이야. 서로게이트쌍이 원래의 코드포인트를 대신해주고 있는 셈이지. UTF8 인코딩의 깊은 의미 위에서 어떤 식으로 인코딩이 일어나 서로게이트쌍이 만들어지는지 직접 봤습니다. 약간 더 깊은 의미를 생각해보죠. UTF8인코딩에서는 코드유닛이 표현할 수 있는 코드포인트는 고작 0x7F까지로 방대한 유니코드를 고려하면 극히 일부만 수용할 수 있습니다. 그 외의 코드포인트는 전부 복잡한 인코딩과정을 거쳐 서로게이트쌍으로 표현해야만 합니다. 인코딩과 디코딩은 전부 계산하는 과정이라 컴퓨터의 연산능력을 사용하게 되고 개발하는 쪽의 복잡함도 늘어납니다. 이럴거면 코드유닛을 좀 크게 잡는 UTF16이나 UTF32쪽이 서로게이트쌍이 발생하는 빈도가 적어서 유리한거 아닌가요? 물론 타당한 생각이지만 여기에는 몇 가지 함정이 숨어있습니다. 0x00 ~ 0x7F범위에 있는 코드포인트는 아스키코드와도 일치합니다. 이 범위에는 숫자와, 주요기호, 알파벳을 포함하므로 많은 데이터가 여기에 속하게 됩니다. 인터넷에서 교환되는 문서의 대부분은 숫자, 영어로 되어있기 때문입니다. 이렇게 생각하면 자주 쓰는 문자(숫자, 영어)는 1바이트만 차지하고 인코딩도 안하죠. 잘 안 쓰는 문자만 인코딩 되므로 그렇게 비효율적이지 않게 됩니다. 이러한 이유로 W3C나 IMC에서는 데이터교환에 있어 UTF8을 사용할 것을 권고하고 있습니다. 데이터가 많이 절약되기 때문입니다. ..음. 진짜로 숫자, 영어가 인터넷에서 교환되는 대부분의 문자인거야? HTML이나 각종 프로토콜이 영어와 숫자를 사용하긴하지만.. 다분히 서양 중심의 사고방식일지도.. UTF8 코드포인트 크기별 인코딩 위의 예에서 살펴본 ‘안’의 코드포인트는 0xC548이었습니다. UTF8은 코드포인트의 크기에 따라 인코딩 방식을 규정하고 있습니다. 코드포인트가 어떤 범위에 소속되냐에 따라 인코딩 방식이 달라집니다. 1바이트구간 0x0 ~ 0x7F : 0xxxxxxx 2바이트구간 0x80 ~ 0x7FF : 110xxxxx 10xxxxxx 3바이트구간 0x800 ~ 0xFFFF : 1110xxxx 10xxxxxx 10xxxxxx 4바이트구간 0x10000 ~ 0x1FFFFF : 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 5바이트구간 0x200000 ~ 0x3FFFFFF : 111110xx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 6바이트구간 0x4000000 ~ 0x7FFFFFFF : 1111110x 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 10xxxxxx 코드포인트의 크기에 따라 1 ~ 6바이트로 인코딩됩니다. 0xC548은 위의 표에서 3바이트 구간에 소속된 코드포인트이므로 3개의 코드유닛으로 서로게이트쌍이 만들어졌습니다. ..서로게이트쌍 첫번째 바이트는 규칙이 비슷하네? 응. 서로게이트쌍이 몇 개인지를 1의 갯수로 표시한뒤 0을 붙이면 되니까. UTF8 인코딩의 약점 앞의 표를 자세히 보면 UTF8의 약점이 나옵니다. 여기서 말하는 약점이란 코드포인트의 크기보다 오히려 인코딩한 결과가 더 커지는 것을 말합니다. 메모리를 효율적으로 사용하려고 UTF8로 인코딩했는데 오히려 원래 코드포인트보다 더 큰 크기를 갖게 되면 이상하죠. 하지만 실제로 ‘안’의 코드포인트는 0xC648이고 0xC6 0x48의 2바이트만 필요한데도 인코딩 결과는 0xEC 0x95 0x88가 되어 3바이트를 차지하게 되었습니다. 즉 코드포인트는 2바이트인데 인코딩 결과는 3바이트가 되어 오히려 용량이 늘어나 버린 것이죠. 각 인코딩 구간별로 꼼꼼히 따져보겠습니다. 1바이트 구간 vs 코드유닛 1바이트 → 동일 2바이트 구간 vs 코드유닛 1~2바이트 → 0xFF까지 1바이트 더 사용. 그 이후는 동일 3바이트 구간 vs 코드유닛 2바이트 → 1바이트 더 사용 4바이트 구간 vs 코드유닛 3바이트 → 1바이트 더 사용 5바이트 구간 vs 코드유닛 3~4바이트 → 0xFFFFFF까지 2바이트 더 사용. 그 이후는 1바이트 더 사용 6바이트 구간 vs 코드유닛 4바이트 – 2바이트 더 사용 위의 비교를 잘 보면 코드포인트가 커질수록 인코딩쪽이 점점 더 비효율적이 되어갑니다. 사실 용량면에서 보건데 최초 1바이트구간을 지나면 무조건 크게 됩니다. UTF8은 정말이지 영어와 숫자에 최적화되어있다고 할 수 있습니다. 우리에게 중요한 한글을 생각해보죠. 한글의 코드포인트는 대부분 0xAC00 ~ 0xD7AF에 위치합니다. 3바이트 구간에 해당됩니다. 3바이트 구간은 무조건 1바이트를 더 사용하게 되는 비효율적인 구간입니다. 따라서 한글만으로 이뤄진 문서가 있다면 UTF8로 인코딩하는 경우 코드포인트로 계산된 공간보다 30%이상 더 큰 문서가 만들어질 것입니다. 그럼 UTF8은 양키들에게만 효율적이라는거야? 양키라기보단 영어지. 근데 한국사람이 만든 문서도 한글만 들어있지는 않으니까. 소설같은건 대부분 한글이잖아. 응. 그럴 땐 UTF8로 저장하는게 분명 비효율적이겠지. UTF8 한글 인코딩의 비효율성 실습 두 번째 실습이니 메모장에 대한 스크린샷은 생략하겠습니다. 우선 메모장에 ‘안녕하세요’ 라고 5자를 적은 뒤 ansi형식으로 저장합니다. 이어서 같은 내용으로 이번에는 UTF8로 저장합니다. 두 개의 파일에 대해 오른버튼을 눌러 속성을 보죠. UTF8헤더를 포함하여 8바이트나 더 커져버렸습니다. 결론 이번 포스팅에서는 유니코드의 인코딩 중 하나인 UTF8을 심층적으로 살펴보고 실습해봤습니다. 다음 시간에는 UTF16을 다루게 됩니다. (이 내용으로 온라인으로 스터디를 진행한 적이 있는데 그 때 유튜브 영상은 아래와 같습니다) 개발++