LabHub

블로그

Unicode & UTF-8 완벽 정복 — 코드포인트, 그래핌, 정규화, 이모지, 한글 NFD/NFC 끝장 가이드 (2025)

들어가며 — 문자열은 어려운 문제다

"👨‍👩‍👧‍👦".length11이라는 사실을 처음 알았을 때 모든 JS 개발자는 멈칫한다. "가족 이모지 한 글자인데?" 문자열은 컴퓨팅에서 가장 오래된 데이터 타입이지만, 여전히 가장 많은 버그를 만들어낸다.

이 글은 Unicode와 UTF-8의 내부 구조를 처음부터 끝까지 해부한다. ASCII에서 16비트 UCS-2의 실패, UTF-8의 영리한 설계, surrogate pair가 왜 생겼는지, "이모지 1개"가 7 byte × 4 code point로 분해되는 이유, NFC/NFD가 무엇인지, 그리고 현대 언어(JS, Python, Go, Rust, Java, Swift)가 문자열을 어떻게 다루는지까지 정리한다.

이 글을 끝까지 읽으면, 다음 버그들이 더 이상 "마법"이 아니게 된다. 왜 "é".length === 2일 수 있는지, 왜 한글 자소 분리가 일어나는지, 왜 DB 컬렉션(collation)이 성능과 정확성을 동시에 결정하는지.


1. ASCII에서 Unicode까지 — 부족함의 역사

1.1 7비트 ASCII (1963)

American Standard Code for Information Interchange. 128개 기호:

영어에는 충분했지만 스페인어 "ñ", 프랑스어 "é", 독일어 "ü"가 없다.

1.2 8비트 확장 — 지역별 code page 전쟁

8번째 비트를 더 쓰면 256개까지 가능. 각 지역이 자기 문자를 128~255 영역에 배치:

이 시기에는 문서 = 바이트열 + 어떤 code page인지 알아야 읽을 수 있음. 메일 헤더에 Content-Type: text/plain; charset=ISO-8859-1 같은 명시가 필요했다. 중국어 페이지를 한국어 브라우저로 열면 한글 글꼴로 해석돼서 무슨 말인지 알 수 없는 문자열("뚜껑 깨진 글자")이 나오는 게 일상이었다.

1.3 Unicode의 등장 (1991)

목표: 모든 언어의 모든 문자에 하나씩 번호(code point)를 부여하자.

Plane 구성:

Unicode는 인코딩이 아니다. "이 문자는 숫자 U+1F600이다"라고 말할 뿐, 그 숫자를 바이트로 어떻게 저장할지는 별개 문제다.


2. 인코딩 — 코드포인트를 바이트로

2.1 UTF-32 — 간단하지만 낭비

각 code point = 4 byte 고정. 배열 인덱싱이 쉽다(s[i]로 i번째 문자 즉시 접근).

실전에서는 거의 안 쓰인다(내부 계산 목적 외).

2.2 UTF-16 — 16비트 기본 + 대리 쌍

UCS-2의 후예. 기본은 16비트로 저장하고, BMP 밖 문자(U+10000~)는 2개의 16비트 값(surrogate pair)으로 표현한다.

Surrogate 범위:

변환 공식:

code_point = 0x10000 + (high - 0xD800) * 0x400 + (low - 0xDC00)

😀 (U+1F600)을 UTF-16으로:

U+1F600 - 0x10000 = 0xF600
high = 0xD800 + (0xF600 >> 10) = 0xD83D
low  = 0xDC00 + (0xF600 & 0x3FF) = 0xDE00
→ 0xD83D 0xDE00 (4 byte)

Windows, Java, JavaScript(내부), .NET이 UTF-16 기반이다. 이 때문에 JS의 string.length는 UTF-16 code unit 수를 센다.

'😀'.length // 2 (surrogate pair)
'a'.length // 1
'한'.length // 1 (BMP 내부)

2.3 UTF-8 — 가변 길이의 걸작

