Proven C BookEnglish GitHub

68 로케일 ① — 프로그램의 지역 설정

먼저 알아야 할 것

67장 문자 분류 · 판정이 로케일에 달렸다는 것
53장 main의 세 얼굴 · 환경 변수

돌아보기

67장에서 isalpha의 답이 “현재 로케일”에 달렸다고 했다. 그런데 나는 로케일을 설정한 적이 없다. 설정하지 않은 것에 답이 달려 있다는 말은 무슨 뜻인가?

답. 설정하지 않아도 하나가 이미 정해져 있기 때문이다. 표준이 그것까지 정해 두었다 — 프로그램이 시작할 때 setlocale(LC_ALL, "C")를 부른 것과 같은 상태가 된다(§7.11.1.1p4). 그래서 아무것도 하지 않은 프로그램은 「C 로케일」이라는 최소 환경에서 돈다.

로케일은 프로그램 밖의 설정이 프로그램 안의 동작을 바꾸는 장치다. 같은 실행 파일이 서울에서는 2026년 8월 6일을, 베를린에서는 06.08.2026을, 그리고 printf("%f")에서 3,14를 찍게 만드는 것이 이것이다. 이 장은 그 장치의 이름과 규칙을, 다음 장은 그것이 바꾸는 것들을 본다.

이 장의 필요성과 맥락

로케일을 두 장으로 편 것은 이것이 C 에서 가장 과소평가된 전역 상태이기 때문이다. 67장이 「판정마저 로케일에 달렸다」를 보인 직후가 그 정체를 캐물을 때다. 그리고 이 장이 없으면 70장의 멀티바이트 변환이 어디에 기대는지 설명할 길이 없다.

이 장이 끝나면

로케일이 무엇인지 밑바닥부터 본다. 왜 있는지, 여섯 범주가 무엇을 나누는지, setlocale의 정확한 계약, ko_KR.UTF-8 같은 이름이 어떤 규칙과 어떤 국제 표준으로 만들어지는지, 환경 변수의 우선순위, 그리고 그 데이터가 기계 어디에 있는지까지.

이 장에서 답할 질문

  1. 로케일은 번역인가?
  2. 범주를 섞어 쓸 수 있는가?
  3. 이 문법은 어느 표준이 정한 것인가?

68.1 왜 있는가 — 관습은 나라마다 다르다

프로그램이 사람에게 무언가를 보여 줄 때, 「옳은 표기」는 하나가 아니다.

무엇한국독일
소수점3.143,14
천 단위1,234,5671.234.567
날짜2026년 8월 6일06.08.2026
통화₩1,2341.234,00 €
정렬가나다순äa와 같은 자리에

표 68.1

이것을 프로그램마다 손으로 적으면 나라 수만큼 코드가 늘어난다. 그래서 유닉스와 C는 다른 길을 택했다 — 관습을 데이터로 만들어 프로그램 밖에 두고, 프로그램은 “지금 어느 관습으로 일할지”만 고른다. 그 데이터 묶음의 이름이 로케일이다.

문. 로케일은 번역인가?

답. 아니다. 이 구별이 중요하다. 로케일은 관습(convention)을 다룬다 — 소수점, 자리 묶음, 날짜 배열, 정렬 순서, 대소문자, 통화 기호. 반면 프로그램이 내는 메시지를 다른 언어로 바꾸는 일, 즉 번역(translation)은 표준 C의 범위 밖이다.

유닉스에는 번역을 위한 LC_MESSAGES 범주와 gettext 같은 도구가 따로 있지만, C 표준이 정한 여섯 범주에 그것은 없다. 이 책이 다루는 것도 관습까지다.

68.2 로케일은 프로그램 전역 상태다

로케일의 성격을 한 줄로 적으면 이렇다 — 프로세스 하나에 로케일 하나.

examples/ch68/locale_probe.c

/* 로케일은 프로그램 전역 상태다 — 무엇으로 시작하고, 무엇으로 바뀌는가. */
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* setlocale 의 반환값은 "지금 설정된 로케일의 이름"이다.
   두 번째 인자를 NULL 로 주면 바꾸지 않고 묻기만 한다. */
