본문 바로가기
프로그래밍/C & C++

[C++] String Localization

by 별준 2022. 2. 27.

References

Contents

  • Wide Characters
  • 스트링 리터럴 현지화
  • Locales and Facets

 

C나 C++ 프로그래밍을 처음 배울 때 각 문자를 아스키(ASCII) 코드를 표현하는 바이트로 취급했습니다. 아스키 코드는 7바이트로 구성되었으며 주로 8비트의 char 타입으로 표현합니다. 하지만 유명한 프로그램들은 전 세계적으로 사용됩니다. 따라서 프로그램을 작성할 때는 당장 전 세계 사용자를 대상으로 삼지는 않더라도 나중에 Localization(현지화)를 지원하거나 다양항 로케일(locale)을 인식하게 만들 수 있도록 디자인하는 것이 좋습니다.

 

1. Wide Characters

모든 언어가 한 문자를 1바이트, 즉 8비트에 담을 수 있는 것은 아닙니다. C++은 wchar_t라는 와이드 문자(확장 문자) 타입을 기본으로 제공합니다. 한국어나 아랍어처럼 아스키 문자를 사용하지 않는 언어는 C++에서 wchar_t 타입으로 표현하면 됩니다. 하지만 C++ 표준은 wchar_t의 크기를 명확히 지정하고 있지 않습니다. 어떤 컴파일러는 16비트를 사용하는 반면 다른 컴파일러는 32비트로 처리하기도 합니다. 그래서 크로스 플랫폼을 지원하도록 코드를 작성하려면 wchar_t의 크기가 일정하다고 가정하면 위험합니다.

 

영어권이 아닌 사용자를 대상으로 하는 프로그램을 작성한다면 처음부터 wchar_t 타입으로 작성하는 것이 좋습니다. 문자열이나 문자 리터럴을 wchar_t 타입으로 지정하려면 리터럴 앞에 L을 붙이면 됩니다. 그러면 와이드 문자 인코딩을 적용합니다.

예를 들어 wchar_t 타입의 값을 m이란 문자로 초기화하려면 다음과 같이 작성합니다.

wchar_t myWideCharacter{ L'm' };

 

흔히 사용하는 타입이나 클래스마다 와이드 문자 버전이 존재합니다. string 클래스의 와이드 문자 버전은 wstring입니다. 스트림에 대해서도 이렇게 w라는 접두어를 이용한 명명 규칙이 적용됩니다.

예를 들어 와이드 문자 버전의 파일 출력 스트림으로 wofstream이 있고 입력 스트림은 wifstream입니다.

 

cout, cin, cerr, clog도 각각 wcout, wcin, wcerr, wclog라는 와이드 문자 버전이 있습니다. 사용법은 일반 버전과 같습니다.

std::wcout << L"I am a wide-character string literal.\n";

 


2. Localizing String Literal

현지화에서 가장 중요한 원칙 중 하나는 소스 코드에 특정 언어로 된 스트링을 절대로 넣으면 안된다는 것입니다. 단, 개발 과정에서 임시로 사용하는 디버그 스트링은 예외입니다. MS 윈도우 어플리케이션을 개발하는 환경에서는 현지화에 관련된 스트링을 STRINGTABLE이란 리소스로 따로 빼둡니다. 다른 플랫폼도 이러한 장치가 마련되어 있습니다. 그래서 어플리케이션을 다른 언어에 맞게 변환할 때 다른 코드를 건드릴 필요없이 이 리소스에 담긴 내용만 번역하면 됩니다. 그리고 이러한 번역 작업을 도와주는 도구는 많이 나와 있습니다.

 

현지화할 수 있도록 소스 코드를 구성하려면 스트링 리터럴을 조합하는 방식으로 문장을 만들면 안됩니다. 설령 각 스트링을 현지화할 수 있더라도 말입니다. 예를 들면 다음과 같습니다.

size_t n{ 5 };
std::wcout << L"Read " << n << L"bytes" << std::endl;

