UTF-8이 어떻게 전 세계 문자를 담아낼까?

UTF-8이 어떻게 전 세계 문자를 담아낼까?

웹 페이지에서 갑자기 한글이 ���처럼 깨져 보인 경험이 한 번쯤은 있으실 텐데요. 파일을 메모장에서 다른 에디터로 열었더니 모든 글자가 외계어로 변하거나, JSON API에서 받은 한글이 전혀 다른 모양으로 나오는 경우 말입니다.

이런 문제 대부분의 뿌리에는 UTF-8이라는 문자 인코딩이 자리잡고 있습니다. 이번 포스팅에서는 UTF-8이 어떻게 전 세계 모든 문자를 담아내는지부터 짚어보겠습니다. 한글이 어떻게 바이트로 변환되는지, 그리고 글자가 깨질 때 무슨 일이 벌어지는지도 함께 살펴보겠습니다.

UTF-8이란?

UTF-8은 Unicode Transformation Format - 8-bit의 약자로, 유니코드(Unicode)에 정의된 모든 문자를 1바이트에서 4바이트까지의 가변 길이로 표현하는 인코딩 방식입니다.

ASCII가 7비트로 128개 문자만 표현할 수 있었던 데 반해 UTF-8은 110만 개가 넘는 유니코드 코드 포인트(U+0000부터 U+10FFFF까지)를 모두 담아낼 수 있는데요. 그러면서도 ASCII 문자는 여전히 1바이트 그대로 표현해서 기존 ASCII 데이터와 완벽하게 호환됩니다.

이 두 가지 특성, 즉 모든 문자를 표현할 수 있다는 점과 ASCII와의 호환성이 UTF-8을 오늘날 인터넷의 사실상 표준으로 만든 핵심 이유입니다.

UTF-8의 가변 길이 구조

UTF-8의 핵심은 가변 길이 구조에 있습니다. 하나의 문자는 1바이트, 2바이트, 3바이트, 또는 4바이트로 표현될 수 있는데요. 각 바이트의 첫 비트만 보면 그 문자가 몇 바이트로 이루어졌는지 알 수 있고, 해당 바이트가 첫 바이트인지 뒤따라 이어지는 바이트인지도 곧장 구분됩니다.

1바이트: 0xxxxxxx                                    (7비트, ASCII와 동일)
2바이트: 110xxxxx 10xxxxxx                           (11비트)
3바이트: 1110xxxx 10xxxxxx 10xxxxxx                  (16비트)
4바이트: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx         (21비트)

여기서 x는 실제 문자 데이터를 담는 비트입니다. 첫 바이트의 0, 110, 1110, 11110이 “이 문자는 1바이트야”, “2바이트야”처럼 길이를 알리는 표시 역할을 하고, 이어지는 바이트는 모두 10으로 시작합니다.

이런 설계 덕분에 디코더는 어떤 위치의 바이트를 보더라도 곧바로 그 바이트가 문자의 시작인지 중간인지 판별할 수 있는데요. 만약 데이터의 일부가 손상되어 중간부터 읽기 시작해도 다음 첫 바이트 표시(0, 110, 1110, 11110)를 만나면 동기화를 회복할 수 있습니다.

한글은 어떻게 UTF-8로 변환될까요?

이 구조를 한글에 직접 적용해보겠습니다. 한글 “한”의 유니코드 코드 포인트는 U+D55C인데요. 이를 16진수에서 2진수로 펼치면 1101 0101 0101 1100으로 16비트입니다.

16비트는 3바이트 UTF-8 형식(1110xxxx 10xxxxxx 10xxxxxx, 4 + 6 + 6 = 16비트)에 딱 들어맞습니다. 이제 16비트를 4비트, 6비트, 6비트로 잘라서 형식에 끼워 넣으면 끝인데요.

U+D55C = 1101 0101 0101 1100      (유니코드 16비트)
         ↓ 4비트, 6비트, 6비트로 분할
         1101  010101  011100
         ↓ UTF-8 형식에 끼워 넣기
         11101101 10010101 10011100
         ↓ 16진수로 변환
         0xED     0x95     0x9C

이렇게 해서 한글 “한”은 UTF-8에서 0xED 0x95 0x9C 세 바이트로 표현됩니다. 컴퓨터가 이 세 바이트를 만나면 첫 바이트의 1110을 보고 “3바이트짜리 문자네”라고 판단해서 세 바이트를 묶어 다시 코드 포인트로 복원하는 거죠.

이모지도 마찬가지인데, 유니코드 코드 포인트가 더 크기 때문에 4바이트가 필요합니다. 예를 들어 🚀(U+1F680)는 21비트라서 4바이트 UTF-8 형식에 들어가게 됩니다.

BOM이라는 골칫거리

UTF-8을 다루다 보면 가끔 파일 맨 앞에 0xEF 0xBB 0xBF 세 바이트가 붙어 있는 경우를 만나게 됩니다. 이를 BOM(Byte Order Mark)이라고 하는데요.

