원문 캡처 · danpla.github.io
원문 캡처 · danpla.github.io

dp_intl은 C++용 Gettext 호환 국제화(i18n) 라이브러리다. 제작자는 Gettext 런타임 라이브러리(libintl)를 개선하는 것을 목표로 하며, 로캘 의존성을 없애고 파일 배치와 로딩을 유연하게 하는 기능 등을 제공한다고 설명한다.

Gettext에서 개선한 점

dp_intl은 로캘에 의존하지 않는다. Gettext에서 언어를 설정하는 방법은 C 표준 라이브러리의 setlocale() 함수를 호출하는 것뿐이다. 이 함수는 로캘에 따라 동작이 달라지는 다른 C 루틴에도 영향을 줘 특정 로캘에서만 나타나는 미묘한 버그를 일으킬 수 있다. 예를 들어 LC_ALL을 인수로 setlocale()을 호출하면 소수점 구분자를 바꾸는 LC_NUMERIC을 포함해 모든 로캘 범주가 바뀐다. 이에 따라 snprintf()나 strtof()처럼 부동소수점 수를 문자열로 변환하거나 문자열에서 읽는 함수의 동작도 달라진다. 소수점 구분자가 마침표인 로캘에서 직렬화한 숫자는 쉼표를 쓰는 로캘에서 올바르게 파싱되지 않을 수 있고, 그 반대도 마찬가지다.

코드에서 영향을 받는 C 루틴을 직접 쓰지 않더라도 서드파티 라이브러리에서 사용할 수 있다. C++ 표준 라이브러리도 해당되며, C++26 기준 std::stof/d() 함수는 strtof/d()를 기반으로 구현된다. C++26 이전의 std::to_string()은 sprintf()를 기반으로 구현됐다. LC_NUMERIC을 유지하려면 LC_ALL 대신 Gettext에 필요한 두 범주인 LC_CTYPE와 LC_MESSAGES만 명시적으로 설정할 수 있다. 하지만 LC_CTYPE도 tolower(), strcasecmp() 같은 ctype.h와 strings.h 함수에 영향을 준다. 특히 대문자 ‘i’가 ‘İ’이고 소문자 ‘I’가 ‘ı’인 터키어 로캘에서는 코드가 깨질 수 있다. 반면 dp_intl은 로캘에 의존하지 않고 시스템 인터페이스 언어 설정을 바탕으로 선호 언어를 직접 결정한다.

Gettext에서 복수형 메시지를 번역하는 루틴은 숫자에 unsigned long 형식을 사용한다. 이 형식의 크기는 플랫폼에 따라 달라진다. 예를 들어 64비트 유닉스 계열 시스템(Linux, macOS)에서는 64비트지만, 64비트 Windows에서는 32비트다. 따라서 숫자를 2³²(4,294,967,295) 이하로 제한하거나 비슷한 우회 방법을 쓰지 않으면 플랫폼 간 코드를 작성하기 어렵다. 32비트 unsigned long을 사용하는 시스템에서는 부호 없는 정수의 오버플로로 값이 2³²를 기준으로 순환해 복수형 번역이 잘못될 수 있다. dp_intl은 모든 플랫폼에서 64비트 정수형인 uint64_t를 사용한다.

Gettext는 메시지 카탈로그를 로컬 파일 시스템에서만 불러올 수 있고, 카탈로그는 bindtextdomain()이 요구하는 <locale>/LC_MESSAGES/<domain>.mo 디렉터리 구조에 있어야 한다. 애플리케이션이 카탈로그를 Android APK 같은 아카이브나 패키지에 저장한다면 Gettext에서 사용하려면 로컬에 압축을 풀어야 한다. dp_intl은 아카이브와 패키지 안의 파일을 포함해 파일처럼 동작하는 객체를 감쌀 수 있는 가상 스트림 클래스를 통해 파일을 읽는다. 언어 선택과 메시지 카탈로그 로딩이 서로 묶여 있지 않아 파일 배치나 이름 규칙도 요구하지 않는다.

