Proven C BookEnglish GitHub

71 와이드 문자 ② — 플랫폼의 현실과 와이드 입출력

먼저 알아야 할 것

70장 와이드 문자 ① · wchar_t의 계약과 변환
63장 스트림의 실제 · 스트림과 버퍼

돌아보기

70장에서 wchar_t의 크기와 인코딩을 표준이 정하지 않았다고 했다. 정하지 않으면 구현이 알아서 고를 텐데, 그게 그렇게 큰 문제인가?

답. 두 진영으로 갈렸다는 것이 문제다. 리눅스·macOS는 4바이트를 골랐고 Windows는 2바이트를 골랐다. 그리고 그 선택이 1990년대의 사실 하나 — 「유니코드는 16비트면 충분하다」 — 위에 서 있었는데, 그 사실이 1996년에 깨졌다.

유니코드가 U+FFFF를 넘어가면서 2바이트 wchar_t한 글자를 한 단위로 담는다는 원래의 약속을 지킬 수 없게 되었다. 그 뒷수습이 서러게이트 쌍이고, 오늘날 Windows 프로그래밍의 문자열 함정 대부분이 여기서 나온다.

이 장은 그 갈라짐의 정확한 모습과, 각 플랫폼·툴킷이 그 위에서 어떤 선택을 했는지를 본다.

이 장의 필요성과 맥락

70장이 표준이 정한 것이었다면 이 장은 플랫폼이 실제로 한 것이다. 둘을 가른 이유가 여기 있다 — wchar_t 는 표준의 약속과 현실의 거리가 이 책에서 가장 먼 낱말이라, 섞어 쓰면 무엇이 계약이고 무엇이 관행인지 구별할 수 없게 된다.

이 장이 끝나면

같은 wchar_t가 플랫폼마다 무엇이 되었는지 본다. UTF-16의 한계와 서러게이트 쌍의 계산, Windows의 W 함수군과 정확한 인코딩, glibc의 선택, GTK와 Qt가 문자열을 담는 방식, 그리고 스트림의 방향이라는 잘 알려지지 않은 규칙까지.

이 장에서 답할 질문

  1. Windows는 왜 2바이트를 골랐는가?
  2. 그러면 내 프로그램은 무엇을 골라야 하는가?

71.1 두 진영으로 갈린 wchar_t

플랫폼sizeof(wchar_t)사실상의 인코딩__STDC_ISO_10646__
리눅스(glibc)·macOS4UTF-32(UCS-4)정의된다
Windows(MSVC·MinGW)2UTF-16정의되지 않는다
AIX 등 일부 유닉스2 또는 4구현마다구현마다

표 71.1

70장 시연이 이 기계의 답을 보였다 — 4바이트, __STDC_ISO_10646__ 정의됨. 즉 wchar_t 하나가 코드포인트 하나다. Windows에서는 그 등식이 성립하지 않는다.

문. Windows는 왜 2바이트를 골랐는가?

답. 시대의 사정이다. Windows NT의 설계가 한창이던 1990년대 초, 유니코드는 16비트 고정 폭 문자집합이었다. 세상의 모든 문자를 65,536개 안에 담는다는 것이 당시의 설계였고, 그 전제 위에서 「2바이트면 한 글자」는 합리적인 선택이었다. 자바의 char, 자바스크립트의 문자열, Qt의 QChar가 모두 같은 시기에 같은 선택을 했다.

1996년 유니코드 2.0이 그 전제를 깼다. 한자만으로도 65,536개가 모자랐고, 범위를 U+10FFFF까지 열면서 16비트로는 담을 수 없는 문자가 생겼다. 이미 16비트로 지어 놓은 시스템들은 타입을 바꿀 수 없었으므로, 대신 인코딩을 바꿨다 — 두 단위로 한 글자를 적는 UTF-16이다.

그래서 오늘날 Windows의 wchar_t“한 글자”가 아니라 “UTF-16 코드 단위 하나”다. 이름과 실체가 어긋난 채로 30년이 지난 셈이다.

71.2 UTF-16의 한계와 서러게이트 쌍

