Skip to content

필사 모드: 유니코드와 UTF-8: 텍스트의 지뢰밭

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며 — "글자 하나"라는 착각

프로그래밍을 시작하면 우리는 문자열을 "글자들의 나열"이라고 배웁니다. 그리고 대개는 그 모델로 잘 지냅니다. 영어권에서 ASCII만 다루던 시절에는 그것이 사실이었으니까요. 한 글자는 1바이트였고, 문자열 길이는 곧 글자 수였으며, 문자열을 뒤집는 것은 배열을 뒤집는 것과 같았습니다.

그런데 우리가 다루는 텍스트는 영어만이 아닙니다. 한글, 일본어, 이모지, 결합 악센트, 오른쪽에서 왼쪽으로 쓰는 문자까지 들어오는 순간, "글자 하나"라는 단순한 모델은 무너집니다. 그리고 이 붕괴는 조용히 일어납니다. 컴파일 에러도 없고, 대부분의 테스트도 통과합니다. 그러다 사용자가 이모지가 섞인 이름을 넣거나, 한국어 사용자가 macOS에서 만든 파일명을 리눅스에서 검색하는 순간, 갑자기 모든 것이 어긋납니다.

이 글은 그 지뢰밭을 지도로 그립니다. 바이트와 코드 포인트와 그래핌 클러스터가 어떻게 다른 층인지, 왜 "👨‍👩‍👧".length가 거짓말을 하는지, UTF-8과 UTF-16의 차이가 왜 실무 버그로 이어지는지를 하나씩 짚습니다. 특히 한글과 일본어를 다루는 우리에게는 남의 이야기가 아닙니다.

세 개의 층 — 바이트, 코드 포인트, 그래핌

가장 먼저 잡아야 할 것은 텍스트가 하나의 층이 아니라 세 개의 층이라는 사실입니다. 이 세 층을 뭉뚱그리는 데서 거의 모든 유니코드 버그가 시작됩니다.

  • 바이트(byte): 저장과 전송의 단위. 파일과 네트워크에 실제로 흐르는 것. 인코딩(UTF-8 등)이 코드 포인트를 바이트로 바꿉니다.
  • 코드 포인트(code point): 유니코드가 각 문자에 부여한 번호. U+AC00(가), U+1F600(😀)처럼 표기합니다. 유니코드는 이 번호의 거대한 사전입니다.
  • 그래핌 클러스터(grapheme cluster): 사람이 "한 글자"라고 인식하는 단위. 이것이 여러 코드 포인트로 이뤄질 수 있다는 점이 함정의 핵심입니다.

예를 들어 家족을 뜻하는 이모지 하나를 봅시다. 사람 눈에는 한 글자입니다. 하지만 그 뒤에는 여러 코드 포인트가 있고, 그 코드 포인트들은 다시 여러 바이트로 인코딩됩니다.

사람이 보는 것:   👨‍👩‍👧   (한 그래핌 = "한 글자")
코드 포인트:       U+1F468 U+200D U+1F469 U+200D U+1F467  (5개)
UTF-8 바이트:      11 바이트

세 층의 개수가 모두 다릅니다. 그래핌은 1개, 코드 포인트는 5개, 바이트는 11개입니다. 그런데 대부분의 언어에서 .length는 이 셋 중 어느 것도 정확히 세지 않습니다.

"👨‍👩‍👧".length는 거짓말을 하는가

자바스크립트에서 다음을 실행해 봅시다.

"👨‍👩‍👧".length        // 8
[..."👨‍👩‍👧"].length   // 5

.length가 8을 돌려줍니다. 그래핌은 1개인데, 코드 포인트도 5개인데, 왜 8일까요? 자바스크립트 문자열의 .lengthUTF-16 코드 유닛의 수를 셉니다. 그래핌도 아니고 코드 포인트도 아닙니다. 이 이모지의 5개 코드 포인트 중 세 개(사람 이모지들)는 UTF-16에서 각각 2개의 코드 유닛(서로게이트 페어)으로 표현되고, ZWJ 두 개는 각각 1개이므로, 3×2 + 2×1 = 8이 됩니다.

.length가 세는 것은 "사람이 보는 글자 수"도 아니고 "유니코드 문자 수"도 아니고, 저 안쪽 인코딩의 구현 세부 사항입니다. 스프레드 연산자 [...str]for...of는 코드 포인트 단위로 순회하므로 5를 돌려줍니다. 그리고 사람이 기대하는 "1"을 얻으려면 그래핌 클러스터 분할기(Intl.Segmenter 등)가 필요합니다.

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

