모바일 다국어 지원: iOS/Android 현지화(i18n/l10n) 전략

모바일 개발

다국어현지화i18niOSAndroid

이 글은 누구를 위한 것인가

  • 앱을 글로벌 시장에 출시하려는 팀
  • iOS String Catalog와 Android strings.xml 복수형 처리를 이해하려는 개발자
  • 번역 워크플로우 자동화로 팀 부담을 줄이고 싶은 팀

들어가며

현지화는 언어 번역 그 이상이다. 날짜 형식("2026년 4월 23일" vs "April 23, 2026"), 통화 기호, RTL(오른쪽→왼쪽) 레이아웃, 복수형("1 item" vs "2 items")까지 모두 고려해야 한다.

이 글은 bluefoxdev.kr의 모바일 다국어 지원 가이드 를 참고하여 작성했습니다.


1. 현지화 아키텍처와 함정

[현지화 구성 요소]

문자열 (String):
  iOS: Localizable.xcstrings (String Catalog, Xcode 15+)
  Android: res/values-ko/strings.xml, values-en/strings.xml

복수형:
  iOS: StringsDict 또는 String Catalog plural
  Android: <plurals> 태그 (zero/one/two/few/many/other)
  언어별로 규칙이 다름 (한국어는 단수/복수 구분 없음)

날짜/시간:
  절대 하드코딩 금지
  iOS: DateFormatter, FormatStyle
  Android: DateTimeFormatter, DateFormat

통화/숫자:
  iOS: NumberFormatter, .currency 스타일
  Android: NumberFormat, Currency

[RTL 지원 (아랍어, 히브리어)]
  iOS: semanticContentAttribute = .forceRightToLeft
  Android: android:supportsRtl="true" + layoutDirection
  레이아웃: Leading/Trailing (Left/Right 대신)
  이미지: 화살표, 방향 아이콘 미러링 필요

[흔한 함정]
  텍스트 길이: 독일어는 영어보다 30% 길다 → 레이아웃 유연하게
  문화적 색상: 빨강 = 위험(서양) vs 행운(중국)
  날짜 순서: MM/DD/YY (미국) vs DD/MM/YY (유럽)
  전화번호 형식: 국가별 완전히 다름

2. iOS String Catalog와 Android 현지화 구현

// iOS - String Catalog (Xcode 15+)
// Localizable.xcstrings 파일이 자동으로 관리됨

// SwiftUI에서 사용
struct ProductView: View {
    let product: Product
    let itemCount: Int
    
    var body: some View {
        VStack(alignment: .leading) {
            // 기본 문자열 (자동으로 String Catalog에서 찾음)
            Text("product.detail.title")
            
            // String Interpolation
            Text("product.price \(product.price, format: .currency(code: Locale.current.currency?.identifier ?? "KRW"))")
            
            // 복수형 처리
            Text("cart.item.count \(itemCount)")
            // String Catalog에서: one = "%lld item", other = "%lld items"
        }
    }
}

// 날짜 포맷 현지화
struct OrderView: View {
    let orderDate: Date
    
    var body: some View {
        VStack {
            // 현지화된 날짜 표시
            Text(orderDate, format: .dateTime.year().month().day())
            // 한국: 2026년 4월 23일, 미국: April 23, 2026
            
            Text(orderDate, format: .dateTime.hour().minute())
            // 24시간/12시간 자동 처리
            
            // 상대적 시간
            Text(orderDate, format: .relative(presentation: .named))
            // "2시간 전", "2 hours ago"
        }
    }
}

// 현지화 유틸리티
enum LocalizationManager {
    // 통화 포맷
    static func formatCurrency(_ amount: Decimal, currencyCode: String = "KRW") -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.currencyCode = currencyCode
        formatter.locale = Locale.current
        return formatter.string(from: amount as NSDecimalNumber) ?? "\(amount)"
    }
    
    // 숫자 포맷 (천단위 구분자)
    static func formatNumber(_ number: Int) -> String {
        number.formatted(.number)
        // 한국: 1,234,567 / 독일: 1.234.567
    }
    
    // 동적 언어 전환 (iOS 13+)
    static func changeLanguage(to languageCode: String) {
        UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
        UserDefaults.standard.synchronize()
        // 앱 재시작 필요 또는 Bundle 교체 방식 사용
    }
    
    // RTL 여부 확인
    static var isRTL: Bool {
        UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft
    }
}

// RTL 대응 레이아웃
struct MessageBubble: View {
    let message: String
    let isFromMe: Bool
    