유니코드의 코드포인트 공간은 U+0000부터 U+10FFFF까지다. 이 중 앞의 U+0000~U+FFFF를 기본 다국어 평면(BMP)이라 부르고, 16비트에 담기는 것은 여기까지다.

그 위의 문자를 16비트 단위로 적기 위해 유니코드는 BMP 안에 쓰지 않는 구역을 따로 비워 두었다.

구역범위무엇
고위 서러게이트U+D800~U+DBFF쌍의 앞쪽 단위(1024개)
저위 서러게이트U+DC00~U+DFFF쌍의 뒤쪽 단위(1024개)
(합쳐서 표현하는 범위)U+10000~U+10FFFF1024 × 1024 = 1,048,576자

표 71.2

examples/ch71/surrogate.c

/* UTF-16 의 한계 — 서러게이트 쌍, 그리고 '길이'가 세 가지인 이유. */
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <uchar.h>
#include <wchar.h>

/* 코드포인트 하나를 UTF-16 단위로 쪼갠다.
   BMP(U+0000~U+FFFF, 단 D800~DFFF 제외)는 그대로 한 단위,
   그 위(U+10000~U+10FFFF)는 두 단위 — 서러게이트 쌍이다. */
static int to_utf16(unsigned long cp, char16_t out[2])
{
    if (cp > 0x10FFFFUL) return 0;                 /* 유니코드 범위 밖 */
    if (cp >= 0xD800UL && cp <= 0xDFFFUL) return 0; /* 서러게이트 자체는 문자가 아니다 */
    if (cp < 0x10000UL) { out[0] = (char16_t)cp; return 1; }

    unsigned long v = cp - 0x10000UL;              /* 20비트로 줄인다 */
    out[0] = (char16_t)(0xD800UL + (v >> 10));     /* 상위 10비트 → 고위 서러게이트 */
    out[1] = (char16_t)(0xDC00UL + (v & 0x3FFUL)); /* 하위 10비트 → 저위 서러게이트 */
    return 2;
}

/* 반대 방향 — 쌍을 다시 코드포인트로 */
static unsigned long from_pair(char16_t hi, char16_t lo)
{
    return 0x10000UL + (((unsigned long)hi - 0xD800UL) << 10)
                     +  ((unsigned long)lo - 0xDC00UL);
}

static void report(const char *name, unsigned long cp)
{
    char16_t u16[2] = { 0, 0 };
    int n = to_utf16(cp, u16);

    char cpname[16];
    snprintf(cpname, sizeof cpname, "U+%04lX", cp);
    printf("  %-9s", cpname);
    if (n == 0)      printf("cannot be represented    ");
    else if (n == 1) printf("%04X        one unit         ", u16[0]);
    else             printf("%04X %04X   back again U+%04lX", u16[0], u16[1],
                             from_pair(u16[0], u16[1]));
    printf("  %s\n", name);
}

/* 문자열 하나를 세 가지 길이로 잰다 */
static void lengths(const char *label, const char *utf8)
{
    size_t bytes = strlen(utf8);

    /* 코드포인트 수와 UTF-16 단위 수를 직접 센다 */
    size_t cps = 0, u16units = 0;
    mbstate_t st;
    memset(&st, 0, sizeof st);
    for (size_t pos = 0; pos < bytes; ) {
        wchar_t wc = 0;
        size_t r = mbrtowc(&wc, utf8 + pos, bytes - pos, &st);
        if (r == (size_t)-1 || r == (size_t)-2) break;
        if (r == 0) r = 1;
        pos += r;
        cps++;
        char16_t tmp[2];
        u16units += (size_t)to_utf16((unsigned long)wc, tmp);
    }
    printf("  UTF-8 %2zu bytes  code points %zu  UTF-16 %zu units   %s\n",
           bytes, cps, u16units, label);
}

