C++ std::locale과 facet: 숫자·통화·날짜를 지역 형식으로 출력하기

이 글의 핵심

std::locale::global로 전역 locale을 바꾸면 라이브러리 전체의 파싱·출력 동작이 함께 바뀌어 예상 밖의 버그가 생깁니다. 스트림별 imbue로 범위를 제한하는 방법, locale 객체를 반복 생성할 때의 비용, UTF-8 문자열을 std::locale만으로 처리하기 어려운 이유를 짚습니다.

기본 사용법

std::locale이 존재하는 이유는 “숫자를 어떻게 쓰는가”, “날짜를 어떤 순서로 쓰는가”, “어떤 문자가 대문자인가” 같은 문화권별 관습이 프로그램 로직과 완전히 분리되어야 하기 때문입니다. 미국에서는 1,234.56, 독일에서는 1.234,56이 같은 숫자를 나타내듯, 이런 표현 규칙을 하드코딩하면 다국어 지원이 불가능해집니다. locale 객체는 이런 관습들의 묶음(카테고리별로 나뉜 “facet” 집합)이고, imbue()는 특정 스트림에 그 규칙을 적용하는 연산입니다. locale::global()은 프로그램 전역의 기본 locale을 바꾸지만, 뒤에서 다룰 것처럼 이는 예상치 못한 부작용을 낳을 수 있어 실무에서는 개별 스트림에 imbue하는 방식이 더 안전하게 여겨집니다.

#include <locale>
#include <iostream>
using namespace std;

int main() {
    // 현재 locale
    locale currentLocale;
    cout << currentLocale.name() << endl;
    
    // locale 설정
    locale::global(locale("ko_KR.UTF-8"));
    
    // 스트림에 적용
    cout.imbue(locale());
}

이 짧은 예제에는 알아 둘 동작이 세 가지 숨어 있습니다. 첫째, 인자 없이 만든 locale currentLocale은 “현재 전역 locale의 복사본”이며, 프로그램 시작 시 전역 locale은 항상 "C"입니다. 운영체제 설정이 한국어여도 C++ 프로그램은 자동으로 한국어 locale을 쓰지 않으므로, 첫 줄은 대부분 C를 출력합니다. 사용자의 환경 변수(LANG, LC_ALL)를 따르고 싶다면 이름 대신 빈 문자열 locale("")을 넘깁니다. 둘째, locale::global()은 이미 만들어진 cout의 locale을 바꾸지 않습니다. 표준 스트림은 프로그램 시작 시점의 locale로 초기화되어 있으므로, 마지막 줄처럼 cout.imbue(locale())를 다시 호출해야 새 전역 locale이 반영됩니다. 셋째, 이름 있는 locale로 locale::global()을 호출하면 내부적으로 C의 setlocale(LC_ALL, ...)도 함께 호출되어 printf, strtod, atof 같은 C 함수의 동작까지 바뀝니다.

locale 이름 형식도 플랫폼마다 다릅니다. Linux(glibc)와 macOS는 ko_KR.UTF-8 형태를 쓰지만, Windows의 MSVC는 ko-KR나 Korean_Korea.949 같은 이름을 받고, Windows 10 이후에는 .UTF-8을 붙여 UTF-8 코드 페이지를 지정할 수 있습니다. 크로스플랫폼 코드에서 locale 이름을 문자열 상수 하나로 하드코딩하면 어느 한쪽에서는 반드시 예외가 나므로, 이름을 설정 파일로 빼거나 후보 목록을 차례로 시도하는 방식이 필요합니다.

숫자 포매팅

cout << 1234567이 로케일에 따라 1,234,567처럼 자동으로 천 단위 구분자가 붙는 것은, << 연산자가 내부적으로 스트림에 imbue된 locale의 num_put facet을 거쳐 숫자를 문자열로 변환하기 때문입니다 — 개발자가 직접 구분자를 삽입하는 코드를 작성하지 않아도, 스트림에 올바른 locale만 설정해 두면 표준 라이브러리가 그 관습을 적용해 줍니다. put_money도 같은 메커니즘으로 동작하지만 통화 기호와 소수점 자릿수까지 locale의 moneypunct facet에서 가져오므로, 나라마다 다른 통화 표기 규칙(기호 위치, 천 단위 구분자, 소수점 자릿수)을 애플리케이션 코드에서 분기 처리할 필요가 없어집니다.

