스토리
Shotomatic Team
약 15분

작은 Electron i18n 계층을 관리하기 쉽게 만든 여섯 가지 결정

언어 설정, 타입이 있는 카탈로그, 폴백, Intl 형식 지정, 렌더러와 메인 프로세스 동기화를 다루는 실용적인 Electron i18n 설계입니다.

Electron 앱을 만들며 노트북에 코드를 입력하는 개발자

기존 Electron 앱에 일본어를 추가하면서 메시지 카탈로그만으로는 해결할 수 없는 질문이 생겼습니다. 운영체제 언어와 저장된 설정이 다르면 무엇을 우선해야 할까요? 렌더러가 선택한 언어를 메인 프로세스에 알리기 전에는 어떻게 동작해야 할까요? 네이티브 대화상자와 React 인터페이스의 언어가 어긋나는 일은 어떻게 막을 수 있을까요?

첫 버전은 의도적으로 작게 만들었습니다. 영어와 일본어를 지원하고, 영어를 폴백으로 사용하며, 큰 다국어 프레임워크 대신 TypeScript와 짧은 테스트 모음에 의존합니다. 초기에 내린 여섯 가지 결정 덕분에 이 작은 계층을 이해하고 관리하기가 쉬워졌습니다.

1. locale만이 아니라 언어 설정을 저장합니다

en이나 ja처럼 구체적인 locale만 저장하면 macOS 언어를 따르려는 선택을 표현할 수 없습니다. 시스템 언어를 처음 실행할 때의 기본값으로만 쓰는 방식에도 같은 문제가 있습니다. 구체적인 locale이 저장된 뒤에는 macOS 언어를 바꿔도 적용되지 않습니다.

사용자가 고른 설정과 실제 적용할 locale을 따로 모델링했습니다.

type AppLocale = "en" | "ja";
type AppLocalePreference = "system" | AppLocale;

const resolveAppLocale = (preference: AppLocalePreference, languages: readonly string[]): AppLocale =>
  preference === "system" ? detectSystemLocale(languages) : preference;

system 설정일 때만 navigator.languages를 확인합니다. 사용자가 직접 고른 언어는 macOS와 달라도 항상 우선합니다. 저장된 값을 사용하기 전에 검증하고, 값이 없거나 손상되었거나 지원하지 않는 이전 버전에서 남은 값이면 system으로 돌아갑니다.

navigator.languages의 순서도 중요합니다. 목록 어딘가에 일본어가 있다는 이유로 앞에 있는 영어를 무시하지 않고, 사용자의 선호 목록에서 처음으로 지원되는 언어를 선택합니다. 지원되는 항목이 하나도 없으면 영어를 사용합니다.

두 값을 나누면 각 상태의 역할이 분명해집니다. 언어 설정은 사용자가 원하는 동작을 기록하고, locale은 지금 인터페이스에 표시할 언어를 정합니다.

2. 언어를 잘못 골라도 선택 항목을 읽을 수 있게 합니다

언어 메뉴에는 언제나 다음 라벨이 표시됩니다.

  • English
  • 日本語

이는 각 언어가 자기 이름을 부르는 표현인 자칭어입니다. 두 라벨을 현재 인터페이스 언어로 번역하면 쉽게 피할 수 있는 복구 문제가 생깁니다. 읽을 수 없는 언어로 바꾼 사용자가 다시 돌아가는 데 필요한 라벨까지 알아보지 못할 수 있습니다.

System default는 언어 이름이 아니라 앱 동작을 설명하므로 번역합니다. 일반적인 번역 변경으로 나중에 라벨이 달라지지 않도록 두 카탈로그의 자칭어를 테스트로 고정했습니다.

3. 영어 카탈로그를 TypeScript 계약으로 사용합니다

영어 카탈로그는 이미 앱의 전체 문구 목록에 가장 가까웠습니다. 그래서 영어 키를 모든 지원 언어가 따라야 하는 계약으로 삼았습니다.

export const enMessages = {
  "settings.language.title": "Language",
  "settings.language.english": "English",
  "settings.language.japanese": "日本語",
} as const;