int main(void)
{
    puts("[code point -> UTF-16]");
    report("'A'", 0x41);
    report("'한'", 0xD55C);
    report("U+FFFD replacement character", 0xFFFD);
    report("a surrogate itself, D800", 0xD800);
    report("emoji U+1F600", 0x1F600);
    report("CJK extension U+2A6B2", 0x2A6B2);
    report("last of Unicode", 0x10FFFF);
    report("out of range", 0x110000);

    puts("\nthe arithmetic goes like this (U+1F600):");
    unsigned long v = 0x1F600UL - 0x10000UL;
    printf("  0x1F600 - 0x10000 = 0x%05lX (20 bits)\n", v);
    printf("  high 10 bits 0x%03lX + 0xD800 = 0x%04lX\n", v >> 10, 0xD800UL + (v >> 10));
    printf("  low  10 bits 0x%03lX + 0xDC00 = 0x%04lX\n",
           v & 0x3FF, 0xDC00UL + (v & 0x3FF));

    const char *loc = setlocale(LC_CTYPE, "C.UTF-8");
    if (!loc) loc = setlocale(LC_CTYPE, "en_US.UTF-8");
    if (!loc) { puts("\nno UTF-8 locale, so the length demonstration is skipped"); return 0; }

    puts("\n[three lengths of the same string]");
    lengths("\"Hi\"", "Hi");
    lengths("\"한글\"", "한글");
    lengths("one emoji", "\xF0\x9F\x98\x80");
    lengths("mixed", "a한\xF0\x9F\x98\x80");
    printf("  wchar_t here is %zu bytes -> UTF-32. On Windows it is 2 bytes -> UTF-16.\n",
           sizeof(wchar_t));
    return 0;
}

실행 결과

[code point -> UTF-16]
  U+0041    → 0041        one unit           'A'
  U+D55C    → D55C        one unit           '한'
  U+FFFD    → FFFD        one unit           U+FFFD replacement character
  U+D800    → cannot be represented      a surrogate itself, D800
  U+1F600   → D83D DE00   back again U+1F600  emoji U+1F600
  U+2A6B2   → D869 DEB2   back again U+2A6B2  CJK extension U+2A6B2
  U+10FFFF  → DBFF DFFF   back again U+10FFFF  last of Unicode
  U+110000  → cannot be represented      out of range

the arithmetic goes like this (U+1F600):
  0x1F600 - 0x10000 = 0x0F600 (20 bits)
  high 10 bits 0x03D + 0xD800 = 0xD83D
  low  10 bits 0x200 + 0xDC00 = 0xDE00

[three lengths of the same string]
  UTF-8  2 bytes  code points 2  UTF-16 2 units   "Hi"
  UTF-8  6 bytes  code points 2  UTF-16 2 units   "한글"
  UTF-8  4 bytes  code points 1  UTF-16 2 units   one emoji
  UTF-8  8 bytes  code points 3  UTF-16 4 units   mixed
  wchar_t here is 4 bytes -> UTF-32. On Windows it is 2 bytes -> UTF-16.

시연이 계산을 그대로 보인다. U+1F600을 예로 들면 이렇다.

  1. 코드포인트에서 0x10000을 뺀다 → 0x0F600 (20비트에 들어간다)
  2. 위쪽 10비트를 0xD800에 더한다 → 0xD83D (고위)
  3. 아래쪽 10비트를 0xDC00에 더한다 → 0xDE00 (저위)

서러게이트 값 자체는 문자가 아니다. U+D800~U+DFFF는 코드포인트로서는 어떤 글자에도 배정되지 않으며, UTF-8이나 UTF-32에 단독으로 나타나면 부정한 데이터다(72장의 검증에서 다시 만난다). 시연에서 U+D800을 UTF-16으로 담으려 할 때 “담을 수 없다”가 나온 이유다.

흔한 오해. “문자열의 길이는 문자 수다”

이 문장이 어느 층의 이야기인지에 따라 답이 셋으로 갈린다. 시연의 뒷부분이 그것을 실측으로 보인다 — 이모지 하나가 UTF-8 4바이트, 코드포인트 1개, UTF-16 2단위다.

그래서 언어마다 「길이」의 뜻이 다르다. C의 strlen은 바이트를 세고, 자바·자바스크립트·C#의 length는 UTF-16 단위를 세며, 파이썬 3의 len은 코드포인트를 센다. 「이모지 하나를 넣었더니 길이가 2로 나온다」는 자바스크립트의 유명한 놀라움이 정확히 이 자리다.

그리고 셋 중 어느 것도 사람이 보는 글자 수는 아니다. 그 넷째 층은 72장에서 본다.