#include <locale>
#include <iomanip>

int main() {
    // 천 단위 구분
    cout.imbue(locale("en_US.UTF-8"));
    cout << 1234567 << endl;  // 1,234,567
    
    // 통화
    cout << showbase << put_money(123456) << endl;  // $1,234.56
}

put_money에 넘긴 123456이 $1,234.56으로 출력되는 이유는 값이 “통화의 최소 단위” 기준이기 때문입니다. 달러는 소수 두 자리(frac_digits == 2)라 센트로 해석되고, showbase를 지정하지 않으면 통화 기호가 빠진 1,234.56만 나옵니다. 또 <iomanip>만 포함한 이 예제는 cout과 endl을 쓰는데도 <iostream>과 using namespace std;가 빠져 있어서, 그대로 복사하면 컴파일되지 않을 수 있습니다. 앞 예제의 헤더와 using 선언을 함께 둔다고 보시면 됩니다.

C++20 이후라면 스트림 대신 std::format에서도 locale을 쓸 수 있습니다. std::format(std::locale("en_US.UTF-8"), "{:L}", 1234567)처럼 L 지정자를 붙였을 때만 locale이 적용되고, 기본 {}는 locale과 무관하게 항상 같은 결과를 냅니다. 이 설계 덕분에 로그나 JSON처럼 고정 형식이 필요한 출력은 기본 서식을, 사용자에게 보여 줄 값만 L을 쓰는 식으로 구분하기가 쉬워졌습니다.

날짜/시간

put_time에 전달하는 "%c"는 “그 locale의 표준적인 날짜/시간 형식”을 뜻하는 특수 지정자이며, 실제 출력 순서와 표기(연-월-일 vs 월/일/연, 오전/오후 표기 등)는 imbue된 locale이 결정합니다. 같은 time_t 값을 한국어 locale과 영어 locale로 각각 출력하면 완전히 다른 형태의 문자열이 나오는 것도 이 때문입니다 — 날짜 형식을 문자열 조합으로 직접 만드는 대신 %c 같은 locale-aware 지정자를 쓰면, 새로운 언어를 지원할 때 애플리케이션 코드를 전혀 바꾸지 않고도 해당 locale을 설치하고 imbue하는 것만으로 올바른 형식이 자동으로 적용됩니다.

#include <locale>
#include <iomanip>

int main() {
    auto now = chrono::system_clock::now();
    auto time = chrono::system_clock::to_time_t(now);
    
    // 한국어
    cout.imbue(locale("ko_KR.UTF-8"));
    cout << put_time(localtime(&time), "%c") << endl;
    
    // 영어
    cout.imbue(locale("en_US.UTF-8"));
    cout << put_time(localtime(&time), "%c") << endl;
}

여기서 locale이 결정하는 것은 표기 형식뿐이고, 시간대는 전혀 다른 문제입니다. localtime은 프로세스의 TZ 환경 변수(또는 시스템 시간대)를 기준으로 변환하므로, 컨테이너에서 TZ가 설정되지 않았다면 한국어 locale로 출력해도 UTC 시각이 찍힙니다. 또 localtime은 내부 정적 버퍼를 반환하므로 여러 스레드에서 동시에 호출하면 결과가 섞일 수 있어, 서버 코드에서는 localtime_r(POSIX)이나 localtime_s(Windows)를 쓰는 편이 안전합니다. 서식 결과가 locale 데이터에 의존한다는 점도 주의할 부분입니다. 같은 ko_KR.UTF-8이라도 glibc 버전에 따라 %c 출력이 달라질 수 있으므로, 출력 문자열을 테스트에서 그대로 비교하면 빌드 서버에서만 깨지는 일이 생깁니다.