Ken Thompson & Rob Pike가 1992년 식당 종이냅킨에 설계했다는 전설의 인코딩. 다음 특성을 모두 만족한다:

  1. ASCII 호환: 기존 ASCII 문서는 그대로 UTF-8로도 유효
  2. 가변 길이: 1~4 byte로 code point를 표현
  3. 자기 동기화: 어느 바이트에서 시작해도 문자 경계 파악 가능
  4. 정렬 보존: 바이트 단위 정렬 = code point 단위 정렬

인코딩 규칙:

U+0000   ~ U+007F   : 0xxxxxxx                            (1 byte, = ASCII)
U+0080   ~ U+07FF   : 110xxxxx 10xxxxxx                   (2 byte)
U+0800   ~ U+FFFF   : 1110xxxx 10xxxxxx 10xxxxxx          (3 byte, 한/중/일 대부분)
U+10000  ~ U+10FFFF : 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (4 byte, 이모지 등)

Leading bit 패턴으로 몇 바이트짜리인지 즉시 알 수 있다:

이 설계 덕분에 임의 바이트 위치에서 뒤로 최대 3 byte만 거슬러 올라가면 현재 문자의 시작 위치를 찾을 수 있다(자기 동기화).

2.4 예시 — "안녕 😀"를 UTF-8로

문자code pointUTF-8 bytes
U+C548EC 95 88
U+B155EB 85 95
(공백)U+002020
😀U+1F600F0 9F 98 80

총 10 byte. "안녕"만 해도 6 byte인데, 같은 문자열을 UTF-16으로 쓰면 6 byte, UTF-32로는 12 byte가 든다. 아시아 텍스트만 보면 UTF-8이 UTF-16보다 비효율로 보이지만, HTML/JSON의 대부분은 ASCII 마크업이라 전체적으로 UTF-8이 제일 작다.

2.5 왜 UTF-8이 승리했나

2025년 현재 웹 페이지의 98% 이상이 UTF-8이다(w3techs 통계). JSON도 UTF-8 기본, Go/Rust의 string은 UTF-8 표현 그대로다. 네트워크와 디스크 세계에서 UTF-8은 사실상 유일한 답이다.


3. Code Point vs Code Unit vs Grapheme

3.1 3개의 구분

3.2 언어별 length가 뭘 세나

// JavaScript (UTF-16 code unit)
'😀'.length // 2
'한'.length // 1

// Python 3 (code point)
len('😀') // 1
len('한') // 1

// Rust (byte)
"😀".len()   // 4
"한".len()    // 3

// Go (byte)
len("😀")   // 4

"length가 1이면 안전하다"는 가정은 어느 언어에서도 맞지 않다. 의도를 분명히 해야 한다.

3.3 Grapheme의 복잡성

"é"를 생각해보자. 두 방법이 있다:

외관이 똑같아도 code point 수가 다르다. "é" === "é"의 결과는 어느 입력이냐에 따라 달라진다.

3.4 한글의 두 가지 방법

"한"이 어떻게 표현되느냐:

macOS 파일시스템(APFS는 비정규화로 바뀌었지만 HFS+ 시절)은 NFD를, 대부분의 텍스트는 NFC를 쓴다. 그래서 맥에서 만든 파일을 Linux/Windows에서 보면 한글 파일명이 이상해지는 일이 생긴다. 자소가 쪼개져서 보이거나, byte 차이로 파일 조회가 실패한다.


4. 정규화 — NFC, NFD, NFKC, NFKD

4.1 왜 정규화가 필요한가

같은 "é"가 두 가지 표현이 가능하면, 검색·비교·해시·저장이 깨진다. Unicode는 정규 형식(normalization form) 4가지를 정의한다:

"K"가 붙은 건 compatibility — 시각적으로는 다르지만 의미가 같은 문자들을 통합한다. 예: "ℌ" (U+210C, Mathematical Fraktur H) → NFKC로는 "H".

4.2 예시

const a = '한' // 'U+D55C'
const b = '\u1112\u1161\u11AB' // 'ㅎ+ㅏ+ㄴ'

a === b // false
a.length // 1
b.length // 3

a.normalize('NFC') === b.normalize('NFC') // true ✅

4.3 실전 규칙