71.3 Windows — W 함수군과 그 정확한 인코딩

Windows API는 문자열을 받는 거의 모든 함수를 두 벌로 제공한다.

접미사문자열 타입인코딩
A(ANSI)char *프로세스의 활성 코드 페이지(한국어 판이면 대개 CP949)
W(Wide)wchar_t *(WCHAR)UTF-16LE

표 71.3

MessageBoxAMessageBoxW가 그 예이고, MessageBox라는 이름은 매크로다 — UNICODE 매크로가 정의되어 있으면 W로, 아니면 A로 펼쳐진다. TCHAR·_T() 같은 이름도 같은 시대의 유산이다.

플랫폼 노트. Windows에서 문자열을 다루는 세 가지 길

W 함수를 쓰고 경계에서 변환한다(고전적 정공법). 프로그램 내부는 UTF-8로 두고, API를 부를 때만 MultiByteToWideChar(CP_UTF8, …)로 UTF-16으로 바꾼다. 돌아올 때는 WideCharToMultiByte. 변환 함수는 필요한 크기를 먼저 묻고(길이 인자에 0을 주면 필요한 문자 수를 돌려준다) 그다음 채우는 두 단계 호출이다.

② 프로그램 전체를 UTF-16으로 짠다. Windows 전용 프로그램의 오래된 방식이다. 문자열 상수마다 L을 붙이고, wcslen·wprintf 계열을 쓴다. 이식성은 포기한다.

③ 활성 코드 페이지를 UTF-8로 만든다(현대적 처방). Windows 10 1903부터 응용 프로그램 매니페스트에 <activeCodePage>UTF-8</activeCodePage>를 넣으면 A 함수들이 UTF-8을 받는다. 그러면 리눅스용으로 짠 UTF-8 코드가 거의 그대로 돈다. 새 프로그램이라면 이 길이 가장 짧다.

콘솔은 또 별개다. 유니코드를 콘솔에 제대로 내보내려면 _setmode(_fileno(stdout), _O_U16TEXT)로 와이드 모드를 켜거나, 코드 페이지를 SetConsoleOutputCP(CP_UTF8)로 바꿔야 한다. 「한글이 물음표로 나온다」는 대개 이 자리의 문제다.

실제 사례. 짝이 맞지 않는 서러게이트 — Windows 파일 이름의 그늘

Windows의 파일 이름은 「UTF-16 단위의 배열」이지 「올바른 유니코드 문자열」이 아니다. 검사하지 않기 때문에, 짝이 맞지 않는 고위 서러게이트 하나만 든 이름 도 파일 시스템에 존재할 수 있다.

그런 이름을 UTF-8로 바꾸려 하면 문제가 생긴다 — 올바른 UTF-8로는 표현할 수 없는 값이기 때문이다. WideCharToMultiByte는 실패하거나 U+FFFD로 바꿔 버리고, 그러면 그 파일을 다시 열 수 없다.

이 문제 때문에 몇몇 프로그램은 「WTF-8」이라는 확장(짝 없는 서러게이트도 UTF-8식으로 적는 비표준 인코딩)을 내부적으로 쓴다. 러스트의 OsString이 Windows에서 그렇게 구현되어 있다.

교훈은 이렇다 — 플랫폼이 주는 문자열이 언제나 올바른 유니코드라고 가정하지 않는다. 파일 이름·명령줄 인자·환경 변수는 특히 그렇다.

71.4 리눅스와 glibc — 크지만 쓰이지 않는다

glibc의 wchar_t는 4바이트 UCS-4이고 __STDC_ISO_10646__도 정의된다. 즉 표준이 그리던 그림에 가장 가까운 구현이다. 그런데 실무의 리눅스 코드는 wchar_t를 거의 쓰지 않는다. 이유가 넷이다.

이유설명
기억을 네 배 쓴다아스키 위주 텍스트에서 4배는 큰 낭비다
경계에서 언제나 변환해야 한다파일·소켓·API는 모두 바이트다
로케일에 매여 있다LC_CTYPE이 UTF-8이 아니면 변환이 실패한다(70장)
UTF-8이 대부분의 일을 해 준다strlen·strcmp·strstr이 그대로 동작한다

