Skip to content
블로그로 돌아가기
튜토리얼

UTF-8 vs UTF-16 vs Unicode 인코딩 완벽 가이드

UTF-8, UTF-16, UTF-32를 개발자 시선으로 정리한 온라인 가이드 — 코드포인트, 서로게이트 페어, BOM, MySQL utf8mb4 함정. 올바른 인코딩 선택법을 알아보세요.

12 분 소요

UTF-8 vs UTF-16 vs Unicode 인코딩 완벽 가이드

utf-8 unicode encoding을 찾는 분이 실제로 알고 싶은 답부터 말씀드립니다. Unicode와 UTF-8은 같지 않습니다. Unicode는 모든 문자에 코드포인트(U+1F600 같은 숫자)를 부여한 번호표입니다. UTF-8, UTF-16, UTF-32는 인코딩, 즉 코드포인트를 바이트로 변환하는 세 가지 방식입니다. 대부분 UTF-8을 고르면 됩니다. 영문 텍스트에서는 ASCII와 바이트 단위로 같고, 모든 이모지를 4바이트까지 확장하며, JSON과 HTML5, 대다수 현대 프로토콜이 UTF-8을 의무화하기 때문입니다.

이 가이드는 인코딩 때문에 한 번이라도 시간을 날려 본 개발자를 위한 것입니다. 😀 입력에서 발생한 MySQL Incorrect string value 오류, "😀".length === 2라는 JavaScript 결과, cat으로는 멀쩡한데 Excel에서만 깨지는 CSV가 익숙하다면 도움이 됩니다. 코드포인트부터 시작해 UTF-8 바이트 메커니즘, 서로게이트 페어, BOM, 9개 언어의 기본 동작, 8가지 실전 함정을 순서대로 살펴본 뒤 의사 결정 매트릭스와 FAQ로 마무리합니다.

읽으면서 바이트 시퀀스를 직접 확인하고 싶다면 Base64 인코더 디코더 - 온라인 무료 즉시 변환 | Go-Tools에 아무 문자열이나 붙여넣어 보십시오. 디코딩된 페이로드가 이 글에서 설명하는 UTF-8 바이트 스트림입니다.

2026년에도 인코딩이 우리를 괴롭히는 이유

지난 12개월 동안 실제 버그 트래커에서 보고된 세 가지 시나리오를 보겠습니다.

  1. MySQL이 이모지를 거부합니다. 사용자가 Hello 😀를 제출하면 서버가 Incorrect string value: '\xF0\x9F\x98\x80'를 반환합니다. 테이블은 utf8인데 개발자는 “이게 UTF-8인데 뭐가 문제지?”라고 묻습니다. 답은 MySQL 역사 속에 묻혀 있습니다(7장에서 다룹니다).
  2. 글자 수 카운터가 망가진 채 배포됩니다. 280자 트윗 검증기가 text.length로 길이를 잰 뒤 이모지가 가득한 메시지를 통과시키지만 API는 이를 거부합니다. 반대 상황도 있습니다. 멀쩡한 글이 프런트엔드에서 거절당하는 경우입니다. 증상 진단은 4장에 있습니다.
  3. 로컬 HTML이 “中文“로 둔갑합니다. Windows-1252로 저장한 파일을 UTF-8로 추측하는 브라우저에서 열면 모지바케가 발생합니다. 이게 5장의 BOM / charset 선언 이야기이고, 같은 바이트 vs 문자 불일치가 쿼리 문자열을 망가뜨리는 사례는 URL 인코딩/디코딩: 퍼센트 인코딩 가이드와 온라인 도구 | Go-Tools에서 다룹니다.

이 가이드를 다 읽고 나면 (a) Unicode와 UTF-8을 한 문장으로 구분할 수 있고, (b) 새 프로젝트에서 UTF-8, UTF-16, UTF-32 중 무엇을 쓸지 고를 수 있고, (c) 모든 주요 언어에서 이모지를 올바르게 세는 코드를 작성할 수 있으며, (d) 바이트 스트림만 보고도 charset 버그를 디버깅할 수 있게 됩니다. 문자 인코딩의 내부 구조는 방대하지만 실무에서 부딪히는 표면은 좁습니다.

Unicode란 무엇인가? 코드포인트 vs 문자 vs 글리프

Unicode는 모든 문자에 고유한 숫자, 즉 코드포인트(예: U+1F600)를 부여하는 문자 테이블입니다. UTF-8, UTF-16, UTF-32는 코드포인트를 바이트로 변환하는 인코딩입니다. Unicode 자체는 바이트를 저장하지 않습니다. 추상적인 문자에서 정수로의 매핑만 정의합니다.

같은 시각적 표시를 가리키느라 대화를 흐리는 용어가 세 가지 더 있습니다.