여기서 얻을 교훈은 분명합니다. "문자열 길이"라는 질문에는 답이 하나가 아닙니다. 저장에 필요한 바이트 수인지, 코드 포인트 수인지, 사용자가 세는 글자 수인지를 먼저 정해야 합니다. 트위터 글자 수 제한, 입력 필드 최대 길이, 커서 이동 같은 UI 로직에서 이 구분을 놓치면 반드시 버그가 납니다.

UTF-8 vs UTF-16 vs UTF-32

유니코드는 "번호부"일 뿐이고, 그 번호를 실제 바이트로 어떻게 바꾸느냐가 인코딩입니다. 대표적인 세 가지를 비교해 봅시다.

인코딩코드 유닛 크기문자당 바이트특징
UTF-88비트1~4바이트 (가변)ASCII 호환, 웹 표준, 공간 효율적
UTF-1616비트2 또는 4바이트BMP는 2바이트, 그 밖은 서로게이트 페어
UTF-3232비트항상 4바이트고정 폭, 단순하지만 공간 낭비

UTF-8은 오늘날 사실상의 표준입니다. ASCII 범위(0~127)는 그대로 1바이트로 인코딩되므로 영어 텍스트는 ASCII와 완전히 같습니다. 그 위로 라틴 확장, 한글, 이모지로 갈수록 2, 3, 4바이트로 늘어납니다. 한글 한 글자는 UTF-8에서 3바이트, 일본어도 대부분 3바이트입니다. 그래서 한국어/일본어 텍스트는 영어보다 파일이 커집니다.

UTF-16은 자바(JVM), 자바스크립트, 윈도우, C#의 내부 문자열 표현입니다. 기본 다국어 평면(BMP, U+0000~U+FFFF)의 문자는 2바이트로 표현하지만, 그 바깥(이모지 대부분)은 두 개의 16비트 유닛, 즉 서로게이트 페어로 표현합니다. 앞에서 본 .length 거짓말의 근원이 바로 이것입니다.

UTF-32는 모든 코드 포인트를 항상 4바이트로 표현합니다. 인덱싱이 O(1)로 단순해지는 장점이 있지만, 대부분의 텍스트에서 공간을 크게 낭비하므로 저장/전송용으로는 거의 쓰지 않습니다.

서로게이트 페어 — UTF-16의 원죄

서로게이트 페어를 조금 더 깊이 봐야 합니다. 유니코드는 원래 16비트면 모든 문자를 담을 수 있으리라 생각했습니다(65,536자). 하지만 곧 부족해졌고, 코드 포인트 공간은 U+10FFFF까지 확장되었습니다. 이미 16비트 유닛에 묶여 있던 UTF-16은 이 확장된 문자를 어떻게든 표현해야 했습니다.

그 해법이 서로게이트 페어입니다. U+D800~U+DFFF 구간을 "혼자서는 문자가 아닌, 짝을 이뤄야만 의미가 있는" 특수 영역으로 예약해 두고, BMP 바깥의 문자를 이 구간의 두 유닛 조합으로 표현합니다.

😀  =  U+1F600
UTF-16:  0xD83D 0xDE00   (하이 서로게이트 + 로우 서로게이트)
"이 둘이 합쳐져야 😀 하나"

문제는 이 두 유닛을 실수로 쪼갤 수 있다는 것입니다. 자바스크립트에서 str.charAt(0)str[0]로 이모지의 첫 글자를 잘라내면, 반쪽짜리 서로게이트가 나와 깨진 문자(대개 )가 됩니다.

const s = "😀";
s[0];              // '\uD83D' — 반쪽짜리, 깨진 문자
s.substring(0, 1); // 마찬가지로 깨짐
s.codePointAt(0);  // 128512 — 올바른 코드 포인트

문자열을 자르거나 인덱싱할 때 코드 유닛 경계가 아니라 코드 포인트 경계를 존중해야 하는 이유가 이것입니다. 사용자 이름을 20자로 자르는 순진한 코드가 이모지를 반토막 내는 사고가 흔합니다.

정규화 — 같아 보이지만 다른 é

이제 한글·일본어 사용자에게 특히 중요한 지뢰를 밟을 차례입니다. 바로 정규화(normalization)입니다.