표 71.4

마지막 줄이 결정적이다. UTF-8은 아스키와 호환되고, 자기동기화되며, 바이트 순서가 코드포인트 순서와 일치하므로 기존 바이트 함수들이 대부분 그대로 쓸모가 있다(72장). 그래서 리눅스 진영은 「내부도 UTF-8」이라는 길을 갔다.

71.5 툴킷의 선택 — GTK와 Qt

응용 프로그램 층에서도 같은 갈림이 있다.

툴킷문자열 타입인코딩코드포인트 타입
GTK / GLibgchar *(= char *)UTF-8gunichar(32비트), gunichar2(16비트)
QtQStringUTF-16QChar(16비트 단위)
Windows APIWCHAR *UTF-16

표 71.5

GLib은 「모든 문자열은 UTF-8」이라는 규칙을 세우고, 그 위에 g_utf8_strlen(글자 수)·g_utf8_next_char(다음 글자로)·g_utf8_validate(검증) 같은 함수를 얹었다. wchar_t는 아예 쓰지 않는다 — 플랫폼마다 달라지는 타입을 API에 넣지 않겠다는 결정이다.

Qt는 반대로 갔다. 내부를 UTF-16으로 고정하고, 바깥과는 QString::fromUtf8· toUtf8로 오간다. Windows API와 붙일 때 변환이 없다는 이점이 있고, 대신 QString::size()가 UTF-16 단위를 센다는 함정을 안는다.

문. 그러면 내 프로그램은 무엇을 골라야 하는가?

답. 새 코드라면 UTF-8 바이트열이다. 이유는 위의 표가 그대로 말한다 — 크기가 작고, 기존 C 함수와 어울리고, 파일·네트워크의 표현과 같고, 로케일에 덜 매인다.

wchar_t를 써야 하는 자리는 좁다. Windows API를 직접 부를 때(그것도 경계 에서만), 그리고 와이드 문자만 받는 기존 라이브러리에 붙일 때다.

고정 폭 코드포인트가 정말로 필요하면 wchar_t가 아니라 char32_t를 쓴다. 크기가 어디서나 4바이트이고 UTF-32임이 매크로로 보장된다 — wchar_t가 주지 못하는 두 가지다.

71.6 스트림에는 방향이 있다

와이드 문자에는 입출력 함수도 딸려 있다 — wprintf·fputws·getwc·fgetws, 그리고 EOF 대신 WEOF. 그런데 잘 알려지지 않은 규칙이 하나 있다.

스트림 하나에는 방향(orientation)이 있고, 한 번 정해지면 바꿀 수 없다.

상태
방향 없음갓 열린 스트림. 아직 아무 방향도 아니다
바이트 방향fputs·fprintf 같은 바이트 함수를 처음 쓰면 이쪽으로 정해진다
와이드 방향fputws·fwprintf 같은 와이드 함수를 처음 쓰면 이쪽으로 정해진다

표 71.6

정해진 뒤에 반대 계열 함수를 쓰면 정의되지 않은 동작이다. fwide 함수가 방향을 묻거나(0을 주면 질의) 아직 없을 때 미리 정한다.

examples/ch71/wide_io.c

/* 스트림에는 '방향'이 있다 — 바이트냐 와이드냐, 한 번 정해지면 못 바꾼다. */
#include <locale.h>
#include <stdio.h>
#include <wchar.h>

/* fwide(stream, 0) 은 방향을 묻기만 한다.
   음수=바이트 방향, 0=아직 없음, 양수=와이드 방향. */
static const char *orientation(FILE *f)
{
    int w = fwide(f, 0);
    return w < 0 ? "byte" : w > 0 ? "wide" : "not set yet";
}