Unicode 표준은 Idempotent 성질을 보장한다: normalize(normalize(s, 'NFC'), 'NFC') === normalize(s, 'NFC'). 한 번 정규화하면 여러 번 적용해도 안전.

4.4 주의: 정규화는 공짜가 아니다

수백 MB 텍스트를 매번 정규화하면 느리다. 한 번 저장 시 정규화하고, 이후엔 안 하는 전략이 보통이다. 또한 NFKC는 "①"→"1" 같은 의미 변화를 일으키므로 UI 표시용 데이터에는 NFC, 검색 인덱스용은 NFKC처럼 용도를 분리하자.


5. 이모지 — Unicode의 가장 복잡한 영역

5.1 단일 코드포인트 이모지

5.2 피부톤 modifier

5.3 ZWJ sequence — 여러 이모지 "접합"

ZWJ(Zero Width Joiner, U+200D)가 이모지들을 하나의 그래핌으로 묶는다.

렌더러(폰트 엔진)가 ZWJ로 연결된 이모지를 하나의 합성 글리프로 그릴지, 아니면 구성 이모지들을 따로 그릴지 결정한다. 폰트가 지원 안 하는 조합은 구성원이 분리돼서 그대로 보인다 (예: 새로운 family 조합).

5.4 Variation Selector

VS-16 (U+FE0F)은 "이 문자를 컬러 이모지로 렌더링해라"라는 지시. VS-15 (U+FE0E)는 반대(텍스트 스타일).

OS/폰트에 따라 기본 스타일이 다르므로 VS-16을 붙이는 게 안전하다.

5.5 국기 (Regional Indicators)

국기 이모지는 2개의 Regional Indicator 문자 조합이다:

ISO 3166-1 alpha-2 국가 코드를 Regional Indicator로 변환한 것. 새 국가가 생겨도 별도 code point 없이 표현 가능. 렌더러가 조합을 국기로 그리면 되고, 지원 안 하면 "KR" 같은 2글자 블록으로 보인다.

5.6 실전: 이모지 "제대로" 세기

// ❌ code unit 기준 (UTF-16)
'👨‍👩‍👧‍👦'.length // 11

// ⚠️ code point 기준
;[...'👨‍👩‍👧‍👦'].length // 7

// ✅ grapheme 기준 (Intl.Segmenter, Node 16+/modern browsers)
const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' })
;[...seg.segment('👨‍👩‍👧‍👦')].length // 1

Intl.Segmenter는 Unicode UAX #29 Text Segmentation을 따르는 표준 API. 한국어에서도 자모 분해된 한글을 하나의 그래핌으로 묶는다.


6. 양방향 텍스트 (BiDi)

6.1 문제

아랍어(أ)와 히브리어는 오른쪽에서 왼쪽으로 쓴다. 한 문서에 영어와 아랍어가 섞이면 렌더링 방향이 문자마다 달라진다.

