사전 UTF-8
포맷

UTF-8

gabury1고친 사람 github-actions[bot]

UTF-8 은 세상의 모든 글자를 바이트로 적는 방법입니다. 영어 글자는 한 바이트로 적습니다. 한글이나 이모지처럼 번호가 큰 글자는 바이트를 더 씁니다. 옛 영어 문서는 손대지 않아도 이미 UTF-8 문서로 읽힙니다. 웹 문서와 소스 코드, 서버가 주고받는 텍스트 대부분이 이 방법으로 적힙니다.

쉽고 빠른 이해

UTF-8 은 글자에 매긴 번호를 바이트 몇 개로 적을지 정한 약속입니다. A 는 한 바이트, é 는 두 바이트, 한 은 세 바이트가 됩니다.

이런 약속이 없으면 보내는 쪽과 받는 쪽이 글자를 서로 다르게 읽습니다. 화면에 깨진 글자가 뜨는 것이 그 결과입니다.

어떻게 도나:

  1. 글자마다 정해진 번호를 찾습니다. 한 의 번호는 16진수로 D55C 입니다
  2. 번호가 크면 바이트를 더 씁니다. 한 글자에 한 바이트에서 네 바이트까지 씁니다
  3. 첫 바이트의 앞쪽 비트가 「이 글자는 몇 바이트짜리다」를 알려 줍니다

대가도 있습니다. 한글은 세 바이트라 한글 위주의 글은 한글 전용 방식보다 커집니다. 글자마다 길이가 달라서 「열 번째 글자」를 바로 찾아가지 못하고 앞에서부터 세어야 합니다.

상세

이 절은 UTF-8 이 글자를 어떻게 바이트로 바꾸는지를 한 한 글자로 따라갑니다. 먼저 글자에 번호를 매기는 일과 번호를 적는 일이 왜 따로인지 봅니다. 이어서 바이트 패턴을 봅니다. 뒤에서는 이 설계로 얻는 성질과 백엔드에서 자주 부딪히는 문제를 짚습니다.

번호를 매기는 일과 적는 일

UTF-8 은 Unicode Transformation Format 8-bit 의 줄임말입니다. 우리말로 옮기면 유니코드 변환 형식 8비트입니다. 이름의 8 은 바이트 하나, 즉 8비트를 단위로 적는다는 뜻입니다.

Unicode(유니코드)는 세상의 글자마다 번호를 하나씩 매긴 표입니다. 이 번호를 코드 포인트라고 부르고 U+ 뒤에 16진수로 적습니다. A 는 U+0041, 한 은 U+D55C 입니다. 번호는 U+10FFFF 까지 있습니다.

번호만 정해서는 파일에 적을 수 없습니다. D55C 라는 수를 바이트 두 개로 적을지, 네 개로 적을지, 어떤 순서로 적을지를 따로 정해야 합니다. 이 두 번째 약속을 문자 인코딩이라고 부릅니다. UTF-8 은 유니코드 번호를 적는 문자 인코딩 중 하나입니다.

같은 번호를 다르게 적는 방식도 있습니다. UTF-16(Unicode Transformation Format 16-bit)은 두 바이트를 단위로 적습니다. UTF-32(Unicode Transformation Format 32-bit)는 모든 글자를 네 바이트로 적습니다. 셋 다 같은 유니코드 번호를 씁니다. 바이트로 옮기는 방법만 다릅니다.

바이트 패턴

UTF-8 은 번호의 크기에 따라 바이트 수를 고릅니다. 번호가 작을수록 짧게 적습니다. 아래 표에서 x 는 번호의 비트가 채워지는 칸입니다.

번호 범위 바이트 수 바이트 모양
U+0000 ~ U+007F 1 0xxxxxxx
U+0080 ~ U+07FF 2 110xxxxx 10xxxxxx
U+0800 ~ U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF 4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

표에서 두 가지를 보면 됩니다. 첫 바이트의 앞쪽 1 의 개수가 그 글자의 바이트 수입니다. 그리고 둘째 바이트부터는 언제나 10 으로 시작합니다. 이런 바이트를 이어지는 바이트라고 부릅니다.

한 을 직접 옮겨 보겠습니다. U+D55C 는 세 번째 줄 범위라 세 바이트를 씁니다. 세 바이트 틀의 x 칸은 바이트마다 4개·6개·6개라서, 번호의 비트도 4·6·6 개씩 끊습니다. D55C 를 2진수로 쓰면 1101 010101 011100 입니다. 이 비트를 앞에서부터 x 칸에 나눠 넣습니다.

