유니코드 #3 DarkKaiser, 2017년 9월 14일2023년 9월 5일 출처 : http://www.bsidesoft.com/?p=3526& UTF16 인코딩의 개요 1회차에서 유니코드 기본 개념을 살펴보고 2회차에서는 UTF8을 공부했습니다. 이번 포스팅에는 대부분의 응용프로그램 내부에서 사용되는 UTF16을 알아봅니다. UTF8만으로는 안되는 걸까… UTF8은 전송 시에 유리하지만 UTF16은 프로그램 실행 시 유리하니까. 그렇긴 하지만. UTF16의 감을 잡기 위해 브라우저의 자바스크립트가 작동하는 절차에 대해 생각해볼까요. 우리가 작성한 xxx.js 파일은 UTF8로 저장합니다. W3C권장사항이고 최근에는 UTF8이 대세입니다. 브라우저에서는 우선 xxx.js를 읽어들여 UTF8기준으로 디코딩하여 코드포인트를 해석합니다. 해석된 코드포인트를 자바스크립트 엔진에게 전달하면 엔진은 코드포인트를 UTF16으로 인코딩하여 메모리에 적재합니다. “파일용 인코딩”과 “프로그램 내부에 사용하는 인코딩”은 다를 수 있습니다. 파일을 디코딩하고 메모리용으로 다시 인코딩하는 작업이 중복되어 초기 작동 시에는 부담이 되지만, 프로그램이 실행될 때는 UTF16이 다국어 문자에 대해 인코딩, 디코딩이 훨씬 적게 발생하여 실행 속도면에서는 유리하게 됩니다. UTF16은 인코딩이 왜 적게 발생하는지, UTF16 인코딩은 어떻게 하는지 구체적으로 살펴보죠. UTF16 코드유닛 UTF16은 16비트 즉 2바이트를 코드유닛으로 사용하는 인코딩 방식입니다. UTF8에 비해 두 배 이상 큰 코드포인트를 갖고 있으므로 UTF8에서 인코딩 없이 표현 가능한 코드포인트가 불과 0x7F 였던 것에 비해 UTF16은 0평면(기본다국어평면 = BMP)을 전부 표현할 수 있습니다. 따라서 UTF16에서는 0평면 이후의 1 ~ 16평면(아스트랄 평면)만 서로게이트쌍으로 표현하면 됩니다. 한글은 0평면 소속이잖아. 응. 중국어, 일본어, 유럽 여러 나라의 말도 있어. 그럼 0평면에 없는 언어들만 인코딩하는거군. 이거 마치 UTF8이 영어를 편애하는 느낌과 비슷한 걸? 민이는 피해자일 때 뿐만 아니라 가해자일 때도 철저한 걸. UTF16 인코딩 UTF16방식의 인코딩에서 UTF8과 다른 특징은 서로게이트쌍이 오직 2개만 생긴다는 것입니다. 유니코드 전체 범위가 4바이트이기 때문에 UTF16 코드유닛 두 개면 4바이트를 해결할 수 있기 때문입니다. 하지만 이건 식별자가 없을 때의 얘기고 서로게이트를 위한 식별자가 들어가면 16비트보다 더 작은 공간만 사용할 수 있게 됩니다. 어떻게 된 걸까요? 인코딩용 식별 비트가 공간을 차지하면 분명 4바이트를 온전히 쓸 수 없을텐데 말이죠. 이를 이해하기 위해 코드포인트의 최대값에 대해 자세히 따져보겠습니다. 코드포인트의 최대값은 0x10FFFF로 이진수로 표현하면 다음과 같습니다. 1 0000 1111 1111 1111 1111 여태 본문에서 4바이트라고 해왔으나 정확히 21비트입니다. 유니코드의 코드포인트의 최대값은 엄밀히 말해 4바이트가 필요한 것이 아닙니다. 정확하게는 21비트가 필요합니다. 사실 바이트로 보면 3바이트(3 * 8 = 24비트)도 다 안쓰는 셈입니다. 실제 유니코드 표준에서는 “최대 31비트를 갖을 수 있다” 라고 정의하고 있습니다만, 현재 규격화된 최대값은 21비트입니다. UTF16 코드유닛 두 개면 32비트이므로 21비트를 수용하고도 인코딩용 식별자를 넣기 위한 충분한 공간이 있는 셈입니다. 하지만 UTF16인코딩은 21비트를 수용하지 않고 20비트만 수용하는데 그 내용을 차근차근 전개해보겠습니다. 우선 UTF16은 서로게이트쌍을 다음과 같이 생성합니다(코드유닛이 2바이트이므로 서로게이트쌍을 구성하는 각각은 16비트입니다) 110110xx xxxxxxxx | 110111xx xxxxxxxx 위의 인코딩 방식으로 보면 앞에 x가 10개, 뒤에 x가 10개로 총 20비트만 표시할 수 있습니다. 유니코드의 최대값은 21비트인데 20비트만 표현할 수 있는 셈입니다. 1비트만큼 데이터가 모자른건 어떻게 될까요? 우선 코드포인트부터 나눠서 생각해보죠. 0평면은 코드포인트 범위가 0x0000 ~ 0xFFFF 이므로 코드포인트 자체를 코드유닛에 쓰면되고 인코딩 대상이 아닙니다. 1 ~ 16평면이 코드포인트는 각각 0x0000 ~ 0xFFFF 범위의 문자에 대한 값과, 앞에 0x01 ~ 0x10의 평면번호를 갖는 것으로 볼 수 있습니다. 즉 1 ~ 16평면은 다음과 같은 데이터 구조라고 할 수 있습니다. 평면번호 0x01 ~ 0x10 | 문자식별용 0x0000 ~ 0xFFFF 이를 2진법으로 표현해보죠. 00001 ~ 10000 | xxxx xxxx xxxx xxxx 이 구조를 자세히보면 뒷부분의 문자식별용 코드포인트부분은 무조건 16비트입니다만, 앞 부분 평면번호는 최소 1비트에서 최대 5비트까지 차지한다는 사실을 알 수 있습니다. 이렇게 코드포인트의 최대값이 21비트가 되는 것이죠. 문자식별용 16비트는 줄일 방법이 없습니다. 하지만 0평면은 인코딩하지 않는다는 점을 이용해 평면번호를 하나씩 줄일 수는 있습니다. 0번 평면은 처음부터 인코딩대상이 아닙니다. 이를 고려하여 1이 아니라 0부터 평면번호값을 시작할 수 있습니다. 즉 인코딩시에는 0을 1평면, 4를 5평면으로 보는 식으로 하나씩 빼서 생각하면 되죠. 이렇게 1씩만 빼도 최대값이 5비트 10000 에서 1111의 4비트로 내려갑니다. 위의 과정으로 통해 평면번호를 5비트에서 4비트로 줄이는데 성공했습니다. 이제 4 + 16 = 20비트 안에 모든 코드포인트를 표현할 수 있게 되었습니다! 어렵다. UTF8과는 전혀 다른 방법으로 인코딩하는 걸? UTF8은 아스키코드를 중심으로 데이터를 줄이는데 신경 쓴 인코딩이야 그럼 UTF16은? 어떻게든 0평면을 인코딩안하고 처리하기 위한 인코딩이라고 해야할까? UTF16 모순점 곰곰히 검토해보면 여기서 한 가지 논리적 모순을 발견할 수 있습니다. UTF16은 2바이트를 사용합니다. 0평면의 범위는 0x0000 ~ 0xFFFF까지로 2바이트를 완전히 사용하는 범위입니다. 그렇다면 UTF16으로 인코딩된 데이터를 보고 이게 그냥 코드포인트를 나타내는건지 서로게이트쌍을 나타내는건지 구분할 방법이 없습니다. UTF8에서는 0으로 시작하는 경우만 코드포인트고 110, 1110, 11110, 111110, 1111110 등으로 시작하면 서로게이트쌍이 된다는 규칙이 있었습니다. UTF16에서는 110110으로 시작하면 서로게이트쌍의 앞 부분, 110111로 시작하면 서로게이트의 뒷 부분입니다. 그러면 이러한 식별자에 무려 6비트를 낭비하게 되는데 그러면 완전히 0평면을 표현할 수 없는거 아닌가요? 그렇습니다. 반대로 2바이트 코드유닛을 전부 다써서 코드포인트를 표현하면 서로게이트쌍이라는 구분을 할 수가 없습니다. 하지만 110110이나 110111로 6비트를 낭비하면 이번에는 0평면을 인코딩없이 표현할 수 없는 것이죠! 이 모순을 어떻게 해결해야 할까요? BMP의 일부만 수용하고 식별기호를 도입한다(물론 이렇게 안되어있습니다) 좀 과격하지만 아예 BMP 코드포인트 중에 UTF16을 위한 식별기호란 값을 정의해서 문자에 할당하지말고 기호로 쓰는건 어떨까요? 헐! 그럼 유니코드 안에는 문자가 아닌 코드포인트도 있다는거야? 응. 다양한 제어용 문자가 미리 예약되어있어. 뭔가 인코딩이 해결할 문제를 코드포인트로 해결한 거 같아. 날카롭네. UTF인코딩도 표준이니 유니코드 표준이 표준 인코딩을 위한 코드포인트를 할당했다고 생각하면 괜찮지않아? ……그럴리… 유니코드 영역(Unicode Area) 유니코드는 앞 서 배운 평면이라는 큰 단위로 그룹을 지었지만, 평면도 범위가 너무 넓기 때문에 각 문자종류별로 묶어서 영역(Area)을 지정해둡니다. 따라서 코드포인트는 평면에 소속되고 동시에 작은 그룹인 특정 영역에 소속됩니다. 예를 들어 대부분의 한글이 소속된 영역은 BMP안에서도 0xAC00 ~ 0xD7AF범위의 영역으로 공식명칭은 “Hangul Syllables” 입니다. 유니코드협회에서는 각 영역별로 표준문자표를 PDF로 배포하기도 합니다. http://unicode.org/charts/PDF/UAC00.pdf 이렇듯 특정 목적으로 문자들을 모아둔 것들을 영역이라고 합니다. 평면으로 관리하기엔 너무 방대하다고 생각했어. 영역도 여러 나라 언어가 포함된 스크립트(Scripts)와 기호와 제어문자가 들어있는 심볼(Symbols)로 나눌 수 있어. 괴롭히는거지? 날카롭네. 서로게이트 영역(Surrogate Area) UTF16인코딩이 사용하는 특수한 값을 다시 생각해보죠. 앞 부분 : 110110xx xxxxxxxx 뒷 부분 : 110111xx xxxxxxxx 이 인코딩 방식에서 서로게이트쌍의 앞 부분의 최소 값은 11011000 00000000(0xD800)이 되고 최대 값은 11011011 11111111(0xDBFF)되며 뒷 부분의 최소 값은 11011100 00000000(0xDC00)이 되고 최대 값은 11011111 11111111(0xDFFF)이 됩니다. 즉 서로게이트쌍의 앞 부분은 0xD800 ~ 0xDBFF 사이의 값으로 표현되고 뒷 부분은 0xDC00 ~ 0xDFFF 사이의 값이 됩니다. 유니코드는 이 범위 코드포인트에 어떠한 문자도 할당하지 않고 UTF16 인코딩을 위한 영역으로 지정했습니다. 앞 부분 0xD800 ~ 0xDBFF 사이의 영역을 상위 서로게이트 영역(High Surrogate Area)이라 부릅니다. 뒷 부분 0xDC00 ~ 0xDFFF 사이의 영역은 하위 서로게이트 영역(Low Surrogate Area)이라 부릅니다. 이렇듯 UTF16 인코딩을 위한 전용 영역이 0평면 내에 별도로 범위로 할당되어 있어, 이 영역에 속한 문자는 없으므로 0평면의 모든 문자를 인코딩 없이 코드유닛에 담는 것이 가능하게 됩니다. 인코딩시 6비트나 식별자로 사용하는 이유 직관적으로 보면 21비트를 20비트로 줄이는 꼼수를 쓰기보단 처음부터 인코딩을 아래와 같이 하면 더 좋지 않을까요? 11010xxx xxxxxxxx | 11011xxx xxxxxxxx 인코딩용 식별기호를 5비트만 쓰면 22비트까지도 표현할 수 있기 때문에 구태여 평면 영역에서 1을 빼는 추가연산을 하지 않아도 되겠죠. 하지만 5비트만 쓰게 되면 6비트로 제한할 때보다 서로게이트영역이 넓어져 버립니다. 5비트인 경우 서로게이트영역으로 차지하게 되는 공간은 앞 부분 : 0xD000 ~ 0xD7FF 뒷 부분 : 0xD800 ~ 0xDFFF 총 0xD000 ~ 0xDFFF가 되어 앞에 6비트를 식별자로 쓸 때의 0xD800 ~ 0xDFFF보다 0x800(2048개)만큼 더 큰 코드포인트 범위를 단지 인코딩을 위한 공간으로 버려야합니다. 인코딩 식별비트를 늘릴 수록 0평면에서 문자에 할당할 수 있는 코드포인트가 줄어드는 셈입니다. 따라서 짜내고 짜내서 20비트까지 최소한으로 가용공간을 만들고 6비트 식별비트까지 늘린거죠. 새삼 0평면에 인코딩을 위한 영역을 잡는다는 발상이 대단해. UTF16은 나중에 정의된 인코딩이라 그 사이의 노하우가 모여있어. 응? UTF가 표준 인코딩이라며, 다른 것도 있었어? 현재 표준은 UTF지만 그 전에는 ISO2022라던가 UCS2, UCS4 같은 게 있었어. 설마 그것도 배워야 해? 아닐지아닐지도 ^^ 괴롭히는거지? 바이트 시퀀스(Byte Sequence) UTF8은 코드유닛이 1바이트입니다. 해서 바이트의 순서라는 개념이 존재하지 않습니다. 하지만 UTF16은 2바이트를 코드유닛으로 사용하죠. 즉 2개의 바이트가 하나의 코드유닛을 가리키는 것입니다. 서로게이트쌍은 코드포인트를 가리키는 코드유닛의 집합니다. 하지만 이번에 다루는 문제는 코드유닛을 이루는 바이트의 집합이죠. 기존에 코드포인트와 코드유닛 차원의 관계를 다뤘다면, 이번에는 좀 더 미시적인 코드유닛 안의 바이트들을 바라볼 차례입니다. UTF16의 경우는 하나의 코드유닛에 2개의 바이트가 들어가 있고 UTF32의 경우는 4개의 바이트가 들어있습니다. 이렇듯 하나의 코드유닛을 구성하는 바이트들을 바이트시퀀스라고 합니다. 바이트시퀀스도 외우는거지? 민이, 정줄 놔버린 느낌 ^^; 하핫, 전혀 괜찮아. 바이트 순서 마크(BOM) UTF16 이상에서 발생하는 바이트시퀀스에는 중요한 문제가 있습니다. “바이트의 순서를 어떻게 볼 것인가?” 라는 문제입니다. 아니 당연히 0xABFF 가 있으면 0xAB와 0xFF를 순서대로 인식하면 되는거 아닌가 생각이 됩니다. 이러한 순서대로 조립하여 원래 값이 0xABFF를 복원하는 방식을 빅엔디언(Big endian)이라고 합니다. 빅엔디언 방식은 빅, 즉 큰 쪽, 즉 자릿수가 높은 쪽이 먼저 나오는 방식입니다. 0xABFF에서 자릿수가 높은 쪽은 AB쪽입니다. 따라서 0xAB, 0xFF 순서대로 바이트를 배치하는 방식이 빅엔디언이 됩니다. 만약 반대인 리틀엔디언(Little endian)방식으로 0xABFF를 저장하면 어찌될까요? 자릿수가 낮은 쪽부터 나오게 되니 0xFF 0xAB 순서대로 바이트가 나열될 것입니다. ‘안녕하세요’로 예를 들어 이해해보죠. 우선 ‘안녕하세요’의 코드포인트는 0xC548, 0xB155, 0xD558, 0xC138, 0xC694 입니다. 빅엔디언으로 바이트를 나열하면 저 순서 그대로 나열할 수 있습니다. 결과적으로 C5 48 B1 55 D5 58 C1 38 C6 94 로 정리됩니다. 리틀엔디언이라면 0xC548은 48 C5로 0xB155는 55 B1으로 각각 앞 뒤가 역전될 것입니다. 결과적으로 48 C5 55 B1 58 D5 38 C1 94 C6 으로 정리됩니다. 이렇게나 엔디언 방식에 따라 실제 만들어지는 바이트의 형태가 달라집니다. 바이트 순서 마크(이하 BOM)이란 바로 현재 이 데이터가 빅엔디언인지 리틀엔디언인지 나타내는 표식입니다. 약자로 빅엔디언은 BE, 리틀엔디언은 LE로 나타냅니다. UTF8은 바이트시퀀스가 존재하지 않기 때문에 이러한 BOM이슈가 없어 파일에 저장할 때 별다른 문제가 없습니다. 하지만 UTF16이상에서는 반드시 BOM을 명시해야만 파일에 쓸 때나 읽을 때 정상적으로 처리할 수 있습니다. BOM의 문제로 1. 파일에 저장하는 경우 BOM이슈가 없는 UTF8이 선호되고 2. UTF16, 32는 파일저장보다는 메모리상에서 구동할 때 인코딩 이슈가 적기 때문에 선호됩니다. 빅엔디언이 정상으로 보이는데 왜 리틀엔디언도 쓰는거야? 글쎄 ^^; 누가 리틀엔디언을 쓰는건데? 인텔, 마이크로소프트 등이야. 이런 썩을! 민이, 정줄 놔버린 느낌 ^^; BOM 바이트 시퀀스 BOM이 무엇인지 이해했으니 실제 BOM의 종류를 알아보죠. 아래 표에서 빅엔디언은 BE로 리틀엔디언은 LE로 표시합니다. UTF8 : EF BB BF UTF16 BE : FE FF UTF16 LE : FF FE UTF32 BE : 00 00 FE FF UTF32 LE : FF FE 00 00 UTF8은 원래 BOM이슈가 없으니 그저 UTF8이라는것만 인식하는 식별자로 쓰이는데 실은 여러분은 1회차 포스팅의 실습편에서 이 코드를 본 적이 있습니다. UTF16과 UTF32는 각각 LE와 BE에 대해 별도의 식별자를 정의해두고 있습니다. 이렇듯 어떤 종류의 BOM인지를 가리키는데 2 ~ 4 바이트를 사용합니다. BOM을 나타내기 위한 바이트들의 묶음을 “BOM 바이트 시퀀스”라 합니다. UTF16 실습 이번에도 메모장을 열어 “안녕🠝” 를 카피하여 붙여넣습니다. 우선 메모장에서 일어나는 일을 확인해보죠. 위와 같이 보인다면 정상입니다. 정상이긴한데 왜 이런지 생각해보죠. 우선 “안녕”은 잘보입니다. 이 두글자의 코드포인트는 0xC548 0xB155 입니다. 즉 0평면(BMP)에 소속된 문자입니다. 이에 비해 🠝의 코드포인트는 0x1F81D 입니다. 1평면에 소속된 문자입니다. 실제 폰트 안에 1평면 코드포인트에 대한 폰트모양이 들어있어도 네모 두 개로 표시됩니다(서로게이트쌍) 이를 통해 메모장은 0평면은 제대로 표시하지만 1평면부터는 제대로 표시할 수 없다는 걸 알 수 있습니다. 윈도우의 기본 메모장은 0평면만 제대로 표시하는 앱입니다. 그렇다고 내부적으로 UTF16인코딩을 처리하지 않는 것은 아닙니다. 이제 인코딩을 “유니코드(big endian)”으로 선택한 뒤 저장합니다. 그리고 HxD로 불러봐보면 다음과 같은 화면을 볼 수 있습니다. 처음 나오는 빨간박스 영역은 BOM바이트시퀀스로 UTF16 빅엔디언이므로 위 표에서 살펴본바대로 FE FF가 나옵니다. 초록색박스 두 개는 각각 안, 녕의 코드포인트값으로 빅엔디언이므로 0xC548 0xB155를 순서대로 C5 48 B1 55로 표시하고 있습니다. 마지막 파란 박스는 1평면에 소속된 🠝를 서로게이트쌍으로 인코딩한 값입니다. BOM바이트 시퀀스와 0평면문자의 BOM은 확인했으니 마지막 1평면의 🠝를 실제로 인코딩해보죠. 🠝의 코드포인트 : 0x1F81D 이진수화 : 0001 1111 1000 0001 1101 서로게이트쌍 : 110110xx xxxxxxxx | 110111xx xxxxxxxx 채우기 : 11011000 01111110 | 11011100 00011101 16진수화 : D8 3E | DC 1D 파란 박스 내부의 값과 정확히 일치합니다. 즉 화면에 0평면을 넘어가는 문자를 제대로 표시하지는 못하지만 UTF16으로 저장시 올바르게 인코딩처리함을 알 수 있습니다(브라우저처럼 1평면 이상을 잘 보여주는 앱은 의외로 드문 편입니다 ^^) 이번에는 “유니코드”를 선택하여 저장합니다. 이 경우 리틀엔디언으로 저장됩니다. 그리도 다시 HxD로 열어보죠. 빨간 BOM바이트시퀀스는 UTF16 리틀엔디언용인 FF FE가 되었습니다. 초록색박스들은 바이트시퀀스가 역전되어 0xC548이 48 C5로 표시됨을 알 수 있습니다. 파란색 영역의 🠝 서로게이트쌍도 0xD83E, 0xDC1D가 각각 바이트시퀀스가 역전되어 3E D8과 1D DC가 되어있습니다. 마지막으로 UTF8로 저장해 UTF16과 용량을 비교해보죠. 본문의 내용이 한글 및 1평면문자로 구성되어 있어 UTF8쪽의 비효율성이 도드라집니다. 오히려 0평면에서는 2바이트만 사용하는 UTF16쪽의 용량이 더 작습니다. 결론 이번 3회차에서는 UTF16을 공부했습니다. UTF8과 UTF16은 매우 다른 컨셉으로 만들졌습니다. UTF8은 역사적으로 볼 때 기존 UCS2, UCS4방식의 인코딩규격이 BOM을 정의하지 않아서 생기는 수많은 혼돈을 해결하고자 아예 BOM이 필요없는 1바이트 단위의 표현이 가능한 인코딩방식으로 설계되었습니다. 이에 비해 UTF16은 UCS2를 계승하면서도 BOM을 명시하고 16비트가 넘어가는 유니코드를 인코딩할 수 있도록 만들어졌습니다. UTF8이 파일이나 네트웍에서 교환되는 표준으로 지정되는 이유는 단지 영문자에 대한 메모리 효율성 때문이 아니라 BOM이슈가 없다는 것도 큽니다. 개발++