69 로케일 ② — 숫자·통화·시간·정렬
먼저 알아야 할 것
setlocaleprintf 서식돌아보기
68장에서 로케일이 관습을 담은 데이터라고 했다. 그러면 그 데이터는 정확히 무엇으로 이루어져 있고, 프로그램은 그것을 어떻게 꺼내 쓰는가?
답. 꺼내는 창구는 놀랄 만큼 좁다 — 함수 하나(localeconv)와 구조체 하나(struct lconv)다. 숫자와 통화에 관한 모든 관습이 그 구조체의 스물네 멤버에 들어 있다.
나머지는 창구가 없다. 날짜 표기는 strftime이 알아서 쓰고, 정렬 순서는 strcoll이 알아서 견준다 — 프로그램이 그 데이터를 직접 볼 방법이 표준에는 없다. 이 비대칭이 이 장의 얼개다: 꺼내 쓰는 것(숫자·통화)과 맡기는 것(시간·정렬).
이 장의 필요성과 맥락
struct lconv 의 스물네 멤버가 목록이 아니라 답이 된다. 특히 LC_NUMERIC 이 데이터를 망가뜨리는 무늬는, 앞 장의 범주 개념 없이는 사고의 원인을 짚을 수 없다.이 장이 끝나면
struct lconv의 스물네 멤버 전부, 자리 묶음 문자열의 인코딩, 통화 표기가 조립되는 규칙, LC_NUMERIC이 데이터를 망가뜨리는 무늬, strftime의 로케일 의존 서식과 불변 서식, strcoll과 정렬 키, 그리고 스레드와 로케일.이 장에서 답할 질문
- 그러면 통화를 찍어 주는 표준 함수는 무엇인가?
- 그러면 한국어 정렬은
strcoll로 충분한가?
69.1 창구 하나 — localeconv
struct lconv *localeconv(void);돌려주는 것은 지금 로케일의 숫자·통화 관습이 채워진 구조체의 주소다. 규칙이 둘 있다. 프로그램이 그 내용을 고쳐서는 안 되고, 다음 localeconv 호출이나 setlocale(LC_ALL/LC_MONETARY/LC_NUMERIC, …) 호출이 그 내용을 덮어쓸 수 있다. 즉 오래 들고 있을 값이 아니라 그 자리에서 읽고 마는 값이다.
examples/ch69/lconv.c
/* struct lconv — 로케일이 숫자와 통화에 대해 아는 것 전부. */
#include <limits.h>
#include <locale.h>
#include <stdio.h>
/* 문자열 멤버: 비어 있으면 눈에 보이게 표시한다 */
static const char *str(const char *s) { return (s && *s) ? s : "(empty string)"; }
/* char 멤버: CHAR_MAX 는 "이 로케일은 정하지 않았다"는 뜻이다 */
static void show_char(const char *name, char v)
{
if (v == CHAR_MAX) printf(" %-20s CHAR_MAX (not specified)\n", name);
else printf(" %-20s %d\n", name, v);
}
/* grouping 문자열의 인코딩:
각 바이트가 그 자리의 그룹 자릿수. CHAR_MAX 면 "더는 묶지 않는다",
0 이면 "직전 값을 계속 되풀이한다". "\3" 은 3자리씩 무한, en_IN 은 "\3\2". */
static void show_grouping(const char *name, const char *g)
{
printf("%-20s ", name);
if (!*g) { puts("(empty string - no grouping)"); return; }
for (const char *p = g; *p; p++) {
if (*p == CHAR_MAX) printf("CHAR_MAX ");
else printf("%d ", *p);
}
puts("");
}
static void dump_all(void)
{
struct lconv *L = localeconv();
puts("[non-monetary numbers]");
printf(" %-20s \"%s\"\n", "decimal_point", str(L->decimal_point));
printf(" %-20s \"%s\"\n", "thousands_sep", str(L->thousands_sep));
show_grouping(" grouping", L->grouping);
puts("[monetary numbers]");
printf(" %-20s \"%s\"\n", "mon_decimal_point", str(L->mon_decimal_point));
printf(" %-20s \"%s\"\n", "mon_thousands_sep", str(L->mon_thousands_sep));
show_grouping(" mon_grouping", L->mon_grouping);
printf(" %-20s \"%s\"\n", "positive_sign", str(L->positive_sign));
printf(" %-20s \"%s\"\n", "negative_sign", str(L->negative_sign));
printf(" %-20s \"%s\"\n", "currency_symbol", str(L->currency_symbol));
printf(" %-20s \"%s\"\n", "int_curr_symbol", str(L->int_curr_symbol));
show_char("frac_digits", L->frac_digits);
show_char("int_frac_digits", L->int_frac_digits);
puts("[rules for assembling a monetary amount]");
show_char("p_cs_precedes", L->p_cs_precedes);
show_char("p_sep_by_space", L->p_sep_by_space);
show_char("p_sign_posn", L->p_sign_posn);
show_char("n_cs_precedes", L->n_cs_precedes);
show_char("n_sep_by_space", L->n_sep_by_space);
show_char("n_sign_posn", L->n_sign_posn);
}
/* 여러 로케일을 한 줄씩 견주어 본다 */
static void compare_row(const char *name)
{
if (!setlocale(LC_ALL, name)) {
printf(" %-14s (not on this machine)\n", name);
return;
}
struct lconv *L = localeconv();
printf(" %-14s point \"%s\" thousands \"%s\" currency \"%s\" ISO \"%s\"\n",
name, str(L->decimal_point), str(L->thousands_sep),
str(L->currency_symbol), str(L->int_curr_symbol));
}
int main(void)
{
/* 시작 로케일은 "C" 다. 표준이 이 값들을 직접 규정한다(§7.11). */
puts("=== the \"C\" locale - the values the standard prescribes ===");
dump_all();
puts("\n=== comparing locales ===");
static const char *names[] = {
"C", "en_US.UTF-8", "ko_KR.UTF-8", "de_DE.UTF-8",
"fr_FR.UTF-8", "ja_JP.UTF-8", "en_IN.UTF-8",
};
for (size_t i = 0; i < sizeof names / sizeof *names; i++)
compare_row(names[i]);
/* 자리 묶음이 3자리 고정이 아닌 로케일이 있다 — 인도식 표기 */
puts("\n=== grouping is not always three digits ===");
static const char *grp[] = { "C", "en_US.UTF-8", "en_IN.UTF-8" };
for (size_t i = 0; i < sizeof grp / sizeof *grp; i++) {
if (!setlocale(LC_ALL, grp[i])) {
printf(" %-14s (not on this machine)\n", grp[i]);
continue;
}
printf(" %-14s ", grp[i]);
show_grouping("grouping", localeconv()->grouping);
}
puts(" -> en_IN's 3,2 means \"three digits from the right, then two at a time\" -");
puts(" 12345678 groups as 1,23,45,678 (the lakh and crore system).");
/* 소수점이 바뀌면 printf 와 strtod 가 함께 바뀐다 */
puts("\n=== LC_NUMERIC changes printf ===");
static const char *num_locales[] = { "C", "de_DE.UTF-8", "fr_FR.UTF-8" };
for (size_t i = 0; i < sizeof num_locales / sizeof *num_locales; i++) {
if (!setlocale(LC_NUMERIC, num_locales[i])) {
printf(" %-14s (not on this machine)\n", num_locales[i]);
continue;
}
printf(" %-14s printf(\"%%.2f\", 1234.5) → %.2f\n",
num_locales[i], 1234.5);
}
return 0;
}
실행 결과
=== the "C" locale - the values the standard prescribes ===
[non-monetary numbers]
decimal_point "."
thousands_sep "(empty string)"
grouping (empty string - no grouping)
[monetary numbers]
mon_decimal_point "(empty string)"
mon_thousands_sep "(empty string)"
mon_grouping (empty string - no grouping)
positive_sign "(empty string)"
negative_sign "(empty string)"
currency_symbol "(empty string)"
int_curr_symbol "(empty string)"
frac_digits CHAR_MAX (not specified)
int_frac_digits CHAR_MAX (not specified)
[rules for assembling a monetary amount]
p_cs_precedes CHAR_MAX (not specified)
p_sep_by_space CHAR_MAX (not specified)
p_sign_posn CHAR_MAX (not specified)
n_cs_precedes CHAR_MAX (not specified)
n_sep_by_space CHAR_MAX (not specified)
n_sign_posn CHAR_MAX (not specified)
=== comparing locales ===
C point "." thousands "(empty string)" currency "(empty string)" ISO "(empty string)"
en_US.UTF-8 point "." thousands "," currency "$" ISO "USD "
ko_KR.UTF-8 point "." thousands "," currency "₩" ISO "KRW "
de_DE.UTF-8 point "," thousands "." currency "€" ISO "EUR "
fr_FR.UTF-8 point "," thousands " " currency "€" ISO "EUR "
ja_JP.UTF-8 point "." thousands "," currency "¥" ISO "JPY "
en_IN.UTF-8 point "." thousands "," currency "₹" ISO "INR "
=== grouping is not always three digits ===
C grouping (empty string - no grouping)
en_US.UTF-8 grouping 3
en_IN.UTF-8 grouping 3 2
-> en_IN's 3,2 means "three digits from the right, then two at a time" -
12345678 groups as 1,23,45,678 (the lakh and crore system).
=== LC_NUMERIC changes printf ===
C printf("%.2f", 1234.5) → 1234.50
de_DE.UTF-8 printf("%.2f", 1234.5) → 1234,50
fr_FR.UTF-8 printf("%.2f", 1234.5) → 1234,50
69.1.1 스물네 멤버를 한눈에
표준은 구조체가 적어도 다음 멤버를 갖되 순서는 정하지 않는다고 말한다. 그래서 지정 초기화나 순서 가정 없이 이름으로만 접근해야 한다.
| 멤버 | 무엇 | “C” 로케일 |
|---|---|---|
decimal_point | 비통화 소수점 | "." |
thousands_sep | 비통화 자리 구분 | "" |
grouping | 비통화 자리 묶음 규칙 | "" |
mon_decimal_point | 통화 소수점 | "" |
mon_thousands_sep | 통화 자리 구분 | "" |
mon_grouping | 통화 자리 묶음 | "" |
positive_sign | 양수 부호 문자열 | "" |
negative_sign | 음수 부호 문자열 | "" |
currency_symbol | 지역 통화 기호 | "" |
frac_digits | 소수 자릿수 | CHAR_MAX |
p_cs_precedes·n_cs_precedes | 기호가 값 앞인가(1/0) | CHAR_MAX |
p_sep_by_space·n_sep_by_space | 기호와 값 사이 칸(0/1/2) | CHAR_MAX |
p_sign_posn·n_sign_posn | 부호의 자리(0~4) | CHAR_MAX |
int_curr_symbol | 국제 통화 기호 + 구분 문자 | "" |
int_frac_digits 및 int_* 여섯 | 국제 표기용 같은 항목들 | CHAR_MAX |
표 69.1
두 가지 관례를 읽어야 한다. 문자열 멤버가 ""이면 이 로케일이 그 값을 정하지 않았다는 뜻이고(decimal_point만은 예외로 언제나 값이 있다), char 멤버가 CHAR_MAX이면 역시 정해지지 않았다는 뜻이다. 시연이 "C" 로케일에서 인쇄한 CHAR_MAX들이 그것이다 — C 로케일은 통화에 대해 아무것도 정하지 않는다.
int_curr_symbol에는 표준이 다른 국제 규격을 직접 끌어온다. 첫 세 글자는 ISO 4217이 정한 국제 통화 기호이고, 네 번째 글자는 그 기호와 금액을 가르는 문자다. 시연에서 "KRW "·"EUR "처럼 뒤에 칸이 붙어 나온 이유가 이것이다.
69.1.2 자리 묶음 문자열의 인코딩
grouping은 사람이 읽는 문자열이 아니라 숫자들의 배열이다. 표준이 그 해석을 이렇게 정한다.
| 원소의 값 | 뜻 |
|---|---|
CHAR_MAX | 여기서부터는 더 묶지 않는다 |
0 | 직전 원소를 남은 자릿수 전체에 되풀이한다 |
| 그 밖의 값 | 이 자리의 그룹이 그만큼의 자릿수. 다음 원소가 그 앞 그룹을 정한다 |
표 69.2
그래서 "\3"은 “세 자리 묶음을 끝까지 되풀이”가 아니라 “첫 그룹이 3, 그다음은 원소가 없으므로 더 묶지 않음”으로 읽힐 것 같지만, 실제 로케일들은 "\3" 하나로 1,234,567을 만든다. glibc가 마지막 원소를 반복으로 취급하기 때문이다. 분명하게 적으려면 "\3\0"처럼 0을 덧붙인다.
시연이 실측으로 보인 것은 en_IN의 3 2다. 오른쪽부터 세 자리, 그다음부터는 두 자리씩 — 12345678이 1,23,45,678이 된다. 인도의 라크·크로르 셈법이고, 자리 묶음이 3자리 고정이라는 가정이 틀렸다는 살아 있는 증거다.
69.1.3 통화 표기는 세 값으로 조립된다
통화 하나를 찍는 데 왜 여섯 개나 되는 멤버가 필요한가. 실제 표기가 그만큼 갈리기 때문이다. p_cs_precedes(기호가 앞인가), p_sep_by_space(칸을 넣는가), p_sign_posn(부호를 어디에)의 조합이 표기를 결정한다.
p_sign_posn | 뜻 |
|---|---|
| 0 | 금액과 통화 기호를 괄호로 감싼다 |
| 1 | 부호 문자열이 금액과 기호 앞에 온다 |
| 2 | 부호 문자열이 금액과 기호 뒤에 온다 |
| 3 | 부호 문자열이 통화 기호 바로 앞에 붙는다 |
| 4 | 부호 문자열이 통화 기호 바로 뒤에 붙는다 |
표 69.3
표준이 이 조합의 결과를 직접 표로 실어 두었다. $를 기호로, +를 부호로 삼아 1.25를 찍으면 이렇게 갈린다(일부).
p_cs_precedes | p_sign_posn | p_sep_by_space=0 | p_sep_by_space=1 |
|---|---|---|---|
| 0 | 0 | (1.25$) | (1.25 $) |
| 0 | 1 | +1.25$ | +1.25 $ |
| 0 | 3 | 1.25+$ | 1.25 +$ |
| 1 | 0 | ($1.25) | ($ 1.25) |
| 1 | 1 | +$1.25 | +$ 1.25 |
| 1 | 4 | $+1.25 | $+ 1.25 |
표 69.4
회계 표기의 괄호(p_sign_posn이 0)가 표준에 들어 있다는 것도 눈여겨볼 만하다. 음수를 -1,234가 아니라 (1,234)로 적는 그 관습이다.
문. 그러면 통화를 찍어 주는 표준 함수는 무엇인가?
답. 표준 C에는 없다. localeconv가 재료를 줄 뿐, 조립은 프로그램의 몫이다. 위의 표대로 여섯 멤버를 보고 문자열을 만들어야 한다.
POSIX에는 strfmon이 있어 그 일을 대신해 준다(strfmon(buf, n, "%n", 1234.5)). Windows에는 GetCurrencyFormat이 있다. 어느 쪽도 표준 C가 아니므로, 이식성이 필요하면 직접 조립하거나 라이브러리를 쓴다.
덧붙이면 금액을 double로 다루지 않는 것이 더 중요한 규율이다(50장). 1원 단위 정수로 다루고 표시할 때만 소수점을 넣는 쪽이 안전하다.
69.2 LC_NUMERIC — 조용히 데이터를 망가뜨리는 자리
이 장에서 가장 실무적인 경고다. LC_NUMERIC은 localeconv의 값만 바꾸는 것이 아니라 printf·scanf·strtod가 쓰는 소수점 문자 자체를 바꾼다.
시연의 마지막 부분이 그것이다. 같은 printf("%.2f", 1234.5)가 1234.50이 되기도 하고 1234,50이 되기도 한다. 그리고 strtod("3.14", …)는 소수점이 쉼표인 로케일에서 3까지만 읽고 멈춘다.
실제 사례. 소수점 하나가 서버를 멈춘 무늬
같은 프로그램이 개발자의 기계(영어 로케일)에서는 3.14를 쓰고, 사용자의 기계(독일어 로케일)에서는 3,14를 쓰는 일이 실제로 반복해서 일어났다.
- 설정 파일을 저장한 뒤 다시 읽지 못한다 — 쓸 때는
3,14로 쓰고 읽을 때는3.14를 기대한다. - CSV 가 열을 잃는다 — 값 안의 쉼표가 열 구분자와 겹친다.
- 두 서버가 주고받는 JSON이 어긋난다 — JSON 표준은 소수점을
.으로 못박았는데,printf가,를 찍는다. - 로그의 숫자를 집계하지 못한다.
공통점이 있다. 전부 기계가 읽을 숫자를 사람용 경로로 내보낸 것이다. 처방은 하나다 — 68장의 관용구로 LC_NUMERIC을 "C"에 묶어 두고, 사람에게 보일 때만 따로 서식을 만든다.
흔한 오해. “우리 서비스는 한국 안에서만 쓰니 로케일 문제는 없다”
두 자리에서 걸린다. 첫째, 로케일은 사용자의 기계가 정한다. 같은 프로그램이 다른 사람의 컴퓨터에서는 다른 로케일로 돈다 — 데스크톱 프로그램이라면 그 기계의 설정이, 서버라면 컨테이너 이미지의 환경 변수가 정한다.
둘째, 한국어 로케일 자체도 "C"가 아니다. ko_KR.UTF-8은 소수점이 .이라 운이 좋았을 뿐, 자리 구분·날짜·정렬은 모두 다르다. %c로 찍은 날짜를 나중에 파싱하려 들면 그때 걸린다.
69.3 LC_TIME — 날짜와 시각의 표기
시간 계산 자체는 로케일과 무관하다 — <time.h> 의 정식 취급은 74장이고, 여기서는 서식 문자만 빌려 쓴다. 로케일이 바꾸는 것은 표기이고, 창구는 strftime의 서식 문자다.
examples/ch69/time_locale.c
/* LC_TIME — 같은 시각이 로케일에 따라 다르게 적힌다. 그리고 안 바뀌는 서식. */
#include <locale.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
/* 재현 가능한 시각을 쓴다: 2026-08-06 (목) 15:04:05 UTC */
static struct tm fixed_time(void)
{
struct tm t = {
.tm_year = 2026 - 1900, .tm_mon = 8 - 1, .tm_mday = 6,
.tm_hour = 15, .tm_min = 4, .tm_sec = 5,
.tm_wday = 4, /* 목요일 */
.tm_yday = 217, .tm_isdst = 0,
};
return t;
}
static void row(const char *locale, const char *fmt)
{
char buf[256];
struct tm t = fixed_time();
if (!setlocale(LC_TIME, locale)) {
printf(" %-14s (not on this machine)\n", locale);
return;
}
size_t n = strftime(buf, sizeof buf, fmt, &t);
if (n == 0) { printf(" %-14s (buffer too small)\n", locale); return; }
printf(" %-14s %s\n", locale, buf);
}
int main(void)
{
static const char *locales[] = {
"C", "en_US.UTF-8", "ko_KR.UTF-8", "de_DE.UTF-8",
"fr_FR.UTF-8", "ja_JP.UTF-8",
};
static const struct { const char *fmt, *what; } cases[] = {
{ "%c", "%c - the locale's own 'date and time' form" },
{ "%x", "%x - the locale's own date form" },
{ "%X", "%X - the locale's own time form" },
{ "%A %B", "%A %B - full names of the weekday and the month" },
{ "%a %b", "%a %b - abbreviated names of the weekday and the month" },
{ "%p %I:%M", "%p %I:%M - am/pm and the 12-hour clock" },
{ "%F %T", "%F %T - ISO 8601. *independent of the locale*" },
{ "%Y-%m-%d", "%Y-%m-%d - a numeric format, also independent of the locale" },
};
for (size_t c = 0; c < sizeof cases / sizeof *cases; c++) {
printf("\n%s\n", cases[c].what);
for (size_t i = 0; i < sizeof locales / sizeof *locales; i++)
row(locales[i], cases[c].fmt);
}
/* 시간대는 로케일이 아니라 TZ 환경 변수가 정한다 — 흔한 혼동이다. */
puts("\nthe time zone comes from TZ, not from LC_TIME:");
setlocale(LC_TIME, "C");
struct tm t = fixed_time();
char buf[128];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S %Z(%z)", &t);
printf(" %s\n", buf);
return 0;
}
실행 결과
%c - the locale's own 'date and time' form
C Thu Aug 6 15:04:05 2026
en_US.UTF-8 Thu 06 Aug 2026 03:04:05 PM KST
ko_KR.UTF-8 2026년 08월 06일 (목) 오후 03시 04분 05초
de_DE.UTF-8 Do 06 Aug 2026 15:04:05 KST
fr_FR.UTF-8 jeu. 06 août 2026 15:04:05
ja_JP.UTF-8 2026年08月06日 15時04分05秒
%x - the locale's own date form
C 08/06/26
en_US.UTF-8 08/06/2026
ko_KR.UTF-8 2026년 08월 06일
de_DE.UTF-8 06.08.2026
fr_FR.UTF-8 06/08/2026
ja_JP.UTF-8 2026年08月06日
%X - the locale's own time form
C 15:04:05
en_US.UTF-8 03:04:05 PM
ko_KR.UTF-8 15시 04분 05초
de_DE.UTF-8 15:04:05
fr_FR.UTF-8 15:04:05
ja_JP.UTF-8 15時04分05秒
%A %B - full names of the weekday and the month
C Thursday August
en_US.UTF-8 Thursday August
ko_KR.UTF-8 목요일 8월
de_DE.UTF-8 Donnerstag August
fr_FR.UTF-8 jeudi août
ja_JP.UTF-8 木曜日 8月
%a %b - abbreviated names of the weekday and the month
C Thu Aug
en_US.UTF-8 Thu Aug
ko_KR.UTF-8 목 8월
de_DE.UTF-8 Do Aug
fr_FR.UTF-8 jeu. août
ja_JP.UTF-8 木 8月
%p %I:%M - am/pm and the 12-hour clock
C PM 03:04
en_US.UTF-8 PM 03:04
ko_KR.UTF-8 오후 03:04
de_DE.UTF-8 03:04
fr_FR.UTF-8 03:04
ja_JP.UTF-8 午後 03:04
%F %T - ISO 8601. *independent of the locale*
C 2026-08-06 15:04:05
en_US.UTF-8 2026-08-06 15:04:05
ko_KR.UTF-8 2026-08-06 15:04:05
de_DE.UTF-8 2026-08-06 15:04:05
fr_FR.UTF-8 2026-08-06 15:04:05
ja_JP.UTF-8 2026-08-06 15:04:05
%Y-%m-%d - a numeric format, also independent of the locale
C 2026-08-06
en_US.UTF-8 2026-08-06
ko_KR.UTF-8 2026-08-06
de_DE.UTF-8 2026-08-06
fr_FR.UTF-8 2026-08-06
ja_JP.UTF-8 2026-08-06
the time zone comes from TZ, not from LC_TIME:
2026-08-06 15:04:05 KST(+0000)
시연이 같은 한 시각을 여섯 로케일로 찍는다. 서식 문자를 두 부류로 갈라 두는 것이 요령이다.
| 부류 | 서식 | 성질 |
|---|---|---|
| 로케일이 정한다 | %c %x %X %A %a %B %b %p %r | 나라마다 다르다 — 사람에게 보여 줄 때만 |
| 로케일과 무관 | %Y %m %d %H %M %S %j %F %T | 어디서나 같다 — 기록·전송·파싱에 |
표 69.5
%F %T가 ISO 8601 표기(2026-08-06 15:04:05)를 그대로 만든다. 로그·파일 이름·API 응답에는 이 서식만 쓰는 것이 규율이다. 시연에서 이 두 줄만 여섯 로케일에서 모두 같은 결과를 낸 것을 볼 수 있다.
플랫폼 노트. 시간대는 로케일이 아니다
흔한 혼동이다. %Z(시간대 이름)와 %z(오프셋)가 보이는 값은 LC_TIME이 아니라 TZ 환경 변수와 tzset이 정한다. 「로케일을 한국으로 바꿨는데 시각이 안 맞는다」는 대개 TZ를 안 바꾼 것이다.
로케일과 시간대는 축이 다르다 — 로케일은 어떻게 적을지, 시간대는 언제인지를 정한다. 독일어 로케일로 서울 시각을 찍는 조합도 완전히 정상이다.
69.4 LC_COLLATE — strcmp는 사전순이 아니다
strcmp는 바이트 값을 견준다. 그래서 "Zebra"가 "apfel"보다 앞서고 (Z=0x5A < a=0x61), 한글은 유니코드 코드포인트 순서로 늘어선다. 사람이 기대하는 순서가 아니다.
examples/ch69/collate.c
/* LC_COLLATE — strcmp 는 사전순이 아니다. strcoll 과 정렬 키(strxfrm). */
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *words[] = {
"Zebra", "apfel", "Apfel", "Äpfel", "banane", "Öl", "Zoo", "zoo",
};
#define N (sizeof words / sizeof *words)
static int by_strcmp(const void *a, const void *b)
{ return strcmp(*(const char *const *)a, *(const char *const *)b); }
static int by_strcoll(const void *a, const void *b)
{ return strcoll(*(const char *const *)a, *(const char *const *)b); }
static void sort_and_show(const char *label, int (*cmp)(const void *, const void *))
{
const char *v[N];
memcpy(v, words, sizeof v);
qsort(v, N, sizeof *v, cmp);
printf(" %-10s", label);
for (size_t i = 0; i < N; i++) printf(" %s", v[i]);
putchar('\n');
}
int main(void)
{
static const char *locales[] = { "C", "en_US.UTF-8", "de_DE.UTF-8", "ko_KR.UTF-8" };
puts("sorting the same words two ways.");
printf(" input ");
for (size_t i = 0; i < N; i++) printf(" %s", words[i]);
puts("\n");
for (size_t i = 0; i < sizeof locales / sizeof *locales; i++) {
if (!setlocale(LC_ALL, locales[i])) {
printf("[%s] not available on this machine\n\n", locales[i]);
continue;
}
printf("[%s]\n", locales[i]);
sort_and_show("strcmp", by_strcmp); /* 바이트 값 순서 — 로케일 무관 */
sort_and_show("strcoll", by_strcoll); /* 로케일이 정한 사전 순서 */
putchar('\n');
}
/* strxfrm — 비교를 여러 번 할 때는 '정렬 키'를 한 번 만들어 두는 편이 낫다.
변환한 키끼리는 strcmp 로 견주어도 strcoll 과 같은 순서가 나온다. */
puts("strxfrm freezes a locale comparison into a 'key':");
for (size_t i = 0; i < sizeof locales / sizeof *locales; i++) {
if (!setlocale(LC_ALL, locales[i])) continue;
char key[64];
size_t need = strxfrm(key, "Äpfel", sizeof key);
printf(" %-12s \"Äpfel\" -> key of %zu bytes: ", locales[i], need);
if (need >= sizeof key) { puts("(buffer too small)"); continue; }
for (size_t k = 0; k < need && k < 12; k++)
printf("%02X ", (unsigned char)key[k]);
puts(need > 12 ? "..." : "");
}
puts("\nchecking that strcmp on the keys agrees with strcoll:");
for (size_t i = 0; i < sizeof locales / sizeof *locales; i++) {
if (!setlocale(LC_ALL, locales[i])) continue;
char ka[64], kb[64];
const char *a = "Äpfel", *b = "banane";
if (strxfrm(ka, a, sizeof ka) >= sizeof ka) continue;
if (strxfrm(kb, b, sizeof kb) >= sizeof kb) continue;
int c1 = strcoll(a, b), c2 = strcmp(ka, kb);
printf(" %-12s strcoll %s 0, key strcmp %s 0 - %s\n", locales[i],
c1 < 0 ? "<" : c1 > 0 ? ">" : "=",
c2 < 0 ? "<" : c2 > 0 ? ">" : "=",
((c1 < 0) == (c2 < 0) && (c1 > 0) == (c2 > 0)) ? "agree" : "disagree");
}
return 0;
}
실행 결과
sorting the same words two ways.
input Zebra apfel Apfel Äpfel banane Öl Zoo zoo
[C]
strcmp Apfel Zebra Zoo apfel banane zoo Äpfel Öl
strcoll Apfel Zebra Zoo apfel banane zoo Äpfel Öl
[en_US.UTF-8]
strcmp Apfel Zebra Zoo apfel banane zoo Äpfel Öl
strcoll apfel Apfel Äpfel banane Öl Zebra zoo Zoo
[de_DE.UTF-8]
strcmp Apfel Zebra Zoo apfel banane zoo Äpfel Öl
strcoll apfel Apfel Äpfel banane Öl Zebra zoo Zoo
[ko_KR.UTF-8]
strcmp Apfel Zebra Zoo apfel banane zoo Äpfel Öl
strcoll Öl Äpfel Apfel Zebra Zoo apfel banane zoo
strxfrm freezes a locale comparison into a 'key':
C "Äpfel" -> key of 6 bytes: C3 84 70 66 65 6C
en_US.UTF-8 "Äpfel" -> key of 43 bytes: 51 C4 9A C2 95 7E C3 90 01 02 0D 02 ...
de_DE.UTF-8 "Äpfel" -> key of 43 bytes: 51 C4 9A C2 95 7E C3 90 01 02 0D 02 ...
ko_KR.UTF-8 "Äpfel" -> key of 6 bytes: 03 03 6E 64 63 6A
checking that strcmp on the keys agrees with strcoll:
C strcoll > 0, key strcmp > 0 - agree
en_US.UTF-8 strcoll < 0, key strcmp < 0 - agree
de_DE.UTF-8 strcoll < 0, key strcmp < 0 - agree
ko_KR.UTF-8 strcoll < 0, key strcmp < 0 - agree
시연의 결과가 그 차이를 그대로 보인다. "C" 로케일에서는 strcoll과 strcmp가 같은 답을 내지만, 다른 로케일에서는 갈린다 — 독일어에서 Äpfel이 apfel 바로 뒤에 오고, 대소문자가 뒤섞여 정렬된다.
69.4.1 strxfrm — 왜 이런 함수가 있는가
strxfrm은 문자열을 정렬 키로 바꾼다. 키끼리는 평범한 strcmp로 견주어도 strcoll과 같은 순서가 나온다. 시연의 마지막 부분이 그것을 확인한다.
왜 필요한가. strcoll 한 번은 싸지 않다 — 로케일 규칙을 매번 적용해야 하기 때문이다. n개를 정렬하면 비교가 대략 번이므로, 변환을 n번 하고 비교는 싸게 하는 편이 낫다.
/* 정렬 전에 키를 만들어 둔다 */
size_t need = strxfrm(NULL, s, 0); /* 필요한 크기를 먼저 묻고 */
char *key = malloc(need + 1); /* 그만큼 잡아 */
strxfrm(key, s, need + 1); /* 채운다 */시연이 인쇄한 키의 크기를 보면 이 함수의 성격이 드러난다. "C" 로케일에서는 키가 원본과 같은 6바이트지만, 독일어 로케일에서는 43바이트다 — 로케일 정렬은 여러 단계의 가중치(기본 글자 → 발음 기호 → 대소문자)를 쌓아 두기 때문이다.
문. 그러면 한국어 정렬은 strcoll로 충분한가?
답. 간단한 목록이라면 충분하다. 한글 음절은 유니코드에서 이미 가나다순으로 배열되어 있어서, ko_KR.UTF-8의 strcoll이 기대에 맞는 순서를 준다.
그러나 실무의 정렬에는 규칙이 더 붙는다 — 숫자를 자연수 순으로(“파일2”가 “파일10”보다 앞), 초성만 뽑아 묶기, 한자를 음으로 읽어 섞기, 대소문자·공백 무시. 이런 것은 strcoll의 범위 밖이고, 유니코드의 정렬 알고리즘(UTS #10)과 그 구현인 ICU가 맡는 자리다.
경계는 이렇게 그으면 된다 — 로케일 정렬로 될 일이면 strcoll, 그 이상이면 전용 라이브러리. 다만 strcmp로 사람에게 보일 목록을 정렬하는 것만은 피한다.
69.5 스레드와 로케일
68장에서 로케일이 프로세스 전역이라고 했다. 여러 갈래로 도는 프로그램에서는 그것이 문제가 된다 — 한 스레드가 사용자에게 보일 날짜를 찍으려고 로케일을 바꾸면, 다른 스레드의 printf("%f")가 함께 흔들린다.
표준 C에는 처방이 없다. 있는 것은 확장이다.
| 체계 | 수단 | 모양 |
|---|---|---|
| POSIX | 스레드 지역 로케일 | newlocale/uselocale/freelocale |
| POSIX | 로케일을 인자로 받는 함수 | strtod_l, strcoll_l, strftime_l 등 |
| Windows | 스레드별 로케일 모드 | _configthreadlocale, _locale_t와 _l 함수들 |
표 69.6
_l 계열이 근본 처방이다 — 전역 상태를 건드리지 않고 이 호출에만 로케일을 지정한다. 이식성이 필요하면 이 두 갈래를 감싸는 얇은 층을 직접 두게 된다.
반례. 라이브러리 안에서 setlocale 부르기
/* 라이브러리의 함수 */
double parse_number(const char *s) {
setlocale(LC_NUMERIC, "C"); /* 남의 프로그램 상태를 바꾼다 */
return strtod(s, NULL);
}이 세 줄이 응용 프로그램의 날짜 표기와 통화 표기를 조용히 망가뜨린다. 게다가 스레드 사이에서는 데이터 경쟁이다.
라이브러리의 규율은 하나다 — 로케일을 읽기만 하고 바꾸지 않는다. 로케일에 흔들리지 않는 파싱이 필요하면 strtod_l을 쓰거나, 직접 자리 표기법을 다룬다.
69.6 실무의 처방
| 하고 싶은 일 | 방법 |
|---|---|
| 사용자에게 날짜·정렬·통화를 그 나라 방식으로 | setlocale(LC_ALL, "") |
| 파일·프로토콜에 숫자 적기 | LC_NUMERIC을 "C"로 묶는다 |
| 로그의 시각 | strftime의 %F %T(ISO 8601) |
| 사람에게 보일 목록 정렬 | strcoll(또는 키를 만들 때 strxfrm) |
| 기계가 견줄 문자열 | strcmp — 로케일에 흔들리지 않아야 한다 |
| 스레드마다 다른 로케일 | uselocale/_l 계열(표준 밖) |
| 라이브러리를 만들 때 | setlocale을 부르지 않는다 |
표 69.7
복습 정리
| 기억할 것 | 요점 |
|---|---|
| 창구 | localeconv 하나. 고치지 말고, 오래 들고 있지도 말 것 |
struct lconv | 스물네 멤버. ""와 CHAR_MAX는 “정해지지 않음” |
int_curr_symbol | 앞 세 글자가 ISO 4217 코드 |
grouping | 숫자들의 배열. CHAR_MAX=끝, 0=반복. 3자리 고정이 아니다 |
| 통화 조립 | cs_precedes·sep_by_space·sign_posn 조합. 표준 함수는 없다 |
LC_NUMERIC | ★ printf·strtod의 소수점을 바꾼다 — 데이터 파손의 주범 |
LC_TIME | %c %x %X %A %B %p는 로케일, %F %T %Y-%m-%d는 불변 |
| 시간대 | 로케일이 아니라 TZ |
LC_COLLATE | strcmp≠사전순. 반복 비교는 strxfrm 키로 |
| 스레드 | 표준에는 없다. uselocale·_l 계열 |
표 69.8
로케일이 무엇을 바꾸는지 끝까지 보았다. 남은 축이 하나 있다 — LC_CTYPE이 지배하는 여러 바이트 문자와 와이드 문자다. 다음 장에서 wchar_t의 정체와 바이트열이 글자로 풀리는 과정을 한 단계씩 본다.