분리해서 봐야 할 세 가지 계층

  • 코드포인트(U+0041, U+1F600): Unicode가 부여한 정수. 공간은 U+0000부터 U+10FFFF까지로 약 110만 개의 슬롯이 있고, 현재 약 15만 개가 할당되어 있습니다.
  • 문자(또는 추상 문자): 의미적 정체성, 예컨대 라틴 대문자 A웃는 얼굴 이모지입니다.
  • 글리프: 폰트가 렌더링하는 시각적 형태. 한 문자에는 여러 글리프가 있습니다. 세리프 A, 이탤릭 A, 손글씨 A가 그 예입니다. Unicode는 글리프에 관여하지 않습니다.
  • 자소 클러스터(grapheme cluster): 사용자가 단일한 “문자”로 인식하는 단위. 코드포인트 하나일 때도 있고 여러 개일 때도 있습니다. 글자 á는 단일 코드포인트 U+00E1일 수도, 두 코드포인트 a + U+0301(결합 양음 부호)일 수도 있습니다. 글자 수·단어 수 제한 2026 — Twitter, SMS, SEO, Instagram에서 Twitter, SMS, SEO가 이 경계선을 각자 어떻게 다르게 긋는지 살펴봅니다.

기억해 둘 흐름은 이것입니다. 코드포인트 → 인코딩 → 바이트 → 렌더링. 각 화살표는 독립적으로 깨질 수 있습니다.

코드포인트 표기법 — U+XXXX\uXXXX

코드포인트는 여러 형태로 표기합니다. U+0041은 정식 Unicode 표기이고, 4~6자리 16진수에 U+ 접두사를 붙입니다. 소스 코드에서는 다음과 같이 씁니다.

  • JavaScript / JSON: "A"(16진수 4자리, BMP 전용), "\u{1F600}"(ES6 중괄호, 모든 코드포인트).
  • Python: "A"(4자리), "\U00000041"(8자리, 대문자 U), "\N{LATIN CAPITAL LETTER A}"(이름으로).
  • 셸 / git log / sed 출력: 종종 é에 해당하는 \xc3\xa9 같은 원시 UTF-8 바이트가 보입니다. 이건 코드포인트가 아니라 인코딩된 형태입니다. 이 이야기가 3장으로 이어집니다.

17개 평면 — BMP와 그 너머

Unicode는 코드포인트 공간을 각각 65,536개씩 묶은 17개의 평면으로 나눕니다(17 × 2^16 = 1,114,112).

  • 평면 0, 기본 다국어 평면(BMP, Basic Multilingual Plane): U+0000부터 U+FFFF까지. 라틴, CJK 한자, 키릴, 아랍어, 그리스어 등 레거시 텍스트에서 마주치는 거의 모든 문자가 여기 있습니다.
  • 평면 1~16, 보충 평면(supplementary planes): U+10000부터 U+10FFFF까지. 대부분의 이모지(U+1F600과 그 친구들), 희귀 CJK 문자, 역사적 문자 체계(이집트 상형문자, 설형문자), 음악 표기법이 있습니다.

U+FFFF에서 그어지는 BMP / 보충 평면의 경계선은 이 글에서 가장 자주 등장하는 숫자입니다. UTF-16이 문자당 한 코드 유닛이 아니게 되는 지점이며, UTF-8이 3바이트에서 4바이트로 점프하는 지점이고, MySQL의 이름을 잘못 붙인 utf8 콜레이션이 손을 드는 지점입니다.

이모지로 빠르게 감 잡기

"a"        → 1 codepoint  U+0061             → 1 grapheme
"é" (NFC)  → 1 codepoint  U+00E9             → 1 grapheme
"é" (NFD)  → 2 codepoints U+0065 U+0301      → 1 grapheme
"😀"        → 1 codepoint  U+1F600 (Plane 1)  → 1 grapheme
"👨‍👩‍👧"      → 5 codepoints (3 people + 2 ZWJ U+200D) → 1 grapheme

마지막 줄이 핵심입니다. 가족 이모지는 사용자가 하나로 인식하는 문자지만 다섯 개의 코드포인트가 두 개의 Zero-Width Joiner로 이어져 있습니다. 스택의 모든 계층이 이를 다르게 셀 수 있고, 7장 함정 6은 이 불일치가 만들어 내는 버그 리포트입니다.

UTF-8 인코딩 메커니즘 — 1~4 바이트가 작동하는 방식

UTF-8은 Unicode 코드포인트를 1~4 바이트로 인코딩합니다. ASCII(U+0000U+007F)는 1바이트를 쓰며 ASCII와 바이트 단위로 동일합니다. 더 높은 코드포인트는 여러 바이트 시퀀스를 쓰는데, 첫 바이트가 전체 길이를 알리고 이어지는 모든 연속 바이트는 10xxxxxx 비트 패턴으로 시작합니다. 이 자기 기술적 레이아웃이 UTF-8이 인코딩 전쟁에서 살아남은 이유입니다.

바이트 패턴 테이블 — 다이어그램 한 장으로 보는 UTF-8

코드포인트 범위UTF-8 바이트바이트 패턴
U+0000U+007F1 byte0xxxxxxx
U+0080U+07FF2 bytes110xxxxx 10xxxxxx
U+0800U+FFFF3 bytes1110xxxx 10xxxxxx 10xxxxxx
U+10000U+10FFFF4 bytes11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

x는 코드포인트의 이진 표현에서 가져온 데이터 비트입니다. 선두의 0 / 110 / 1110 / 11110은 디코더에게 총 몇 바이트인지 알려 주고, 선두의 10은 모든 연속 바이트를 표시합니다. 이 중복성 덕분에 UTF-8은 자기 동기화(self-synchronizing)가 가능합니다. 한 바이트가 빠져도 뒤를 모두 망치는 대신 다음 시작 바이트에서 재개할 수 있습니다.

