UTF-8 구현하기

구현으로 알아보는 유니코드와 UTF-8

UTF-8은 문자 인코딩에서 자주 마주치게 되는 용어입니다. 벨Bell 연구소에서 유닉스Unix의 성공에 이어 발표한 Plan 9이란 운영체제는 당시 리눅스에 밀리고 말았지만, UTF-8이라는 유명한 유산을 남기고 갔는데요. 이것은 2023년에 이르러 웹에서 약 98%의 점유율을 가질 정도로 퍼지게 되었습니다.

그런데 이 UTF-8 인코딩은 어떤 방식으로 문자를 표현하고 있을까요? 다른 인코딩 방식에 비해 나은 점은 뭘까요? 그리고 유니코드Unicode와는 어떤 관계가 있을까요?

이 글에서는 UTF-8를 직접 구현해보며 알아보겠습니다. 이를 통해 영어와 한글, 이모지emoji를 포함해 유니코드에서 2212^{21}개에 달하는 문자를 UTF-8로 완전하게 커버할 수 있게 됩니다.

매핑으로서 바라보는 유니코드와 UTF-8

문자 ‘A’가 있다고 생각해봅시다. 이것이 비트로 어떻게 표현되는지는 일단 머릿속에서 지우고, 추상적인 한 심볼symbol로서 떠올려봅시다.

유니코드는 이런 추상적인 심볼에서 비트 표현에 이르기까지 여러 단계로 구분하고 있는데요. 여기서는 대신 간단히 두 단계의 매핑mapping에 집중하겠습니다.

unicode-utf mapping
그림 1. 유니코드와 UTF-8 매핑. 유니코드는 문자마다 하나의 코드 포인트를, UTF-8 인코딩은 코드 포인트마다 하나의 비트 표현을 매깁니다.

하나는 심볼로서의 문자를 숫자에 대응시키는 매핑인데요. 이 숫자를 코드 포인트code point라고 부릅니다. 유니코드가 이러한 매핑입니다.

예를 들어, 유니코드는 그리스 문자 ‘α’에 945번이라는 코드 포인트를 매겨놓습니다. 이를 16진법을 이용해 U+03B1로 표기하기도 합니다. 뒤에서 보겠지만 유니코드는 총 2212^{21}개의 코드 포인트를 가지는데요. 이것이 그런 코드 포인트 중 하나가 됩니다.

또 다른 하나는 이 코드 포인트에서 구체적인 비트 표현으로 대응시키는 매핑입니다. 그 비트 표현을 코드 유닛 시퀀스code unit sequence라고 부릅니다. UTF-8은 이러한 매핑 중 하나입니다. 이름에서 8은 코드 유닛 시퀀스가 8비트임을 의미하는데요. 즉 8비트 단위로 시퀀스를 나열하기 때문에, 인코딩된 결과는 8비트, 16비트, 24비트와 같이 8비트의 배수만큼 길어집니다. 이 의미는 구현할 때 좀더 분명해질 것입니다.

정리하면 유니코드는 문자와 코드 포인트 사이의 매핑이고, UTF-8 인코딩은 코드 포인트와 비트 표현 사이의 매핑입니다. 이런식으로 문자는 비트 표현을 갖게 됩니다. 그리고 컴퓨터는 이를 이용해 문자 데이터를 읽고 쓸 수 있게 됩니다.

직접 구현해보는 UTF-8 인코딩

문자와 코드 포인트 간의 관계는 이미 유니코드에 정해져있습니다. 따라서 코드 포인트가 주어졌을 때 그것을 바이트 시퀀스byte sequence, 즉 바이트의 나열로 바꾸는 함수를 구현하면 됩니다.

문자를 비트로 표현하는 이 과정은 인코딩encoding이라고 불리는데요. UTF-8로 인코딩하는 방법 또한 이미 정해져있기 때문에 그것을 구현하기만 하면 됩니다.

인코딩 방법

UTF-8 인코딩은 코드 포인트의 이진 표현binary representation이 몇 비트를 차지하느냐에 따라 케이스를 나눕니다. 구체적으로는 네 개의 케이스가 있는데요. 필요한 비트가 많을수록 더 많은 바이트에 나눠 담습니다.