문제의 출발점은 이렇습니다. 유니코드에는 같은 글자를 표현하는 두 가지 방법이 있는 경우가 많습니다. 프랑스어 é를 봅시다.

  • 조합형(NFC): U+00E9 — "é" 그 자체인 단일 코드 포인트.
  • 분해형(NFD): U+0065 U+0301 — "e"(U+0065) 뒤에 결합 악센트(U+0301)를 붙인 것.

둘은 화면에 똑같이 é로 보입니다. 하지만 바이트 수준에서는 완전히 다른 데이터입니다. 그래서 이런 일이 벌어집니다.

const a = "é";           // NFC: 1 코드 포인트
const b = "é";     // NFD: 2 코드 포인트
a === b;                 // false !
a.length;                // 1
b.length;                // 2
a.normalize() === b.normalize();  // true (둘 다 NFC로 정규화)

눈에는 같은 글자인데 === 비교가 실패합니다. 데이터베이스에서 사용자 이름을 검색했는데 "분명히 있는데 안 나오는" 유령 버그의 흔한 정체가 이것입니다. 한쪽은 NFC로, 다른 쪽은 NFD로 저장되어 있으면 문자열 일치가 실패합니다.

유니코드는 네 가지 정규화 형태를 정의합니다.

형태이름방식
NFC정준 조합분해했다가 다시 최대한 합침 (가장 흔한 저장 형태)
NFD정준 분해최대한 분해
NFKC호환 조합호환 문자까지 정규화 후 조합
NFKD호환 분해호환 문자까지 정규화 후 분해

실무 원칙은 간단합니다. 입력받은 문자열은 저장하기 전에 하나의 형태(보통 NFC)로 정규화하라. 그러면 비교, 검색, 중복 검사가 안정됩니다.

macOS와 리눅스의 é 전쟁 — 한글이 특히 위험하다

정규화가 남의 이야기가 아닌 이유는 운영체제마다 선호하는 형태가 다르기 때문입니다. 특히 애플의 파일 시스템은 역사적으로 파일 이름을 NFD에 가까운 형태로 저장해 왔고, 리눅스와 윈도우는 대개 NFC를 씁니다.

이 차이가 한글에서 극적으로 드러납니다. 한글 "각"은 두 가지로 표현될 수 있습니다.

"각" (NFC):  U+AC01                     (1 코드 포인트, 완성형 음절)
"각" (NFD):  U+1100 U+1161 U+11A8        (ㄱ+ㅏ+ㄱ, 자모 3개로 분해)

macOS에서 "보고서_최종.hwp" 같은 파일을 만들어 압축하거나 git에 커밋한 뒤, 리눅스 서버에서 그 파일을 이름으로 찾으면 안 나올 수 있습니다. 바이트가 다르기 때문입니다. 한국 개발자라면 "맥에서 만든 zip을 서버에 풀었더니 한글 파일명이 깨지거나 검색이 안 된다"는 경험이 한 번쯤 있을 겁니다. 범인은 인코딩이 아니라 정규화 형태의 불일치인 경우가 많습니다.

일본어도 마찬가지로 탁점·반탁점이 붙은 가나(が, ぱ 등)가 분해형과 조합형을 가질 수 있어 같은 함정에 빠집니다. 그래서 파일 이름, 사용자 입력, 검색 키를 다룰 때는 어느 정규화 형태로 통일할지를 팀 규칙으로 정해 두는 것이 좋습니다.

이모지와 ZWJ — 한 글자를 조립하는 법

앞에서 家족 이모지가 5개의 코드 포인트라고 했습니다. 이 조립의 비밀이 ZWJ(Zero Width Joiner, U+200D)입니다. ZWJ는 눈에 보이지 않는 "접착제" 문자로, 앞뒤의 이모지를 하나로 합쳐 렌더링하라는 신호입니다.

👨 (남자) + ZWJ + 👩 (여자) + ZWJ + 👧 (여자아이)
  = 👨‍👩‍👧  (렌더링되면 가족 하나)

폰트나 플랫폼이 이 ZWJ 시퀀스를 이해하면 합쳐진 이모지 하나를 그리고, 이해하지 못하면 사람 셋이 나란히 표시됩니다. 그래서 같은 텍스트가 기기마다 다르게 보일 수 있습니다.

여기에 피부톤 수정자(Fitzpatrick modifier), 국기(두 개의 지역 표시 문자 조합), 성별·직업 조합까지 더해지면 한 그래핌이 코드 포인트 대여섯 개로 늘어나는 일이 흔합니다. 이 모든 것이 사용자에게는 "이모지 한 개"입니다. 문자열을 다룰 때 이 그래핌 클러스터를 존중하지 않으면, 이모지를 잘못 자르거나, 길이를 잘못 세거나, 커서를 절반만 움직이는 버그가 납니다.

