들어가며 — 문자열은 어려운 문제다
"👨👩👧👦".length이 11이라는 사실을 처음 알았을 때 모든 JS 개발자는 멈칫한다. "가족 이모지 한 글자인데?" 문자열은 컴퓨팅에서 가장 오래된 데이터 타입이지만, 여전히 가장 많은 버그를 만들어낸다.
- 사용자 이름 필드에 "하늘"이라고 입력했는데 DB에서 조회하면 안 나옴 → NFC vs NFD 정규화 차이
- 트윗 글자 수 제한이 "280"인데 한글로는 140자밖에 못 씀 → byte vs code unit vs code point
- 이모지 이름의 배경색이 뜻대로 안 나옴 → variation selector
- macOS에서 "café"라고 저장한 파일이 Linux에선 "cafe" 같은 이상한 이름으로 보임 → 파일시스템마다 정규화 전략이 다름
- Excel에서 CSV 열었더니 한글이 깨짐 → BOM이 없어서 UTF-8로 인식 못 함
이 글은 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개 기호:
- 0~31: 제어 문자 (줄바꿈, 탭 등)
- 32~126: 출력 가능 문자 (영문자, 숫자, 기호)
- 127: DEL
영어에는 충분했지만 스페인어 "ñ", 프랑스어 "é", 독일어 "ü"가 없다.
1.2 8비트 확장 — 지역별 code page 전쟁
8번째 비트를 더 쓰면 256개까지 가능. 각 지역이 자기 문자를 128~255 영역에 배치:
- ISO-8859-1 (Latin-1): 서유럽
- ISO-8859-5: 러시아어
- ISO-8859-6: 아랍어
- Windows-1252: MS의 Latin-1 변형
- EUC-KR / CP949: 한국어 (2바이트 이상 필요 — 실제로 "확장 ASCII"의 범주를 벗어남)
- Shift_JIS: 일본어
- GB2312 / GBK / GB18030: 중국어
이 시기에는 문서 = 바이트열 + 어떤 code page인지 알아야 읽을 수 있음. 메일 헤더에 Content-Type: text/plain; charset=ISO-8859-1 같은 명시가 필요했다. 중국어 페이지를 한국어 브라우저로 열면 한글 글꼴로 해석돼서 무슨 말인지 알 수 없는 문자열("뚜껑 깨진 글자")이 나오는 게 일상이었다.
1.3 Unicode의 등장 (1991)
목표: 모든 언어의 모든 문자에 하나씩 번호(code point)를 부여하자.
- 처음엔 16비트면 충분하다고 생각 (65,536개) → UCS-2
- 하지만 한자(CJK) 수만 해도 7만 개 넘어서 모자람
- 1996년 Unicode 2.0부터 17개 plane × 65,536 = 1,114,112개 공간 마련
- Code point:
U+0000부터U+10FFFF까지의 정수
Plane 구성:
- BMP (Basic Multilingual Plane): U+0000 ~ U+FFFF — 대부분의 현대 문자
- SMP (Supplementary): U+10000 ~ U+1FFFF — 이모지, 역사 문자, 수학 기호
- Plane 2~16: 드물게 사용 (추가 한자, 특수 용도)
Unicode는 인코딩이 아니다. "이 문자는 숫자 U+1F600이다"라고 말할 뿐, 그 숫자를 바이트로 어떻게 저장할지는 별개 문제다.
2. 인코딩 — 코드포인트를 바이트로
2.1 UTF-32 — 간단하지만 낭비
각 code point = 4 byte 고정. 배열 인덱싱이 쉽다(s[i]로 i번째 문자 즉시 접근).
- 장점: code point 기준 random access가 O(1)
- 단점: ASCII 문자 1개에 4바이트. HTML 문서가 4배 뚱뚱해짐
실전에서는 거의 안 쓰인다(내부 계산 목적 외).
2.2 UTF-16 — 16비트 기본 + 대리 쌍
UCS-2의 후예. 기본은 16비트로 저장하고, BMP 밖 문자(U+10000~)는 **2개의 16비트 값(surrogate pair)**으로 표현한다.
Surrogate 범위:
- High surrogate: 0xD800 ~ 0xDBFF (1024개)
- Low surrogate: 0xDC00 ~ 0xDFFF (1024개)
변환 공식:
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년 식당 종이냅킨에 설계했다는 전설의 인코딩. 다음 특성을 모두 만족한다:
- ASCII 호환: 기존 ASCII 문서는 그대로 UTF-8로도 유효
- 가변 길이: 1~4 byte로 code point를 표현
- 자기 동기화: 어느 바이트에서 시작해도 문자 경계 파악 가능
- 정렬 보존: 바이트 단위 정렬 = 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 패턴으로 몇 바이트짜리인지 즉시 알 수 있다:
0xxxxxxx: 단독 문자10xxxxxx: continuation byte (문자 중간)110xxxxx: 2 byte 문자 시작1110xxxx: 3 byte 문자 시작11110xxx: 4 byte 문자 시작
이 설계 덕분에 임의 바이트 위치에서 뒤로 최대 3 byte만 거슬러 올라가면 현재 문자의 시작 위치를 찾을 수 있다(자기 동기화).
2.4 예시 — "안녕 😀"를 UTF-8로
| 문자 | code point | UTF-8 bytes |
|---|---|---|
| 안 | U+C548 | EC 95 88 |
| 녕 | U+B155 | EB 85 95 |
| (공백) | U+0020 | 20 |
| 😀 | U+1F600 | F0 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이 승리했나
- HTML/이메일/설정 파일은 대부분 ASCII + 가끔 non-ASCII → UTF-8이 최소
- 스트리밍 친화적 (바이트 단위 파싱)
- Endianness 문제 없음 (UTF-16은 BE/LE 혼란, BOM 필요)
- 기존 ASCII 도구/라이브러리가 그대로 동작
- W3C가 2012년 "UTF-8 should be the default"로 명시
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개의 구분
- Code point (코드포인트): Unicode가 문자에 부여한 번호.
U+AC00. - Code unit (코드 유닛): 인코딩에서의 단위. UTF-8은 1 byte, UTF-16은 2 byte, UTF-32는 4 byte.
- Grapheme cluster (그래핌): 사용자가 "한 글자"로 인식하는 단위.
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의 복잡성
"é"를 생각해보자. 두 방법이 있다:
- Precomposed: U+00E9 (1 code point, 1 grapheme)
- Decomposed: U+0065 (e) + U+0301 (combining acute) → 2 code point이지만 시각적으로 1 grapheme
외관이 똑같아도 code point 수가 다르다. "é" === "é"의 결과는 어느 입력이냐에 따라 달라진다.
3.4 한글의 두 가지 방법
- Precomposed: U+AC00~U+D7A3 = 11,172개 완성형 음절 (하나의 code point)
- Decomposed: 초성 + 중성 + 종성 = 2~3 code point의 자모 조합
"한"이 어떻게 표현되느냐:
- NFC (완성형):
U+D55C(한 글자) - NFD (조합):
U+1112 (ㅎ) + U+1161 (ㅏ) + U+11AB (ㄴ)(3개)
macOS 파일시스템(APFS는 비정규화로 바뀌었지만 HFS+ 시절)은 NFD를, 대부분의 텍스트는 NFC를 쓴다. 그래서 맥에서 만든 파일을 Linux/Windows에서 보면 한글 파일명이 이상해지는 일이 생긴다. 자소가 쪼개져서 보이거나, byte 차이로 파일 조회가 실패한다.
4. 정규화 — NFC, NFD, NFKC, NFKD
4.1 왜 정규화가 필요한가
같은 "é"가 두 가지 표현이 가능하면, 검색·비교·해시·저장이 깨진다. Unicode는 정규 형식(normalization form) 4가지를 정의한다:
- NFC (Composed): 가능한 한 합친다. 일반 텍스트 저장/검색에 기본
- NFD (Decomposed): 완전히 분해한다. 파일시스템 일부, 언어 처리
- NFKC (Compatibility Composed): 호환 문자도 통일 (예: "①" → "1")
- NFKD (Compatibility Decomposed): 호환 + 분해
"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 실전 규칙
- DB에 저장하기 전에 NFC로 통일 (PostgreSQL, MySQL의 기본 collation은 NFC 가정)
- 사용자 비교할 때(username, email) 반드시 정규화 먼저
- 파일시스템 경로는 플랫폼에 의존 — macOS에서 받은 경로는 NFD일 수 있으니 NFC로 변환 권장
- 검색 인덱싱 시 NFKC — "①"과 "1"을 동일시하고 싶으면
Unicode 표준은 Idempotent 성질을 보장한다: normalize(normalize(s, 'NFC'), 'NFC') === normalize(s, 'NFC'). 한 번 정규화하면 여러 번 적용해도 안전.
4.4 주의: 정규화는 공짜가 아니다
수백 MB 텍스트를 매번 정규화하면 느리다. 한 번 저장 시 정규화하고, 이후엔 안 하는 전략이 보통이다. 또한 NFKC는 "①"→"1" 같은 의미 변화를 일으키므로 UI 표시용 데이터에는 NFC, 검색 인덱스용은 NFKC처럼 용도를 분리하자.
5. 이모지 — Unicode의 가장 복잡한 영역
5.1 단일 코드포인트 이모지
😀= U+1F600 (1 code point, UTF-8 4 byte)
5.2 피부톤 modifier
👋(손 흔들기) +🏽(medium skin) =👋🏽(2 code point)- 피부톤은 U+1F3FB ~ U+1F3FF (5단계)
5.3 ZWJ sequence — 여러 이모지 "접합"
ZWJ(Zero Width Joiner, U+200D)가 이모지들을 하나의 그래핌으로 묶는다.
👨👩👧👦(가족: 남자, 여자, 여자애, 남자애) =U+1F468 + U+200D + U+1F469 + U+200D + U+1F467 + U+200D + U+1F466(7 code point)🏳️🌈(무지개 깃발) =U+1F3F3 + U+FE0F + U+200D + U+1F308(4 code point)👨💻(개발자 남자) =U+1F468 + U+200D + U+1F4BB(3 code point)
렌더러(폰트 엔진)가 ZWJ로 연결된 이모지를 하나의 합성 글리프로 그릴지, 아니면 구성 이모지들을 따로 그릴지 결정한다. 폰트가 지원 안 하는 조합은 구성원이 분리돼서 그대로 보인다 (예: 새로운 family 조합).
5.4 Variation Selector
**VS-16 (U+FE0F)**은 "이 문자를 컬러 이모지로 렌더링해라"라는 지시. **VS-15 (U+FE0E)**는 반대(텍스트 스타일).
❤(U+2764) + VS-16 → 빨간 하트❤(U+2764) + VS-15 → 흑백 하트
OS/폰트에 따라 기본 스타일이 다르므로 VS-16을 붙이는 게 안전하다.
5.5 국기 (Regional Indicators)
국기 이모지는 2개의 Regional Indicator 문자 조합이다:
- 🇰🇷 = U+1F1F0 (K) + U+1F1F7 (R) = "KR"
- 🇺🇸 = U+1F1FA (U) + U+1F1F8 (S)
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→ 대문자İ(U+0130, dotted I) - 터키어 소문자
ı(U+0131, dotless i) → 대문자I
'i'.toUpperCase() // "I"
'i'.toLocaleUpperCase('tr-TR') // "İ"
이 때문에 과거 Windows의 파일명 비교 함수가 터키어 로케일에서 버그를 일으킨 사례들이 있다. 보안 체크에 toLowerCase() === "admin" 같은 코드는 터키어 환경에서 우회될 수 있다.
교훈:
- 보안 검사에는 locale-independent 비교 사용
- 사용자 표시용은 locale 고려
- 독일어
ß(U+00DF) 대문자는SS(2 글자) 또는ẞ(최근 추가) — 대소문자 변환은 길이 보존이 아니다
8. Collation — 정렬과 비교
8.1 왜 단순 비교가 아닌가
문자열을 바이트 단위로 비교하면 "a" < "B"가 된다(0x61 > 0x42이므로 실제는 "B" < "a"). 하지만 사용자 기대는 "a" < "B".
한국어 정렬은 더 복잡하다:
- 유니코드 code point 순서:
"가", "까", "나", "다", ...— 완성형 범위 순서와 일치 - 하지만
"ㄱ" (U+3131)과"가" (U+AC00)는 거리가 멀다 (자모가 앞쪽에 위치) - 사용자 기대:
"ㄱ", "가", "간", "갈", ..., "ㄲ", "까", ...
8.2 ICU와 CLDR
**ICU (International Components for Unicode)**는 IBM이 개발하고 Unicode Consortium이 유지하는 C/C++/Java 라이브러리. 각 언어의 정렬 규칙(collation rules)이 **CLDR (Common Locale Data Repository)**에 XML로 정의돼 있다.
- 한국어:
ko-KRcollation → 초성/중성/종성 순으로 3단계 비교 - 독일어:
de-DE→ä, ö, ü를a, o, u와 비슷하게 (phonebook 스타일은 또 다름) - 프랑스어: accent의 마지막을 기준으로 비교 (역순!)
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:
utf8mb4_general_ci: 빠르지만 부정확utf8mb4_unicode_ci: ICU 기반, 정확utf8mb4_0900_ai_ci(MySQL 8 기본): Unicode 9.0 기반, accent-insensitive, case-insensitive
성능 트레이드오프: ICU collation은 메모리/CPU를 더 쓴다. 대량 ORDER BY에선 인덱스와 collation이 맞아야 한다.
8.4 utf8 vs utf8mb4 — MySQL의 저주
MySQL의 utf8은 3 byte UTF-8만 지원한다(역사적 이유). 즉, 이모지(4 byte)가 들어가면 잘리거나 에러. 2025년 시점에 신규 DB는 반드시 utf8mb4 사용. 레거시 DB 마이그레이션은 흔한 작업.
9. 언어별 문자열 표현 상세
9.1 JavaScript
- 내부: UTF-16 code unit 시퀀스
s.length,s.charCodeAt(i): code unit 기준s.codePointAt(i): code point (surrogate pair 처리)[...s]: code point iteratorIntl.Segmenter: grapheme iterator
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
- 내부: PEP 393 flexible string representation — 문자열의 최대 code point에 따라 1/2/4 byte 중 선택
len(s): code point 기준s.encode('utf-8'): bytes- grapheme 처리는
regex라이브러리(표준re는 안 됨)
len("😀") # 1
len("한") # 1
len("é") # 1 or 2 (정규화 상태에 따라)
9.3 Go
string은 read-only byte slice + UTF-8 규약len(s): bytefor i, r := range s:r은 rune (int32 = code point),i는 byte offsetunicode/utf8패키지로 유효성 검사
s := "한글"
len(s) // 6 (byte)
len([]rune(s)) // 2 (code point)
9.4 Rust
String은 UTF-8 인코딩된Vec<u8>s.len(): bytes.chars():chariterator (code point)s.graphemes(true)(unicode-segmentationcrate): grapheme- 인덱싱 금지:
s[0]은 컴파일 에러. 이유는 byte 단위 인덱스가 UTF-8 boundary에 안 맞을 수 있어서.
9.5 Java
String은 내부적으로 UTF-16 (Java 9+부터 Latin-1만이면 1 byte로 최적화 — Compact Strings)s.length(): UTF-16 code units.codePointCount(0, s.length()): code pointBreakIterator또는 ICU4J for grapheme
9.6 Swift
String은 grapheme cluster 컬렉션으로 설계됨 (Unicode-correct by default)s.count: grapheme 기준 ✨s.unicodeScalars: code points.utf16,s.utf8: 바이트 뷰
"👨👩👧👦".count // 1 ✅ (유일하게 "올바른" 기본값)
이 덕분에 iOS 앱에서는 이모지 글자 수 세기가 상대적으로 쉽다.
10. BOM — 보이지 않는 3바이트
10.1 Byte Order Mark
UTF-16/32는 엔디언(BE/LE)이 있으므로 파일 맨 앞에 BOM으로 표시:
- UTF-8:
EF BB BF(엔디언 무의미하지만 "UTF-8이다"라는 표식) - UTF-16 BE:
FE FF - UTF-16 LE:
FF FE - UTF-32 BE:
00 00 FE FF - UTF-32 LE:
FF FE 00 00
10.2 BOM 논쟁
- Windows Notepad: UTF-8 저장 시 기본 BOM 포함
- Unix 관행: UTF-8에 BOM 붙이지 않음 (shebang
#!등 깨뜨릴 수 있음)
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)는 시각적으로 똑같지만 다른 문자.
paypal.comvspаypal.com(두 번째 "a"가 키릴)- 도메인 등록 시 IDN(International Domain Names)으로 처리되면 브라우저가 punycode로 표시 (
xn--pypal-4ve.com) - 2017년 Firefox가 한때 IDN을 항상 원래 문자로 표시해서 이 공격에 취약했음. 이후 혼합 스크립트 감지로 강화.
11.2 Zalgo — 결합 문자 폭주
a̵̛̪̬̰̘͎̝͍̰̟̅̔͌͘͝͠b̶͖̮̼͔͕̘̟̯̬̽̋̓̑̓̄͘c̸̛̗̳̭̾̀̎̓̏̿̾̒ 같은 "깨진" 텍스트는 여러 combining character를 한 base 문자에 중첩시킨 것. 일부 앱이 렌더링 도중 무한루프에 빠지거나 줄 높이가 폭발해서 UI가 깨진다.
대응:
- 입력 검증: 1 base 문자당 combining 최대 N개 제한
- 렌더링 제한: 줄 높이 CSS
max-height - 서버 측에서 정규화 + combining character 개수 제한
11.3 Unicode Security Mechanisms (UTS #39)
Unicode Consortium이 정의한 권장사항 집합:
- Identifier: 프로그래밍 언어 식별자에 안전한 문자 세트
- Confusable detection: 시각적 유사 문자 쌍 목록
- Restriction levels: ASCII-only, Single Script, Moderately Restrictive 등
도메인/사용자명/식별자에 사용자 입력을 허용할 때 UTS #39 체커(uts46 라이브러리 등)로 검증.
12. Emoji의 속도 — 신규 추가와 표준화
12.1 매년 업데이트
Unicode는 매년 새 버전을 낸다(최근엔 9월 말). 2024년 Unicode 16.0, 2025년은 17.0 예정.
- 이모지 추가: Unicode Emoji 15.1 (2023)에 push/pull hand, Unicode Emoji 16.0 (2024)에 root vegetable, shovel 등
- 제안 → 심사 → 승인 → 플랫폼 구현의 긴 여정 (보통 1~2년)
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 도구
- Unicode Character Inspector (r12a.github.io): 문자열 분석
- Babelstone BabelMap: Unicode 문자 찾기
- Shapecatcher: 손으로 그려서 문자 찾기
- Emojipedia: 이모지 meta 정보
14.4 브라우저 devtools
Chrome/Firefox의 console에서 "문자".codePointAt(0).toString(16) 같이 빠른 확인.
15. 체크리스트 — 다국어 앱을 만들 때
데이터 저장
- DB charset/collation =
utf8mb4(MySQL) orUTF8(PostgreSQL) - 문자열 입력은 NFC로 정규화 후 저장
- 파일명은 NFC로 통일 (특히 macOS → 다른 OS 업로드)
- BOM 여부 명확히 (CSV는 BOM 필요할 수 있음)
비교 & 검색
- 사용자 이름/이메일 비교 시 normalize + lowercase (locale-independent)
- Full-text search는 ICU collation 또는 전용 엔진 (Elasticsearch, OpenSearch)
- 보안 검사에서 Homograph/Confusable 필터 (UTS #39)
길이 제한
- "문자 수"의 정의 명시 (byte / code unit / code point / grapheme)
- UI 입력 제한 = grapheme 기준
- DB 컬럼 크기 = byte 기준 (utf8mb4면 문자당 최대 4 byte)
렌더링
- 이모지 폰트가 CDN에 있거나 OS 의존
- 신규 이모지는 오래된 OS에서 tofu(□)로 보일 수 있음 → fallback 텍스트
- RTL 언어(아랍어/히브리어) 지원 테스트
- BiDi control character 입력 시 UI 깨짐 방지
API
- JSON은 UTF-8 기본, non-ASCII escape 여부 명시
- URL은 UTF-8로 percent-encode
- HTTP header
Content-Type: text/html; charset=utf-8명시
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
- 2025년 기준 IDN(International Domain Names)은 대부분 메이저 TLD에서 지원
- 이메일 로컬 파트의 국제화(EAI)는 아직 일부 서비스만 지원 —
김철수@회사.한국같은 주소는 여전히 일부에서 거부됨 - 점진적 수용 단계
마무리 — 문자열은 본질적으로 어렵다
문자열이 "간단한 데이터 타입"이라는 건 ASCII 시대의 추억이다. 현대의 문자열은:
- 코드포인트의 시퀀스이자
- 바이트의 시퀀스이자
- 그래핌의 시퀀스이자
- 언어별 정렬 규칙의 대상이자
- 보안 취약점의 잠재적 진원지다.
"한 문자"라는 개념 자체가 맥락에 따라 다르다는 걸 받아들이는 순간, 많은 버그가 납득되기 시작한다. "café".length가 4일 수도 5일 수도 있는 이유, "😀😀".slice(0, 1)이 깨진 결과를 낼 수 있는 이유, 한글 파일명이 OS마다 다르게 보이는 이유가 모두 여기 있다.
교훈 3가지:
- 항상 UTF-8을 기본으로. 입출력 경계에서 모든 인코딩을 UTF-8로 통일.
- 저장 전 정규화(NFC), 비교 전 정규화. 저장 시점이 안전하다.
- Grapheme 기반 API 사용.
Intl.Segmenter(JS),BreakIterator(Java),graphemes(Rust crate), SwiftString.count.
다음 글에서는 시간과 날짜 — TimeZone, DST, 윤초, 달력 시스템의 기술적 복잡성을 다룬다. Unicode만큼이나 "간단해 보이지만 악마의 디테일"이 많은 영역이다. Date.now()에서 시작해 NTP, 윤초, Temporal API, 그리고 왜 new Date("2024-03-31")이 브라우저와 서버에서 다른 날이 될 수 있는지까지.
Unicode를 이해하는 건 단순히 인코딩 버그를 피하는 기술이 아니다. 인간 언어의 복잡성을 컴퓨터가 어떻게 포용하는가에 대한 깊은 이해다. 그리고 현대 소프트웨어의 거의 모든 사용자가 비영어권이라는 현실에서, 이 이해는 더 이상 "옵션"이 아니다.
현재 단락 (1/334)
`"👨👩👧👦".length`이 **11**이라는 사실을 처음 알았을 때 모든 JS 개발자는 멈칫한다. "가족 이모지 한 글자인데?" 문자열은 컴퓨팅에서 가장 오래된 데...