20개 이상의 언어를 위한 React Native 앱 구축: i18n 교훈

발행: (2026년 2월 24일 오전 09:13 GMT+9)
15 분 소요
원문: Dev.to

Source: Dev.to

모바일 앱에서 20개가 넘는 언어를 지원하는 것은 체크리스트 항목이 아니라, UI 레이아웃, 타이포그래피, 데이터 저장, API 설계, 그리고 릴리스 워크플로우 등 스택의 모든 계층에 영향을 미치는 지속적인 엔지니어링 약속입니다.

아래는 다국어 지원을 폭넓게 구현한 언어 학습 앱을 만들면서 배운 핵심 교훈들입니다.

i18n 라이브러리 선택

React Native에서 주요 옵션은 다음과 같습니다:

라이브러리주요 특징크기 (gzipped)
i18next + react‑i18next전체 기능(네임스페이스, 복수형, 인터폴레이션, 언어 감지)~20 KB
react‑native‑localize + custom solution낮은 수준, 더 많은 제어; 간단한 요구에 적합
expo‑localizationExpo 관리 워크플로에 이상적; 베어 RN에서는 제한적

나는 i18nextreact‑i18next를 선택했습니다. 네임스페이스 지원은 번역 파일이 ~200개의 키를 초과할 때 중요합니다 – 기능별(온보딩, 설정, 레슨, 오류)로 분할하면 파일을 관리하기 쉬워지고 지연 로딩이 가능해집니다.

// i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { getLocales } from 'expo-localization';

i18n
  .use(initReactI18next)
  .init({
    resources: {
      en: { translation: require('./locales/en.json') },
      es: { translation: require('./locales/es.json') },
      // …
    },
    lng: getLocales()[0].languageCode ?? 'en',
    fallbackLng: 'en',
    interpolation: { escapeValue: false },
  });

Source:

텍스트 확장: 레이아웃 킬러

영어는 가장 간결한 문자 언어 중 하나입니다. UI 문자열을 독일어, 핀란드어, 포르투갈어 등으로 번역하면 버튼과 라벨이 넘칠 수 있습니다.

확장 비율 (영어 대비)

언어평균 텍스트 확장
German+25 % – 35 %
Finnish+25 % – 30 %
Portuguese+20 % – 30 %
Spanish+15 % – 25 %
French+15 % – 20 %
Japanese–15 % – 25 % (보통 짧음)
Chinese–30 % – 40 %

일반적인 실패 사례

  • 버튼이 두 줄로 감김
  • 네비게이션 라벨이 잘림
  • 테이블 셀에서 오버플로우 발생
  • 입력 필드의 플레이스홀더 텍스트가 잘림

위험을 완화하는 방법

  • 처음부터 최악의 경우(독일어) 텍스트를 기준으로 디자인한다.
  • 모든 UI 문자열은 해당 컴포넌트가 완성되었다고 간주되기 전에 독일어 번역으로 테스트해야 한다는 규칙을 적용한다 – 라틴 문자 언어 중 독일어가 가장 긴 문자열을 일관되게 만든다.

실용적인 코드 예시

// ❌ Bad: fixed‑width button
<Button style={{ width: 120 }}>
  {t('save_button')}
</Button>
// ✅ Good: minimum width with flexible growth
<Button style={{ minWidth: 120, paddingHorizontal: 16 }}>
  {t('save_button')}
</Button>

adjustsFontSizeToFit에 적절한 minimumFontScale을 지정하면 레이아웃을 깨뜨리지 않고 대부분의 오버플로우 상황을 처리할 수 있습니다. 버튼의 경우 고정된 너비보다 paddingHorizontal을 사용하는 것이 좋습니다.

오른쪽‑왼쪽 레이아웃: 아랍어 및 히브리어에선 선택 사항이 아님

아랍어(북아프리카 및 중동에서 표준)와 히브리어는 오른쪽‑왼쪽(RTL) 스크립트입니다. 이를 지원하려면 전역 RTL 플래그가 필요하며, 컴포넌트별 조정이 아닙니다.

import { I18nManager } from 'react-native';
import * as Updates from 'expo-updates';

async function activateRTL() {
  if (!I18nManager.isRTL) {
    I18nManager.forceRTL(true);
    await Updates.reloadAsync(); // app restart required
  }
}