static void show_current(const char *when)
{
    printf("%s LC_ALL=%s\n", when, setlocale(LC_ALL, NULL));
}

int main(void)
{
    /* ① 프로그램은 언제나 "C" 로케일로 시작한다 (표준 §7.11.1.1p4) */
    show_current("right at start: ");

    /* ② ""(빈 문자열)은 "환경이 정한 로케일"이라는 뜻이다.
          LC_ALL > LC_xxx > LANG 순으로 환경 변수를 본다. */
    const char *applied = setlocale(LC_ALL, "");
    if (applied)
        printf("setlocale(LC_ALL,\"\"): %s\n", applied);
    else
        puts("setlocale(LC_ALL,\"\") failed - the environment has no locale");

    /* ③ 한 글자를 담는 데 필요한 최대 바이트 수는 로케일에 달렸다.
          MB_CUR_MAX 는 매크로처럼 보이지만 *지금 로케일*을 보는 값이다. */
    printf("MB_CUR_MAX:            %zu\n", (size_t)MB_CUR_MAX);

    /* ④ 요청한 로케일이 이 기계에 없으면 setlocale 은 널을 돌려준다.
          그래서 반환값을 반드시 본다 — 조용히 실패하는 자리다. */
    static const char *candidates[] = {
        "C", "C.UTF-8", "en_US.UTF-8", "ko_KR.UTF-8", "ko_KR.EUC-KR",
        "de_DE.UTF-8", "tr_TR.UTF-8", "ja_JP.UTF-8", "no_SUCH.locale",
    };

    puts("\navailable on this machine:");
    char saved[128];
    snprintf(saved, sizeof saved, "%s", setlocale(LC_ALL, NULL));

    for (size_t i = 0; i < sizeof candidates / sizeof *candidates; i++) {
        const char *got = setlocale(LC_ALL, candidates[i]);
        if (got) printf("  %-15s yes  MB_CUR_MAX=%zu\n",
                        candidates[i], (size_t)MB_CUR_MAX);
        else     printf("  %-15s no\n", candidates[i]);
    }

    /* ⑤ 원래대로 되돌린다 — 이름 문자열을 그대로 다시 넘기면 된다.
          단, setlocale 이 돌려준 포인터는 다음 호출에 덮어써질 수 있으므로
          복사해 두어야 한다. */
    setlocale(LC_ALL, saved);
    show_current("after restoring:");

    /* ⑥ 범주는 따로 바꿀 수 있다. 실무의 관용구가 이것이다 —
          사람에게 보일 것은 환경대로, 기계가 읽을 숫자는 "C" 로. */
    setlocale(LC_ALL, "");
    setlocale(LC_NUMERIC, "C");
    printf("a mixed setting - LC_CTYPE=%s, LC_NUMERIC=%s\n",
           setlocale(LC_CTYPE, NULL), setlocale(LC_NUMERIC, NULL));
    /* LC_ALL 로 물으면 범주가 하나라도 다를 때 전체가 세미콜론으로 나열된다 */
    printf("asking through LC_ALL: %.60s...\n", setlocale(LC_ALL, NULL));
    return 0;
}

실행 결과

right at start:  LC_ALL=C
setlocale(LC_ALL,""): C.UTF-8
MB_CUR_MAX:            6

available on this machine:
  C               yes  MB_CUR_MAX=1
  C.UTF-8         yes  MB_CUR_MAX=6
  en_US.UTF-8     yes  MB_CUR_MAX=6
  ko_KR.UTF-8     yes  MB_CUR_MAX=6
  ko_KR.EUC-KR    yes  MB_CUR_MAX=2
  de_DE.UTF-8     yes  MB_CUR_MAX=6
  tr_TR.UTF-8     yes  MB_CUR_MAX=6
  ja_JP.UTF-8     yes  MB_CUR_MAX=6
  no_SUCH.locale  no