틀     1110xxxx  10xxxxxx  10xxxxxx
채움   11101101  10010101  10011100
16진수     ED        95        9C

결과는 ED 95 9C 세 바이트입니다. 같은 일을 파이썬으로 해 보면 다음과 같습니다. 파이썬의 encode() 는 인코딩을 안 적으면 UTF-8 로 적습니다.

Python
len("A".encode())           # 1
len("é".encode())           # 2
"한".encode()  # b'\xed\x95\x9c'
len("\U0001F600".encode())  # 4

마지막 줄의 U+1F600 은 웃는 얼굴 이모지입니다. U+FFFF 를 넘는 번호라 네 바이트가 됩니다.

읽는 쪽이 바이트를 끊는 법

읽는 프로그램은 바이트 덩어리를 받아 글자 단위로 끊어야 합니다. 이 일을 디코딩이라고 부릅니다. UTF-8 에서는 바이트 하나의 앞쪽 비트만 보고 무엇인지 알 수 있습니다.

flowchart TD
    B["바이트 하나를 읽는다"] --> Q1{"0 으로 시작하나"}
    Q1 -->|예| O1["한 바이트 글자"]
    Q1 -->|아니오| Q2{"10 으로 시작하나"}
    Q2 -->|예| C["앞 글자에 이어지는 바이트"]
    Q2 -->|아니오| L["110 · 1110 · 11110 이면 첫 바이트 · 1 의 개수가 길이"]

그림처럼 바이트마다 역할이 겹치지 않습니다. 그래서 파일 한가운데서 읽기 시작해도 10 으로 시작하는 바이트를 건너뛰면 다음 글자의 첫 바이트가 나옵니다. 중간 바이트 하나가 깨져도 그 글자 하나만 망가지고 뒤 글자는 제대로 읽힙니다. 이 성질을 자기 동기화라고 부릅니다.

ASCII 와 맞물리는 설계

ASCII(American Standard Code for Information Interchange)는 영어 글자와 숫자, 기호에 0부터 127까지 번호를 매긴 옛 약속입니다. 한 글자를 한 바이트로 적습니다. 유니코드는 이 128개 글자에 같은 번호를 그대로 물려받았습니다.

UTF-8 은 U+007F 까지를 한 바이트로, 그것도 ASCII 와 같은 값으로 적습니다. 그래서 영어로만 된 ASCII 파일은 한 바이트도 바꾸지 않고 UTF-8 파일이 됩니다. 반대로 ASCII 만 아는 옛 프로그램도 UTF-8 문서의 영어 부분은 읽을 수 있습니다.

여러 바이트짜리 글자에는 127 이하의 바이트가 끼어들지 않습니다. 첫 바이트든 이어지는 바이트든 맨 앞 비트가 1 이기 때문입니다. 덕분에 / 나 " 같은 기호를 찾는 코드가 한글 바이트 중간을 기호로 잘못 읽는 일이 없습니다. 경로나 JSON(JavaScript Object Notation) 문서를 바이트 단위로 훑는 도구가 UTF-8 에서 안전하게 도는 이유입니다.

바이트 순서

UTF-16 처럼 두 바이트를 한 단위로 쓰면 어느 바이트를 먼저 적을지 정해야 합니다. 컴퓨터마다 이 순서가 다를 수 있어서 파일 맨 앞에 순서를 알리는 표시를 붙이기도 합니다. 이 표시가 바이트 순서 표시, BOM(Byte Order Mark)입니다.

UTF-8 은 한 바이트가 단위라 순서를 정할 일이 없습니다. 그래도 일부 편집기는 파일 맨 앞에 EF BB BF 세 바이트를 붙여 「이 파일은 UTF-8 이다」라고 표시합니다. 이 세 바이트를 모르는 프로그램은 첫 줄 앞에 보이지 않는 글자가 있다고 읽습니다. CSV(Comma-Separated Values, 쉼표로 구분한 값) 파일의 첫 칸 이름이 이상하게 안 맞거나 셸 스크립트 첫 줄의 #! 를 운영체제가 알아보지 못하는 문제가 여기서 납니다.

글자마다 다른 길이

UTF-8 의 대가는 글자마다 바이트 수가 다르다는 점입니다. 문자열의 바이트 길이와 글자 수가 다릅니다. "한글" 은 두 글자지만 여섯 바이트입니다.