주의사항

  • RTL 스위치를 켜면 앱이 재로드됩니다 – 실시간으로 토글할 수 없습니다.
  • 모든 서드파티 컴포넌트가 RTL 플래그를 따르는 것은 아니며, 사용자 정의 아이콘(chevrons, back arrows)은 수동으로 미러링해야 합니다.
  • 아랍어 텍스트 안의 숫자는 여전히 왼쪽‑오른쪽으로 표시됩니다; 혼합 방향성은 명시적인 bidi 제어 문자가 필요할 수 있습니다.
  • 항상 실제 디바이스에서 테스트하세요 – 시뮬레이터에서의 RTL 렌더링은 과거에 여러 엣지 케이스를 보여왔습니다.

Source:

Font Support: The CJK Problem

React Native의 기본 폰트 스택은 라틴어, 키릴 문자, 그리스어를 잘 지원합니다. CJK(중국어, 일본어, 한국어)의 경우 시스템 폰트를 사용합니다:

  • iOS – 안정적 (PingFang SC/TC, Hiragino Sans)
  • Android – 일관성 없음; 제조사와 OS 버전에 따라 다름

언어 학습 앱에서는 렌더링 품질이 가독성에 직접적인 영향을 미치므로 보장된 폰트가 필요합니다.

Solutions

  1. CJK 폰트 번들링(예: Noto Sans CJK, Source Han Sans). APK 용량이 2–5 MB 정도 증가할 것으로 예상됩니다.
  2. 관리형 Expo 앱의 경우 @expo-google-fonts/noto-sans-sc(및 유사 패키지)를 사용합니다.

현재 언어에 맞는 폰트 패밀리를 자동으로 선택하는 언어‑인식 Text 래퍼를 만듭니다:

// components/LText.tsx
import { Text, TextProps } from 'react-native';
import { useLanguage } from '../hooks/useLanguage';

const FONT_MAP: Record<string, string> = {
  zh: 'NotoSansSC',
  ja: 'NotoSansJP',
  ko: 'NotoSansKR',
  ar: 'NotoSansArabic',
  default: 'System',
};

export function LText({ style, ...props }: TextProps) {
  const { language } = useLanguage();
  const fontFamily = FONT_MAP[language] ?? FONT_MAP.default;
  return <Text style={[{ fontFamily }, style]} {...props} />;
}

이제 UI 텍스트는 현재 언어에 맞는 올바른 폰트를 자동으로 사용합니다.

복수형: 단순히 “0, 1, many”가 아니다

영어에는 두 가지 복수형(단수와 복수)만 있습니다. 많은 언어는 더 많은 형태를 가지고 있습니다:

언어복수형 형태 수
러시아어4 (one, few, many, other)
아랍어6
폴란드어4 (러시아어와 다른 규칙)

i18next는 CLDR(공통 로케일 데이터 저장소) 규칙을 따르며 _zero, _one, _two, _few, _many, _other와 같은 접미사를 사용합니다.

예시 – 러시아어 복수형

// locales/ru.json
{
  "items_count_one": "{{count}} элемент",
  "items_count_few": "{{count}} элемента",
  "items_count_many": "{{count}} элементов",
  "items_count_other": "{{count}} элемента"
}

코드에서 사용:

import { useTranslation } from 'react-i18next';

function ItemCounter({ count }: { count: number }) {
  const { t } = useTranslation();
  return <Text>{t('items_count', { count })}</Text>;
}

i18nextcount 값과 현재 로케일의 복수 규칙에 따라 올바른 키를 자동으로 선택합니다.

TL;DR 체크리스트

항목
i18n 라이브러리i18next + react-i18next 사용; 네임스페이스별로 번역을 구성합니다.
레이아웃독일어 길이 문자열을 기준으로 디자인; 고정 너비를 피하고 adjustsFontSizeToFit을 사용합니다.
RTLI18nManager.forceRTL(true) 호출 후 앱을 재시작; 실제 디바이스에서 테스트합니다.
폰트신뢰할 수 있는 CJK 폰트를 번들에 포함하거나 Expo Google Fonts 사용; Text를 래핑해 폰트 패밀리를 전환합니다.
복수형i18next의 CLDR 기반 복수 규칙을 활용; 언어별로 필요한 모든 키를 제공합니다.

국제화를 처음부터 핵심 요소로 다루면 비용이 많이 드는 사후 작업을 피하고 전 세계 사용자에게 다듬어진 경험을 제공할 수 있습니다.

i18next에서 복수형 처리

{
  "items_count_zero": "{{count}} предметов",
  "items_count_one": "{{count}} предмет",
  "items_count_few": "{{count}} предмета",
  "items_count_many": "{{count}} предметов",
  "items_count_other": "{{count}} предмета"
}