after restoring: LC_ALL=C.UTF-8
a mixed setting - LC_CTYPE=C.UTF-8, LC_NUMERIC=C
asking through LC_ALL: LC_CTYPE=C.UTF-8;LC_NUMERIC=C;LC_TIME=C.UTF-8;LC_COLLATE=C.U...

시연의 첫 줄이 표준의 규정을 그대로 보인다. 무엇을 하기도 전에 LC_ALLC다. 그리고 setlocale(LC_ALL, "") 한 번에 환경이 정한 로케일로 바뀐다.

전역 상태라는 사실에서 두 가지가 따라 나온다.

첫째, 내가 부르지 않아도 바뀔 수 있다. 링크한 라이브러리가 setlocale을 부르면 그 순간부터 내 printf("%f")도 영향을 받는다. GUI 툴킷이 흔히 그렇게 한다.

둘째, 스레드 사이에 경쟁이 된다. C23은 이 점을 명시한다 — setlocale 호출은 다른 setlocale 호출이나 로케일에 영향받는 함수들과 데이터 경쟁을 일으킬 수 있다(§7.11.1.1p5). 스레드마다 다른 로케일을 쓰고 싶다면 표준 C로는 길이 없고, POSIX의 uselocale 계열이 필요하다(69장).

68.3 여섯 범주 — 무엇이 무엇을 지배하는가

로케일은 한 덩어리가 아니라 범주(category)로 나뉜다. 표준이 정한 것은 여섯 개이고, 각 범주가 무엇에 영향을 주는지도 표준이 직접 열거한다.

범주무엇을 지배하는가영향받는 함수
LC_CTYPE문자 분류, 대소문자, 여러 바이트 문자 변환isalpha 계열(67장), mbrtowc 계열(70장)
LC_NUMERIC비통화 숫자의 소수점과 자리 묶음printf·scanf·strtod
LC_MONETARY통화 표기 정보localeconv
LC_COLLATE문자열 비교 순서strcoll, strxfrm
LC_TIME날짜·시각 표기strftime, wcsftime
LC_ALL(위 전부를 한꺼번에 가리키는 이름)

표 68.2

이 표를 외울 필요는 없지만 LC_NUMERICprintf를 지배한다는 한 줄만은 기억해 둘 만하다. 이 장치가 실무에서 데이터를 망가뜨리는 거의 모든 사고가 그 한 줄에서 나온다(69장).

플랫폼 노트. POSIX가 더한 범주들

표준 C의 여섯 개 위에 POSIX와 glibc가 범주를 더 얹었다. LC_MESSAGES (메시지 번역), 그리고 glibc의 LC_PAPER(용지 크기), LC_NAME(이름 표기 순서), LC_ADDRESS, LC_TELEPHONE, LC_MEASUREMENT(미터법 여부), LC_IDENTIFICATION. 시연에서 LC_ALL로 물었을 때 세미콜론으로 길게 이어져 나온 목록이 그것이다.

표준이 “LC_로 시작하고 대문자가 이어지는 이름은 구현이 더 정의할 수 있다”고 열어 둔 자리다(§7.11p3). 그래서 이 이름들은 리눅스에서는 통하고 다른 곳에서는 통하지 않을 수 있다.

68.4 setlocale의 정확한 계약

함수는 하나뿐이고 모양도 단순한데, 계약이 촘촘하다.

char *setlocale(int category, const char *locale);
두 번째 인자
"C"표준이 정한 최소 환경. 프로그램의 출발점
""(빈 문자열)환경이 정한 로케일 — 환경 변수를 본다
NULL바꾸지 말고 지금 값을 묻기만 하라
그 밖의 문자열구현이 정한 이름("ko_KR.UTF-8" 등)

표 68.3

반환값의 계약이 두 갈래다. 성공하면 설정된(또는 지금 설정되어 있는) 로케일의 이름을 담은 문자열을 돌려주고, 실패하면 널을 돌려준다.

흔한 오해. setlocale은 실패하지 않는다”

가장 조용한 사고가 여기서 난다. 요청한 로케일이 그 기계에 설치되어 있지 않으면 setlocale은 널을 돌려주고 아무것도 바꾸지 않는다. 반환값을 보지 않으면 프로그램은 로케일이 바뀐 줄 알고 계속 돌고, 날짜는 영어로 나오고 한글은 깨진다.