문자열 뒤집기 — 가장 유명한 함정

"문자열을 뒤집어라"는 코딩 인터뷰의 고전입니다. 순진한 답은 이렇습니다.

function reverse(s) {
  return s.split("").reverse().join("");
}
reverse("hello");   // "olleh"  — 잘 됨

ASCII에서는 완벽합니다. 하지만 유니코드가 들어오면 무너집니다.

reverse("😀");        // "\uDE00\uD83D" — 서로게이트 페어가 뒤집혀 깨짐 → �
reverse("é");   // 악센트가 앞 글자에서 떨어져 나감 → "́e"

split("")은 UTF-16 코드 유닛 단위로 쪼개므로 서로게이트 페어를 반토막 냅니다. 결합 문자의 경우에는 악센트가 엉뚱한 글자에 붙습니다. 코드 포인트 단위로 처리하면 서로게이트 문제는 풀리지만, 결합 문자와 ZWJ 이모지는 여전히 깨집니다. 진짜로 올바르게 뒤집으려면 그래핌 클러스터 단위로 분할해야 합니다.

function reverseGraphemes(s, locale = "ko") {
  const seg = new Intl.Segmenter(locale, { granularity: "grapheme" });
  return [...seg.segment(s)].map(x => x.segment).reverse().join("");
}

이 예제가 주는 교훈은 문자열 뒤집기 자체가 중요해서가 아닙니다. "글자 단위로 처리한다"는 순진한 가정이 얼마나 자주, 얼마나 조용히 깨지는지를 보여주기 때문입니다. 자르기, 자릿수 세기, 커서 이동, 정규식 매칭까지 같은 함정이 곳곳에 숨어 있습니다.

실무 체크리스트

지금까지의 지뢰밭을 실무 규칙으로 압축하면 다음과 같습니다.

  • 인코딩은 UTF-8로 통일하라. 파일, DB, HTTP 헤더, 소스 코드까지 전부 UTF-8이면 인코딩 혼란의 절반이 사라집니다.
  • 길이의 정의를 먼저 정하라. 바이트인지, 코드 포인트인지, 그래핌인지. UI 글자 수 제한은 그래핌 기준이어야 사용자 기대와 맞습니다.
  • 입력은 저장 전에 정규화하라. 보통 NFC로. 검색 키와 비교 대상도 같은 형태로 맞춥니다.
  • 문자열을 코드 유닛 단위로 자르지 마라. 이모지와 결합 문자를 반토막 내지 않으려면 코드 포인트, 이상적으로는 그래핌 경계를 존중해야 합니다.
  • 파일 이름의 정규화 형태를 의심하라. 특히 macOS와 리눅스를 오가는 파이프라인, 한글·일본어 파일명에서.
  • 테스트에 이모지와 결합 문자를 넣어라. ASCII로만 테스트하면 이 버그들은 절대 안 잡힙니다.

마치며

텍스트는 "글자들의 나열"이라는 우리의 첫 직관은, 영어와 ASCII라는 좁은 세계에서만 참이었습니다. 실제 텍스트에는 바이트와 코드 포인트와 그래핌이라는 세 개의 층이 있고, 이모지와 결합 문자와 정규화가 그 층들을 서로 어긋나게 만듭니다. .length가 거짓말을 하고, 같아 보이는 é가 다르며, 문자열 뒤집기가 이모지를 깨뜨리는 것은 모두 이 어긋남에서 옵니다.

한글과 일본어를 다루는 우리에게 이것은 특히 절실합니다. 완성형과 조합형 사이, macOS와 리눅스 사이에서 같은 글자가 다른 바이트가 되는 세계에 살고 있으니까요. 다행히 원리는 단순합니다. 세 층을 구분하고, UTF-8로 통일하고, 입력을 정규화하고, 코드 유닛이 아니라 그래핌을 존중하는 것. 이 네 가지 습관만으로도 텍스트의 지뢰밭 대부분을 안전하게 건널 수 있습니다.

참고 자료

현재 단락 (1/104)

프로그래밍을 시작하면 우리는 문자열을 "글자들의 나열"이라고 배웁니다. 그리고 대개는 그 모델로 잘 지냅니다. 영어권에서 ASCII만 다루던 시절에는 그것이 사실이었으니까요. 한 ...

작성 글자: 0원문 글자: 6,702작성 단락: 0/104