문장을 이렇게 구성하면 네덜란드어처럼 어순이 전혀 다른 언어로 현지화하기 힘듭니다. 네덜란드 어로 표현하면 다음과 같이 작성해야 합니다.

std::wcout << n << L" bytes gelezen" << std::endl;

따라서 이 스트링을 제대로 현지화할 수 있게 구성하려면 다음과 같이 작성합니다.

std::wcout << Format(IDS_TRANSFERRED, n) << std::endl;

여기서 IDS_TRANSFERRED는 스트링 리소스 테이블에 담긴 항목 중 하나입니다. IDS_TRANSFERRED의 영어 버전이 'Read $1 bytes'라면 네덜란드어 버전은 '$1 bytes gelezen'과 같이 정의할 수 있습니다. 여기서 Format() 함수는 스트링 리소스를 불러오면서 $1에 해당하는 부분을 n이란 값으로 대체합니다.

 


3. 서구권이 아닌 문자 집합

와이드 문자를 도입한 것만으로도 현지화에 큰 도움이 됩니다. 한 문자에 필요한 공간의 크기를 원하는 대로 정할 수 있기 때문입니다. 문자가 차지하는 공간을 결정했다면 아스키 코드처럼 각 문자를 코드 포인트(code point)라 부르는 숫자 형태로 표현할 수 있습니다. 이렇게 직접 정의한 문자 집합이 아스키 코드와 다른 점은 8비트로 제한되지 않는다는 것뿐입니다. 이때 프로그래머의 모국어를 포함한 다른 언어를 포괄해야 하기 때문에 문자와 코드 포인트 사이의 대응 관계는 얼마든지 달라질 수 있습니다.

 

유지버셜 문자 집합(Universal Character Set, UCS)는  ISO 10646이라는 국제 표현이고, 유니코드도 국제 표준 문자 집합입니다. 둘 다 십만 개 이상의 문자를 담고 있으며, 각 문자마다 고유 이름과 코드 포인트가 정해져 있습니다. 두 표준에서 서로 겹치는 문자와 코드 포인트도 있고, 두 표준 모두 인코딩 방식을 따로 정해두고 있습니다. 예를 들어 유니코드는 한 문자를 1~4개의 8비트로 인코딩하는 UTF-8 방식, 한 문자를 1~2개의 16비트값으로 인코딩하는 UTF-16 방식, 유니코드 문자를 정확히 32비트로 인코딩하는 UTF-32 방식이 있습니다.

 

어플리케이션마다 사용하는 인코딩 방식이 얼마든지 다를 수 있습니다. 아쉽게도 C++ 표준은 와이드 문자(wchar_t)의 크기를 명확히 정해두지 않았습니다. 윈도우 환경에서는 16비트이지만, 32비트로 표현하는 플랫폼도 존재합니다. 따라서 와이드 문자로 인코딩하는 프로그램에서 크로스 플랫폼을 지원하게 하려면 이러한 점을 반드시 인지해야 합니다. 참고로 C++에서 제공하는 char8_t, char16_t와 char32_t를 이용하면 이런 문제를 대처하는 데 도움이 됩니다.

현재 지원되는 문자 타입을 정리하면 다음과 같습니다.

  • char: 8비트 값을 담습니다. 주로 아스키 문자나 한 문자를 1_4개의 char로 인코딩하는 UTF-8 방식의 유니코드 문자를 저장하는데 사용됩니다.
  • charx_t: x비트의 값을 저장하면 x는 8비트(C++20), 16비트, 32비트가 될 수 있습니다. 이 타입은 UTF-x 방식의 유니코드 문자를 저장하는 기본 타입으로 사용될 수 있습니다.
  • wchar_t: 와이드 문자를 표현하는 타입으로, 구체적인 크기와 인코딩 방식은 컴파일러마다 다릅니다.

wchar_t에 비해 charx_t가 좋은 점은 문자가 차지하는 크기를 컴파일러에 관계없이 항상 최소한의 사이즈로 보장할 수 있다는 것입니다. 반면 wchar_t는 이러한 최소 기준이 없습니다.

 