실습 예제 — (U+4E2D) 인코딩

코드포인트 0x4E2DU+0800U+FFFF 범위에 들어가므로 3바이트 템플릿을 씁니다.

  1. 이진: 0x4E2D = 0100 1110 0010 1101 (16비트).
  2. x 슬롯에 맞추어 4-6-6으로 분할: 0100 / 111000 / 101101.
  3. 1110xxxx 10xxxxxx 10xxxxxx에 대입: 11100100 10111000 10101101.
  4. 16진: 0xE4 0xB8 0xAD.

이 URL 인코딩 후 %E4%B8%AD이 되는 이유가 이것입니다. 퍼센트 인코딩은 각 UTF-8 바이트를 %XX로 감쌀 뿐 코드포인트를 직접 인코딩하지 않습니다. 7장 함정 3에서 이 사슬을 자세히 다룹니다.

실습 예제 — 😀(U+1F600) 인코딩

코드포인트 0x1F600은 BMP를 넘어가므로 4바이트 템플릿을 씁니다.

  1. 이진: 0x1F600 = 0 0001 1111 0110 0000 0000 (패딩 포함 21비트).
  2. 3-6-6-6으로 분할: 000 / 011111 / 011000 / 000000.
  3. 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx에 대입: 11110000 10011111 10011000 10000000.
  4. 16진: 0xF0 0x9F 0x98 0x80.

이 4바이트가 바로 MySQL의 utf8 콜레이션이 저장하지 못하는 값입니다. utf8은 문자당 최대 3바이트만 허용하기 때문입니다. 해결책은 7장 함정 1에 있습니다.

UTF-8이 살아남은 이유 — 세 가지 강점

  1. ASCII 호환성. 순수 ASCII 텍스트 파일은 바이트 수준에서 자신의 UTF-8 인코딩과 같습니다. Unicode 이전부터 있던 수십 년 묵은 도구들(grep, awk, 고전적인 셸 파이프)이 그 부분집합에 대해서는 그대로 작동합니다.
  2. 자기 동기화. 연속 바이트는 항상 10으로 시작하므로 어떤 시작 바이트와도 충돌하지 않습니다. 네트워크 전송 중 한 바이트가 유실되어도 다음 문자 경계에서 재동기화되며 쓰레기 값이 줄줄이 이어지지 않습니다.
  3. 바이트 순서 없음. UTF-8은 16비트나 32비트 유닛이 아니라 바이트 스트림이므로 엔디언이 무관합니다. UTF-16과 UTF-32는 어느 쪽이 먼저인지 선언하기 위해 Byte Order Mark가 필요하지만 UTF-8은 그렇지 않습니다(보통 붙이지 않는 편이 낫습니다. 5장 참고).

유효하지 않은 UTF-8 — 명세가 금지하는 것

엄격한 디코더는 다음 바이트 시퀀스를 거부합니다.

  • 5바이트 또는 6바이트 시퀀스. 초기 RFC는 허용했지만 RFC 3629(2003)는 21비트 Unicode 공간에 맞추어 UTF-8을 4바이트로 제한했습니다.
  • 오버롱 인코딩. /를 단일 바이트 0x2F 대신 세 바이트 0xE0 0x80 0xAF로 인코딩하는 것. 한때 새니타이즈 후 디코딩하는 경로 검증기에서 디렉터리 순회 공격의 단골 진입점이었습니다.
  • 고립된 서로게이트 코드포인트(U+D800U+DFFF). UTF-16 전용으로 예약되어 있어 UTF-8에 절대 나타나선 안 됩니다.
  • 잘린 시퀀스. 3바이트 시작 바이트 뒤에 연속 바이트가 하나만 따라오는 경우. 사용자 입력이 다중 바이트 문자 중간의 바이트 경계에서 잘릴 때 흔히 발생합니다.

이 중 어느 것이라도 구체적으로 보고 싶다면 Base64 인코더 디코더 - 온라인 무료 즉시 변환 | Go-Tools에 문자열을 넣어 인코딩한 뒤 다시 바이트로 디코딩해 보십시오. 인코더와 디코더 사이의 바이트 배열이 이 장이 설명하는 UTF-8 스트림입니다.

UTF-16과 서로게이트 페어 — JavaScript length가 거짓말하는 이유

utf-8 vs utf-16을 둘러싼 가장 흔한 검색은 “왜 내 코드에서 "😀".length가 2지?”입니다. 답은 서로게이트 페어이며, JavaScript, Java, C#, Windows가 모두 물려받은 1990년대의 결정입니다.

한 문단으로 보는 UTF-16

UTF-16은 16비트 코드 유닛으로 Unicode를 표현합니다. BMP의 문자(U+0000U+FFFF)는 정확히 한 코드 유닛을 차지합니다. 보충 평면의 문자(U+10000U+10FFFF)는 두 코드 유닛을 차지하며 이를 서로게이트 페어라고 부릅니다. U+D800U+DBFF 범위의 상위 서로게이트가 먼저 오고 U+DC00U+DFFF 범위의 하위 서로게이트가 뒤따릅니다. U+D800U+DFFF 블록은 Unicode에서 영구히 예약되어 있어 실제 문자는 그곳에 살지 않습니다. UTF-16은 JavaScript, Java, C#(.NET), Windows 커널 API, Objective-C NSString, Qt의 내부 문자열 형식입니다. 모두 65,536자면 충분해 보이던 시절에 설계되었습니다.