실전 예시

예시 1: 다국어 메시지

std::locale이 다루는 것은 “숫자·날짜·문자 분류 같은 서식 규칙”이지, 애플리케이션의 UI 문자열 자체를 번역해 주지는 않습니다 — “Hello”를 “안녕하세요”로 바꾸는 것은 locale의 역할이 아니라 별도의 메시지 관리 계층이 필요하며, 이 I18n 클래스가 그 계층의 최소 구현입니다. map<lang, map<key, value>>라는 이중 맵 구조는 “언어 → 키 → 번역된 문자열”이라는 조회 경로를 표현하고, get()이 두 단계 모두에서 find가 실패하면 키 자체를 반환하는 폴백은 번역이 아직 준비되지 않은 항목이 있어도 프로그램이 깨지지 않고 최소한 원본 키라도 표시하게 만드는 흔한 안전장치입니다. 실무에서는 이런 메시지 테이블을 코드에 직접 쓰지 않고 .po/.json 같은 외부 리소스 파일로 분리해, 번역가가 C++ 코드를 건드리지 않고도 번역을 추가·수정할 수 있게 하는 것이 일반적입니다.

#include <map>

class I18n {
private:
    map<string, map<string, string>> messages;
    string currentLang = "en";
    
public:
    void addMessage(const string& lang, const string& key, const string& value) {
        messages[lang][key] = value;
    }
    
    void setLanguage(const string& lang) {
        currentLang = lang;
    }
    
    string get(const string& key) const {
        auto langIt = messages.find(currentLang);
        if (langIt != messages.end()) {
            auto msgIt = langIt->second.find(key);
            if (msgIt != langIt->second.end()) {
                return msgIt->second;
            }
        }
        return key;  // 기본값
    }
};

int main() {
    I18n i18n;
    
    i18n.addMessage("en", "hello", "Hello");
    i18n.addMessage("en", "goodbye", "Goodbye");
    i18n.addMessage("ko", "hello", "안녕하세요");
    i18n.addMessage("ko", "goodbye", "안녕히 가세요");
    
    i18n.setLanguage("ko");
    cout << i18n.get("hello") << endl;
    cout << i18n.get("goodbye") << endl;
}

예시 2: 숫자 파싱

이 예시는 서식 지정만이 아니라 파싱에서도 locale이 필요한 이유를 보여줍니다 — "1,234.56"은 미국식으로는 1234.56이지만, 콤마와 마침표의 역할이 뒤바뀐 독일식 표기("1.234,56")에서는 같은 문자열이 전혀 다른 의미가 됩니다. istringstream에 알맞은 locale을 imbue해 두면 >> 연산자가 그 locale의 숫자 파싱 규칙(num_get facet)을 사용해 구분자 위치를 올바르게 해석합니다. 다만 이는 동시에 이 방식의 위험도 보여줍니다 — 사용자가 어떤 형식으로 입력했는지 프로그램이 사전에 알지 못한다면 잘못된 locale로 파싱해 조용히 틀린 숫자를 얻을 수 있으므로, 사용자 입력을 파싱할 때는 입력이 온 맥락(웹 폼의 언어 설정 등)에 맞는 locale을 명시적으로 선택해야 합니다.

double parseNumber(const string& str, const locale& loc) {
    istringstream iss(str);
    iss.imbue(loc);
    
    double value;
    iss >> value;
    
    return value;
}

int main() {
    // 미국 형식
    double us = parseNumber("1,234.56", locale("en_US.UTF-8"));
    cout << us << endl;  // 1234.56
    
    // 유럽 형식
    double eu = parseNumber("1.234,56", locale("de_DE.UTF-8"));
    cout << eu << endl;  // 1234.56
}