스트링 리터럴 앞에 특정한 접두어를 붙여서 타입을 지정할 수도 있습니다. C++에서 제공하는 스트링 접두어는 다음과 같습니다.

  • u8: UTF-8 인코딩을 적용한 char 스트링 (char8_t는 C++20부터 사용가능)
  • u: char16_t 스트링 리터럴을 표현하며, UTF-16을 적용합니다. (guaranteed since C++20)
  • U: char32_t 스트링 리터럴을 표현하며, UTF-32를 적용합니다. (guaranteed since C++20)
  • L: wchar_t 스트링 리터럴을 표현하며, 인코딩 방식은 컴파일러마다 다릅니다.

이러한 스트링 리터럴은 모두 일반 스트링 리터럴 접두어인 R과 조합할 수 있습니다.

const char8_t* s1{ u8R"(Raw UTF-8 encoded string literal)" };
const wchar_t* s2{ LR"(Raw wide string literal)" };
const char16_t* s3{ uR"(Raw char16_t string literal)" };
const char32_t* s4{ UR"(Raw char32_t string literal)" };

 

만약 유니코드 인코딩을 사용하고 싶다면, \uABCD 표기를 사용하여 특정한 유니코드 코드 포인트를 스트링 리터럴에 추가할 수 있습니다. 예를 들어, \u03C0은 pi 문자를 표현하고, \u00B2는 제곱을 표현합니다. 그래서 \(\pi r^2\)이란 수식을 다음과 같이 표현할 수 있습니다.

const char8_t* formula{ u8"\u03C0 r\u00B2" };

마찬가지로 문자 리터럴 앞에도 이러한 접두어를 붙여서 타입을 구체적으로 지정할 수 있습니다. C++은 문자 리터럴에 대해 u, U, L 뿐만 아니라 C++17부터 u8 접두어도 지원합니다. 예를 들면, u'a', U'a', L'a', u8'a'로 사용할 수 있습니다.

 

C++은 std::string 클래스 이외에 wstring, u8string(C++20), u16string, u32string도 제공합니다. 각각 다음과 같이 정의되어 있습니다.

  • using string = basic_string<char>;
  • using wstring = basic_string<wchar_t>;
  • using u8string = basic_string<char8_t>;
  • using u16string = basic_string<char16_t>;
  • using u32string = basic_string<char32_t>;

비슷하게 std::string_view, wstring_view, u8string_view(C++20), u16string_view, u32string_view도 제공합니다.

 

멀티바이트 문자(multibyte character)란 여러 바이트로 구성된 문자로서 인코딩 방식은 컴파일러마다 다를 수 있습니다. 마치 유니코드를 UTF-8을 사용하여 1~4개의 8비트로 표현하거나, UTF-16을 사용하여 1~2개의 16비트값으로 표현하는 방식과 비슷합니다. char8_t/char16_t/char32_t와 멀티바이트 문자를 서로 변환하는 mbrtoc8()와 c8rtomb() (C++20), mbrtoc16()과 c16rtomb(), mbrtoc32()와 c32rtomb() 등의 변환 함수도 제공됩니다.

 

하지만 아쉽게도 char8_t, char16_t, char32_t에 대한 지원은 여기까지 입니다. 아래에서 소개할 변환 클래스가 몇 가지 더 있긴 하지만, cout이나 cin에 대해 이 문자 타입들을 지원하는 버전은 없습니다. 그래서 이러한 타입들을 콘솔에 출력하거나 사용자로부터 입력받기가 상당히 까다롭습니다. 만약 이러한 스트링으로 무언가를 더 하고 싶다면, 서드파티 라이브러리를 찾아보는 수 밖에 없습니다. 참고로 유니코드와 globalization을 지원하는 대표적인 라이브러리는 ICU(International Components for Unicode)가 있습니다.

 


4. Locales and Facets