String.length 함정

"a".length          // 1   — BMP, single code unit
"é".length          // 1   — BMP (U+00E9), single code unit
"中".length         // 1   — BMP (U+4E2D), single code unit
"😀".length         // 2   — supplementary plane (U+1F600), surrogate pair!
"a😀".length        // 3   — one BMP + two surrogate units

String.prototype.length는 문자 수가 아니라 UTF-16 코드 유닛 수를 보고합니다. 보충 평면의 어떤 것이든 2로 읽힙니다. Java의 String.length()와 C#의 string.Length에도 같은 함정이 있습니다.

JS에서 코드포인트를 올바르게 세는 법

[..."😀"].length              // 1 — spread iterator walks codepoints
Array.from("😀").length       // 1 — Array.from also walks codepoints
"😀".match(/./gu).length      // 1 — /u flag = unicode-aware regex

// "😀".charAt(0) returns the lone high surrogate (visually broken)
"😀".codePointAt(0)           // 128512 — the full codepoint U+1F600

전개 연산자와 Array.from은 이터레이터 프로토콜을 쓰며, 언어 명세는 이를 코드포인트를 순회하는 동작으로 정의합니다. 일반 인덱스 접근(str[0], charAt)은 여전히 코드 유닛을 반환하며 이모지에서는 서로게이트 페어의 절반을 건네줍니다.

Python — len()이 이미 (거의) 올바른 일을 합니다

len("😀")           # 1   — Python 3 strings are codepoint-indexed
len("👨‍👩‍👧")        # 5   — codepoints (3 humans + 2 ZWJ), not graphemes
# Python 2 was byte-indexed by default — len("😀") returned 4

Python 3는 문자열을 가변 1, 2, 4 바이트 표현(PEP 393)으로 저장하고 코드포인트로 인덱싱합니다. len("😀")은 1이지만, 그래도 자소 수는 아닙니다. 가족 이모지는 여전히 5로 읽힙니다. 사용자가 인식하는 문자 수를 세려면 자소 라이브러리가 필요합니다. JavaScript의 Intl.Segmenter(Node 22+, 모든 최신 브라우저), Python의 grapheme이나 regex, 아니면 그냥 Swift를 쓰면 됩니다. Swift의 String.count는 주류 언어 중 자소 카운트가 기본인 거의 유일한 언어입니다.

UTF-16 vs UCS-2 — 조용한 이주

1996년 이전 Unicode는 16비트에 맞춰질 것이라 약속했고, 그에 대응하는 인코딩이 UCS-2였습니다. 고정 2바이트 매핑이었습니다. Unicode 2.0이 보충 평면을 추가하면서 그 약속이 깨졌습니다. UTF-16은 서로게이트 페어를 쓴 패치 버전입니다. JavaScript 명세는 일부 부분에서 여전히 옛 UCS-2 어휘를 인용하는데, 이 때문에 언어가 본래 불법이어야 할 고립된 서로게이트를 용인합니다. “WTF-16”이라는 별명은 농담이 아닙니다. 웹 플랫폼 API(DOM, fetch, TextEncoder)는 유효한 UTF-8로 인코딩할 수 없으므로 고립된 서로게이트를 거부합니다.

UTF-32, BOM, 그리고 바이트 순서 문제

UTF-32 — 단순하지만 낭비가 심한 인코딩

UTF-32는 코드포인트당 4바이트를 고정으로 씁니다. U+00410x00000041로, U+1F6000x0001F600으로 저장됩니다. 장점은 상수 시간 랜덤 액세스입니다. n번째 코드포인트가 바이트 오프셋 4n에 있습니다. 단점은 크기입니다. 순수 ASCII 텍스트는 UTF-8 대비 네 배로 부풀고, CJK 텍스트조차 두 배가 됩니다. 거의 어떤 시스템도 UTF-32를 디스크에 저장하지 않습니다. 내부적으로 Python 3는 가장 높은 코드포인트에 따라 문자열마다 1, 2, 4 바이트를 선택하고, Linux fontconfig 스택은 메모리상의 글리프 테이블에 UTF-32를 씁니다.

바이트 순서 — UTF-16 / UTF-32에서 엔디언이 중요한 이유

UTF-8은 단일 바이트 스트림이므로 엔디언이 적용되지 않습니다. UTF-16과 UTF-32는 다중 바이트 유닛에서 동작하며, CPU마다 한 숫자의 어느 끝이 먼저 오는지에 대해 의견이 다릅니다.

U+0041 ('A') in UTF-16 BE → 00 41
U+0041 ('A') in UTF-16 LE → 41 00

x86과 ARM CPU는 리틀 엔디언이고, 구형 PowerPC와 “네트워크 바이트 순서”는 빅 엔디언입니다. UTF-16 파일을 쓸 때는 어느 한쪽으로 정해야 하고, 그 정보를 독자에게 알려야 합니다. 그 역할이 BOM의 몫입니다.

BOM — 무엇이고 언제 쓰는가

Byte Order Mark는 파일 시작 부분에 놓인 U+FEFF입니다. 인코딩된 형태로 인코딩과 (UTF-16 / UTF-32의 경우) 바이트 순서를 모두 알립니다.