    var body: some View {
        HStack {
            if isFromMe { Spacer() }
            
            Text(message)
                .padding(10)
                .background(isFromMe ? Color.blue : Color.gray.opacity(0.2))
                .foregroundColor(isFromMe ? .white : .primary)
                .cornerRadius(12)
                // Left/Right 대신 Leading/Trailing 사용 → RTL 자동 반전
                .padding(isFromMe ? .leading : .trailing, 50)
            
            if !isFromMe { Spacer() }
        }
    }
}

// 번역 키 자동 추출 스크립트 (Fastlane)
// lane :extract_strings do
//   sh "xcodebuild -exportLocalizations -localizationPath ./Localizations -project App.xcodeproj"
// end
// Android - 다국어 현지화

// res/values/strings.xml (기본, 영어)
// res/values-ko/strings.xml (한국어)
// res/values-ar/strings.xml (아랍어, RTL)
// res/values-de/strings.xml (독일어)

// strings.xml 예시
/*
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="product_price">Price: %1$s</string>
    <string name="cart_title">Shopping Cart</string>
    
    <!-- 복수형 -->
    <plurals name="cart_item_count">
        <item quantity="one">%d item</item>
        <item quantity="other">%d items</item>
    </plurals>
    
    <!-- 한국어 (values-ko/strings.xml) -->
    <!-- 복수형 구분 없음 (other만 사용) -->
    <!-- <plurals name="cart_item_count">
         <item quantity="other">%d개</item>
    </plurals> -->
</resources>
*/

// Compose에서 현지화 사용
@Composable
fun ProductScreen(product: Product, cartCount: Int) {
    Column {
        // 기본 문자열
        Text(stringResource(R.string.cart_title))
        
        // 파라미터가 있는 문자열
        val price = NumberFormat.getCurrencyInstance(Locale.getDefault())
            .format(product.price)
        Text(stringResource(R.string.product_price, price))
        
        // 복수형
        Text(pluralStringResource(R.plurals.cart_item_count, cartCount, cartCount))
    }
}

// 날짜/시간 현지화
fun formatDate(date: LocalDate): String {
    val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
        .withLocale(Locale.getDefault())
    return date.format(formatter)
    // 한국: 2026년 4월 23일, 영어: April 23, 2026
}

fun formatCurrency(amount: Double, currencyCode: String = "KRW"): String {
    val formatter = NumberFormat.getCurrencyInstance(Locale.getDefault())
    formatter.currency = Currency.getInstance(currencyCode)
    return formatter.format(amount)
}

// RTL 지원 (AndroidManifest.xml)
// <application android:supportsRtl="true">

// Compose RTL 자동 지원
// start/end 패딩 사용 → RTL에서 자동 반전
@Composable
fun MessageBubble(message: String, isFromMe: Boolean) {
    Row {
        if (isFromMe) Spacer(Modifier.weight(1f))
        Box(
            Modifier
                .background(if (isFromMe) Color.Blue else Color.LightGray, RoundedCornerShape(12.dp))
                .padding(start = if (!isFromMe) 0.dp else 50.dp, end = if (isFromMe) 0.dp else 50.dp)
        ) {
            Text(message, modifier = Modifier.padding(10.dp))
        }
        if (!isFromMe) Spacer(Modifier.weight(1f))
    }
}

// 번역 자동화 - Google ML Kit Translation
val translator = Translation.getClient(
    TranslatorOptions.Builder()
        .setSourceLanguage(TranslateLanguage.ENGLISH)
        .setTargetLanguage(TranslateLanguage.KOREAN)
        .build()
)

// 번역 모델 다운로드 후 사용 (오프라인 가능)
fun translateText(text: String, onResult: (String) -> Unit) {
    translator.downloadModelIfNeeded()
        .addOnSuccessListener {
            translator.translate(text)
                .addOnSuccessListener { translatedText -> onResult(translatedText) }
        }
}
#!/bin/bash
# 번역 워크플로우 자동화

# 1. iOS: 번역할 문자열 추출
xcodebuild -exportLocalizations \
    -localizationPath ./Localizations \
    -project App.xcodeproj \
    -exportLanguage en

# 2. XLIFF 파일 번역 API로 전송 (DeepL/Google)
# Python 스크립트로 XLIFF 파싱 후 번역

# 3. 번역 완료된 XLIFF 임포트
xcodebuild -importLocalizations \
    -localizationPath ./Localizations/ko.xcloc \
    -project App.xcodeproj

# Android: 번역 추출 (Google Play 앱 번역 서비스)
# Android Studio > Tools > Google Play Developer API
# 또는 fastlane supply로 자동화