문자 집합은 나라마다 데이터를 표현하는 방식이 다른 여러 가지 요소 중 한 예에 불과합니다. 영국과 미국처럼 사용하는 문자가 비슷한 나라마저도 날짜나 화폐를 표현하는 방식이 다릅니다.

이렇게 특정한 데이터를 문화적 배경에 따라 그룹으로 묶는 방식을 C++에서는 로케일(locale)이라고 부릅니다. 로케일은 날짜 포맷, 시간 포맷, 숫자 포맷 등으로 구성되는데, 이러한 요소를 패싯(pacet)이라고 부릅니다. 로케일을 예로는 U.S. English가 있고, 패싯의 예로는 날짜 포맷이 있습니다. C++은 그 밖에 다양한 패싯을 기본으로 제공할 뿐만 아니라 이를 커스터마이즈하거나 새로운 패싯을 추가하는 기능도 제공합니다.

 

로케일을 쉽게 다루도록 도와주는 서드파티 라이브러리도 많이 나와 있는데, 대표적인 예로 boost.locale 입니다. 이 라이브러리는 ICU를 기반으로 구축한 것인데, 대조(collation)와 변환(convension)을 지원하고, 스트링 전체를 한 번에 대문자로 변환(문자 하나씩이 아닌)하는 기능도 제공합니다.

 

4.1 Using Locales

I/O 스트림을 사용할 때는 데이터의 포맷을 특정한 로케일에 맞춥니다. 로케일은 스트림에 붙일 수 있는 객체로서 <locale> 헤더 파일에 정의되어 있습니다. 로케일 이름은 구현마다 다릅니다. POSIX 표준에서는 언어와 지역을 두 글자로 표현하고 그 뒤에 옵션으로 인코딩 방식을 붙여서 표현합니다. 예를 들어 영어권 중에서 미국에 대한 로케일은 en_US로 표현하고, 영국에 대해서는 en_GB로 표기합니다. 한국어는 ko_KR이고, 유닉스 확장 완성형 인코딩은 euc_KR로, UTF-8은 utf8을 옵션으로 붙여서 표기합니다.

 

윈도우 환경에서는 두 가지 포맷으로 로케일을 표현합니다. 첫 번째 방식은 POSIX 포맷과 비슷한데, underscore(_) 대신 대쉬(-)를 사용한다는 점이 다릅니다. 두 번째 포맷은 예전 방식으로 다음과 같이 표현합니다.

lang[_country_region[.code_page]]

대괄호 사이에 나온 부분은 모두 옵션입니다. 몇 가지 예를 표현하면 다음과 같습니다.

  POSIX 윈도우 예전 윈도우
미국식 영어 en_US en-US English_United States
영국식 영어 en_GB en-GB English_Great Britain

대부분의 OS는 사용자가 로케일을 지정하는 메커니즘을 제공합니다. C++에서는 std::locale의 객체 생성자에 공백 스트링을 인수로 전달하면 사용자 환경에 정의된 locale로 생성합니다.

이 객체를 생성한 뒤부터는 locale의 정보를 조회할 수 있고, 그 결과에 따라 코드를 작성할 수 있습니다.

다음 코드는 스트림의 imbue() 메소드를 이용하여 사용자 환경에 지정된 로케일을 사용하도록 설정합니다. 이 코드를 실행하면 wcout에 보낸 데이터가 모두 시스템에 설정된 포맷으로 표현됩니다.

int main()
{
    std::wcout.imbue(std::locale{ "" });
    std::wcout << 32767 << std::endl;
}

이렇게 하면 현재 시스템에 설정된 로케일이 미국식 영어일 때, 32767을 전다러하면 숫자가 32,767로 출력됩니다. 하지만 시스템 로케일이 벨기에로 지정되어 있다면 같은 숫자더라도 32.767로 출력됩니다.

 

디폴트 로케일은 사용자 로케일이 아니라 classic/neutral 로케일입니다. 클래식 로케일은 ANSI C 관례를 따르며 C로 표현합니다. 이러한 클래식 로케일 C는 미국식 영어에 대한 로케일과 거의 같지만 약간 차이가 있습니다.