export type MessageKey = keyof typeof enMessages;

const catalogs = {
  en: enMessages,
  ja: jaMessages,
} as const satisfies Record<AppLocale, Record<MessageKey, string>>;

카탈로그 계약은 개발 중 흔히 생기는 두 가지 실수를 잡습니다. 컴포넌트는 오타가 있거나 존재하지 않는 키를 요청할 수 없고, 각 언어는 영어에 있는 키를 빠뜨릴 수 없습니다. 영어 메시지를 추가하고 일본어 항목을 넣지 않으면 카탈로그를 바꾼 곳 근처에서 타입 검사가 실패합니다.

타입이 있는 키가 번역 품질까지 보장하지는 않습니다. 하지만 언어가 둘이고 메시지 구조가 단순한 앱에서는 구조적인 완성도를 평범한 컴파일러 검사로 바꾸는 것만으로 충분했습니다.

4. 런타임 폴백과 완성도 검사를 함께 둡니다

컴파일 시점의 완성도 검사가 런타임 폴백을 대신하지는 않습니다. 오래된 로컬 데이터, 예상하지 못한 빌드 불일치, 나중에 추가할 동적 카탈로그 때문에 값이 없을 수 있습니다. 주변 화면을 망가뜨리는 것보다 영어 메시지를 반환하는 편이 안전합니다.

그렇다고 폴백이 끝나지 않은 작업을 숨기게 하지는 않았습니다. 타입 검사는 모든 카탈로그에 같은 키 집합을 요구하고, 테스트는 대표 메시지, 값 치환, 바뀌면 안 되는 라벨을 비교합니다. 런타임 복원력과 개발 중 피드백은 서로 다른 문제를 해결하므로 둘 다 유지합니다.

문자열 보간은 {count} 같은 간단한 이름 있는 값으로 제한했습니다. 복수형 규칙, 문법적 성, 복잡한 메시지 선택이 필요해지면 ICU MessageFormat이나 전용 라이브러리를 쓸 근거가 됩니다. 문구에 필요하지 않은 기능을 미리 넣으면 현재 문제를 해결하지 못한 채 시스템만 살펴보기 어려워집니다.

5. 날짜와 숫자는 메시지 카탈로그 밖에서 형식을 지정합니다

라벨만 번역하고 날짜와 숫자가 다른 문화권의 표기 방식을 따르면 인터페이스가 현지에 맞게 느껴지지 않습니다. 컴포넌트는 실제 적용된 앱 locale을 받고, 그에 맞는 BCP 47 태그를 플랫폼 형식 지정 도구에 전달합니다.

const getIntlLocale = (locale: AppLocale) => (locale === "ja" ? "ja-JP" : "en-US");

const label = new Intl.DateTimeFormat(getIntlLocale(locale), {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
}).format(date);

Intl.DateTimeFormatIntl.NumberFormat을 메시지 함수 밖에 두면 카탈로그가 범용 형식 지정 계층으로 커지는 일을 막을 수 있습니다. 날짜, 시간, 파일 크기, 개수를 표시하는 곳에서 locale 의존성도 바로 드러납니다.

화면 표시용 형식과 기계가 읽는 출력 형식은 분리해야 합니다. 현지화한 날짜에는 파일명에 어울리지 않는 슬래시나 다른 문자가 들어갈 수 있습니다. 내보낸 파일명에는 예측할 수 있고 안전한 형식을 사용하며, 현지 형식은 사람에게 보여 주는 텍스트에만 씁니다.

6. 렌더러의 선택을 메인 프로세스와 동기화합니다

Electron 앱에는 문구를 보여 주는 곳이 둘 이상 있습니다. React가 인터페이스 대부분을 렌더링하지만, 네이티브 대화상자와 캡처 관련 안내, 운영체제와 상호작용하는 화면은 메인 프로세스가 담당합니다. 렌더러 카탈로그가 끝났다고 앱 전체의 현지화가 끝난 것은 아닙니다.