Gettext의 복수형 메시지 번역 루틴은 원문 언어로 영어와 몇몇 다른 언어처럼 복수형이 두 가지인 경우만 지원한다. 이 방식에서는 1에 ‘one’을, 나머지 모든 수에 ‘many’를 사용한다. 이 패턴에 맞지 않는 언어는 원문 언어로 쓸 수 없다. 예를 들어 프랑스어는 복수형이 두 가지이지만 ‘one’을 0과 1에 모두 쓰므로 Gettext의 원문 언어로 적합하지 않다. dp_intl은 복수형 규칙을 API에 하드코딩하지 않고 설정할 수 있게 해 어떤 언어든 원문에 사용할 수 있다.

그 밖의 개선점으로는 C 문자열뿐 아니라 std::string_view도 사용할 수 있고, 오류를 자세히 설명하는 예외를 제공하며, C++ 표준 라이브러리 외의 의존성이 없는 작은 코드 크기를 들었다.

Gettext 호환성과 인코딩

dp_intl의 번역 루틴은 Gettext 라이브러리를 본떠 만든 C++ 클래스 메서드다. Gettext의 함수와 다른 점은 dp_intl이 C++ 라이브러리라 루틴이 자유 함수가 아닌 클래스 메서드이고, 인수 순서가 다르다는 두 가지다.

메시지 카탈로그 파일을 다루는 Gettext 도구를 dp_intl이 구현하지는 않지만, API는 소스 코드에서 번역할 문자열을 추출하는 xgettext 도구와 호환되도록 설계됐다. 따라서 메시지 카탈로그 작업 흐름은 다른 Gettext 기반 프로젝트와 같다. 다만 xgettext가 dp_intl API 호출을 인식하도록 -k/--keyword 옵션을 지정해야 한다.

메시지 카탈로그 형식도 호환된다. dp_intl은 Gettext 런타임 라이브러리가 사용하는 MO 파일 형식을 완전히 지원하며, 여기에는 Plural-Forms 헤더 항목에 정의하는 복수형 규칙 수식도 포함된다. 다만 카탈로그는 UTF-8을 사용해야 한다.

Gettext와 호환되지 않는 부분은 dp_intl이 UTF-8만 지원한다는 점이다. Gettext와 달리 실행 시점에 1바이트 인코딩을 변환하지 않는다. 제작자는 UTF-8이 널리 쓰이기 전에는 Gettext가 다양한 언어를 지원하려고 여러 1바이트 인코딩을 지원해야 했지만, 전 세계적으로 UTF-8이 채택된 지금은 이를 실용적으로 지원하기 어렵다고 설명한다. GNU Gettext 프로젝트의 msgfmt도 버전 0.22부터 원본 PO 파일의 인코딩과 관계없이 기본적으로 UTF-8 형식의 MO 파일을 만든다.

MO 카탈로그가 1바이트 인코딩을 사용한다면 다음 방법 중 하나로 UTF-8로 바꿔야 한다.

  • msgfmt 버전 0.22 이상으로 MO 파일을 다시 생성한다. 원본 PO 파일의 인코딩은 그대로 둘 수 있다.
  • PO 파일을 UTF-8로 변환한 뒤 MO 파일을 다시 생성한다. GNU Gettext 프로젝트의 msgconv 도구를 사용하거나 직접 변환할 수 있다. 직접 변환한다면 각 PO 파일의 Content-Type 헤더 항목을 charset=UTF-8로 바꾸는 것도 잊지 않아야 한다.

원본 PO 파일이 없다면 GNU Gettext 프로젝트의 msgunfmt 유틸리티를 사용해 MO 파일에서 복구할 수 있다.

프로젝트에 통합하기