예를 들면 숫자에 구두점을 붙이지 않습니다.

int main()
{
    std::wcout.imbue(std::locale{ "C" });
    std::wcout << 32767 << std::endl;
}

 

다음 코드는 위와 동일 코드지만 미국식 영어에 대한 로케일을 명시적으로 지정했습니다. 그래서 32767이란 숫자가 시스템 로케일과 별개로 미국식 표기법을 적용하여 쉼표를 붙여서 출력됩니다.

int main()
{
    std::wcout.imbue(std::locale{ "en-US" }); // en_US for POSIX
    std::wcout << 32767 << std::endl;
}

 

 

locale 객체로부터 로케일 정보를 조회할 수 있습니다. 예를 들어 다음 코드는 사용자 시스템에 설정된 로케일에 대한 locale 객체를 생성합니다. name() 메소드를 이용하면 이 로케일을 표현하는 C++ string을 구할 수 있습니다. 이렇게 구한 string 객체에 find() 메소드를 호출하면 인수로 지정한 서브스트링을 검색할 수 있습니다. 지정한 서브스트링을 찾지 못하면 std::string::npos를 리턴합니다. 이 코드는 윈도우 이름과 POSIX 이름을 모두 검사합니다.

int main()
{
    std::locale loc("");
    std::cout << std::locale().name() << std::endl;
    if (loc.name().find("ko_KR") == std::string::npos &&
        loc.name().find("ko-KR") == std::string::npos) {
        std::wcout << L"Welcome non-KR speaker!" << std::endl;
    }
    else {
        std::wcout << L"Welcome KR speaker!" << std::endl;
    }
}

다만.. 제 PC에서 MSVC로 실행시켜 봤지만, 원하는 결과는 나오지 않았습니다. locale 객체를 빈 스트링("")으로 생성하였지만, name()으로 반환되는 결과는 빈 스트링이였습니다. WSL2로 테스트를 해 본 결과, LANG 환경변수가 "C.UTF-8"이 었기 때문에 빈 스트링으로 locale 객체를 생성하면 name()으로 반환되는 스트링은 "C" 였습니다. 이 부분에 대해서 찾아봤지만, 명확히 설명해주는 부분이 없어서 아직 의문인 채로 남아있습니다... 

다만, C 스타일로 setlocale(LC_ALL, "")을 호출하여 반환되는 스트링을 확인해보면, 

#include <iostream>
#include <locale>
#include <string>

int main()
{
    std::cout << std::setlocale(LC_ALL, "") << std::endl;
}

"Korean_Korea.949"로 옛날 윈도우 스타일 포맷으로 로케일을 표현하고 있습니다. 한국으로 설정된 것 같긴 합니다.. !

나중에 프로그램에서 읽은 데이터를 파일에 쓸 때는 클래식 로케일인 "C"를 지정하는 것이 좋습니다. 그렇지 않으면 파싱하기 힘듭니다. 반면 유저 인터페이스에서 데이터를 출력할 때는 사용자 로케일 ""을 지정하는 것이 좋습니다.

 

4.2 Global Locale

std::locale::global() 함수는 지정된 로케일로 어플리케이션에서의 global C++ 로케일을 변경합니다. std::locale의 기본 생성자는 이 global 로케일의 복사본을 리턴합니다. 기억해야 할 것은 로케일을 사용하는 C++ 표준 라이브러리 객체(ex, cout 스트림)은 생성된 시점의 global 로케일의 복사본을 저장합니다. 따라서 글로벌 로케일을 변경하더라도 이미 생성된 객체에는 영향을 끼치지 못합니다. 만약 필요하다면 스트림의 imbue() 메소드를 사용하여 생성된 후에 로케일을 변경해주어야 합니다.

 

다음 예제 코드는 디폴트 로케일에서 숫자를 출력하고, 그 다음 글로벌 로케일을 US English로 변경하여 동일한 숫자를 한 번 더 출력합니다.