인코딩BOM 바이트
UTF-8EF BB BF
UTF-16 BEFE FF
UTF-16 LEFF FE
UTF-32 BE00 00 FE FF
UTF-32 LEFF FE 00 00

utf-8 BOM은 존재하지만 UTF-8에는 바이트 순서가 없으므로 어떤 바이트 순서 정보도 담지 않습니다. 유일한 역할은 “이 파일은 UTF-8입니다”라고 선언하는 것입니다. 다른 신호가 없는 도구에는 유용하지만, 파일이 매직 넘버나 디렉티브로 시작해야 하는 도구에는 해롭습니다.

BOM 의사 결정 매트릭스 — 추가해야 할까요?

형식UTF-8 BOMUTF-16 BOMUTF-32 BOM
HTMLNo (구형 파서의 <!doctype> 감지를 망가뜨림)
JSONNo (RFC 8259가 금지)
JavaScript / CSS 소스피하세요 (구형 Node와 IE가 막힘)
Excel에서 여는 CSVYes (Excel은 BOM 없는 UTF-8을 ANSI로 읽어 CJK를 망가뜨림)
XML선택 (XML 선언이 이미 인코딩을 명시)필수필수
일반 텍스트 .txt선택 (Windows 메모장은 기본으로 추가)필수필수

요약하면 웹으로 서빙하는 어떤 것에서도 UTF-8 BOM은 빼고, Excel에서 열려는 CSV에는 붙이고, 나머지는 독자가 결정하도록 두십시오.

9개 언어 비교 — 기본 인코딩 동작

이 지식이 진가를 발휘하는 곳이 다국어 작업입니다. 같은 문자열 "a😀é"가 Bash 스크립트에서 호출하는 런타임마다 다른 길이를 만들어 냅니다.

언어 간 동작 테이블

언어소스 파일 인코딩문자열 저장 방식length / len 카운트기본 I/O 인코딩4바이트 이모지 안전?
자바스크립트 (JavaScript, V8 / SpiderMonkey)UTF-8UTF-16UTF-16 코드 유닛UTF-8 (Node, Web)Yes, 다만 .length === 2
Python 3UTF-8 (PEP 3120)가변 1 / 2 / 4 byte (PEP 393)코드포인트UTF-8 (3.7부터 PEP 540)Yes, len === 1
JavaUTF-8 (javac 기본)UTF-16UTF-16 코드 유닛플랫폼 charset → UTF-8 (JEP 400, JDK 18+)Yes, 다만 .length() === 2
GoUTF-8UTF-8 바이트바이트 (코드포인트는 utf8.RuneCountInString)UTF-8Yes, len(s)는 바이트 반환
RustUTF-8UTF-8 바이트 (String 불변성).len() 바이트, .chars().count() 코드포인트UTF-8Yes, 명시적
C# (.NET)UTF-8 (.NET Core 3.0부터 기본)UTF-16UTF-16 코드 유닛UTF-8 (.NET 5부터 Encoding.Default)Yes, 다만 .Length === 2
RubyUTF-8 (2.0부터)문자열별 인코딩 태그코드포인트 (.length)UTF-8Yes, length === 1
PHP(소스 인코딩 없음)바이트 문자열바이트 (strlen); 코드포인트는 mb_strlendefault_charset에 의존Yes, mb_* 계열 사용 시
MySQL컬럼 charset바이트 (LENGTH), 문자 (CHAR_LENGTH)character_set_* 시스템 변수utf8mb4에서만

이 테이블이 진짜 말해 주는 것