시연의 no_SUCH.locale 줄이 그 경우다. 리눅스 배포판은 용량을 아끼려고 로케일을 최소한만 넣어 두는 일이 많아서(컨테이너 이미지가 특히 그렇다), 개발 기계에서 되던 것이 배포 환경에서 안 되는 전형적인 자리이기도 하다.

if (!setlocale(LC_ALL, "")) {
    fprintf(stderr, "경고: 로케일을 적용하지 못했다. C 로케일로 계속한다.\n");
}

반환된 포인터에도 규칙이 있다. 그 문자열은 다음 setlocale 호출이 덮어쓸 수 있는 정적 영역을 가리킨다. 나중에 되돌릴 생각이라면 시연처럼 복사해 두어야 한다.

문. 범주를 섞어 쓸 수 있는가?

답. 쓸 수 있고, 그것이 실무의 정석이다. 시연의 마지막 부분이 그 관용구다.

setlocale(LC_ALL, "");        /* 우선 환경대로 전부 맞추고 */
setlocale(LC_NUMERIC, "C");   /* 숫자만 "C" 로 되돌린다 */

사람에게 보여 줄 날짜·정렬·통화는 환경을 따르되, 기계가 읽고 쓸 숫자는 로케일에 흔들리지 않게 고정하는 것이다. 이 두 줄이 69장에서 볼 데이터 파손 사고의 대부분을 막는다.

이렇게 범주가 섞이면 setlocale(LC_ALL, NULL)이 돌려주는 문자열이 LC_CTYPE=…;LC_NUMERIC=…;… 꼴로 길어진다. 시연의 마지막 줄이 그것이다 — “전부 같으면 이름 하나, 다르면 목록”이 규칙이다.

68.5 로케일 이름의 문법

ko_KR.UTF-8. 이 이름이 어떻게 생겼는지 뜯어보면 그 안에 국제 표준 네 개가 들어 있다.

자리보기무엇이 정하는가
언어koISO 639-1 두 글자(또는 ISO 639-2/-3 세 글자)
_+지역_KRISO 3166-1 alpha-2 국가 코드
.+문자집합.UTF-8문자집합 이름(IANA 등록부·ISO 8859 계열 등)
@+변종@euro같은 언어·지역의 다른 관습(선택)

표 68.4

전체 문법은 이렇게 적을 수 있다.

언어[_지역][.문자집합][@변종]

ko_KR.UTF-8      한국어, 대한민국, UTF-8
de_DE@euro       독일어, 독일, 유로 이전/이후 변종
sr_RS@latin      세르비아어를 라틴 문자로
C  또는  POSIX   표준이 정한 최소 로케일

여기서 언어와 지역이 다른 표준의 코드를 빌려 온 것이라는 점이 중요하다. ko는 ISO 639-1이 한국어에 준 코드이고 KR은 ISO 3166-1이 대한민국에 준 코드다. 즉 로케일 이름은 C가 발명한 것이 아니라, 이미 있던 국제 코드 체계를 조립한 것이다.

문. 이 문법은 어느 표준이 정한 것인가?

답. C 표준이 아니다. C는 "C""" 두 개만 정하고, 나머지는 구현이 정한 문자열이라고만 말한다(§7.11.1.1p3).

그런데 표준의 각주 하나가 길을 가리킨다 — “ISO/IEC 9945가 C를 위한 로케일과 charmap 형식을 규정한다.” ISO/IEC 9945는 곧 POSIX(IEEE Std 1003.1)다. 위의 언어_지역.문자집합@변종 문법도, 로케일 정의 파일의 형식도, 환경 변수의 우선순위도 모두 POSIX가 정한다.

그래서 이 장의 이름 규칙은 유닉스 계열에서 통하는 규칙이다. Windows는 다른 체계를 쓰고, 웹은 또 다른 체계를 쓴다 — 뒤에서 견주어 본다.

68.5.1 문자집합 이름과 정규화