parseNumber는 실패를 확인하지 않는다는 한계가 있습니다. iss >> value가 실패하면 C++11 이후 value는 0이 되고 스트림에 failbit가 설정되지만, 함수는 그 0을 정상 값처럼 반환합니다. 실제 코드라면 if (!(iss >> value) || !(iss >> std::ws).eof())처럼 변환 성공과 남은 문자 여부를 함께 검사해야 합니다. 또 천 단위 구분자가 locale의 grouping 규칙과 맞지 않게 들어오면(예: "12,34.5") 구현에 따라 failbit가 설정될 수 있습니다.

제가 locale 관련 버그로 가장 자주 본 형태는 반대 방향입니다. 사용자 표시용으로 locale::global(locale(""))을 설정해 둔 프로그램이 독일어 환경에서 실행되자, 같은 프로세스의 설정 파일 파서가 "3.14"를 3으로 읽고, printf("%f")로 만든 JSON에는 3,140000이 들어가 다른 서비스가 파싱에 실패하는 식입니다. 기계가 읽는 데이터(설정, 프로토콜, 로그)는 std::locale::classic()을 imbue한 스트림이나 C++17의 std::from_chars/std::to_chars처럼 locale에 영향받지 않는 API로 처리하고, locale은 사람에게 보여 주는 경계에서만 쓰는 것이 이 문제를 구조적으로 막는 방법입니다.

예시 3: 문자 분류

<cctype>의 전역 isalpha/isupper/tolower는 C 로케일(기본적으로 ASCII 범위)만 기준으로 판단하는 반면, <locale>이 제공하는 두 번째 인자를 받는 오버로드(isalpha(c, loc))는 지정한 locale의 문자 분류 규칙(ctype facet)을 따릅니다. 이 차이는 ASCII를 벗어난 문자를 다룰 때 실질적인 영향을 미칩니다 — 예를 들어 터키어 locale에서는 대소문자 변환 규칙이 영어와 미묘하게 다르고(점 없는 ı와 점 있는 i 문제가 유명한 사례), 전역 함수만 쓰면 이런 지역별 예외를 반영할 방법이 없습니다. 다만 char 기반 API는 태생적으로 멀티바이트 UTF-8 문자(한글, 이모지 등)를 한 글자 단위로 정확히 다루지 못하므로, 이 접근은 여전히 단일 바이트 범위(대체로 라틴 문자권)에 가장 적합하다는 한계를 함께 알아둘 필요가 있습니다.

#include <locale>

int main() {
    locale loc("en_US.UTF-8");
    
    char c = 'A';
    
    if (isalpha(c, loc)) {
        cout << c << "는 알파벳" << endl;
    }
    
    if (isupper(c, loc)) {
        cout << c << "는 대문자" << endl;
    }
    
    char lower = tolower(c, loc);
    cout << "소문자: " << lower << endl;  // a
}

<cctype>의 한 인자 버전을 쓸 때는 locale과 별개로 유명한 함정이 하나 더 있습니다. isalpha(c)에 음수 char(UTF-8의 한글 바이트는 signed char로 보면 음수)를 그대로 넘기면 정의되지 않은 동작이므로, isalpha(static_cast<unsigned char>(c))처럼 변환해야 합니다. 두 인자 버전(isalpha(c, loc))은 ctype<char> facet을 쓰므로 이 문제는 없지만, 여전히 UTF-8의 한 글자를 여러 바이트로 나눠 보기 때문에 “한글인가”를 판별하는 용도로는 쓸 수 없습니다. 유니코드 문자 단위 처리가 필요하면 ICU처럼 코드 포인트와 문자 속성 데이터베이스를 갖춘 라이브러리를 쓰는 것이 현실적인 선택입니다.

통화 포매팅