함정

  • JavaScript의 Intl.PluralRules는 i18next 외부에서 런타임 복수형 처리를 위한 좋은 친구입니다.
  • 가능하면 번역된 문자열에 숫자를 삽입하지 마세요. UI가 숫자와 복수형 명사를 별도로 조합하도록 하세요.
  • 날짜와 숫자 형식은 로케일에 따라 다릅니다. Intl.DateTimeFormatIntl.NumberFormat을 사용하세요 — 구분자를 직접 하드코딩하지 마세요.

번역 워크플로우: 인간 문제

기술적인 i18n은 쉬운 편이다. 20개 이상의 언어에 대한 번역을 관리하는 것은 운영상의 큰 도전이다.

소규모에서 효과적인 워크플로우

  1. 소스 문자열은 영어만 사용 – 번역된 번역을 다시 번역하지 않는다. 전화 게임식 오류가 누적된다.
  2. 자동 키 추출i18next-parser가 코드베이스를 스캔해 번역가에게 제공할 키‑전용 JSON을 생성한다.
  3. 번역 메모리 – Weblate, Crowdin, 혹은 번역 메모리 스크립트가 포함된 공유 Google Sheet와 같은 도구를 사용하면 비용을 크게 절감하고 일관성을 높일 수 있다.
  4. 기계 번역 1차 + 인간 검수 – 유럽 언어는 DeepL, 아시아 언어는 Google Cloud Translation을 활용한다. 인간 검수가 관용구 오류와 문맥 불일치를 잡아낸다.
  5. 번역가를 위한 스크린샷 컨텍스트 – “back” 같은 문자열은 UI를 보지 않으면 애매하다. Crowdin의 인‑컨텍스트 에디터나 자동 스크린샷 생성 도구를 사용하면 모호성을 없앨 수 있다.

가장 안 좋은 결과는 일관성 없는 용어 사용이다 — 같은 개념에 대해 화면마다 서로 다른 세 단어가 쓰이는 경우가 발생한다. 이는 세 명의 번역가가 각각 다른 화면을 작업하면서 용어집이 없었기 때문이다. 초기 단계에서 용어집을 만들고 이를 강제 적용하라.

Performance: Lazy‑Loading Locales

Bundling 20+ locale files adds up. At even 50 KB per language, 20 languages is ~1 MB of translation JSON loaded at startup — most of which the user never needs.

Lazy‑loading solution

i18n.use(initReactI18next).init({
  partialBundledLanguages: true,
  resources: {
    en: { translation: require('./locales/en.json') }, // bundle default
  },
  backend: {
    loadPath: `${FileSystem.documentDirectory}locales/{{lng}}/{{ns}}.json`,
  },
});

// On language change:
async function switchLanguage(lng: string) {
  await downloadLocaleIfNeeded(lng); // fetch from CDN, write to FileSystem
  await i18n.changeLanguage(lng);
}

Trade‑off – first‑launch latency on a language change. Accept a loading state the first time a non‑bundled language is selected; subsequent loads are instant from the filesystem cache.

테스트 문제

대부분의 프로젝트에서 i18n 자동 테스트는 충분히 투자되지 않고 있습니다. 최소한으로 구현할 수 있는 접근 방식:

  • 스냅샷 테스트를 각 로케일마다 실행해 레이아웃 회귀를 잡아냅니다.
  • 문자열 길이 테스트 – UI에 중요한 문자열이 최대 길이를 초과하지 않는지 검증합니다.
  • RTL 스모크 테스트 – 아랍어로 전환하고 주요 네비게이션 흐름이 깨지지 않는지 확인하는 단일 E2E 테스트를 수행합니다.

누락된 번역 린팅 – 영어 로케일에 존재하는 키가 다른 로케일에 없을 경우 CI 단계에서 실패하도록 합니다.

# CI step using i18next-parser output
for lang in es fr de ja zh ar ko; do
  node scripts/check-missing-keys.js --base en --target $lang
done

i18n 부채는 대부분의 기술 부채보다 더 빠르게 쌓입니다. 프로덕션이 아니라 CI에서 누락된 번역을 잡아내는 것이 설정 비용을 감수할 가치가 있습니다.

저는 iOS용 AI 기반 언어 튜터인 Pocket Linguist를 만들고 있습니다. 이 앱은 간격 반복, 카메라 번역, 대화형 AI를 활용해 더 빠르게 회화 수준의 유창함에 도달하도록 도와줍니다. 무료로 체험해 보세요.

0 조회
Back to Blog

관련 글

더 보기 »