.UTF-8, .utf8, .UTF8 — 셋 다 같은 것을 가리킨다. glibc는 이름에서 대소문자 와 -를 무시하고 견주기 때문이다. 그래서 locale -ako_KR.utf8이라고 찍어도 프로그램에서는 "ko_KR.UTF-8"로 요청해도 된다.

문자집합 이름 자체는 또 다른 등록부에서 온다. UTF-8·EUC-KR·ISO-8859-1 같은 이름은 IANA의 문자집합 등록부에 등록된 것이고, 그 뿌리에는 ISO/IEC 8859 계열이나 유니코드(ISO/IEC 10646)가 있다.

문자집합 부분이 이름의 나머지보다 중요할 때가 많다. ko_KR.UTF-8ko_KR.EUC-KR은 언어와 지역이 같지만 한 글자가 몇 바이트인지가 다르다. 시연에서 두 로케일의 MB_CUR_MAX가 6과 2로 갈린 것이 그 차이다.

실제 사례. 같은 나라, 다른 인코딩 — ko_KR.EUC-KR의 시대

1990년대와 2000년대 초의 한국 유닉스 환경은 ko_KR.eucKR(또는 ko_KR.EUC-KR) 이 기본이었다. 한글 한 글자가 2바이트인 세계다. 그 시절에 쓰인 코드에는 “한글은 2바이트”라는 가정이 곳곳에 박혀 있다 — 문자열 길이를 2로 나누고, 커서를 2씩 옮기고, 잘림을 짝수 바이트로 맞추는 코드들이다.

UTF-8로 옮겨 오면서 그 가정이 전부 깨졌다. 한글은 3바이트가 되었고, 그 코드 들은 글자를 반쪽으로 자르기 시작했다. 이것이 인코딩 전환기에 한국에서 실제로 겪은 사고이고, 72장에서 볼 “바이트·코드포인트·글자” 세 층위를 구별해야 하는 이유이기도 하다.

68.5.2 다른 세계의 이름 — BCP 47과 Windows

같은 「한국어(대한민국)」를 가리키는 이름이 체계마다 다르다.

체계표기근거
POSIX·Cko_KR.UTF-8ISO/IEC 9945(POSIX)
BCP 47(웹·XML·HTTP)ko-KRRFC 5646(태그)·RFC 4647(대응)
Windows(Vista 이후)ko-KRBCP 47을 따른다
Windows(옛 방식)Korean_Korea.949Windows 고유
유니코드 CLDRko_KRUTS #35 (LDML)

표 68.5

BCP 47은 인터넷 표준으로, HTML의 lang="ko-KR"이나 HTTP의 Accept-Language 헤더에 쓰이는 그 태그다. 밑줄 대신 붙임표를 쓰고 문자집합 부분이 없다 — 웹은 인코딩을 따로 다루기 때문이다. 문자체계를 밝힐 때는 sr-Latn-RS처럼 ISO 15924 코드가 가운데 들어간다.

프로그램이 두 세계에 걸쳐 있으면 이름을 옮겨야 한다. 브라우저가 보낸 ko-KR을 받아 setlocale에 넘기려면 ko_KR.UTF-8로 바꿔야 하고, 그 변환표를 어디선가 들고 있어야 한다는 뜻이다. 이 자리에서 실수가 잦다.

플랫폼 노트. 로케일 데이터의 현대적 출처 — CLDR

실제 관습 데이터(어느 나라가 날짜를 어떻게 적는가, 통화 기호가 무엇인가)를 누가 관리하는가? 오늘날의 답은 유니코드 컨소시엄의 CLDR(Common Locale Data Repository)이고, 그 형식은 UTS #35(LDML)가 정한다. ICU·자바·안드로이드· 브라우저가 모두 여기서 데이터를 가져온다.

glibc의 로케일 정의는 그보다 오래된 계보에 있다 — ISO/IEC TR 14652(문화 규약 명세 방법)와 ISO/IEC 15897(문화 요소 등록 절차)이 그 배경이고, 실제 파일은 /usr/share/i18n/locales/에 사람이 읽을 수 있는 형식으로 들어 있다.