미국 달러($1,234.56)와 한국 원화(₩123,456)를 같은 put_money(123456) 호출로 서로 다르게 출력하는 이 예시가 잘 보여주는 것은, “123456”이라는 내부 값 자체는 바뀌지 않고 오직 imbue된 locale만 바뀌었다는 점입니다. 이는 통화 코드를 애플리케이션 로직에 하드코딩하지 않고 프레젠테이션 계층에서만 결정하도록 분리하는 설계의 전형적인 예이며, 다만 실무에서 유의할 점은 한국 원화처럼 소수 단위가 없는 통화와 달러처럼 센트 단위가 있는 통화가 같은 정수 표현(여기서는 최소 단위인 센트/원 기준)을 서로 다르게 해석한다는 것 — put_money에 넘기는 값의 단위 자체가 통화마다 다르므로, 여러 통화를 동시에 다루는 코드는 이 점을 반드시 문서화하고 테스트해야 합니다.

#include <iomanip>

int main() {
    cout.imbue(locale("en_US.UTF-8"));
    cout << showbase << put_money(123456) << endl;  // $1,234.56
    
    cout.imbue(locale("ko_KR.UTF-8"));
    cout << showbase << put_money(123456) << endl;  // ₩123,456
}

통화 기호가 실제로 어떻게 찍히는지는 locale 데이터와 터미널 인코딩 모두에 달려 있습니다. ko_KR.UTF-8의 ₩는 UTF-8로 여러 바이트인데, Windows 콘솔처럼 출력 코드 페이지가 UTF-8이 아닌 환경에서는 깨진 문자로 보입니다. 또 국제 통화 형식이 필요하면 put_money(value, true)로 USD, KRW 같은 ISO 4217 코드 형식을 선택할 수 있습니다. 여러 통화를 다루는 금융 코드에서는 locale에 기대기보다 통화 코드와 소수 자릿수를 데이터로 명시하고 표시 단계에서만 locale을 쓰는 편이 오류를 줄입니다.

자주 발생하는 문제

문제 1: locale 설치

std::locale의 생성자는 이름으로 지정한 locale이 운영체제에 설치되어 있어야만 성공합니다 — 이는 C++ 표준 라이브러리가 자체적으로 locale 데이터를 내장하지 않고, glibc 같은 시스템 C 라이브러리가 제공하는 locale 데이터베이스에 위임하기 때문입니다. 개발 머신에는 ko_KR.UTF-8이 설치되어 있어 문제없이 동작하던 코드가, 최소 이미지로 빌드된 Docker 컨테이너나 다른 배포 서버에서는 해당 locale이 없어 runtime_error를 던지며 실패하는 경우가 실무에서 흔한 배포 함정입니다. 이런 실패가 예측 가능하다는 것을 알고 있다면, 프로덕션 환경에서는 항상 try/catch로 감싸고 폴백 locale(또는 최소한 “C” locale)을 준비해 두는 것이 안전합니다.

// ❌ locale 없음
try {
    locale loc("ko_KR.UTF-8");
} catch (const runtime_error& e) {
    cout << "locale 없음" << endl;
}

// ✅ 시스템에 locale 설치
// Linux: locale-gen ko_KR.UTF-8
// macOS: 기본 설치됨

문제 2: 전역 locale 변경

locale::global()이 위험한 이유는 그 영향 범위가 호출한 코드의 스코프를 훨씬 넘어서기 때문입니다 — 프로세스 전역 상태를 바꾸므로, 그 시점 이후 새로 생성되는 모든 스트림의 기본 locale이 영향을 받고, 이름 있는 locale이면 setlocale도 함께 호출되어 printf의 소수점 문자나 strtod/atof의 파싱 규칙 같은 C 표준 라이브러리 함수의 동작까지 바뀝니다. 여러 스레드가 동시에 실행 중인 서버 애플리케이션에서 한 요청 처리 중에 locale::global을 호출하면, 그 순간 다른 스레드에서 처리 중인 무관한 요청의 숫자·날짜 서식까지 예기치 않게 바뀌는 경쟁 조건이 생길 수 있습니다. cout.imbue(...)처럼 특정 스트림 객체에만 적용하면 그 영향이 해당 스트림으로 정확히 국한되므로, 전역 변경이 정말로 필요한 극히 드문 경우(프로그램 시작 시 단 한 번, 다른 스레드가 시작되기 전)를 제외하면 스트림별 imbue가 항상 더 안전한 선택입니다.