dp_intl을 프로젝트에 통합하는 방법은 세 가지다. 소스 트리에 직접 포함하거나, CMake의 FetchContent로 필요할 때 내려받거나, 시스템에 전통적인 방식으로 설치할 수 있다. 세 방법 모두 C++17을 지원하는 C++ 컴파일러와 CMake 3.22 이상이 필요하다.

소스 트리에 포함하기

dp_intl은 외부 의존성이 없는 작은 라이브러리이므로 프로젝트 소스 트리에 직접 넣은 다음 add_subdirectory()를 사용할 수 있다.

add_subdirectory("path/to/dp_intl")
target_link_libraries(your_target PRIVATE dp_intl::dp_intl)

소스 트리에 두는 파일을 최소화하려면 dp_intl 디렉터리에 다음만 남겨도 된다.

include/
src/
CMakeLists.txt
license.txt

FetchContent로 내려받기

FetchContent를 사용하면 CMake가 아카이브나 Git 저장소에서 dp_intl 소스 코드를 자동으로 내려받는다.

include(FetchContent)

FetchContent_Declare(
 dp_intl
 URL https://github.com/danpla/dp_intl/archive/refs/tags/v<version>.tar.gz
 URL_HASH SHA256=<TAR.GZ file hash>)
FetchContent_MakeAvailable(dp_intl)

target_link_libraries(your_target PRIVATE dp_intl::dp_intl)

설치하기

dp_intl 디렉터리에서 다음 명령을 실행하면 전통적인 방식으로 설치할 수 있다.

cmake -DCMAKE_BUILD_TYPE=Release -B build
cmake --build build
cmake --install build --strip

CMAKE_INSTALL_PREFIX 옵션을 사용하면 기본 설치 디렉터리를 바꿀 수 있다.

설치에는 dp_intl 라이브러리와 헤더 파일 외에도 CMake 패키지와 다른 빌드 시스템에서 쓸 수 있는 pkg-config 파일이 포함된다. CMake에서는 find_package(dp_intl REQUIRED)와 target_link_libraries(your_target PRIVATE dp_intl::dp_intl)를 사용하고, pkg-config에서는 pkg-config --cflags --libs dp_intl을 사용한다.

빌드 옵션

cmake -LH -B build를 실행하면 CMake 옵션을 확인할 수 있다. CMAKE_로 시작하는 표준 옵션 외에 DP_INTL_로 시작하는 dp_intl 전용 옵션이 있다. 최종 사용자에게 유용할 가능성이 큰 옵션은 정적 라이브러리 대신 공유 라이브러리를 빌드하는 DP_INTL_BUILD_SHARED_LIB다.

add_subdirectory()나 FetchContent로 추가한 라이브러리의 옵션은 add_subdirectory() 또는 FetchContent_MakeAvailable()을 호출하기 전에 set() 명령으로 지정한다.

set(DP_INTL_BUILD_SHARED_LIB YES)
add_subdirectory("path/to/dp_intl")

설치할 라이브러리의 옵션은 CMake 구성 단계에서 -D 플래그로 지정한다.

cmake -DDP_INTL_BUILD_SHARED_LIB=YES -B build
cmake --build build

문서

dp_intl은 Gettext의 런타임 부분(libintl)만 구현하므로 전체 번역 작업 흐름은 Gettext 기반 프로젝트와 같다. 자세한 내용은 GNU Gettext 설명서에 나와 있다. Gettext를 처음 접한다면 dp_intl API를 살펴보기 전에 설명서의 ‘Introduction’ 절을 읽어 기본 개념을 익히는 것을 권한다.

dp_intl API 문서는 온라인과 라이브러리 헤더에서 확인할 수 있다. API 탐색은 메시지 카탈로그(MO 파일)를 불러와 번역하는 dp::intl::Translator 클래스와, Translator 클래스가 사용할 언어와 해당 MO 파일을 선택하는 dp::intl::matchLang() 함수부터 시작할 수 있다. Translator 클래스의 문서는 여기에서 볼 수 있다.