두 계보가 다르므로 같은 로케일이라도 값이 미세하게 다를 수 있다. 자바로 짠 프로그램과 C로 짠 프로그램이 같은 날짜를 다르게 찍는 일이 여기서 생긴다.

68.6 환경 변수의 우선순위

setlocale(LC_ALL, "")이 「환경이 정한 로케일」을 쓴다고 했다. 그 환경이 정확히 무엇인가 — POSIX가 우선순위를 정해 두었다.

순위변수
1LC_ALL있으면 이것이 모든 범주를 덮어쓴다
2LC_CTYPE·LC_TIME해당 범주만 정한다
3LANG위의 것이 없는 범주의 기본값

표 68.6

LANG=ko_KR.UTF-8 LC_NUMERIC=C ./program처럼 부르면 대부분은 한국어 관습을 쓰되 숫자만 C 관습이 된다. 서버 프로그램을 띄우는 스크립트가 LC_ALL=C를 박아 두는 것도 같은 이유다 — 로그와 파싱을 환경으로부터 격리하는 것이다.

$ locale                 # 지금 환경이 무엇을 정하고 있는지 본다
$ locale -a              # 이 기계에 설치된 로케일 목록
$ locale -k LC_NUMERIC   # 특정 범주의 값들을 자세히

68.7 로케일 데이터는 어디에 있는가

로케일은 프로그램에 들어 있지 않다. 기계에 설치되어 있어야 한다.

단계무엇
정의 파일/usr/share/i18n/locales/ko_KR — 사람이 읽는 형식
문자집합 정의/usr/share/i18n/charmaps/UTF-8.gz
컴파일localedef -i ko_KR -f UTF-8 ko_KR.UTF-8
설치 위치/usr/lib/locale/(또는 locale-archive)
찾는 경로LOCPATH 환경 변수로 바꿀 수 있다

표 68.7

이 구조를 알면 앞의 “없는 로케일” 문제를 스스로 풀 수 있다. 컨테이너에서 ko_KR.UTF-8이 없다면 정의 파일을 넣고 localedef로 만들면 된다.

실제 사례. 이 책의 로케일 표는 어떻게 확인했는가

이 책을 만드는 기계에도 처음에는 C·C.UTF-8·POSIX 셋뿐이었다. 그래서 다음 장의 표들을 확인하려고 glibc 2.41의 로케일 정의에서 필요한 것들을 직접 만들었다.

localedef -i ko_KR -f UTF-8 <경로>/ko_KR.UTF-8
localedef -i de_DE -f UTF-8 <경로>/de_DE.UTF-8
export LOCPATH=<경로>

그래서 이 책에 실린 다른 로케일의 값들은 어림한 것이 아니라 그렇게 만들어 돌려 본 결과다. 반대로 독자의 기계에 그 로케일이 없다면 예제는 “이 기계에 없음”을 인쇄한다 — 예제들이 로케일의 존재를 전제하지 않도록 짜 두었다.

복습 정리

기억할 것요점
정체프로그램 밖의 관습 설정. 번역이 아니다
시작값언제나 "C" — 표준이 그렇게 정한다
범위프로세스 전역. 라이브러리가 바꿀 수 있고 스레드 사이에 경쟁
범주LC_CTYPE·LC_NUMERIC·LC_MONETARY·LC_COLLATE·LC_TIME(+LC_ALL)
setlocale""=환경, NULL=질의. 반환값이 널이면 실패
이름언어_지역.문자집합@변종 — ISO 639·ISO 3166·문자집합 등록부
근거문법과 형식은 POSIX(ISO/IEC 9945). 웹·윈도는 BCP 47
환경LC_ALL > LC_범주 > LANG
실무LC_ALL, "" 뒤에 LC_NUMERIC, "C"

표 68.8

로케일이 무엇이고 어떻게 고르는지를 보았다. 다음 장은 그것이 무엇을 어떻게 바꾸는지다 — 숫자, 통화, 시간, 정렬을 차례로 뜯어본다.