72 실무 — 유니코드와 멀티바이트를 다루는 법
먼저 알아야 할 것
mbstate_t돌아보기
70·71장을 지나며 「UTF-8 바이트열을 그대로 다루라」는 권고가 여러 번 나왔다. 그런데 바이트열을 그대로 다루면 「세 번째 글자를 지워라」 같은 일은 어떻게 하는가?
답. 대부분의 프로그램은 그런 일을 하지 않기 때문이다. 실제로 하는 일을 세어 보면 이렇다 — 읽고, 저장하고, 비교하고, 이어 붙이고, 찾고, 그대로 내보낸다. 이 여섯 가지는 UTF-8 바이트열에서 아무 해독 없이 된다.
글자 단위로 만져야 하는 일 — 커서 옮기기, 줄 바꿈 자리 찾기, 글자 수 세기 — 은 편집기나 렌더러의 몫이고, 그 층에서는 코드포인트도 모자라 그래핌 클러스터가 필요하다. 이 장은 그 경계를 그어 주는 장이다: 어디까지 바이트로 가고, 어디서부터 해독하며, 그때 무엇을 조심하는가.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 그래핌 클러스터는 어떻게 세는가?
72.1 원칙 — UTF-8을 안에, 변환은 경계에만
먼저 결론이다.
| 자리 | 무엇을 쓰는가 |
|---|---|
| 프로그램 내부 | UTF-8 바이트열(char *, 길이는 바이트 수) |
| 파일·네트워크·데이터베이스 | UTF-8 |
| Windows API 를 부를 때 | 경계에서만 UTF-16 으로 변환 |
| 글자 단위 편집·렌더링 | 필요한 자리에서만 코드포인트·그래핌으로 해독 |
표 72.1
이 설계가 이기는 이유는 UTF-8의 성질에서 그대로 나온다.
| UTF-8의 성질 | 그래서 얻는 것 |
|---|---|
| 아스키와 완전히 호환된다 | 기존 코드·프로토콜·파일 형식이 그대로 돈다 |
| 후행 바이트가 언제나 0x80 이상 | '/'·'\\'·',' 같은 구분자와 절대 겹치지 않는다 |
| 자기동기화된다 | 아무 위치에서나 글자 경계를 찾을 수 있다 |
| 바이트 순서가 코드포인트 순서와 같다 | strcmp가 코드포인트 순 정렬을 준다 |
| 엔디안이 없다 | 바이트 순서 표시(BOM)가 필요 없다 |
표 72.2
examples/ch72/utf8_scan.c
/* UTF-8 을 직접 읽는다 — 규칙, 그리고 거절해야 하는 것들(RFC 3629). */
#include <stdio.h>
#include <stddef.h>
#include <string.h>
/* UTF-8 의 규칙은 표 하나로 끝난다.
0xxxxxxx → 1바이트, U+0000..U+007F
110xxxxx 10xxxxxx → 2바이트, U+0080..U+07FF
1110xxxx 10xxxxxx 10xxxxxx → 3바이트, U+0800..U+FFFF
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx → 4바이트, U+10000..U+10FFFF
후행 바이트는 언제나 10xxxxxx 다. 그래서 어느 바이트를 보든
'글자의 시작인지 중간인지' 알 수 있다 — 이것이 자기동기화다. */
typedef enum { OK, BAD_LEAD, BAD_TRAIL, TRUNCATED, OVERLONG, SURROGATE, TOO_BIG } verdict;
static const char *why(verdict v)
{
switch (v) {
case OK: return "ok";
case BAD_LEAD: return "a value that cannot be a lead byte";
case BAD_TRAIL: return "a trail byte that is not 10xxxxxx";
case TRUNCATED: return "not enough bytes";
case OVERLONG: return "overlong encoding - the value could be written shorter";
case SURROGATE: return "surrogates (U+D800-DFFF) cannot be encoded";
case TOO_BIG: return "beyond U+10FFFF";
}
return "?";
}
/* 한 글자를 읽는다. 성공하면 소비한 바이트 수를 *len 에 넣는다. */
static verdict decode(const unsigned char *s, size_t n,
unsigned long *cp, size_t *len)
{
if (n == 0) return TRUNCATED;
unsigned char c = s[0];
size_t need;
unsigned long v;
if (c < 0x80) { need = 1; v = c; }
else if ((c & 0xE0) == 0xC0) { need = 2; v = c & 0x1Fu; }
else if ((c & 0xF0) == 0xE0) { need = 3; v = c & 0x0Fu; }
else if ((c & 0xF8) == 0xF0) { need = 4; v = c & 0x07u; }
else return BAD_LEAD; /* 10xxxxxx 로 시작하거나 11111xxx */
if (n < need) return TRUNCATED;
for (size_t i = 1; i < need; i++) {
if ((s[i] & 0xC0) != 0x80) return BAD_TRAIL;
v = (v << 6) | (unsigned long)(s[i] & 0x3Fu);
}
/* 여기부터가 '문법은 맞지만 받아들이면 안 되는' 것들이다 */
static const unsigned long lowest[5] = { 0, 0, 0x80, 0x800, 0x10000 };
if (v < lowest[need]) return OVERLONG;
if (v >= 0xD800UL && v <= 0xDFFFUL) return SURROGATE;
if (v > 0x10FFFFUL) return TOO_BIG;
*cp = v; *len = need;
return OK;
}
static void scan(const char *label, const unsigned char *s, size_t n)
{
printf("\n[%s]", label);
for (size_t i = 0; i < n; i++) printf(" %02X", s[i]);
puts("");
for (size_t pos = 0; pos < n; ) {
unsigned long cp = 0;
size_t len = 0;
verdict v = decode(s + pos, n - pos, &cp, &len);
if (v == OK) {
printf(" %zu bytes -> U+%04lX\n", len, cp);
pos += len;
} else {
printf(" rejected: %s\n", why(v));
pos += 1; /* 한 바이트만 버리고 다시 맞춘다 */
break;
}
}
}
int main(void)
{
/* 정상 */
scan("ASCII", (const unsigned char *)"Hi", 2);
scan("the Hangul syllable '한'", (const unsigned char *)"\xED\x95\x9C", 3);
scan("emoji", (const unsigned char *)"\xF0\x9F\x98\x80", 4);
/* 거절해야 하는 것들 */
scan("starts with a trail byte", (const unsigned char *)"\x9C", 1);
scan("three bytes cut short", (const unsigned char *)"\xED\x95", 2);
scan("a byte that is not a trail byte", (const unsigned char *)"\xED\x41\x9C", 3);
scan("overlong encoding C0 80", (const unsigned char *)"\xC0\x80", 2);
scan("surrogate ED A0 80", (const unsigned char *)"\xED\xA0\x80", 3);
scan("out of range F5 80 80 80", (const unsigned char *)"\xF5\x80\x80\x80", 4);
puts("\nself-synchronization - point at any byte and you can find the character boundary:");
const unsigned char *s = (const unsigned char *)"a한글b";
size_t n = strlen((const char *)s);
for (size_t i = 0; i < n; i++)
printf(" byte %zu (%02X): %s\n", i, s[i],
(s[i] & 0xC0) == 0x80 ? "inside a character" : "start of a character");
return 0;
}
실행 결과
[ASCII] 48 69
1 bytes -> U+0048
1 bytes -> U+0069
[the Hangul syllable '한'] ED 95 9C
3 bytes -> U+D55C
[emoji] F0 9F 98 80
4 bytes -> U+1F600
[starts with a trail byte] 9C
rejected: a value that cannot be a lead byte
[three bytes cut short] ED 95
rejected: not enough bytes
[a byte that is not a trail byte] ED 41 9C
rejected: a trail byte that is not 10xxxxxx
[overlong encoding C0 80] C0 80
rejected: overlong encoding - the value could be written shorter
[surrogate ED A0 80] ED A0 80
rejected: surrogates (U+D800-DFFF) cannot be encoded
[out of range F5 80 80 80] F5 80 80 80
rejected: beyond U+10FFFF
self-synchronization - point at any byte and you can find the character boundary:
byte 0 (61): start of a character
byte 1 (ED): start of a character
byte 2 (95): inside a character
byte 3 (9C): inside a character
byte 4 (EA): start of a character
byte 5 (B8): inside a character
byte 6 (80): inside a character
byte 7 (62): start of a character
시연의 마지막 부분이 자기동기화를 보인다. 아무 바이트나 짚어도 (b & 0xC0) == 0x80인지만 보면 그것이 글자 중간인지 시작인지 알 수 있다 — 이 성질 덕분에 버퍼 가운데를 잘라도 복구할 수 있고, 손상된 데이터에서도 다음 글자로 넘어갈 수 있다.
72.2 검증 — 문법이 맞아도 거절해야 하는 것들
UTF-8을 읽는 코드는 반드시 검증해야 한다. 규칙은 RFC 3629(STD 63)가 정하고, 문법만으로는 걸러지지 않는 것이 셋 있다.
| 거절할 것 | 보기 | 왜 |
|---|---|---|
| 과잉 인코딩(overlong) | C0 80 | U+0000을 두 바이트로 적었다 — 검사를 우회하는 고전적 공격 |
| 서러게이트 인코딩 | ED A0 80 | U+D800은 문자가 아니다(71장) |
| 범위 초과 | F5 80 80 80 | U+10FFFF를 넘는다 |
표 72.3
시연이 이 셋을 각각 거절하는 것을 보인다. 특히 첫 줄이 중요하다 — 과잉 인코딩은 보안 문제다. ..나 /를 여러 바이트로 적어 경로 검사를 통과시킨 뒤, 뒤쪽 층에서 다시 해석되게 만드는 공격이 실제로 있었다. 그래서 “가장 짧은 표현만 유효하다”가 규격의 요구 사항이 되었다.
흔한 오해. “UTF-8은 바이트열이니 검증 없이 넘겨도 된다”
중간에 그냥 흘려보내는 것(pass-through)은 대개 안전하다. 문제는 해석하는 순간이다.
검증하지 않은 바이트열은 층마다 다르게 해석될 수 있고, 그 어긋남이 보안 구멍이 된다 — 앞 층은 “이상한 바이트”로 보고 통과시키고, 뒤 층은 그것을 /로 해석하는 식이다. 웹 서버·파일 시스템·데이터베이스가 걸쳐 있는 곳에서 특히 그렇다.
규율은 하나다 — 입력 경계에서 한 번 검증하고, 그 뒤로는 유효하다고 믿는다. 검증한 자리를 코드에 분명히 표시해 두는 것까지가 규율이다. 제12부 proven 의 u8 계열이 이 규율을 타입으로 강제하는 예다.
72.3 「글자 수」의 세 층위, 그리고 넷째
71장에서 길이가 셋이라고 했다. 사람이 보는 글자까지 넣으면 넷이다.
examples/ch72/graphemes.c
/* '글자 수'는 세 가지다 — 바이트, 코드포인트, 그리고 사람이 보는 글자. */
#include <locale.h>
#include <stdio.h>
#include <string.h>
#include <wchar.h>
/* 사람이 보는 한 글자(그래핌 클러스터)의 완전한 규칙은 유니코드 UAX #29 에
있고, 제대로 구현하려면 표가 필요하다. 여기서는 자주 만나는 네 가지만
추린 *간이 규칙*으로 센다 — 개념을 눈으로 보는 것이 목적이다.
① 결합 표시(U+0300~U+036F 등)는 앞 글자에 붙는다
② 한글 조합 자모(U+1100~U+11FF)는 앞 글자에 붙는다
③ ZWJ(U+200D) 다음 글자는 앞 글자에 붙는다
④ 지역 표시자(U+1F1E6~U+1F1FF)는 둘이 하나가 된다(국기) */
static int is_combining(unsigned long c)
{
return (c >= 0x0300 && c <= 0x036F) /* 결합 발음 기호 */
|| (c >= 0x1160 && c <= 0x11FF) /* 한글 조합 중성·종성 */
|| (c >= 0xFE00 && c <= 0xFE0F) /* 변이 선택자 */
|| (c >= 0x1F3FB && c <= 0x1F3FF); /* 피부색 수정자 */
}
static int is_regional(unsigned long c) { return c >= 0x1F1E6 && c <= 0x1F1FF; }
static void measure(const char *label, const char *s)
{
size_t bytes = strlen(s);
size_t cps = 0, clusters = 0;
mbstate_t st;
memset(&st, 0, sizeof st);
unsigned long prev = 0;
int prev_was_zwj = 0, prev_regional = 0;
for (size_t pos = 0; pos < bytes; ) {
wchar_t wc = 0;
size_t r = mbrtowc(&wc, s + pos, bytes - pos, &st);
if (r == (size_t)-1 || r == (size_t)-2) break;
if (r == 0) r = 1;
pos += r;
cps++;
unsigned long c = (unsigned long)wc;
int joins = (cps > 1) && (is_combining(c) || prev_was_zwj || c == 0x200D
|| (is_regional(c) && prev_regional));
if (!joins) clusters++;
prev_was_zwj = (c == 0x200D);
prev_regional = is_regional(c) && !prev_regional;
prev = c;
}
(void)prev;
printf(" bytes %2zu code points %2zu visible characters %2zu %s\n",
bytes, cps, clusters, label);
}
int main(void)
{
const char *loc = setlocale(LC_CTYPE, "C.UTF-8");
if (!loc) loc = setlocale(LC_CTYPE, "en_US.UTF-8");
if (!loc) { puts("no UTF-8 locale - skipping this demonstration"); return 0; }
puts("[the same letter can be written two ways]");
measure("\"가\" precomposed U+AC00", "\uAC00");
measure("\"가\" decomposed U+1100 U+1161", "\u1100\u1161");
measure("\"e\\u0301\" a combining accent", "e\u0301");
measure("\"\\u00E9\" a precomposed letter", "\u00E9");
puts("\n[an emoji can be several code points that look like one character]");
measure("a smiling face", "\U0001F600");
measure("a family (joined with ZWJ)", "\U0001F468\u200D\U0001F469\u200D\U0001F467");
measure("a flag (two regional indicators)", "\U0001F1F0\U0001F1F7");
measure("a waving hand + skin tone", "\U0001F44B\U0001F3FD");
puts("\n[one sentence]");
measure("\"Hello, 세계!\"", "Hello, 세계!");
puts("\nin short: before asking for a 'character count', decide *which layer* you are counting.");
puts(" bytes = the unit of storage and transmission, code points = the unit of Unicode,");
puts(" grapheme clusters = what the cursor steps over at once (UAX #29).");
puts("the count above is a simplified version using four common rules - a complete decision");
puts("belongs to a dedicated library such as ICU.");
return 0;
}
실행 결과
[the same letter can be written two ways]
bytes 3 code points 1 visible characters 1 "가" precomposed U+AC00
bytes 6 code points 2 visible characters 1 "가" decomposed U+1100 U+1161
bytes 3 code points 2 visible characters 1 "e\u0301" a combining accent
bytes 2 code points 1 visible characters 1 "\u00E9" a precomposed letter
[an emoji can be several code points that look like one character]
bytes 4 code points 1 visible characters 1 a smiling face
bytes 18 code points 5 visible characters 1 a family (joined with ZWJ)
bytes 8 code points 2 visible characters 1 a flag (two regional indicators)
bytes 8 code points 2 visible characters 1 a waving hand + skin tone
[one sentence]
bytes 14 code points 10 visible characters 10 "Hello, 세계!"
in short: before asking for a 'character count', decide *which layer* you are counting.
bytes = the unit of storage and transmission, code points = the unit of Unicode,
grapheme clusters = what the cursor steps over at once (UAX #29).
the count above is a simplified version using four common rules - a complete decision
belongs to a dedicated library such as ICU.
| 층 | 단위 | 쓰는 자리 |
|---|---|---|
| 바이트 | char | 저장·전송·버퍼 크기 |
| 코드 단위 | UTF-16 단위 등 | 자바·자바스크립트의 length |
| 코드포인트 | 유니코드 하나 | 정규화·분류·변환 |
| 그래핌 클러스터 | 사람이 보는 글자 | 커서 이동·글자 수·자르기 |
표 72.4
시연이 그 차이를 실측으로 보인다. 이모지 가족 하나가 18바이트, 코드포인트 5개, 보이는 글자 1개다. ZWJ(U+200D)라는 보이지 않는 문자가 사람 셋을 하나로 묶은 것이다. 태극기는 지역 표시자 두 개가 모여 하나가 된다.
한글에서도 같은 일이 일어난다. “가”는 완성형 U+AC00 하나로 적을 수도 있고, 조합형 U+1100(ㄱ) + U+1161(ㅏ) 둘로 적을 수도 있다 — 화면에 보이는 것은 같은 글자다.
문. 그래핌 클러스터는 어떻게 세는가?
답. 규칙은 유니코드 부속서 UAX #29가 정한다. 「어디서 글자가 끊기는가」를 문자 속성의 조합으로 정의한 표이고, 제대로 구현하려면 유니코드 데이터베이스가 필요하다.
시연의 셈은 자주 만나는 네 가지만 추린 간이 판이다 — 결합 표시, 한글 조합 자모, ZWJ 연결, 지역 표시자 쌍. 그것만으로도 이모지와 한글의 대부분을 맞게 세지만, 완전하지는 않다.
실무의 결론은 이렇다 — 직접 구현하지 않는다. 그래핌 단위가 정말 필요한 자리(편집기의 커서, 화면 폭 계산)라면 ICU나 그에 준하는 라이브러리를 쓴다. 대신 언제 그것이 필요한지를 아는 것이 이 절의 목적이다: 저장·비교·전송에는 필요 없고, 사람이 보는 것을 다룰 때만 필요하다.
72.4 정규화 — 같은 글자, 다른 바이트
앞에서 본 “가”의 두 가지 표기는 실무에서 곧바로 문제가 된다. 바이트가 다르면 strcmp가 다르다고 답하기 때문이다. 사용자에게는 같은 글자인데 검색이 안 되고, 파일 이름이 겹치고, 로그인 아이디가 다르게 취급된다.
유니코드는 이 문제를 정규화(normalization, UAX #15)로 다룬다. 형태가 넷이다.
| 형태 | 무엇 | 보기 |
|---|---|---|
| NFC | 최대한 합친다(기본 권장) | ㄱ+ㅏ → 가 |
| NFD | 최대한 쪼갠다 | 가 → ㄱ+ㅏ |
| NFKC·NFKD | 모양이 다른 호환 문자까지 통일 | ㈜ → (주), 전각 A → A |
표 72.5
실제 사례. macOS 의 파일 이름과 NFD
macOS 의 HFS+ 파일 시스템은 파일 이름을 NFD 에 가까운 형태로 정규화해서 저장했다. 그래서 한국어 파일 이름을 macOS 에서 만들어 리눅스로 옮기면 이름이 “ㄱㅏㄴㅏㄷㅏ”처럼 자모가 풀린 채로 보이는 일이 흔했다.
같은 이유로 압축 파일이 깨지고, 웹 서버가 404를 내고, git status가 멀쩡한 파일을 「수정됨」으로 표시했다. Git 은 결국 core.precomposeunicode 설정을 두어 macOS 에서 이름을 NFC 로 되돌리도록 했다.
교훈은 둘이다. 하나, 문자열을 견주기 전에 정규화한다. 특히 파일 이름· 사용자 이름·검색어처럼 사람이 입력한 것을 키로 쓸 때 그렇다. 둘, 어떤 형태로 정규화할지를 시스템 전체에서 하나로 정한다 — 대개 NFC 다.
표준 C 에는 정규화 함수가 없다. ICU 나 그에 준하는 라이브러리가 필요하다.
72.5 대소문자와 정렬은 언어에 달렸다
toupper가 한 글자를 한 글자로 바꾼다는 가정도 유니코드에서는 무너진다.
| 보기 | 무슨 일이 | 그래서 |
|---|---|---|
터키어 i | 대문자가 İ(점 있는 I) | 로케일 없이는 옳게 바꿀 수 없다 |
터키어 I | 소문자가 ı(점 없는 i) | 영어와 정반대 |
독일어 ß | 대문자가 SS 두 글자 | 1:1 대응이 아니다 |
그리스어 Σ | 낱말 끝에서는 ς | 자리에 따라 다르다 |
표 72.6
실제 사례. 터키어 i 문제 — 실제로 무너진 코드들
「대소문자를 무시하고 견주려면 둘 다 대문자로 바꿔서 견준다」는 흔한 관용구가 터키어 로케일에서 깨진다. "file"을 대문자로 바꾸면 "FİLE"이 되어 "FILE"과 다르기 때문이다.
이 때문에 확장자 검사가 실패하고, HTTP 헤더 이름이 매칭되지 않고, 설정 키가 인식되지 않는 사고가 실제로 여러 소프트웨어에서 났다. 자바에는 toUpperCase(Locale.ROOT)라는 별도 호출이 이 문제 때문에 생겼다.
처방은 층을 나누는 것이다. 프로토콜·식별자·파일 확장자처럼 기계가 견줄 것은 로케일과 무관하게 아스키 규칙으로만 접는다. 사람에게 보여 줄 이름을 대문자로 만드는 일에만 로케일을 쓴다.
/* 기계용 — 아스키만, 로케일과 무관하게 */
static char ascii_lower(char c)
{ return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; }72.6 아직 살아 있는 레거시 — 2바이트 인코딩
세상이 전부 UTF-8이 된 것은 아니다. 옛 파일, 옛 데이터베이스, 옛 장비의 프로토콜에는 지역별 2바이트 인코딩이 그대로 남아 있다.
| 인코딩 | 지역 | 배경 규격 |
|---|---|---|
| EUC-KR / CP949(UHC) | 한국 | KS X 1001 (옛 KS C 5601) |
| Shift_JIS / CP932 | 일본 | JIS X 0208 |
| Big5 | 대만·홍콩 | —(사실상 표준) |
| GBK / GB 18030 | 중국 | GB 18030-2022(중국 국가표준, 필수 준수) |
표 72.7
이들의 공통 구조는 「선행 바이트 + 후행 바이트」다. 그리고 어떤 인코딩은 후행 바이트가 아스키 영역과 겹친다 — 여기서 유명한 사고가 난다.
examples/ch72/legacy_lead.c
/* 레거시 2바이트 인코딩 — 후행 바이트가 아스키와 겹칠 때 무슨 일이 나는가. */
#include <stdio.h>
#include <string.h>
/* 인코딩마다 '선행 바이트'와 '후행 바이트'의 범위가 다르다.
위험한 것은 후행 바이트 범위가 아스키 영역과 겹치는 인코딩이다. */
static void show_table(void)
{
puts("byte ranges per encoding (two-byte characters)");
puts(" encoding lead byte trail byte can 0x5C('\\') be a trail?");
puts(" EUC-KR A1-FE A1-FE no");
puts(" CP949(UHC) 81-FE 41-5A,61-7A,81-FE no");
puts(" Shift_JIS 81-9F,E0-FC 40-7E,80-FC * yes");
puts(" Big5 81-FE 40-7E,A1-FE * yes");
puts(" GBK 81-FE 40-FE (except 7F) * yes");
}
/* Shift_JIS 로 적은 경로: C:\表\ソ.txt
表 = 95 5C, ソ = 83 5C — 둘 다 후행 바이트가 0x5C 다(iconv 로 확인). */
static const unsigned char sjis_path[] = {
'C', ':', 0x5C, 0x95, 0x5C, 0x5C, 0x83, 0x5C, '.', 't', 'x', 't', 0
};
/* EUC-KR 로 적은 경로: C:\한글.txt (한 = C7 D1, 글 = B1 DB) */
static const unsigned char euckr_path[] = {
'C', ':', 0x5C, 0xC7, 0xD1, 0xB1, 0xDB, '.', 't', 'x', 't', 0
};
static int sjis_is_lead(unsigned char c)
{ return (c >= 0x81 && c <= 0x9F) || (c >= 0xE0 && c <= 0xFC); }
static int euckr_is_lead(unsigned char c) { return c >= 0xA1 && c <= 0xFE; }
/* 선행 바이트를 건너뛰며 마지막 구분자를 찾는다 — 올바른 방법 */
static long safe_last_sep(const unsigned char *s, int (*is_lead)(unsigned char))
{
long last = -1;
for (size_t i = 0; s[i]; ) {
if (is_lead(s[i]) && s[i + 1]) { i += 2; continue; } /* 두 바이트 문자 */
if (s[i] == 0x5C) last = (long)i;
i += 1;
}
return last;
}
static void dump(const char *label, const unsigned char *s)
{
printf(" %-10s", label);
for (size_t i = 0; s[i]; i++) printf(" %02X", s[i]);
puts("");
}
static void test(const char *name, const unsigned char *path,
int (*is_lead)(unsigned char))
{
printf("\n[%s]\n", name);
dump("bytes", path);
/* 흔히 쓰는 방법 — 바이트만 보고 마지막 '\' 를 찾는다 */
const char *hit = strrchr((const char *)path, 0x5C);
long naive = hit ? (long)(hit - (const char *)path) : -1;
long safe = safe_last_sep(path, is_lead);
printf(" last '\\' found by strrchr: position %ld\n", naive);
printf(" a scan that knows lead bytes: position %ld\n", safe);
if (naive == safe) puts(" -> the same. Safe in this encoding.");
else {
puts(" -> different! strrchr mistook the *trail byte* of a character for the separator.");
printf(" cutting there leaves the file name as the broken fragment \"");
for (size_t i = (size_t)naive + 1; path[i]; i++) printf("%02X ", path[i]);
puts("\".");
}
}
int main(void)
{
show_table();
test("Shift_JIS: C:\\表\\ソ.txt", sjis_path, sjis_is_lead);
test("EUC-KR: C:\\한글.txt", euckr_path, euckr_is_lead);
puts("\nthis accident is famous enough to have a name - in Japan, 'dame-moji' (ダメ文字),");
puts("typically letters such as 表, ソ, 十 and ダ. UTF-8 does not have the problem:");
puts("its trail bytes are always 0x80 or above, so they never collide with ASCII.");
const unsigned char utf8_path[] = "C:\\한글.txt";
dump("UTF-8", utf8_path);
const char *h = strrchr((const char *)utf8_path, 0x5C);
printf(" last '\\' found by strrchr: position %ld - always right\n",
h ? (long)(h - (const char *)utf8_path) : -1);
return 0;
}
실행 결과
byte ranges per encoding (two-byte characters)
encoding lead byte trail byte can 0x5C('\') be a trail?
EUC-KR A1-FE A1-FE no
CP949(UHC) 81-FE 41-5A,61-7A,81-FE no
Shift_JIS 81-9F,E0-FC 40-7E,80-FC * yes
Big5 81-FE 40-7E,A1-FE * yes
GBK 81-FE 40-FE (except 7F) * yes
[Shift_JIS: C:\表\ソ.txt]
bytes 43 3A 5C 95 5C 5C 83 5C 2E 74 78 74
last '\' found by strrchr: position 7
a scan that knows lead bytes: position 5
-> different! strrchr mistook the *trail byte* of a character for the separator.
cutting there leaves the file name as the broken fragment "2E 74 78 74 ".
[EUC-KR: C:\한글.txt]
bytes 43 3A 5C C7 D1 B1 DB 2E 74 78 74
last '\' found by strrchr: position 2
a scan that knows lead bytes: position 2
-> the same. Safe in this encoding.
this accident is famous enough to have a name - in Japan, 'dame-moji' (ダメ文字),
typically letters such as 表, ソ, 十 and ダ. UTF-8 does not have the problem:
its trail bytes are always 0x80 or above, so they never collide with ASCII.
UTF-8 43 3A 5C ED 95 9C EA B8 80 2E 74 78 74
last '\' found by strrchr: position 2 - always right
시연이 그 사고를 그대로 재현한다. Shift_JIS에서 「表」는 95 5C, 「ソ」는 83 5C인데, 그 뒤 바이트 0x5C가 아스키로는 역슬래시(\)다. 그래서 strrchr(path, '\\')이 문자의 후행 바이트를 경로 구분자로 잘못 짚는다. 시연에서 올바른 위치 5 대신 7을 짚고, 그 자리에서 자르면 파일 이름이 깨진다.
일본에서는 이 글자들을 「다메모지」(ダメ文字)라 부른다. 表·ソ·十·ダ 같은 글자가 경로·이스케이프·SQL 처리에서 반복해서 사고를 냈다.
| 인코딩 | 후행 바이트 범위 | 0x5C가 후행에? |
|---|---|---|
| EUC-KR | A1~FE | 아니다 — 안전 |
| CP949 | 41~5A, 61~7A, 81~FE | 아니다 — 안전 |
| Shift_JIS | 40~7E, 80~FC | ★ 그렇다 |
| Big5 | 40~7E, A1~FE | ★ 그렇다 |
| GBK | 40~FE(7F 제외) | ★ 그렇다 |
| UTF-8 | 80~BF | 아니다 — 구조적으로 불가능 |
표 72.8
한국어 인코딩이 이 사고에서 자유로웠던 것은 운이 좋아서다. EUC-KR의 후행 바이트가 모두 0xA1 이상이라 아스키와 겹치지 않았기 때문이다. 반대로 일본어· 중국어 코드를 다루는 프로그램은 반드시 선행 바이트를 아는 훑기를 써야 한다.
반례. 레거시 인코딩 문자열을 바이트로 훑기
char *ext = strrchr(filename, '.'); /* Shift_JIS 에서 오작동 가능 */
for (char *p = s; *p; p++) /* 후행 바이트를 문자로 오인 */
if (*p == ',') split(p);선행 바이트를 만나면 두 칸을 건너뛰어야 한다. 시연의 safe_last_sep이 그 형태다. 표준 함수로 하려면 mblen이나 mbrtowc로 한 글자씩 나아간다 — 단, 그러려면 로케일이 그 인코딩이어야 한다(70장).
더 나은 처방은 애초에 이 문제를 만들지 않는 것이다 — 입력 경계에서 UTF-8로 바꾸고, 내부에서는 UTF-8만 다룬다.
72.7 변환은 무엇으로 하는가
| 수단 | 어디 | 성격 |
|---|---|---|
iconv | POSIX | 인코딩 이름으로 지정. 표준 C 밖이지만 유닉스 어디에나 있다 |
MultiByteToWideChar 계열 | Windows | 코드 페이지 번호로 지정 |
| ICU | 이식 | 가장 완전하다. 정규화·정렬·그래핌까지 |
mbrtowc 계열 | 표준 C | 로케일의 인코딩만 다룰 수 있다는 제약(70장) |
| 직접 구현 | — | UTF-8↔UTF-16/32 정도는 짧다. 시연이 그 예 |
표 72.9
표준 C만으로 임의의 인코딩을 다룰 수 없다는 점을 다시 확인해 두자. mbrtowc는 「지금 로케일의 인코딩」만 안다. 그래서 「EUC-KR 파일을 읽어 UTF-8로 저장한다」 같은 일은 표준 C의 범위 밖이고, iconv나 직접 만든 변환표가 필요하다.
복습 정리
| 기억할 것 | 요점 |
|---|---|
| 설계 | 안은 UTF-8 바이트열, 변환은 경계에서만 |
| 검증 | 입력 경계에서 한 번. 과잉 인코딩·서러게이트·범위 초과를 거절 |
| 길이 | 바이트·코드 단위·코드포인트·그래핌 — 어느 층인지 먼저 정한다 |
| 정규화 | 비교 전에 NFC 로. 표준 C 에는 없다 |
| 대소문자 | 기계용은 아스키 규칙으로. 터키어 i 를 기억 |
| 레거시 | Shift_JIS·Big5·GBK 는 후행 바이트가 아스키와 겹친다 |
| 훑기 | 레거시 인코딩은 선행 바이트를 알고 건너뛴다 |
| 변환 | iconv·ICU·플랫폼 API. 표준 C 는 로케일 인코딩만 |
표 72.10
67장에서 시작한 문자 이야기를 여섯 장에 걸쳐 마쳤다 — 한 바이트의 판정에서 로케일, wchar_t, 그리고 실무의 처방까지. 다음 장은 표준이 최근에 보탠 것들과 「안전한 함수」를 둘러싼 오래된 논쟁이다.