세 가지 철학과 세 가지 버그 묶음으로 정리됩니다.

  • UTF-8 내부(Go, Rust, Ruby). 네이티브 문자열이 바이트입니다. length는 잘 정의되어 있지만 세는 대상이 한정되어 있습니다. UI나 검증 경계를 넘을 때만 코드포인트나 자소로 변환하십시오.
  • UTF-16 내부(JavaScript, Java, C#). 1990년대의 가정을 물려받았습니다. length는 코드 유닛이고, 서로게이트 페어는 2로 셉니다. 사용자에게 보이는 카운트가 필요할 때는 코드포인트 인식 순회를 쓰십시오.
  • 코드포인트 인덱싱(Python 3). len이 코드포인트를 돌려주는데, ZWJ 이모지를 만나기 전까지는 맞아 보입니다. 그 후엔 여전히 자소 라이브러리가 필요합니다.

PHP는 특수한 경우입니다. 내장 str* 함수가 모두 바이트 단위로 동작하며 UTF-8 시퀀스를 불투명한 덩어리로 취급합니다. 비 ASCII 프로젝트라면 반드시 mb_*(multibyte) 계열을 써야 하고, 해마다 반복되는 버그 리포트가 이걸 얼마나 자주 놓치는지 보여 줍니다.

실무 지침은 이렇습니다. UTF-8을 모든 곳의 와이어 포맷으로 유지하십시오. 파일, HTTP 본문, 데이터베이스 컬럼 모두 마찬가지입니다. 그리고 경계에서 런타임의 네이티브 문자열 타입으로 변환하십시오. 이게 8장에서 다시 다룰 “UTF-8 샌드위치”입니다.

실전 엔지니어링 함정 8가지

다음 패턴들은 글로벌 코드베이스의 코드 리뷰에서 빠짐없이 등장합니다.

함정 1: MySQL utf8은 3바이트 변종 — utf8mb4로 전환

증상. INSERT INTO users (bio) VALUES ('Hello 😀');Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'를 반환합니다.

근본 원인. MySQL의 역사적 utf8utf8mb3의 별칭입니다. 문자당 최대 3바이트로 제한된 UTF-8 변종입니다. U+FFFF를 넘는 모든 코드포인트(모든 이모지, 수천 개의 희귀 CJK 문자, 모든 역사적 문자 체계)는 UTF-8 4바이트가 필요하므로 거부됩니다.

해결책.

ALTER DATABASE mydb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SET NAMES utf8mb4;  -- client connection
# my.cnf
[mysqld]
character-set-server = utf8mb4
collation-server     = utf8mb4_unicode_ci

MySQL 8.0도 여전히 utf8utf8mb3의 별칭으로 출하합니다. utf8mb3은 폐기 예정이지만 아직 제거되지는 않았습니다. 모든 새 컬럼, 모든 새 데이터베이스, 모든 새 연결에서 utf8mb4를 쓰십시오. 레거시 변종에는 장점이 없습니다.

함정 2: Windows-1252 폴백 — 물음표의 미스터리

증상. Windows 동료의 메모장에서 내보낸 .txt 파일이 그 사람 PC에서는 "smart quotes"와 em 대시로 보입니다. 내 서버에서는 ? 또는 U+FFFD(대체 문자)가 됩니다.

근본 원인. 구형 메모장은 기본이 Windows-1252(CP-1252)이며, 곡선형 따옴표 "0x93으로 인코딩합니다. UTF-8 디코더는 0x93을 선행 시작 바이트 없이 떠도는 연속 바이트(상위 비트 10)로 보고 대체 문자를 채워 넣습니다.

해결책. 원본 인코딩을 감지하고(Unix의 file, Python의 chardet / charset-normalizer, Node의 jschardet), 올바른 코덱으로 디코딩한 다음 저장 전에 UTF-8로 다시 인코딩하십시오. 수집 단계에서 UTF-8로 표준화하면 재발을 막을 수 있습니다.

함정 3: URL 퍼센트 인코딩 ≠ UTF-8 (그러나 UTF-8 위에 쌓음)

증상. fetch("/search?q=中文")이 한 백엔드 프레임워크에서는 404를 반환하고 다른 곳에서는 잘 작동합니다.

근본 원인. 퍼센트 인코딩은 코드포인트가 아니라 바이트에서 동작합니다. 은 코드포인트는 하나지만 UTF-8 바이트로는 셋(E4 B8 AD)이고, 각각이 개별적으로 %E4%B8%AD로 퍼센트 인코딩됩니다. URL에서 9개의 ASCII 문자가 되는 셈입니다. URL을 UTF-8 대신 Latin-1로 디코딩하는 프레임워크는 핸들러에 망가진 세 바이트를 단일 바이트 문자 셋으로 해석해서 건네줍니다.

해결책. 클라이언트에서 encodeURIComponent("中文")을 쓰고(브라우저는 UTF-8 + 퍼센트 인코딩을 한 단계로 처리합니다), 서버 프레임워크가 URL을 UTF-8로 디코딩하는지 확인하십시오(모든 현대 프레임워크가 기본으로 그렇게 합니다). 시각적으로 확인하려면 URL 인코더 디코더 - 온라인 URL 파싱 도구 | Go-Tools中文을 붙여넣고 %E4%B8%AD%E6%96%87이 되는 것을 보십시오. 전체 사슬은 URL 인코딩/디코딩: 퍼센트 인코딩 가이드와 온라인 도구 | Go-Tools에서 다룹니다.

함정 4: Base64 입력은 바이트인데 문자열을 타이핑했습니다

증상. btoa("你好")InvalidCharacterError: The string contains characters outside the Latin1 range를 던집니다.

근본 원인. btoa는 ASCII / Latin-1 시대에 설계되었습니다. 각 입력 문자가 단일 바이트(코드포인트 0-255)에 들어맞을 것을 기대합니다. 你好는 JS 엔진에서 코드포인트 U+4F60 U+597D로 표현되는 UTF-16이며, 둘 다 255를 한참 넘습니다.

해결책. 먼저 UTF-8 바이트로 인코딩한 다음 그 바이트를 Base64로 인코딩하십시오.

// Wrong:
btoa("你好");  // throws

// Correct:
const bytes = new TextEncoder().encode("你好");
// Uint8Array(6) [228, 189, 160, 229, 165, 189]
const b64 = btoa(String.fromCharCode(...bytes));
// "5L2g5aW9"

자세한 이야기는 Base64 인코딩이란? 초보자를 위한 온라인 가이드Base64 고급 가이드: MIME · Data URL · 성능 · 보안 | 온라인 개발자 도구에 있습니다. Base64 인코더 디코더 - 온라인 무료 즉시 변환 | Go-Tools는 한 단계로 변환을 처리하고 중간 바이트 스트림도 보여 줍니다.

함정 5: 유효성 검사용 String.length (Twitter / SMS 제한)

증상. 280자 컴포저가 클라이언트에서 통과한 글을 API가 422로 거부합니다. 또는 그 반대로 멀쩡한 글을 클라이언트가 거절하는 경우입니다.

근본 원인. JavaScript의 .length는 UTF-16 코드 유닛을 셉니다. 이모지 하나가 2로 카운트됩니다. Twitter는 코드포인트를 셉니다(이모지 = 1). 글자 수가 어느 API를 믿느냐에 따라 정반대 방향으로 틀립니다.

해결책. 코드포인트 카운트는 [...text].length를, 진짜 자소 카운트는 Intl.Segmenter를 쓰십시오(Bluesky / iMessage가 택하는 방식). 플랫폼별 숫자와 SMS GSM-7 vs UCS-2 경계는 글자 수·단어 수 제한 2026 — Twitter, SMS, SEO, Instagram에 정리되어 있습니다.

함정 6: ZWJ 이모지 가족은 N개 코드포인트지만 자소는 1개

증상. "👨‍👩‍👧".length === 8. 코드포인트를 세면 5입니다. 사용자에게는 이미지 하나입니다.

근본 원인. Zero-Width Joiner(U+200D)는 여러 이모지 코드포인트를 단일 렌더링 클러스터로 묶습니다. 사람 이모지 셋에 ZWJ 둘을 더하면 코드포인트는 다섯, UTF-16 코드 유닛은 여덟, 자소는 하나가 됩니다.

해결책.

const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
[...seg.segment("👨‍👩‍👧")].length;  // 1

Intl.Segmenter는 Node 22+와 모든 최신 브라우저에 있습니다. 더 오래된 런타임에서는 grapheme-splitter 패키지가 UAX #29를 구현합니다.

함정 7: JSON \uXXXX 이스케이프 — U+FFFF 이상의 코드포인트는 서로게이트 페어가 필요

증상. JSON 페이로드에 "😀"가 들어 있고, 받는 쪽 디코더는 JSON의 서로게이트 페어를 이해하는지에 따라 😀로 정확히 렌더링하거나 두 개의 박스 문자를 보여 줍니다.

근본 원인. JSON의 \uXXXX 이스케이프는 정확히 4자리 16진수만 받습니다. UTF-16 코드 유닛 한 개입니다. 😀(U+1F600)를 인코딩하려면 서로게이트 페어 😀가 필요합니다. JSON에는 \u{...} 중괄호 문법이 없습니다.

해결책. 서로게이트 페어를 받아들이거나(명세를 준수하는 모든 파서가 처리합니다), 이모지를 그대로 적으십시오. JSON은 이스케이프 문법 바깥의 모든 UTF-8 문자를 허용하며, 대부분의 현대 파서가 그 형태를 선호합니다.

함정 8: HTTP Content-Type: charset= 기본값은 생각과 다릅니다

증상. UTF-8 HTML 페이지가 한 브라우저에서는 모지바케로, 다른 브라우저에서는 정상으로 렌더링됩니다.

근본 원인. RFC 2616은 본래 명시적 charset이 없는 text/* 응답에 대해 ISO-8859-1을 기본값으로 강제했습니다. RFC 7231(2014)이 그 기본값을 제거하면서 각 브라우저가 추측하게 되었습니다. 일부는 콘텐츠를 스니핑하고, 일부는 UTF-8로 폴백하며, 일부는 시스템 로케일을 기본으로 씁니다.

해결책. 서버에서 항상 Content-Type: text/html; charset=utf-8을 보내고 동시에 문서 헤드에 <meta charset="utf-8">을 두십시오. 둘 중 하나만으로도 충분하지만, 헤더를 떼어내는 레거시 프록시를 위한 이중 안전장치로 둘 다 두는 편이 좋습니다.

이 함정들을 바이트 수준에서 직접 보고 싶다면 Base64 인코더 디코더 - 온라인 무료 즉시 변환 | Go-Tools가 가장 빠른 확인 경로입니다. 문자열을 붙여넣고 Base64로 인코딩하면 디코딩된 페이로드가 곧 UTF-8 스트림입니다.

올바른 인코딩 선택 — 의사 결정 매트릭스

utf-8 vs utf-16 질문에 대한 답은 거의 항상 UTF-8입니다. 아래 표는 예외 사례를 다룹니다.

의사 결정 매트릭스

시나리오선택이유
웹 페이지, API JSON, 소스 파일UTF-8 (BOM 없음)ASCII 호환, 바이트 순서 없음, 라틴 텍스트에서 가장 작음, RFC 8259가 JSON에 UTF-8을 의무화
대규모 CJK 저장 (중국어 DB, 일본어 게임 데이터)UTF-8 (utf8mb4)UTF-8은 CJK 문자당 3바이트, UTF-16은 2바이트지만 마크업과 JSON 키의 ASCII 오버헤드 덕분에 실무에서는 여전히 UTF-8이 앞섭니다. 게다가 주변 생태계 전체가 UTF-8입니다
Windows 네이티브 API, 레거시 Java / C# 코드UTF-16플랫폼 기본값; 매 API 호출마다 변환하면 버그를 부릅니다
인덱스 중심의 메모리 내 텍스트 처리UTF-32상수 시간 코드포인트 액세스; 파서의 핫 패스에서만 가치가 있습니다
Windows의 Excel에서 여는 CSVUTF-8 with BOMExcel은 BOM 없는 UTF-8을 ANSI로 읽어 CJK 헤더를 망가뜨립니다
제약이 없는 새 프로젝트UTF-8 (BOM 없음)인코딩 전쟁은 결정적으로 끝났습니다

두 가지 어림 법칙

  1. 플랫폼이 강제하지 않는 한 어디서나 UTF-8을 기본으로 두십시오. W3C, IETF, Unicode Consortium 모두 같은 입장입니다.
  2. 중간이 아니라 경계에서 변환하십시오. 수집 시점에 바이트를 언어의 네이티브 문자열 타입으로 디코딩하고, 비즈니스 로직에서는 바이트가 아닌 문자열을 다루며, 출력 시점에 UTF-8로 다시 인코딩하십시오. 이 “UTF-8 샌드위치”가 파이프라인 중간의 모지바케 버그를 한 부류 통째로 제거합니다.

자주 묻는 질문

UTF-8은 항상 ASCII와 하위 호환됩니까?

그렇습니다. 유효한 모든 ASCII 파일은 자신의 UTF-8 표현과 비트 단위로 동일합니다. 첫 128개 코드포인트(U+0000U+007F)는 최상위 비트가 0인 단일 바이트로 인코딩됩니다. 레거시 ASCII 전용 도구들(초기 grep, sed, 고전적인 셸 파이프)은 순수 ASCII UTF-8 파일을 수정 없이 처리합니다. 문제는 비 ASCII 바이트(상위 비트가 1)가 스트림에 들어올 때 비로소 시작됩니다.

파일에 UTF-8 BOM을 사용해야 합니까?

기본은 사용하지 않는 쪽입니다. HTML, JSON, JavaScript, CSS 파일은 시작 부분에 BOM이 나타나면 일부 파서에서 깨지거나 경고합니다. 표준적인 예외는 Windows의 Excel에서 열려는 CSV입니다. BOM이 없으면 Excel은 ANSI로 추측해 중국어, 일본어, 한국어 헤더를 망가뜨립니다. BOM 의사 결정 매트릭스는 5장을 참고하십시오.

JavaScript에서 "😀".length === 2인 이유는 무엇입니까?

JavaScript 문자열은 UTF-16으로 저장되며 .length는 문자 수가 아니라 코드 유닛 수를 반환합니다. 😀(U+1F600)는 보충 평면에 있어 서로게이트 페어(16비트 코드 유닛 두 개)가 필요하므로 .length가 2입니다. 진짜 카운트를 얻으려면 [..."😀"].length, Array.from("😀").length, 또는 Intl.Segmenter를 쓰십시오.

Unicode와 UTF-8의 차이는 무엇입니까?

Unicode는 모든 문자에 코드포인트(U+1F600 같은 숫자)를 부여하는 문자 테이블입니다. UTF-8은 그 코드포인트를 바이트로 변환하는 여러 인코딩 중 하나입니다(코드포인트당 1~4바이트). Unicode는 문자가 무엇인지 정의하고, UTF-8은 그것이 어떻게 파일이나 네트워크를 통해 이동하는지 정의합니다. UTF-16과 UTF-32는 같은 Unicode 테이블의 다른 인코딩들입니다.

MySQL에서 utf8mb4가 항상 utf8보다 안전합니까?

새 프로젝트라면 그렇습니다. MySQL의 utf8은 이름을 잘못 붙인 3바이트 제한 변종 utf8mb3이며, U+FFFF 이상의 어떤 문자도 저장할 수 없습니다. 모든 이모지, 다수의 희귀 CJK 문자, 모든 역사적 문자 체계가 해당됩니다. utf8mb4는 완전한 4바이트 UTF-8입니다. 한 가지 주의점은 인덱스 길이입니다. utf8mb4 문자마다 최대 4바이트가 들 수 있으므로 InnoDB의 레거시 인덱스 한도인 767바이트가 고유 인덱스를 191자로 제한합니다(MySQL 5.7+의 innodb_large_prefix로 해결되었고 8.0에서는 기본값입니다).

알 수 없는 파일의 인코딩을 어떻게 감지합니까?

Unix에서는 file, Python에서는 chardet이나 charset-normalizer, Node에서는 jschardet을 쓰십시오. 어느 것도 완벽하지 않습니다. 모두 바이트 분포에서 통계적으로 추측합니다. UTF-8 감지는 연속 바이트 패턴 덕분에 매우 신뢰할 만합니다. Windows-1252, ISO-8859-1, 기타 단일 바이트 레거시 인코딩은 서로 거의 구분되지 않으므로 감지가 결국 언어 휴리스틱으로 귀결되는 경우가 많습니다.

UTF-16은 모든 Unicode 문자를 표현할 수 있습니까?

그렇습니다. UTF-16은 1,114,112개의 코드포인트를 모두 다룹니다. BMP 문자(U+0000U+FFFF)는 16비트 코드 유닛 하나(2바이트)를 쓰고, 보충 평면 문자(U+10000U+10FFFF)는 서로게이트 페어(4바이트)를 씁니다. 커버리지는 UTF-8, UTF-32와 같으며 바이트 레이아웃과 처리 의미만 다릅니다. 셋 사이의 선택은 능력이 아니라 생태계 적합성의 문제입니다.

태그: unicode utf-8 utf-16 character-encoding surrogate-pair encoding