#include <iostream>
#include <locale>
#include <sstream>

void print()
{
    std::stringstream stream;
    stream << 32767;
    std::cout << stream.str() << std::endl;
}

int main()
{
    print();
    std::locale::global(std::locale{ "en-US" });
    print();
}

출력은 다음과 같습니다.

 

4.3 Character Classification

<locale> 헤더 파일을 보면 std::isspace(), isblank(), iscntrl(), isupper(), islower(), isalpha(), isdigit(), ispunct(), isxdigit(), isalnum(), isprint(), isgraph() 등과 같은 문자 분류 함수들이 정의되어 있습니다. 이들 함수는 두 개의 매개변수(분류할 문자와 분류에 적용할 로케일)를 받습니다.

예를 들어 프랑스 로케일을 적용해서 isupper()를 호출하는 예는 다음과 같습니다.

bool result{ std::isupper('É', std::locale { "fr-FR" }) }; // result = true

 

4.4 Character Conversion

<locale>에는 std::toupper()와 std::tolower()라는 문자 변환 함수도 정의되어 있습니다. 이들 함수 또한 두 개의 매개변수(변환할 문자와 변환에 적용할 로케일)를 받습니다.

auto upper { std::toupper('é', std::locale { "fr-FR" }) }; // É

 

4.5 Using Pacets

특정한 로케일에서 패싯을 구하려면 std::use_facet() 함수를 호출하면 됩니다. 이때 use_facet()의 인수로 locale을 지정합니다. 예를 들어, 다음 문장은 WINDOWS 로케일 이름을 사용하여 영국식 영어 로케일을 지정했을 때 화폐 금액에 대한 표준 구두법을 조회합니다.

std::use_facet<std::moneypunct<wchar_t>>(std::locale{ "en-GB" });

이 문장의 가장 안쪽에 있는 템플릿 타입은 사용할 문자의 타입을 지정합니다. 주로 wchar_t나 char을 사용합니다. 템플릿 클래스가 중첩되어 조금 복잡하지만, 영국식 화폐 금액 구두법에 대한 모든 정보를 담은 객체를 구할 수 있습니다. 표준 패싯에서 제공하는 데이터는 모두 <locale>헤더와 관련 파일에 정의되어 있습니다.

다음 표는 C++ 표준에서 정한 패싯의 범주를 보여줍니다. 각 패싯에 대한 자세한 사항은 표준 라이브러리 레퍼런스를 참조하시길 바랍니다 !

 

다음 예제 코드는 미국식 영어와 영국식 영어에 대한 로케일과 패싯으로 두 나라의 화폐 기호를 출력하는 예를 보여줍니다. 여기서 주의할 점은 이 코드를 실행하는 환경에 따라서 미국 또는 영국 화폐 기호가 물음표나 박스로 표기되거나 아무 것도 나타나지 않을 수 있습니다.

#include <iostream>
#include <locale>
#include <string>

int main()
{
    std::locale locUSEng{ "en-US" };   // "en_US" for POSIX
    std::locale locBritEng{ "en-GB" }; // "en_GB" for POSIX

    std::wstring dollars{ std::use_facet<std::moneypunct<wchar_t>>(locUSEng).curr_symbol() };
    std::wstring pounds{ std::use_facet<std::moneypunct<wchar_t>>(locBritEng).curr_symbol() };

    std::wcout << L"In the US, the currency symbol is " << dollars << std::endl;
    std::wcout << L"In the Great Britain, the currency symbol is " << pounds << std::endl;
}

저의 경우에는 영국 화폐 기호는 표현되지 않고 있습니다.

 

 


문자열 현지화에 관련된 내용은 생각보다 어렵고, 제 PC에서 제대로 동작하지 않는 것들이 많았고 포스팅에 담지 못한 내용들도 있습니다. 기회가 된다면 조금 더 깊게 알아볼 필요가 있는 부분인 것 같습니다.. !

댓글