렌더러는 타입이 있는 IPC 엔드포인트를 통해 저장된 설정과 실제 적용된 locale을 모두 보냅니다.

type AppLocaleSyncRequest = {
  preference: "system" | "en" | "ja";
  locale: "en" | "ja";
};

await window.electron.appLocale.syncPreference({
  preference,
  locale,
});

두 값을 모두 보내면 각각의 의미가 유지됩니다. 메인 프로세스는 실제 적용된 locale을 즉시 사용하면서도, 해당 값이 macOS에서 왔는지 직접 선택한 값인지 알 수 있습니다. 첫 동기화 전에는 Electron의 app.getLocale()에서 보수적인 기본값을 구하고, 조회할 수 없으면 영어로 돌아갑니다.

provider가 마운트될 때와 창이 다시 포커스를 얻을 때 동기화합니다. 두 번째 동기화는 개발 중에 유용합니다. 메인 프로세스를 새로 불러오면 메모리의 문맥은 사라지지만 렌더러는 계속 실행될 수 있기 때문입니다. IPC 실패는 렌더러를 막지 않습니다. 다음 동기화 전까지 네이티브 문구만 안전한 폴백을 사용합니다.

렌더러에서 메인 프로세스로 동기화하면서 완료 기준도 달라졌습니다. 이제 React 문구, 메인 프로세스 문구, macOS가 제공하는 인터페이스 텍스트를 별도 영역으로 검토합니다. 현지화된 설정 화면이 렌더러 밖의 영어 종료 확인창을 가릴 수는 없습니다.

경계를 지키는 테스트

첫 테스트 모음은 번역된 모든 화면의 스냅샷보다 상태와 프로세스 경계에 집중합니다. 다음을 검증합니다.

  1. ja-JP가 지원되는 시스템 선호 목록의 첫 언어일 때 일본어로 결정됩니다.
  2. 사용자가 직접 고른 언어가 시스템 언어보다 우선합니다.
  3. 저장된 값이 없거나 지원되지 않으면 안전하게 시스템 설정으로 돌아갑니다.
  4. 모든 locale이 영어 카탈로그의 키 집합을 충족합니다.
  5. 두 인터페이스에서 English日本語가 바뀌지 않습니다.
  6. 날짜와 숫자가 실제 적용된 앱 locale을 사용합니다.
  7. 메인 프로세스가 언어 설정과 실제 적용된 locale을 모두 받습니다.

컴포넌트 테스트도 중요한 렌더링 상태를 다루지만, 이런 작은 테스트로 동작을 정의하는 함수 가까이에서 대부분의 회귀를 잡을 수 있습니다. 모든 변경 때 실행하기에도 부담이 적습니다.

작은 i18n 계층에도 분명한 경계가 필요합니다

이 설계에서 중요한 점은 코드 양이 아닙니다. 언어 설정과 locale, 복구와 완성도, 메시지와 형식 지정, 렌더러와 메인 프로세스를 나눈 경계입니다.

이 경계 덕분에 나중에 각 부분을 따로 교체할 수 있습니다. 세 번째나 네 번째 언어를 추가하면 자동 생성 카탈로그가 필요할 수 있습니다. 문법이 더 복잡해지면 ICU 메시지가 필요할 수 있습니다. 어느 경우에도 저장된 언어 설정의 의미나 네이티브 문구를 어느 프로세스가 소유하는지를 다시 정의할 필요는 없습니다.

Mac 앱인 Shotomatic의 Action Capture 기능에 일본어를 추가하면서 이 i18n 계층을 만들었습니다. 위 구현 예시는 출시된 앱 코드를 단순화했지만, 상태 모델과 프로세스 경계는 실제 사용하는 코드와 같습니다.

관련 글

글 더 보기

클릭 기록을 알아보기 쉬운 단계별 가이드로 정리하세요

Action Capture는 클릭을 순서대로 기록합니다. 기록한 단계를 Mac에서 편집해 가이드로 내보낼 수 있습니다.

작은 Electron i18n 계층을 관리하기 쉽게 만든 여섯 가지 결정 | 블로그 | Shotomatic