6.2 Unicode BiDi 알고리즘 (UAX #9)

각 code point는 bidirectional category를 갖는다 (L, R, AL, EN, AN, WS 등). 알고리즘이 문단을 분석해 어느 방향으로 쓸지 자동 결정. 대부분의 브라우저/OS는 이를 구현한다.

개발자가 강제할 필요 있으면 LRE/RLE/PDF 또는 LRI/RLI/PDI control character 사용. 하지만 보안 위험도 있다:

6.3 Trojan Source 공격 (CVE-2021-42574)

2021년 케임브리지 연구팀이 발견한 공격: 소스코드에 bidi control character를 넣으면 IDE에서 보는 코드와 실제 컴파일되는 코드가 다르게 만들 수 있다.

if (access_level != "user‮ ⁦// Check admin‬ ⁦") {
  // 화면엔 안전해 보이지만 실제로는 ...
}

대응: GitHub는 2021년부터 bidi character를 시각적으로 표시, Rust/Go/Python 컴파일러도 경고/에러. 코드 리뷰 시 "보이는 그대로"가 아닐 수 있다는 걸 인지하자.


7. 대소문자 변환 — "Turkish I" 함정

영어에서 "i".upper() == "I". 하지만 터키어에서는 다르다:

'i'.toUpperCase() // "I"
'i'.toLocaleUpperCase('tr-TR') // "İ"

이 때문에 과거 Windows의 파일명 비교 함수가 터키어 로케일에서 버그를 일으킨 사례들이 있다. 보안 체크에 toLowerCase() === "admin" 같은 코드는 터키어 환경에서 우회될 수 있다.

교훈:


8. Collation — 정렬과 비교

8.1 왜 단순 비교가 아닌가

문자열을 바이트 단위로 비교하면 "a" < "B"가 된다(0x61 > 0x42이므로 실제는 "B" < "a"). 하지만 사용자 기대는 "a" < "B".

한국어 정렬은 더 복잡하다:

8.2 ICU와 CLDR

ICU (International Components for Unicode)는 IBM이 개발하고 Unicode Consortium이 유지하는 C/C++/Java 라이브러리. 각 언어의 정렬 규칙(collation rules)이 CLDR (Common Locale Data Repository)에 XML로 정의돼 있다.

8.3 DB collation

PostgreSQL:

CREATE COLLATION korean_phonebook (locale = 'ko-KR');
CREATE TABLE users (name TEXT COLLATE korean_phonebook);
SELECT * FROM users ORDER BY name;

MySQL:

성능 트레이드오프: ICU collation은 메모리/CPU를 더 쓴다. 대량 ORDER BY에선 인덱스와 collation이 맞아야 한다.

8.4 utf8 vs utf8mb4 — MySQL의 저주

MySQL의 utf83 byte UTF-8만 지원한다(역사적 이유). 즉, 이모지(4 byte)가 들어가면 잘리거나 에러. 2025년 시점에 신규 DB는 반드시 utf8mb4 사용. 레거시 DB 마이그레이션은 흔한 작업.


9. 언어별 문자열 표현 상세

9.1 JavaScript

const s = '👨‍👩'
s.length // 5 (surrogate + ZWJ + surrogate)
;[...s].length // 3 (man + ZWJ + woman code points)
new Intl.Segmenter('en', { granularity: 'grapheme' }).segment(s).length // 1 (family grapheme)

9.2 Python 3

len("😀")  # 1
len("한")  # 1
len("é")   # 1 or 2 (정규화 상태에 따라)

9.3 Go

s := "한글"
len(s)                   // 6 (byte)
len([]rune(s))           // 2 (code point)

9.4 Rust

9.5 Java

9.6 Swift

"👨‍👩‍👧‍👦".count  // 1 ✅ (유일하게 "올바른" 기본값)

이 덕분에 iOS 앱에서는 이모지 글자 수 세기가 상대적으로 쉽다.


10. BOM — 보이지 않는 3바이트

10.1 Byte Order Mark

UTF-16/32는 엔디언(BE/LE)이 있으므로 파일 맨 앞에 BOM으로 표시:

10.2 BOM 논쟁

BOM이 붙은 JSON을 파서에 넣으면 에러 나는 경우가 많다. "JSON 첫 줄이 왜 {가 아니지?" 같은 버그의 주범.

10.3 Excel과 CSV

Excel은 CSV 파일이 UTF-8 BOM으로 시작하지 않으면 ANSI(locale code page)로 읽는다. 한국어 Windows에선 CP949로 해석돼서 한글이 깨진다.

대응:

with open('export.csv', 'w', encoding='utf-8-sig') as f:  # BOM 포함
    ...

Excel이 인식하는 더 현대적인 방법은 UTF-8 + 탭 구분(.tsv) 또는 .xlsx 직접 생성이다.


11. 보안 — Homograph과 Zalgo

11.1 Homograph 공격

라틴 "a"(U+0061)와 키릴 "а"(U+0430)는 시각적으로 똑같지만 다른 문자.

11.2 Zalgo — 결합 문자 폭주

a̵̛̪̬̰̘͎̝͍̰̟̅̔͌͘͝͠b̶͖̮̼͔͕̘̟̯̬̽̋̓̑̓̄͘c̸̛̗̳̭̾̀̎̓̏̿̾̒ 같은 "깨진" 텍스트는 여러 combining character를 한 base 문자에 중첩시킨 것. 일부 앱이 렌더링 도중 무한루프에 빠지거나 줄 높이가 폭발해서 UI가 깨진다.

대응:

11.3 Unicode Security Mechanisms (UTS #39)

Unicode Consortium이 정의한 권장사항 집합:

도메인/사용자명/식별자에 사용자 입력을 허용할 때 UTS #39 체커(uts46 라이브러리 등)로 검증.


12. Emoji의 속도 — 신규 추가와 표준화

12.1 매년 업데이트

Unicode는 매년 새 버전을 낸다(최근엔 9월 말). 2024년 Unicode 16.0, 2025년은 17.0 예정.

12.2 "새 이모지가 안 보여요"

사용자 OS/폰트가 그 버전의 Unicode를 지원해야 렌더링된다. Twitter/Facebook은 자체 이모지 폰트(Twemoji, Noto Color Emoji)를 CDN에 올려서 브라우저 기본 폰트와 무관하게 보이게 한다.

12.3 Android vs iOS vs Windows 디자인 차이

같은 code point여도 OS마다 그림이 다르다. "이모지 번역기"로 지도 이모지(🗾)의 변천사를 보면 재밌다. 사업적으로는 동일한 메시지가 다르게 해석될 수 있다는 점이 문제다.


13. 실전: 흔한 버그 시나리오 10가지

13.1 사용자 이름 중복 체크

// ❌ 정규화 안 함
if (await db.users.findOne({ name: input })) return 'taken'

// ✅
const normalized = input.normalize('NFC').trim().toLowerCase()
if (await db.users.findOne({ name: normalized })) return 'taken'

13.2 비밀번호 길이 제한

UTF-8 byte로 제한할지 code point로 제한할지 명시. DB 컬럼 VARCHAR(255)가 MySQL utf8mb4에서는 255 × 4 = 1020 bytes 상한이다. 이모지 포함 비밀번호가 길면 잘릴 수 있다.

13.3 이모지 트윗 글자 수

// Twitter/X는 "가중치" 방식: ASCII 0.5, 이외 1, 이모지 1
// → 280 문자 = 일반 텍스트 280, 이모지 200개 정도
function twitterWeight(s) {
  const seg = new Intl.Segmenter('en', { granularity: 'grapheme' })
  let weight = 0
  for (const { segment } of seg.segment(s)) {
    weight += /^[\u0020-\u007E]$/.test(segment) ? 0.5 : 1
  }
  return weight
}

13.4 파일명 업로드

macOS에서 NFD로 들어온 한글 파일명을 서버가 그대로 저장하면 Linux/Windows 다운로드 시 이상. 저장 전 NFC로 통일.

13.5 이메일 비교

로컬 파트(@ 앞)는 RFC상 case-sensitive이지만 실제로는 대부분 서비스가 대소문자 무시. 정규화 + lowercase로 통일 저장 권장.

13.6 URL 인코딩

encodeURIComponent('한글') // "%ED%95%9C%EA%B8%80" (UTF-8 bytes)
decodeURIComponent('%ED%95%9C%EA%B8%80') // "한글"

Legacy EUC-KR 페이지와 섞이면 깨진다. 모든 시스템을 UTF-8로 통일이 원칙.

13.7 JSON에서 non-BMP 이스케이프

JSON.stringify는 "😀" 그대로 출력하지만, 일부 파서는 surrogate pair로 escape된 형태("\\uD83D\\uDE00")만 인식. 문제가 생기면 JSON.stringify(s, null, 0)로 확인하고, 필요시 non-ASCII를 전부 escape.

13.8 정규식

/\w+/는 ASCII만 매칭. 한글 단어 매칭은 \p{L}+ with u flag:

/^\p{L}+$/u.test('한글')      // true
/^\w+$/.test('한글')           // false

13.9 로그 파일이 ?????로 보임

Java 프로세스의 file.encoding이 기본 UTF-8이 아니면(구식 Windows JRE) 로그가 깨진다. JVM 18+부터 file.encoding=UTF-8 기본이지만, 컨테이너/OS locale도 확인.

13.10 한국어 검색이 부정확

MySQL utf8mb4_general_ci는 한국어 자모 구분을 대충 한다. utf8mb4_ko_0900_as_cs 또는 ICU collation으로 전환.


14. 툴과 디버깅

14.1 명령행

# 파일의 encoding 추정
$ file -i document.txt
document.txt: text/plain; charset=utf-8

# 바이트 단위 덤프
$ xxd small.txt | head
00000000: ec95 88eb 8595 20f0 9f98 80                   ..... ....

# code point로 변환
$ python3 -c "print([hex(ord(c)) for c in '안녕'])"
['0xc548', '0xb155']

14.2 Python의 unicodedata

import unicodedata
unicodedata.name('😀')  # 'GRINNING FACE'
unicodedata.category('한')  # 'Lo' (Letter, other)
unicodedata.normalize('NFC', '한') == unicodedata.normalize('NFD', '한')  # False

14.3 Online 도구

14.4 브라우저 devtools

Chrome/Firefox의 console에서 "문자".codePointAt(0).toString(16) 같이 빠른 확인.


15. 체크리스트 — 다국어 앱을 만들 때

데이터 저장

비교 & 검색

길이 제한

렌더링

API


16. Unicode의 미래 — 어디로 가나

16.1 이모지의 포화

매년 추가되지만 승인되는 이모지 수는 점점 줄고 있다(2024년 8개 추가). 현재 3,700개+ → 누가 새 이모지를 승인하는가가 계속 논쟁. Unicode Consortium은 2022년 "이제는 유지보수 중심"으로 방향 전환.

16.2 변수 글꼴 (Variable Fonts)

이모지에도 variation selector뿐 아니라 폰트 축(axis)을 조정하는 OpenType variable font 개념이 확산. 같은 이모지를 "좀 더 행복하게" 같은 세밀한 조정이 가능한 실험들이 진행 중.

16.3 AI 시대의 Unicode

LLM tokenizer(BPE, SentencePiece)는 바이트 또는 code point를 기본 단위로 학습한다. UTF-8 바이트 기반 tokenizer(예: Llama의 BPE)는 언어 편향 없이 어떤 문자든 다룰 수 있다. 하지만 한국어 같은 경우 grapheme ≠ token이라 한 글자가 3~4 토큰이 되는 비효율이 있다. 이 문제 해결을 위한 Unicode-aware tokenizer 연구가 활발하다.

16.4 DNS / Email의 IDN


마무리 — 문자열은 본질적으로 어렵다

문자열이 "간단한 데이터 타입"이라는 건 ASCII 시대의 추억이다. 현대의 문자열은:

"한 문자"라는 개념 자체가 맥락에 따라 다르다는 걸 받아들이는 순간, 많은 버그가 납득되기 시작한다. "café".length가 4일 수도 5일 수도 있는 이유, "😀😀".slice(0, 1)이 깨진 결과를 낼 수 있는 이유, 한글 파일명이 OS마다 다르게 보이는 이유가 모두 여기 있다.

교훈 3가지:

  1. 항상 UTF-8을 기본으로. 입출력 경계에서 모든 인코딩을 UTF-8로 통일.
  2. 저장 전 정규화(NFC), 비교 전 정규화. 저장 시점이 안전하다.
  3. Grapheme 기반 API 사용. Intl.Segmenter(JS), BreakIterator(Java), graphemes(Rust crate), Swift String.count.

다음 글에서는 시간과 날짜 — TimeZone, DST, 윤초, 달력 시스템의 기술적 복잡성을 다룬다. Unicode만큼이나 "간단해 보이지만 악마의 디테일"이 많은 영역이다. Date.now()에서 시작해 NTP, 윤초, Temporal API, 그리고 왜 new Date("2024-03-31")이 브라우저와 서버에서 다른 날이 될 수 있는지까지.

Unicode를 이해하는 건 단순히 인코딩 버그를 피하는 기술이 아니다. 인간 언어의 복잡성을 컴퓨터가 어떻게 포용하는가에 대한 깊은 이해다. 그리고 현대 소프트웨어의 거의 모든 사용자가 비영어권이라는 현실에서, 이 이해는 더 이상 "옵션"이 아니다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다