BOM은 원래 UTF-16이나 UTF-32에서 바이트 순서(빅 엔디안 vs 리틀 엔디안)를 표시하기 위해 만들어진 표시입니다. UTF-8은 바이트 순서가 단일하기 때문에 사실상 필요가 없지만 “이 파일은 UTF-8이다”라는 표식으로 일부 도구(특히 Windows 메모장)가 자동으로 붙이곤 합니다.

문제는 이 BOM이 일부 시스템에서 보이지 않는 이상한 문자가 파일 맨 앞에 끼어 있는 것처럼 동작할 수 있다는 점인데요. JSON 파서나 YAML 파서, 또는 셸 스크립트의 #!/bin/bash 라인 앞에 BOM이 붙으면 파싱 오류로 이어집니다.

BOM이 끼어든 셸 스크립트
$ cat -A script.sh
M-oM-;M-?#!/bin/bash    # BOM이 #! 앞에 붙어 셔뱅을 망가뜨림

그래서 UTF-8 파일을 만들 때는 가급적 BOM 없이 저장하는 것이 안전합니다.

깨진 글자(Mojibake)의 정체

웹에서 한글이 “안녕”이 아니라 ”������“이나 “안녕“처럼 보이는 경우를 가끔 만나게 되는데요. 이것을 일본어 용어로 “모지바케(文字化け)“라고 부르며, 영어로도 그대로 mojibake로 통용됩니다.

모지바케는 대부분 인코딩 정보가 어긋났을 때 발생합니다. 예를 들어, 파일은 UTF-8로 저장되었는데 브라우저가 EUC-KR로 해석하려고 하면 같은 바이트 시퀀스가 전혀 다른 글자로 매핑되어 버립니다.

"안녕"의 UTF-8 바이트 → EC 95 88 EB 85 95 (6바이트)
이 6바이트를 EUC-KR로 해석 → '안녕'과 전혀 다른 글자가 출력됨

이런 문제를 피하려면 HTTP 응답에 Content-Type: text/html; charset=UTF-8 헤더를 명시하고, HTML 문서에는 <meta charset="UTF-8">을 가장 먼저 선언해야 하는데요. 데이터베이스 연결과 파일 입출력에서도 일관되게 UTF-8을 사용해야 깨짐을 막을 수 있습니다.

자바스크립트에서 UTF-8 다루기

자바스크립트의 문자열은 내부적으로 UTF-16으로 저장되지만 네트워크나 파일로 보낼 때는 보통 UTF-8 바이트로 변환해야 하는데요. 이때 사용하는 표준 API가 TextEncoderTextDecoder입니다.

문자열을 UTF-8 바이트 배열로 변환하려면 TextEncoder를 사용합니다.

const encoder = new TextEncoder();
const bytes = encoder.encode("안녕");
console.log(bytes);
// Uint8Array(6) [236, 149, 136, 235, 133, 149]
// = 0xEC 0x95 0x88 0xEB 0x85 0x95

반대로 UTF-8 바이트 배열을 문자열로 되돌리려면 TextDecoder를 쓰는데요.

const decoder = new TextDecoder("utf-8");
const text = decoder.decode(new Uint8Array([236, 149, 136, 235, 133, 149]));
console.log(text); // '안녕'

문자열의 UTF-8 바이트 길이를 구할 때도 TextEncoder가 유용합니다.

function utf8ByteLength(str) {
  return new TextEncoder().encode(str).length;
}

console.log(utf8ByteLength("Hello")); // 5  (영문 5자 × 1바이트)
console.log(utf8ByteLength("안녕")); // 6  (한글 2자 × 3바이트)
console.log(utf8ByteLength("🚀")); // 4  (이모지 1자 × 4바이트)

String.prototype.length는 UTF-16 코드 단위 개수를 반환하기 때문에 한글이나 이모지가 있을 때 실제 바이트 크기와 어긋날 수 있는데요. 데이터베이스 컬럼의 바이트 제한이나 HTTP 헤더 크기를 정확히 검사할 때는 위와 같이 TextEncoder로 직접 측정하는 것이 안전합니다.

마치며

지금까지 UTF-8의 가변 길이 구조부터 한글이 어떻게 3바이트로 변환되는지, BOM이나 모지바케 같은 실무에서 마주치는 문제까지 짚어보았습니다.

UTF-8은 단순히 “한글을 3바이트로 저장한다” 수준이 아니라, 첫 바이트만 보면 길이를 알 수 있고 ASCII와 완벽하게 호환되도록 잘 다듬어진 인코딩인데요. 이 구조를 이해해두면 URL 인코딩에서 한글이 왜 %EC%95%88%EB%85%95처럼 풀리는지, 깨진 글자를 만났을 때 어디서부터 의심해야 하는지가 한층 명확해집니다.

더 자세한 명세는 RFC 3629를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord