UTF-8 구현하기

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

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

UTF-8은 영어와 한글, 이모지emoji를 포함해 유니코드에서 2212^{21}개에 달하는 문자를 완전하게 커버할 수 있습니다. 그런데 이 인코딩은 어떤 방식으로 문자를 표현하고 있는 걸까요? 다른 인코딩 방식에 비해 나은 점은 뭘까요? 그리고 유니코드Unicode와는 어떤 관계가 있을까요?

앞으로 소개할 내용을 통해, UTF-8를 직접 구현해보며 이런 질문에 대한 답을 알아볼 것입니다.

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

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

유니코드는 이런 추상적인 심볼에서 비트 표현에 이르기까지 여러 단계로 구분하고 있지만, 여기서는 간단히 두 단계로 살펴보겠습니다.

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

하나는 심볼을 코드 포인트code point라는 숫자의 매핑입니다. 유니코드가 이러한 매핑입니다. 예를 들어, 유니코드는 그리스 문자 ‘α’에 945번이라는 코드 포인트를 매겨놓습니다. 이를 16진법을 이용해 U+03B1로 표기하기도 합니다.

또 다른 하나는 코드 포인트에서 구체적인 비트 표현으로 대응시키는 매핑입니다. 그 비트 표현을 코드 유닛 시퀀스code unit sequence라고 부릅니다. UTF-8은 이러한 매핑 중 하나입니다.

이름에서 8은 비트 표현 단위가 8비트임을 의미합니다. 즉 8비트 단위로 시퀀스를 나열하기 때문에, 인코딩된 결과는 8비트, 16비트, 24비트와 같이 8비트의 배수만큼 길어집니다. 이것은 구현할 때 좀더 분명해질 것입니다.

정리하면 유니코드는 문자와 코드 포인트 사이의 매핑이고, UTF-8 인코딩은 코드 포인트와 비트 표현 사이의 매핑입니다. 이런식으로 문자는 컴퓨터에게 필요한 비트 표현을 갖게 됩니다.

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

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

인코딩 방법

UTF-8 인코딩은 코드 포인트의 이진 표현binary representation이 몇 비트를 차지하느냐에 따라 케이스를 나눕니다. 필요한 비트가 많을수록 더 많은 바이트에 나눠 담는 방식입니다.

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

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

각 바이트의 앞자리에는 정해진 비트 패턴이 있기 때문에, 각 바이트가 어떤 역할을 맡는지 알 수 있습니다.

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

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

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

인코딩 알고리즘

앞서 설명한 내용은 수도코드로 다음과 같이 표현할 수있습니다. 이 함수는 코드 포인트 숫자를 받고 그것을 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++를 선택해보겠습니다. 다음은 수도코드를 그대로 옮겨 구현한 것입니다.

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';
}

수도코드는 바이트 시퀀스를 리턴했지만, 여기서는 매개변수 buf에 리턴값을 전달합니다. 여기서 리턴값 마지막의 널null 문자 \0는 나중에 출력의 편의를 위해 임의로 붙인 것입니다.

실행해보기

만든 함수가 잘 동작하는지 확인해볼 차례입니다. 실제로 코드 포인트 U+1F602에 해당하는 문자 ‘😂’가 터미널에 출력됩니다.

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

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

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

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

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

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

수도코드 및 구현

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

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에서 확인할 수 있습니다.

유니코드 내부 구조

앞서 자주 등장했던 용어인 유니코드도 간략히 알아보겠습니다.

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

코드 포인트가 어떤 플레인에 속하는지는 간단히 알 수 있습니다. 코드 포인트 U+XXYYYY가 있다고 해봅시다. 여기서 XX가 플레인의 번호가 됩니다. 예를 들어, U+23450번 플레인, U+123451번 플레인의 코드 포인트가 됩니다.

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

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

그런데 이후 한자를 비롯한 여러 다른 문자를 포함하기 위해 별도의 플레인을 사용하기 시작했습니다. 예를 들자면 이모지는 1번 플레인에, 한자는 2번과 3번 플레인에 들어있습니다.

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

코드 유닛과 비트 표현 매핑

코드 포인트를 비트 표현에 매핑할 때, 기본 단위로서 사용하는 비트 길이가 있습니다. 이를 코드 유닛code unit이라고 부릅니다. 예를 들어 8비트를 기본 단위로 사용한다면, 코드 포인트를 8비트의 코드 유닛 시퀀스로 인코딩한다고 부릅니다. UTF-8이 그런 방법입니다.

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

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

UTF-32는 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바이트씩 인코딩됩니다.

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

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

따라서 소위 ‘占쏙옙’ 현상은 알 수 없는 이유로 인해 데이터가 대체 문자로 바뀌었는데 EUC-KR 인코딩으로 읽었을 때 나타난 것입니다.

그 외의 것들

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

문자열 길이

문자열에서 길이란 복잡한 주제입니다. 바이트의 길이는 간단하게 알 수 있지만, 보통은 문자의 개수를 알고 싶을 것입니다.

하지만 가변 폭 인코딩인 UTF-8는 바이트의 개수가 문자의 개수와 일치하지는 않는데요. UTF-8로 인코딩 문자의 개수를 세는 함수를 만들 수는 있겠지만, 그러려면 인코딩된 바이트를 전부 파악해야 하기 때문에, nn개의 바이트에 대해 Θ(n)\Theta(n)의 시간 복잡도가 필요하게 됩니다.

URL 인코딩

UTF-8는 URL 인코딩, 또는 퍼센트 인코딩으로 부르는 것과도 관련이 있습니다. URL에서 ASCII 문자가 아닌 것들은 UTF-8 인코딩을 통해 표현하기 때문입니다.

예를 들어 ‘가’와 같은 문자는, 그 UTF-8 인코딩 결과인 EA B0 80을 이용해 %EA%B0%80과 같은 식으로 표현됩니다. 이것이 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 인코딩 간의 관계, 즉 코드 포인트와 코드 유닛 시퀀스의 관계는 다른 방식의 인코딩, UTF-16와 UTF-32에도 그대로 적용됩니다. 다만 각각 코드 유닛이 16비트와 32비트라는 점이 다를 뿐입니다.

레퍼런스

  • Unicode Explained (Jukka Korpela, 2006)