Utf-8 encoding
그림 2. UTF-8 인코딩. 코드 포인트의 이진 표현 길이에 따라 UTF-8의 비트 표현이 달라집니다.

예를 들어, 그리스 문자 ‘α’의 코드 포인트는 945이고, 이는 이진 표현으로 011 1011 0001입니다. 따라서 두 개의 바이트로 나눠 담고, 인코딩 결과는 1100 1110 1011 0001, 즉 16진수로 CE B1이 됩니다.

각 바이트의 앞자리에는 정해진 비트 패턴이 있는데요. 이것 덕분에 각 바이트가 어떤 의미를 가지는지, 그리고 어느 바이트에서 읽기를 시작하든지 그 의미를 분명하게 알 수 있습니다.

prefixes in utf-8
그림 3. UTF-8 바이트 비트 패턴. 앞에 붙은 비트 패턴이 그 바이트의 의미를 정합니다.

예를 들어, 110으로 시작하는 바이트는, 그 뒤에 10으로 시작하는 바이트가 두 개 따라온다는 의미를 가집니다. 한편 0으로 시작하는 바이트는 나머지 7개의 비트가 곧 인코딩 결과가 되는데요. 이 7비트는 곧 ASCII 인코딩을 그대로 따르게 됩니다. 유니코드가 처음 127개의 문자에 대한 코드 포인트를 ASCII 코드와 동일하게 배치했기 때문입니다.

UTF-8는 바이트를 네 개까지 사용하면 2212^{21}개의 문자를 표현할 수 있는데요. 마침 유니코드 또한 그만큼, 즉 2212^{21}개의 코드 포인트를 명시하고 있습니다. 따라서 유니코드의 모든 문자를 UTF-8로 표현할 수 있게 됩니다.

인코딩 구현: 수도코드

아래 수도코드pseudocode는 코드 포인트를 나타내는 숫자를 입력받고 그것을 UTF-8로 인코딩한 바이트 시퀀스를 리턴합니다.

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

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

xxx xxxx \leftarrow 코드 포인트의 이진 표현

리턴 바이트 시퀀스 0xxx xxxx

 

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

yyy yyxx xxxx \leftarrow 코드 포인트의 이진 표현

리턴 바이트 시퀀스 110y yyyy 10xx xxxx

 

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

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

리턴 바이트 시퀀스 1110 zzzz 10yy yyyy 10xx xxxx

 

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

w wwzz zzzz yyyy yyxx xxxx \leftarrow 코드 포인트의 이진 표현

리턴 바이트 시퀀스 1111 0www 10zz zzzz 10yy yyyy 10xx xxxx

 

리턴 U+FFFD

맨 아래에서는 21비트를 넘어가는 코드 포인트를 만나는 예외적인 경우를 처리하는데요. 이때는 특별한 문자인 ‘�’를 리턴합니다. U+FFFD로 정의된 이 문자는 대체 문자replacement character라고 불립니다.

구현 예시: C++

비트를 조작하는 저수준 작업이 많이 때문에, 구현 언어로 C++를 선택해볼 수 있습니다. 물론 다른 선호하는 언어로도 구현할 수 있을 것입니다.

Codepoint 타입을 32비트 정수 타입으로 선언하고, 앞서 소개한 수도코드를 따라 다음처럼 만들 수 있습니다.

typedef uint32_t Codepoint;

