71 와이드 문자 ② — 플랫폼의 현실과 와이드 입출력
먼저 알아야 할 것
wchar_t의 계약과 변환돌아보기
70장에서 wchar_t의 크기와 인코딩을 표준이 정하지 않았다고 했다. 정하지 않으면 구현이 알아서 고를 텐데, 그게 그렇게 큰 문제인가?
답. 두 진영으로 갈렸다는 것이 문제다. 리눅스·macOS는 4바이트를 골랐고 Windows는 2바이트를 골랐다. 그리고 그 선택이 1990년대의 사실 하나 — 「유니코드는 16비트면 충분하다」 — 위에 서 있었는데, 그 사실이 1996년에 깨졌다.
유니코드가 U+FFFF를 넘어가면서 2바이트 wchar_t는 한 글자를 한 단위로 담는다는 원래의 약속을 지킬 수 없게 되었다. 그 뒷수습이 서러게이트 쌍이고, 오늘날 Windows 프로그래밍의 문자열 함정 대부분이 여기서 나온다.
이 장은 그 갈라짐의 정확한 모습과, 각 플랫폼·툴킷이 그 위에서 어떤 선택을 했는지를 본다.
이 장의 필요성과 맥락
wchar_t 는 표준의 약속과 현실의 거리가 이 책에서 가장 먼 낱말이라, 섞어 쓰면 무엇이 계약이고 무엇이 관행인지 구별할 수 없게 된다.이 장이 끝나면
wchar_t가 플랫폼마다 무엇이 되었는지 본다. UTF-16의 한계와 서러게이트 쌍의 계산, Windows의 W 함수군과 정확한 인코딩, glibc의 선택, GTK와 Qt가 문자열을 담는 방식, 그리고 스트림의 방향이라는 잘 알려지지 않은 규칙까지.이 장에서 답할 질문
- Windows는 왜 2바이트를 골랐는가?
- 그러면 내 프로그램은 무엇을 골라야 하는가?
71.1 두 진영으로 갈린 wchar_t
| 플랫폼 | sizeof(wchar_t) | 사실상의 인코딩 | __STDC_ISO_10646__ |
|---|---|---|---|
| 리눅스(glibc)·macOS | 4 | UTF-32(UCS-4) | 정의된다 |
| Windows(MSVC·MinGW) | 2 | UTF-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+10FFFF | 1024 × 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을 예로 들면 이렇다.
- 코드포인트에서 0x10000을 뺀다 → 0x0F600 (20비트에 들어간다)
- 위쪽 10비트를 0xD800에 더한다 → 0xD83D (고위)
- 아래쪽 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
MessageBoxA와 MessageBoxW가 그 예이고, 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 / GLib | gchar *(= char *) | UTF-8 | gunichar(32비트), gunichar2(16비트) |
| Qt | QString | UTF-16 | QChar(16비트 단위) |
| Windows API | WCHAR * | 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)로 되돌리려 해도 바뀌지 않는다. 그리고 이 프로그램의 stdout은 printf로 시작했기 때문에 이미 바이트 방향이다 — 여기서 wprintf를 부르면 계약 밖이다.
반례. printf와 wprintf를 섞어 쓰기
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 단위가 모두 다르다 |
| Windows | W 함수는 UTF-16LE. 새 코드는 매니페스트로 UTF-8 코드 페이지 |
| 파일 이름 | 올바른 유니코드가 아닐 수 있다(짝 없는 서러게이트) |
| 툴킷 | GTK=UTF-8 char*, Qt=UTF-16 QString |
| 고정 폭이 필요하면 | wchar_t가 아니라 char32_t |
| 스트림 방향 | 한 번 정해지면 못 바꾼다. 섞으면 정의되지 않은 동작 |
표 71.7
플랫폼의 현실까지 보았다. 다음 장은 결론이다 — 그래서 실무에서는 유니코드와 멀티바이트 인코딩을 어떻게 다루는가. 세 층위의 길이, 정규화, UTF-8 검증, 그리고 아직도 살아 있는 레거시 2바이트 인코딩의 함정을 본다.