Proven C BookEnglish GitHub

72 실무 — 유니코드와 멀티바이트를 다루는 법

먼저 알아야 할 것

71장 와이드 문자 ② · UTF-16과 플랫폼의 갈라짐
70장 와이드 문자 ① · 변환 함수와 mbstate_t
9장 문자와 텍스트 · 코드포인트와 인코딩

돌아보기

70·71장을 지나며 「UTF-8 바이트열을 그대로 다루라」는 권고가 여러 번 나왔다. 그런데 바이트열을 그대로 다루면 「세 번째 글자를 지워라」 같은 일은 어떻게 하는가?

답. 대부분의 프로그램은 그런 일을 하지 않기 때문이다. 실제로 하는 일을 세어 보면 이렇다 — 읽고, 저장하고, 비교하고, 이어 붙이고, 찾고, 그대로 내보낸다. 이 여섯 가지는 UTF-8 바이트열에서 아무 해독 없이 된다.

글자 단위로 만져야 하는 일 — 커서 옮기기, 줄 바꿈 자리 찾기, 글자 수 세기 — 은 편집기나 렌더러의 몫이고, 그 층에서는 코드포인트도 모자라 그래핌 클러스터가 필요하다. 이 장은 그 경계를 그어 주는 장이다: 어디까지 바이트로 가고, 어디서부터 해독하며, 그때 무엇을 조심하는가.

이 장의 필요성과 맥락

세 장(70·71·72)의 마지막이자 처방을 내는 자리다. 처방을 맨 뒤에 둔 것은 「UTF-8 우선」이라는 결론이 앞의 두 장을 겪은 뒤라야 설득력을 갖기 때문이다. 아무 근거 없이 먼저 말하면 유행 따르기로 들린다.

이 장이 끝나면

실무의 처방을 모은다. UTF-8 우선 설계가 왜 이기는지, 「글자 수」의 세 층위와 그래핌 클러스터, 정규화, 언어에 달린 대소문자, 직접 만든 UTF-8 검증기, 그리고 아직 살아 있는 레거시 2바이트 인코딩의 함정까지.

이 장에서 답할 질문

  1. 그래핌 클러스터는 어떻게 세는가?

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 80U+0000을 두 바이트로 적었다 — 검사를 우회하는 고전적 공격
서러게이트 인코딩ED A0 80U+D800은 문자가 아니다(71장)
범위 초과F5 80 80 80U+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

표 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-KRA1~FE아니다 — 안전
CP94941~5A, 61~7A, 81~FE아니다 — 안전
Shift_JIS40~7E, 80~FC★ 그렇다
Big540~7E, A1~FE★ 그렇다
GBK40~FE(7F 제외)★ 그렇다
UTF-880~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 변환은 무엇으로 하는가

수단어디성격
iconvPOSIX인코딩 이름으로 지정. 표준 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, 그리고 실무의 처방까지. 다음 장은 표준이 최근에 보탠 것들과 「안전한 함수」를 둘러싼 오래된 논쟁이다.