void encodeUtf8(const Codepoint cp, char8_t *buf) {
  bool within_7bits = cp < (1 << 7);
  if (within_7bits) {
    buf[0] = cp;
    buf[1] = '\0';

    return;
  }

  bool within_11bits = cp < (1 << 11);
  if (within_11bits) {
    Byte y = (cp & 0b111'1100'0000) >> 6;
    Byte x = (cp & 0b000'0011'1111);

    buf[0] = 0b1100'0000 | y;
    buf[1] = 0b1000'0000 | x;
    buf[2] = '\0';

    return;
  }

  bool within_16bits = cp < (1 << 16);
  if (within_16bits) {
    Byte z = (cp & 0b1111'0000'0000'0000) >> 12;
    Byte y = (cp & 0b0000'1111'1100'0000) >> 6;
    Byte x = (cp & 0b0000'0000'0011'1111);

    buf[0] = 0b1110'0000 | z;
    buf[1] = 0b1000'0000 | y;
    buf[2] = 0b1000'0000 | x;
    buf[3] = '\0';

    return;
  }

  bool within_21bits = cp < (1 << 21);
  if (within_21bits) {
    Byte w = (cp & 0b1'1100'0000'0000'0000'0000) >> 18;
    Byte z = (cp & 0b0'0011'1111'0000'0000'0000) >> 12;
    Byte y = (cp & 0b0'0000'0000'1111'1100'0000) >> 6;
    Byte x = (cp & 0b0'0000'0000'0000'0011'1111);

    buf[0] = 0b1111'0000 | w;
    buf[1] = 0b1000'0000 | z;
    buf[2] = 0b1000'0000 | y;
    buf[3] = 0b1000'0000 | x;
    buf[4] = '\0';

    return;
  }

  buf[0] = 0xEF;
  buf[1] = 0xBF;
  buf[2] = 0xBD;
  buf[3] = '\0';
}

비트 조작을 통해 간단히 구현할 수 있는데요. 수도코드는 바이트 시퀀스를 리턴했지만, 여기서는 함수 호출의 사이드 이펙트side effect로서 매개변수 buf에 리턴값을 전달합니다. 여기서 리턴값 마지막의 널null 문자 \0는 나중에 출력의 편의를 위해 임의로 붙인 것입니다.

실행해보기

만든 함수가 잘 동작하는지 확인해볼 수 있는데요.

int main() {
  char8_t buf[5];
  encodeUtf8(0x1F602, buf);
  printf("%s\n", buf); // result: 😂
}

실제로 코드 포인트 U+1F602에 해당하는 문자 ‘😂’가 다음과 같이 터미널에 출력됩니다.

😂

한편, 터미널이 바이트 시퀀스를 이해하고 화면에 렌더하는 것은 별개의 일입니다.

  1. 폰트가 그 코드 포인트에 해당하는 문자를 지원하지 않을 수 있습니다. 이 경우 유니코드를 지원하는 폰트를 사용하여 해결할 수 있습니다.
  2. 터미널 자체가 인코딩 결과를 이해하지 못할 수도 있습니다. 리눅스Linux나 맥Mac에서는 보통 터미널이 UTF-8 인코딩을 네이티브하게 지원할 것입니다. 반면 윈도우WindowsSetConsoleOutpuCP(CP_UTF8)과 같은 API 호출이 필요할 수 있습니다.

UTF-8 디코딩 함수 구현하기

이번에는 인코딩 결과에서 코드 포인트를 얻는 decodeUtf8() 함수를 만들어 봅시다. 이는 텍스트 편집기와 같은 프로그램이 실제로 비트로부터 문자를 얻는 작업과 같을 것입니다.

이 함수는 위에서 만든 encodeUtf8() 함수의 역함수inverse function로서 정반대의 작업을 할 것입니다. 즉 바이트 시퀀스를 받아서 코드 포인트를 계산해 리턴할 것입니다.

수도코드 및 구현

수도코드는 다음과 같이 간단하게 만들어볼 수 있습니다.

UTF-8 디코딩 (바이트 시퀀스)

만약 바이트 시퀀스가 0xxx xxx 꼴이면
리턴 코드 포인트 xxx xxxx

 

만약 바이트 시퀀스가 110x xxxx 10yy yyyy 꼴이면
리턴 코드 포인트 xxx xxyy yyyy

 

만약 바이트 시퀀스가 1110 xxxx 10yy yyyy 10zz zzzz 꼴이면
리턴 코드 포인트 xxxx yyyy yyzz zzzz

 

만약 바이트 시퀀스가 1111 1xxx 10yy yyyy 10zz zzzz 10ww wwww 꼴이면
리턴 코드 포인트 x xxyy yyyy zzzz zzww wwww

 

리턴 U+FFFD

그러면 C++로 다음과 같이 구현해볼 수 있고요.

Codepoint decodeUtf8(const char8_t *buf) {
  if ((buf[0] & 0b1000'0000) == 0) {
    // get xxx xxxx
    // from bytes 0xxx xxxx

    return (Codepoint) buf[0];
  }

  if (((buf[0] & 0b1110'0000) >> 5) == 0b110 &&
      ((buf[1] & 0b1100'0000) >> 6) == 0b10) {
    // get xxx xxyy yyyy
    // from bytes 110x xxxx 10yy yyyy

    Codepoint x = buf[0] &  0b1'1111;
    Codepoint y = buf[1] & 0b11'1111;

    Codepoint cp = (x << 6) | y;
    return cp;
  }

  if (((buf[0] & 0b1111'0000) >> 4) == 0b1110 &&
      ((buf[1] & 0b1100'0000) >> 6) == 0b10 &&
      ((buf[2] & 0b1100'0000) >> 6) == 0b10) {
    // get xxxx yyyy yyzz zzzz
    // from bytes 1110 xxxx 10yy yyyy 10zz zzzz

    Codepoint x = buf[0] &    0b1111;
    Codepoint y = buf[1] & 0b11'1111;
    Codepoint z = buf[2] & 0b11'1111;

    Codepoint cp = (x << 12) | (y << 6) | z;
    return cp;
  }

  if (((buf[0] & 0b1111'1000) >> 3) == 0b11110 &&
      ((buf[1] & 0b1100'0000) >> 6) == 0b10 &&
      ((buf[2] & 0b1100'0000) >> 6) == 0b10 &&
      ((buf[3] & 0b1100'0000) >> 6) == 0b10) {
    // get x xxyy yyyy zzzz zzww wwww
    // from bytes 1111 0xxx 10yy yyyy 10zz zzzz 10ww wwww

    Codepoint x = buf[0] &     0b111;
    Codepoint y = buf[1] & 0b11'1111;
    Codepoint z = buf[2] & 0b11'1111;
    Codepoint w = buf[3] & 0b11'1111;

    Codepoint cp = (x << 18) | (y << 12) | (z << 6) | w;
    return cp;
  }

  return 0xFFFD;
}

이 함수를 다음과 같이 테스트해볼 수 있습니다.

int main() {
  Codepoint cp = decodeUtf8(u8"😂");
  printf("%x\n", cp); // result: 1f602
}

이제 유니코드와 UTF-8 인코딩 사이의 변환을 자유자재로 할 수 있게 되었습니다.

여기까지 코드는 지스트Gist에서 확인할 수 있습니다.

유니코드 내부 구조

앞서 UTF-8 인코딩을 살펴봤는데요. 유니코드도 간단히 알아보고 넘어가보겠습니다.

유니코드는 U+0000부터 U+10FFFF까지, 2212^{21}개의 코드 포인트를 사용합니다. 이런 코드 포인트의 범위를 코드 스페이스code space라고 부릅니다. 이 코드 스페이스는 열일곱개의 영역으로 나뉘는데요. 이를 각각 플레인plane이라고 부릅니다. 하나의 플레인은 2162^{16}개, 즉 65536개의 코드포인트를 가지게 됩니다.

코드 포인트 U+XXYYYY가 있다고 해봅시다. 0부터 10까지의 숫자를 갖는 XX는 곧 플레인의 번호를 가리키게 됩니다. 예를 들어, U+23450번 플레인, U+123451번 플레인의 2345 번째 코드 포인트가 됩니다.

code space and planes
그림 4. 유니코드의 코드 스페이스와 플레인. 코드 스페이스는 총 17개의 플레인으로 구성되어 있습니다. 그리고 각 플레인은 블록이라는 이름으로 구역을 나누고 있습니다.

원래 유니코드를 만들었을 때, 16비트로도 충분히 모든 문자를 표현할 수 있을거라고 예상했습니다. 그래서 실제로 자주 쓰이는 문자는 0번 플레인, 즉 코드 포인트 0+00000+FFFF 사이에 할당되어있습니다. 이 플레인을 다국어 기본 평면Basic Multilingual Plane (BMP)이라고 부르는데요. 실제로 유니코드의 버전 3.0까지는 이 플레인만 사용하고 있습니다. 한글도 여기에 포함되어 있습니다.

그런데 이후 한자를 비롯한 여러 다른 문자를 포함하기 위해 별도의 플레인을 사용하기 시작했습니다. 이모지는 1번 플레인인 다국어 보충 평면Supplementary Multilingual Plane (SMP)을 사용하고 있습니다. 한자는 2번과 3번 플레인인 상형 문자 보충 평면Supplementary Ideographic Plane (SIP), 상형 문자 제삼평면Tertiary Ideographic Plane (TIP)이라고 불리는 플레인을 쓰고 있습니다. 여기서 이런 이름을 소개하려는 것은 아니지만요. 유니코드는 내부적으로 이러한 플레인이라는 구역으로 코드 포인트를 관리하고 있습니다.

유니코드 버전 15 기준으로 4번 플레인부터 13번 플레인까지는 비어있는 상태입니다. 사실상 아직도 많은 코드 포인트가 비어있다고 볼 수 있습니다. 그리고 플레인 안에는 블록이라는 영역으로 나누고 있는데요. 이런 것이 주제는 아니기 때문에 언급만 하는 정도로 정리하고 넘어가겠습니다.

코드 유닛과 비트 표현 매핑

UTF-8의 숫자 8이 가진 의미를 앞서 언급했는데요. 코드 포인트를 비트 표현으로 매핑할 때, 기본 단위로서 사용할 비트 길이가 있습니다. 이를 코드 유닛code unit이라고 부릅니다.

예를 들어 8비트를 기본 단위로 사용한다면, 코드 포인트를 8비트의 코드 유닛 시퀀스code unit sequence로 인코딩한다고 부릅니다. UTF-8이 그런 방법입니다.

앞서 살펴보았듯이, 코드 포인트가 얼마인지에 따라 8비트를 하나에서 네 개까지 사용하게 됩니다. 문자마다 코드 유닛의 개수, 즉 바이트의 개수가 달라지므로, UTF-8은 가변 폭 인코딩variable-width encoding이라고 부릅니다.

그러면 16비트의 코드 유닛을 사용하면 어떨까요? UTF-16이 그런 인코딩입니다. UTF-16도 때에 따라 하나에서 두 개의 코드 유닛을 쓰기 때문에 가변 폭 인코딩으로 분류됩니다.

32비트의 코드 유닛도 쓸 수 있습니다. UTF-32가 그렇습니다. 하지만 UTF-32는 오직 하나의 코드 유닛만 사용하는데요. 2212^{21}개의 모든 코드 포인트를 항상 32비트 안에 담을 수 있기 때문입니다. 그래서 고정 폭 인코딩fixed-width encoding이라고 부릅니다.

various utf encoding examples
그림 5. 같은 문자의 세 UTF 인코딩. 뒤에 붙은 숫자, 즉 8, 16, 32는 기본 단위로서 사용할 비트 길이를 의미합니다.

결론적으로 이모지 문자 ‘😂’는 세 가지 인코딩에서 모두 네 바이트를 사용하는데요. 바이트의 개수는 같지만, 내부적으로는 각각 다른 개수의 코드 유닛으로 나누어 인코딩하기 때문에, 이 네 바이트는 그 의미가 달라집니다

占쏙옙

오늘날 웹은 대부분 UTF-8 인코딩을 사용하지만, 그렇지 않았던 과도기가 있었습니다. 그 시절의 한국에서는 웹페이지와 브라우저가 EUC-KR 이라고 불리는 인코딩을 많이 사용했는데요. 이 떄 모종의 이유로 웹페이지의 대부분이 ‘占쏙옙’과 같은 단어로 뒤덮이는 현상이 종종 있었습니다.

wrongly encoded utf-8
그림 6. 오라클 웹사이트의 ‘占쏙옙’ 현상.

EUC-KR 방식에서 나타나는 ‘占쏙옙’은 16진수로 EF BF BD EF BF BD인 바이트 시퀀스를 디코딩한 결과입니다. 여기서는 한글이 2바이트씩 인코딩되기 때문에 총 6바이트의 크기를 가진 것입니다.

한편 이는 UTF-8 인코딩에서, 앞서 소개한 대체 문자 ‘�’를 연달아 두 개 쓴 것과 같은 코드 유닛 시퀀스입니다.

euc-kr vs utf-8
그림 7. 같은 비트 표현의 다른 디코딩. EUC-KR 상의 ‘占쏙옙’과 UTF-8 상의 두 개의 대체 문자가 같은 표현을 가집니다.

따라서 이런 소위 ‘占쏙옙’ 현상은, UTF-8 인코딩에 실패해 대체 문자로 바뀐 데이터를 EUC-KR로 읽었을 때 나타나는 현상으로 이해할 수 있습니다.

그 외의 것들

본문에서 다루지 않았던 내용을 짤막하게 언급하고 넘어가자면 이렇습니다.

문자열 길이

가변 폭 인코딩인 UTF-8는 바이트의 개수가 곧 문자의 개수와 일치하지는 않는데요. 하지만 UTF-8로 인코딩된 바이트 시퀀스를 읽어서 문자의 개수를 세는 strlen()과 같은 함수를 만들어볼 수 있습니다.

다시 말해 단순히 바이트의 개수를 리턴할 수 없고, 인코딩된 비트를 전부 파악해야 합니다. 즉 nn개의 바이트에 대해 O(n)O(n)의 시간 복잡도가 필요할 것입니다.

URL 인코딩

UTF-8는 URL 인코딩, 또는 퍼센트 인코딩으로 불리는 것과도 관련이 있는데요. URL에서 ASCII 문자가 아닌 것들은 UTF-8 인코딩을 통해 표현합니다.

예를 들어 ‘가’와 같은 문자는, 그 UTF-8 인코딩 결과인 EA B0 80을 이용해 %EA%B0%80과 같은 식으로 URL 상에서 표현됩니다. 이는 한글에 포함된 URL이 길어지는 이유가 됩니다.

문자의 순서: 콜레이션

문자 데이터를 정렬할 때는 문자의 순서를 생각해야 하는데요. 코드 포인트 순서가 반드시 문자의 순서라고 볼 수는 없습니다.

예를 들어, 문자 ‘0’이 문자 ‘A’보다 먼저일 필요는 없는데요. 따라서 문자 순서를 따로 매길 필요가 있습니다. 이를 콜레이션collation이라고 부릅니다. 특히 데이터베이스에서 이런 콜레이션을 직접적으로 관리할 수 있습니다.

노말라이즈

유니코드 상에서 어떤 문자들은 표현 방법이 하나만 있는 것은 아닙니다.

예를 들어 악센트가 있는 A 문자 ‘À’ (U+00C0)를 봅시다. 이 문자는 두 문자를 조합해서 만들 수 있습니다. 문자 ‘A’ (U+0041)에 악센트 조합 문자인 ‘ ̀ ’ (U+0300)를 더해서 ‘À’로요. (방금 실제로 두 문자를 조합했습니다.)

그런데 하나의 문자를 하나의 표현으로 통일시키는 노말라이즈normalize 작업이 필요할 수 있습니다.

예를 들면 자바스크립트JavaScript의 메소드인 normalize()가 이를 맡고 있습니다. 그래서 특별한 경우에는 두 문자열의 비교 전에 먼저 노말라이즈를 해야할 필요가 생깁니다. 그리고 이것 UTF-8 인코딩이 아닌 유니코드가 가진 특징이기 때문에, UTF-16과 UTF-32 또한 똑같은 문제를 가지게 됩니다.

마치며

여기까지 유니코드와 UTF-8에 대해 살펴보았습니다. 유니코드는 문자와 숫자, UTF-8는 그 숫자와 비트 표현간의 매핑이었습니다. 그리고 UTF-8은 8비트 단위로 길이가 가변적인 인코딩이었습니다. 또한 인코딩과 디코딩을 정해진 규칙에 따라 비트 조작을 통해 간단히 구현해보았습니다.

여기서 알아본 유니코드와 UTF-8 인코딩 간의 관계, 즉 코드 포인트와 코드 유닛 시퀀스의 관계는 다른 방식의 인코딩, UTF-16와 UTF-32에도 그대로 적용됩니다. 다만 각각 코드 유닛이 16비트와 32비트라는 점이 다른데요. 이 인코딩은 다음 기회에 알아보도록 하겠습니다.

레퍼런스

  • Unicode Explained (Jukka Korpela, 2006)