// ❌ 전역 변경 (다른 코드 영향)
locale::global(locale("ko_KR.UTF-8"));

// ✅ 스트림별 설정
cout.imbue(locale("ko_KR.UTF-8"));

문제 3: 성능

locale 객체를 이름으로 생성하는 것은 단순한 값 초기화가 아니라, 운영체제의 locale 데이터베이스를 조회하고 그 안의 여러 facet(숫자·날짜·통화·문자 분류 등) 객체를 구성하는 상대적으로 무거운 작업입니다. 반복문 안에서 매번 locale loc("en_US.UTF-8")을 새로 생성하면 이 조회·구성 비용이 반복 횟수만큼 곱해지고, 실제 포매팅/파싱 작업 자체보다 이 초기화 비용이 훨씬 커지는 경우도 드물지 않습니다. locale 객체는 내부적으로 참조 카운트 기반 공유 구조를 가지므로 복사 비용은 저렴하지만, 문자열로부터의 최초 생성 자체가 무거우므로 반복 호출되는 경로에서는 한 번만 생성해 재사용하는 것이 표준적인 최적화입니다.

// ❌ 매번 locale 생성
for (int i = 0; i < 1000; i++) {
    locale loc("en_US.UTF-8");  // 느림
    // ...
}

// ✅ 재사용
locale loc("en_US.UTF-8");
for (int i = 0; i < 1000; i++) {
    // ...
}

문제 4: Docker 이미지에서만 실패

로컬에서는 잘 되던 locale("ko_KR.UTF-8")이 컨테이너에서 std::runtime_error(libstdc++의 메시지는 locale::facet::_S_create_c_locale name not valid)로 실패하는 경우가 많습니다. debian:slim 계열 이미지는 locales 패키지를 설치하고 locale-gen을 실행해야 하고, Alpine(musl)은 glibc와 locale 지원 방식이 달라 이름 있는 locale이 기대처럼 동작하지 않을 수 있습니다. 설치 여부는 locale -a로 확인할 수 있으며, 이름이 ko_KR.utf8처럼 소문자·하이픈 없는 형태로 표시되어도 ko_KR.UTF-8로 지정하면 대체로 인식됩니다. CI에서 locale 의존 테스트를 돌린다면 이미지 빌드 단계에 locale 생성을 넣어 두는 것이 가장 확실합니다.

FAQ

Q1: 설정 파일이나 JSON을 파싱할 때 locale 영향을 피하려면?

A: 스트림에 std::locale::classic()을 imbue하거나, C++17의 std::from_chars/std::to_chars를 쓰세요. from_chars는 locale, 공백 처리, 예외가 모두 없는 저수준 변환이라 기계가 읽는 형식에 적합합니다.

Q2: std::codecvt로 UTF-8 변환을 해도 되나요?

A: std::wstring_convert와 <codecvt>의 UTF-8 변환 facet은 C++17에서 deprecated되었습니다. 당장 동작은 하지만 새 코드라면 ICU나 플랫폼 API(Windows의 MultiByteToWideChar 등), 또는 검증된 UTF-8 라이브러리를 쓰는 편이 낫습니다.

Q3: 스레드마다 다른 locale을 쓰고 싶으면?

A: 표준 C++에는 스레드별 전역 locale이 없으므로, 요청마다 필요한 locale을 스트림에 imbue하거나 std::format(loc, ...)처럼 locale을 인자로 넘기는 API를 쓰세요. POSIX의 uselocale()은 스레드별 C locale을 바꾸지만 C++ 스트림에는 영향을 주지 않습니다.

Q4: 설치된 locale 목록은 어떻게 확인하나요?

A: Linux/macOS에서는 locale -a 명령으로 확인합니다. 코드에서 이름을 추측하지 말고, 배포 대상 환경에서 이 목록을 먼저 확인한 뒤 정확한 이름을 설정에 넣는 것이 안전합니다.


같이 보면 좋은 글