직접 만들어보는 UTF-16과 UTF-32

구현으로 알아보는 UTF 인코딩과 그 차이점

이전 글에서 유니코드Unicode와 UTF-8을 살펴봤습니다. 그 중에 UTF-8는 8비트를 기본 단위로 하는 인코딩이라는 것과, 그 단위를 코드 유닛code unit이라고 부른다는 것을 언급했습니다.

이런 점에서 볼 때, UTF-16은 코드 유닛이 16비트일 뿐 전체적인 방식은 UTF-8와 크게 다르지 않습니다. 그리고 UTF-32 또한 마찬가지입니다. 즉 UTF-8를 알고 있다면 나아가 UTF-16과 UTF-32를 이해하기가 수월해집니다.

앞으로의 내용을 통해, UTF-16과 UTF-32가 어떻게 구현되고 어떤 점이 서로 다른지 구체적으로 알게 될 것입니다. 이번에도 인코딩과 디코딩 함수를 직접 만들어보며 알아보겠습니다.

서로게이트 블록

UTF-16 구현에 앞서 짚고 넘어가야 할 것이 있는데, 첫 번째 플레인 BMP에 있는 서로게이트surrogate 블록입니다.

이전 글에서 모든 코드 포인트는 플레인으로 나뉘어 있고, 그 플레인은 다시 블록으로 나뉜다고 소개했습니다. 길게 얘기를 늘어놓긴 했지만, 간단히 말하면 일부 코드 포인트 범위가 서로게이트 블록이라고 불린다는 뜻입니다.

이 블록을 알아야 하는 이유는, 이 블록은 16비트 코드 유닛을 두 개 사용할 것이라는 일종의 신호 역할을 맡기 때문입니다.

Surrogate block in BMP
그림 1. BMP 내 서로게이트 블록. 특수한 용도로 예약되어 있습니다.

곧 보겠지만, UTF-16은 16비트로 코드 포인트를 충분히 표현할 수 있으면 그대로 16비트를 사용합니다. 하지만 16비트가 충분하지 못한 경우에 서로게이트 블록을 이용해 32비트를 쓰는 것입니다.

서로게이트 블록이 왜 하필이면 U+D800부터 U+DFFF까지의 범위를 갖는지 그 이유가 곧 밝혀질 것입니다.

UTF-16 인코딩 만들기

UTF-16 인코딩은 코드 포인트를 16비트로 표현할 수 있다면, 그 이진 표현binary representation을 그대로 인코딩해 하나의 코드 유닛을 만듭니다. 하지만 유니코드는 2212^{21} 개의 문자를 갖고 있으므로 총 21비트가 필요하기 때문에, 더 많은 비트가 필요한 경우도 생깁니다. 이런 경우에는 두 개의 코드 유닛에 나눠 담습니다. 거의 비슷한 방식을 UTF-8 또한 사용하므로 이해가 어렵지는 않을 것입니다.

utf-16 encoding
그림 2. UTF 16 인코딩. 16비트로 표현할 수 있다면, 그 이진 표현을 그대로 사용합니다. 더 많은 비트가 필요하다면, 두 16비트 공간에 나누어 담습니다. 이 두 공간을 서로게이트라고 부릅니다.

두 개의 코드 유닛이 필요한 경우, 코드 포인트의 21비트를 적절히 세 부분으로 나누어 인코딩합니다. 이 두 코드 유닛을 서로게이트 페어surrogate pair라고도 부릅니다. 그 중 첫 번째 것을 하이 서로게이트high surrogate, 두 번째를 로우 서로게이트low surrogate라고 부릅니다.

각 코드 유닛에 예약된 앞 6비트를 보면, 서로게이트 블록이 왜 U+D800부터 U+DFFF까지인지를 알 수 있을 것입니다. (한번 계산해보세요.)

수도코드 및 구현

앞서 소개한 UTF-16 인코딩을 수도코드로 정리하면 다음과 같습니다.

UTF-16 인코딩 (코드 포인트)

만약 코드 포인트가 16비트 이하 값이면
리턴 코드 포인트를 그대로 갖는 바이트 시퀀스

 

만약 코드 포인트가 21비트 이하 값이면

x xxxx yyyy yyzz zzzz zzzz \leftarrow 코드 포인트의 이진 표현