int main(void)
{
    setlocale(LC_ALL, "");

    /* 파일 하나를 열어 방향이 정해지는 과정을 본다 */
    FILE *f = tmpfile();
    if (!f) { perror("tmpfile"); return 1; }

    printf("direction of a freshly opened stream: %s\n", orientation(f));

    /* fwide 로 *미리* 정할 수도 있다 — 아직 방향이 없을 때만 먹힌다 */
    fwide(f, 1);
    printf("after fwide(f, 1):                    %s\n", orientation(f));

    fputws(L"a line written wide\n", f);
    printf("after fputws:                         %s\n", orientation(f));

    /* 이미 와이드로 정해진 스트림에 바이트 계열을 쓰면 정의되지 않은 동작이다.
       그래서 여기서는 시도하지 않고, 방향을 되돌리려 해도 안 된다는 것만 본다. */
    fwide(f, -1);
    printf("trying fwide(f, -1) to go back:       %s (it does not change)\n", orientation(f));

    rewind(f);
    wchar_t line[64];
    if (fgetws(line, 64, f)) printf("the line read back:                   %ls", line);
    fclose(f);

    /* 두 번째 스트림은 바이트 쪽으로 정해 본다 */
    FILE *g = tmpfile();
    if (!g) { perror("tmpfile"); return 1; }
    printf("\na second stream:                      %s\n", orientation(g));
    fputs("a line written as bytes\n", g);
    printf("after fputs:                          %s\n", orientation(g));
    fclose(g);

    /* 표준 스트림도 마찬가지다. 이 프로그램은 지금까지 printf 만 썼으므로… */
    printf("\ndirection of stdout:                  %s\n", orientation(stdout));
    puts("-> because this program started with printf.");
    puts("   mixing wprintf in here would be undefined behaviour.");
    return 0;
}

실행 결과

direction of a freshly opened stream: not set yet
after fwide(f, 1):                    wide
after fputws:                         wide
trying fwide(f, -1) to go back:       wide (it does not change)
the line read back:                   a line written wide

a second stream:                      not set yet
after fputs:                          byte

direction of stdout:                  byte
-> because this program started with printf.
   mixing wprintf in here would be undefined behaviour.

시연이 그 규칙을 그대로 보인다. 갓 연 스트림은 방향이 없고, fwide(f, 1)로 와이드로 정할 수 있으며, 그 뒤 fwide(f, -1)로 되돌리려 해도 바뀌지 않는다. 그리고 이 프로그램의 stdoutprintf로 시작했기 때문에 이미 바이트 방향이다 — 여기서 wprintf를 부르면 계약 밖이다.

반례. printfwprintf를 섞어 쓰기

printf("이름: ");
wprintf(L"%ls\n", name);      /* 이미 바이트 방향인 stdout 에 와이드 출력 */

운이 좋으면 출력 순서가 뒤섞이는 정도이고, 나쁘면 아무것도 나오지 않는다. Windows에서 _O_U16TEXT를 켠 뒤 printf를 부르면 프로그램이 곧바로 죽기도 한다.

규율은 하나다 — 스트림 하나에는 한 계열만. 와이드 출력을 쓰기로 했다면 프로그램 전체에서 그렇게 하고, 아니면 아예 쓰지 않는다. 이 책의 권고는 후자다: 와이드 함수로 얻는 것보다 잃는 이식성이 크다.

복습 정리

기억할 것요점
갈라짐리눅스 4바이트 UTF-32, Windows 2바이트 UTF-16
원인1990년대의 “유니코드=16비트” 전제가 1996년에 깨졌다
서러게이트U+10000 이상을 D800~DBFF + DC00~DFFF 쌍으로. 그 값 자체는 문자가 아니다
길이바이트·코드포인트·UTF-16 단위가 모두 다르다
WindowsW 함수는 UTF-16LE. 새 코드는 매니페스트로 UTF-8 코드 페이지
파일 이름올바른 유니코드가 아닐 수 있다(짝 없는 서러게이트)
툴킷GTK=UTF-8 char*, Qt=UTF-16 QString
고정 폭이 필요하면wchar_t가 아니라 char32_t
스트림 방향한 번 정해지면 못 바꾼다. 섞으면 정의되지 않은 동작

표 71.7

플랫폼의 현실까지 보았다. 다음 장은 결론이다 — 그래서 실무에서는 유니코드와 멀티바이트 인코딩을 어떻게 다루는가. 세 층위의 길이, 정규화, UTF-8 검증, 그리고 아직도 살아 있는 레거시 2바이트 인코딩의 함정을 본다.