이 차이가 백엔드에서 자주 문제를 냅니다. 바이트 수로 자른 문자열은 글자 한가운데서 끊길 수 있습니다. 끊긴 조각은 올바르지 않은 UTF-8 입니다. 읽는 쪽은 오류를 내거나 대체 문자 �(U+FFFD)로 바꿔 보여 줍니다. 컬럼 길이 제한이나 메시지 크기 제한이 바이트 단위인지 글자 단위인지 따져야 하는 이유입니다.

「n 번째 글자」를 바로 찾아가지도 못합니다. 앞에서부터 첫 바이트를 세어 나가야 합니다. n 번째 글자로 자주 건너뛰는 프로그램은 메모리 안에서 UTF-32 처럼 모든 글자를 같은 길이로 적는 방식을 쓰기도 합니다. 길이가 같으면 n 번째 글자의 위치를 곱셈 한 번으로 구합니다.

한국어 글 위주라면 크기도 따져 볼 만합니다. 한글 음절은 UTF-8 에서 세 바이트지만 EUC-KR(Extended Unix Code for Korean) 같은 한국어 전용 인코딩에서는 두 바이트입니다. 대신 한국어 전용 인코딩은 한국어와 영어 밖의 글자를 대부분 못 적습니다.

올바르지 않은 바이트열

모든 바이트 조합이 UTF-8 인 것은 아닙니다. 10 으로 시작하는 바이트가 첫 바이트 없이 나오거나, 세 바이트짜리 첫 바이트 뒤에 이어지는 바이트가 모자라면 깨진 바이트열입니다.

같은 번호를 필요 이상으로 길게 적는 것도 금지됩니다. 예를 들어 /(U+002F)는 한 바이트 2F 로만 적어야 합니다. 경로에서 / 를 걸러 내는 검사가 바이트 2F 만 찾는다고 해 봅시다. 두 바이트로 늘려 적은 C0 AF 에는 2F 가 없어서 검사를 통과합니다. 그런데 뒤에서 디코딩하면 이 바이트열이 / 가 됩니다. 이 틈을 막으려고 올바른 디코더는 이런 바이트열을 오류로 처리합니다.

백엔드에서 만나는 곳

UTF-8 은 웹과 서버 사이 텍스트의 사실상 기본값입니다. HTTP(HyperText Transfer Protocol) 응답은 Content-Type: application/json; charset=utf-8 처럼 인코딩을 헤더에 적습니다. HTML(HyperText Markup Language) 문서는 <meta charset="utf-8"> 로 알립니다. XML(Extensible Markup Language)은 인코딩 선언이 없으면 UTF-8 로 읽습니다.

데이터베이스에서는 이름 함정이 하나 있습니다. MySQL 은 인코딩을 문자 집합(character set)이라고 부릅니다. MySQL 의 utf8 문자 집합은 utf8mb3 의 옛 별칭이고, 한 글자에 세 바이트까지만 담습니다. 네 바이트가 필요한 이모지를 넣으면 저장이 실패하거나 글자가 망가집니다. 네 바이트까지 담으려면 utf8mb4 를 씁니다.

관련 항목

UTF-8 이 속하는 상위 분류

문자 인코딩 · 포맷 · 텍스트 파일 · 바이너리

UTF-8 이 적는 번호를 정하는 표준

Unicode · 코드 포인트 · ISO/IEC 10646 · 기본 다국어 평면 · 보충 평면

UTF-8 과 같은 번호를 다르게 적는 인코딩

UTF-16 · UTF-32 · UCS-2 · 대리 쌍

UTF-8 이전에 쓰이던 글자별 인코딩

ASCII · EUC-KR · CP949 · ISO-8859-1 · Shift_JIS

UTF-8 을 읽고 쓸 때 거치는 처리 단계

인코딩 · 디코딩 · 바이트 순서 표시 · 엔디언 · 유니코드 정규화 · 그래핌 클러스터

UTF-8 에서 자주 나는 오류

모지바케 · 대체 문자 · 과잉 길이 인코딩 · utf8mb4 · 문자열 자르기

UTF-8 로 텍스트를 싣는 포맷과 프로토콜

JSON · XML · HTML · HTTP · URI · 퍼센트 인코딩 · Content-Type · MySQL

다른 이름: utf-8 · utf8 · Unicode Transformation Format 8-bit · 유니코드 변환 형식 8비트