리턴 바이트 시퀀스 1101 10ww wwyy yyyy 1101 11zz zzzz zzzz

 

리턴 U+FFFD

이 수도코드를 그대로 옮기다시피 해서 실제로 구현해볼 수 있습니다. 저수준의 비트 조작을 많이 쓸 것이기 때문에 C++로 예를 들면 다음과 같습니다.

typedef uint32_t Codepoint;

void encodeUtf16(const Codepoint cp, char16_t *buf) {
  bool within_16bits = cp < (1 << 16);
  if (within_16bits) {
    buf[0] = cp;
    buf[1] = '\0';

    return;
  }

  bool within_21bits = cp < (1 << 21);
  if (within_21bits) {
    Codepoint x = (cp & 0b1'1111'0000'0000'0000'0000) >> 16;
    Codepoint y = (cp & 0b0'0000'1111'1100'0000'0000) >> 10;
    Codepoint z = (cp & 0b0'0000'0000'0011'1111'1111);
    Codepoint w = x - 1;

    buf[0] = 0b1101'1000'0000'0000 | (w << 6) | y;
    buf[1] = 0b1101'1100'0000'0000 | z;
    buf[2] = '\0';

    return;
  }

  // return U+FFFD in UTF-16
  buf[0] = 0xFFFD;
  buf[1] = '\0';
}

UTF-8 인코딩에서 구현했던 것처럼, 리턴값은 buf 매개변수로 전달합니다. 리턴값 마지막의 널 문자 \0는 출력의 편의를 위해 붙였습니다.

반대로 UTF-16 인코딩 결과로부터 코드 포인트를 얻는 디코딩 함수는, UTF-8에서 했던 것과 크게 다르지 않습니다. 이 부분은 직접 해보는 것으로 남겨두고 넘어가겠습니다.

UTF-16의 사용

UTF-16은 대표적으로 자바스크립트JavaScript와 같은 언어에서 사용하고 있습니다. 하나의 코드 유닛으로 표현할 수 있는 한글 문자는 length 값이 1인 반면, 두 개의 코드 유닛이 필요한 이모지는 그 값이 2가 됩니다.

"한".length; // === 1
"😂".length; // === 2

여기서 볼 수 있듯이, UTF-16 또한 UTF-8처럼 코드 유닛 개수와 문자 개수가 일치하지는 않습니다. 즉 length는 문자의 개수가 아니라 코드 유닛의 개수를 가집니다.

UTF-8 과의 비교

한글 문자를 표현할 때 UTF-16과 UTF-8 중에 어느 것이 메모리에 더 효율적일까요?

한글 문자는 유니코드에서 U+AC00부터 U+D7AF까지의 코드 포인트에 할당되어 있으므로, 16비트가 필요함을 의미합니다. 따라서 UTF-8은 세 개의 코드 유닛, 즉 3바이트로 인코딩하지만, UTF-16은 하나의 코드 유닛, 즉 2바이트로 인코딩합니다.

그러므로 한글 문자는 UTF-16이 유리하다고 볼 수 있을 것입니다. 물론 그 차이가 정말 의미가 있는지, 그리고 다른 언어와 함께 사용할 때 어떨지는 다른 문제입니다.

UTF-32의 구현

UTF-32는 구현하기가 비교적 간단합니다. 앞서 언급했듯 유니코드의 모든 문자는 기껏해야 21비트의 코드 포인트를 가집니다. 그런데 UTF-32는 코드 유닛이 32비트이므로, 항상 하나의 코드 유닛에 담아 인코딩할 수 있습니다.

이 내용을 수도코드로 굳이 표현하자면 다음과 같습니다.

UTF-32 인코딩 (코드 포인트)

리턴 코드 포인트를 그대로 갖는 바이트 시퀀스

수도코드는 C++로 다음과 같이 구현할 수 있습니다.

void encodeUtf32(const Codepoint cp, char32_t *buf) {
  buf[0] = cp;
  buf[1] = '\0';
}

반대로 코드 유닛을 받아 코드 포인트를 구하는 디코딩 함수는 이번에도 직접 해보는 것으로 남겨두겠습니다. 여기까지 모든 코드는 디코딩을 포함해 지스트Gist에서 확인할 수 있습니다.

UTF-32의 사용

UTF-32의 단점은 무엇일까요? 최대 21비트의 코드 포인트를 위해 매번 32비트를 쓰기 때문에, 적어도 11비트가 항상 낭비된다는 것입니다.

장점은 무엇이 있을까요? UTF-8이나 UTF-16과는 달리, 코드 유닛의 개수가 곧 코드 포인트의 개수가 된다는 점인데요. 따라서 UTF-32로 인코딩했다면, 임의의 nn번째 코드 포인트에 바로 접근이 가능하게 됩니다.

그렇다면 nn번째 문자에도 바로 접근이 가능할까요? 이전 글에서 문자의 개수가 코드 유닛의 개수와 일치하지 않는 문제를 언급했고, 유니코드의 설계상 문제이므로 UTF-32 또한 피할 수는 없습니다. (구체적인 예는 이전 글의 노말라이즈 문제를 참고하세요.)

마치며

유니코드와 모든 UTF 인코딩을 알아보았습니다. UTF 인코딩이란 유니코드에 정의된 코드 포인트를 비트로 표현하는 방법이었고, UTF-8, UTF-16, 그리고 UTF-32가 구체적으로 어떻게 다른지 구현을 통해 살펴보았습니다.

문자 인코딩의 간략한 역사

UTF-8은 하위 호환성backward compatibility을 유지하면서 발전을 보인 좋은 예시입니다. 초창기에 만들어져 널리 쓰인 아스키ASCII 인코딩은 영문자를 비롯해 여러 특수문자를 7비트로 충분히 표현했지만, 머지 않아 다른 언어를 포함하기에는 비트가 부족하다는 것이 분명해졌습니다.

대안으로 8비트로 확장한 아스키extended ASCII가 나오기도 했지만, 16비트면 모든 문자를 표현하기에 충분하다는 생각이 한때 퍼졌던 것 같습니다. 그래서 초창기 유니코드 또한 16비트로 고안되었고, 자바스크립트를 비롯한 여러 프로그래밍 언어 또한 이를 기반으로 했으며, 윈도우 운영체제는 이런 생각이 와이드 캐릭터wide character라는 이름으로 남아있습니다. (윈도우 상에서 C++로 프로그래밍할 때 만나게 되는 WCHAR라는 데이터 타입이 바로 그것입니다.)

하지만 16비트로도 충분하지 못하다는 것이 다시 분명해졌고, UTF-16은 서로게이트 페어가 그 대안이었습니다. 한편, UTF-8은 시기 상 UTF-16보다 먼저 표준이 제정되기는 했지만, 웹 페이지에서 점유율이 증가하며 현재 사실상의 표준으로 이르게 된 것은 나중입니다.

UTF-8은 하위 호환성으로 아스키를 지원하기 때문에, 영문자를 기준으로 메모리 사용량에 있어서는 UTF-16보다 유리합니다. 그냥 아스키와 다를 바가 없으니까요. 그런데 웹은 HTML이나 CSS, JavaScript 같은 리소스가 마침 거의 영문자이므로, UTF-8이 우세해진 것은 아마 시간 문제였을지도 모릅니다.

UTF-8과는 정반대라고 볼 수 있는 UTF-32는 앞서 살펴봤듯 인코딩과 디코딩이 아주 간단하지만 메모리를 더 차지합니다. 하지만 웹은 UTF-8을 선택해서 인코딩과 디코딩에 드는 컴퓨팅 자원을 더 쓰는 대신 트래픽 사용량을 줄였습니다. 알고리즘에서 흔하게 나타나는 시간과 메모리 공간 사이의 트레이드 오프trade-off인 것입니다.

바이트 오더

본문에서 생략한 주제로, 인코딩에서 바이트 오더byte order, 즉 엔디안endianness 문제가 있습니다. 사실 UTF-16 인코딩은 각 엔디안을 위한 UTF-16LE, UTF-16BE 인코딩이 따로 존재합니다. 아니면 바이트 오더 마크byte order mark, 즉 BOM 문자를 맨 앞에 둬서 바이트 순서를 나타내는 방법도 있습니다. 이 모든 얘기는 UTF-32 인코딩도 마찬가지입니다. 따라서 실제로 UTF 인코딩을 처리하는 일은 이런 케이스를 고려할 필요가 있습니다.

레퍼런스

  • Unicode Explained (Jukka Korpela, 2006)