Skip to main content

Kotlin Multiplatform i18n: 플랫폼 간 공유 현지화

공유 Kotlin 코드에서 번역을 한 번 작성하고 올바른 로케일 폴백 체인과 함께 Android, iOS, 웹에 배포하세요.

1

KMP i18n용 Gradle 구성

공유 모듈의 commonMain 소스 세트에 i18n 종속성을 추가하세요. XML 기반 문자열에는 moko-resources, 형식 안전한 Compose 문자열에는 Lyricist를 선택하거나 둘 다 사용할 수 있어요. kmp-localechain은 두 라이브러리 위에 스마트 로케일 폴백을 추가해요.

build.gradle.kts
// build.gradle.kts (shared module)
plugins {
    kotlin("multiplatform")
    id("com.android.library")
}

kotlin {
    androidTarget()
    iosArm64()
    iosSimulatorArm64()
    js(IR) { browser(); nodejs() }

    sourceSets {
        val commonMain by getting {
            dependencies {
                // Option A: moko-resources (code-gen from XML)
                implementation("dev.icerock.moko:resources:0.24.4")

                // Option B: Lyricist (type-safe Compose strings)
                implementation("cafe.adriel.lyricist:lyricist:1.7.0")

                // Locale fallback chains (works with any library)
                implementation("com.i18nagent:locale-chain-kmp:0.1.0")
            }
        }
    }
}
세 라이브러리 모두 Maven Central에 게시돼요. 모든 대상(Android, iOS, JS)에서 사용할 수 있도록 commonMain 종속성에 추가하세요.
2

공유 문자열 형식 정의

번역 가능한 모든 문자열을 담는 Kotlin 데이터 클래스를 commonMain에 만드세요. 이 클래스가 단일 기준 원본이 되어 모든 플랫폼이 동일한 형식 안전 정의를 읽어요. 문자열 파일이 중복되거나 플랫폼 간 불일치가 생기지 않아요.

commonMain/.../Strings.kt
// commonMain/kotlin/com/myapp/Strings.kt
package com.myapp.i18n

/**
 * Shared string definitions — the single source of truth.
 * Each platform reads from the same keys.
 */
data class AppStrings(
    val greeting: String,
    val farewell: String,
    val itemCount: (count: Int) -> String,
    val nav: NavStrings,
)

data class NavStrings(
    val home: String,
    val settings: String,
    val about: String,
)

// English defaults
val EnStrings = AppStrings(
    greeting = "Hello!",
    farewell = "Goodbye!",
    itemCount = { count ->
        if (count == 1) "$count item" else "$count items"
    },
    nav = NavStrings(
        home = "Home",
        settings = "Settings",
        about = "About",
    ),
)

// Japanese
val JaStrings = AppStrings(
    greeting = "こんにちは!",
    farewell = "さようなら!",
    itemCount = { count -> "${count}個のアイテム" },
    nav = NavStrings(
        home = "ホーム",
        settings = "設定",
        about = "概要",
    ),
)

// Add more locales following the same pattern: DeStrings, EsStrings, etc.
별도 단수형/복수형 키 대신 복수형에 람다 속성을 사용하세요. 람다가 개수를 받아 올바른 형식을 반환해요. 컴파일러가 확인할 수 있도록 복수형 로직을 Kotlin에 유지해요.
3

Android 연결

androidMain에서 expect/actual 패턴을 구현해 java.util.Locale로 기기 로케일을 읽으세요. Android는 비즈니스 로직에 공유 Kotlin 문자열을 사용하면서 알림과 위젯 같은 시스템 UI 요소에는 표준 values/strings.xml을 사용할 수 있어요.

androidMain/.../StringProvider.kt
// androidMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import java.util.Locale

actual fun currentLocale(): String =
    Locale.getDefault().toLanguageTag()  // e.g. "pt-BR"

// Android can also use standard resources/values-*/strings.xml
// alongside the shared Kotlin definitions.
// Use shared strings for business logic, XML for system UI.

// In your Activity or Compose screen:
@Composable
fun GreetingScreen() {
    val strings = rememberStrings()  // resolves via locale
    Text(text = strings.greeting)
    Text(text = strings.itemCount(cartSize))
}
Locale.getDefault().toLanguageTag()는 "pt-BR" 같은 IETF 태그를 반환하지만 일부 Android 버전의 이전 API는 "pt-rBR"을 반환해요. 일관된 결과를 위해 항상 toLanguageTag()(API 21 이상)를 사용하세요.
4

iOS 연결

iosMain에서 Foundation의 NSLocale을 사용해 currentLocale()을 구현하세요. KMP 공유 프레임워크가 문자열 정의를 Swift로 내보내므로 SwiftUI 뷰에서 생성된 Kotlin 프레임워크를 통해 바로 호출할 수 있어요.

iosMain/.../StringProvider.kt
// iosMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import platform.Foundation.NSLocale
import platform.Foundation.currentLocale
import platform.Foundation.languageCode
import platform.Foundation.countryCode

actual fun currentLocale(): String {
    val locale = NSLocale.currentLocale
    val lang = locale.languageCode
    val country = locale.countryCode
    return if (country != null) "$lang-$country" else lang
}

// In SwiftUI (via KMP exported framework):
// let strings = StringProviderKt.stringsFor(locale: "ja")
// Text(strings.greeting)
KMP 프레임워크를 Xcode로 내보낼 때 문자열 제공자 모듈도 내보내세요. Podspec 또는 XCFramework 구성에 i18n 패키지를 포함해 Swift 코드에서 가져올 수 있게 하세요.
5

JS/브라우저 연결

jsMain에서 window.navigator.language로 브라우저 로케일을 읽으세요. Kotlin/JS 웹 앱과 Compose for Web 대상을 모두 지원해요. 같은 공유 문자열이 중복 없이 브라우저에 렌더링돼요.

jsMain/.../StringProvider.kt
// jsMain/kotlin/com/myapp/StringProvider.kt
package com.myapp.i18n

import kotlinx.browser.window

actual fun currentLocale(): String =
    window.navigator.language  // e.g. "en-US", "pt-BR"

// In a Kotlin/JS or Compose for Web app:
fun main() {
    val locale = currentLocale()
    val strings = stringsFor(locale)
    document.getElementById("greeting")?.textContent = strings.greeting
}
서버 측 Kotlin/JS(Node.js)에서는 window.navigator.language 대신 Accept-Language 헤더나 구성 변수에서 로케일을 읽으세요.
6

Compose Multiplatform용 Lyricist

Lyricist는 Compose 네이티브 i18n 방식을 제공해요. 문자열 객체에 @LyricistStrings 애너테이션을 적용하면 Lyricist가 CompositionLocal 제공자를 생성해요. languageTag를 바꿔 런타임에 언어를 전환하면 UI가 자동으로 재구성돼요.

Lyricist integration
// Using Lyricist for Compose Multiplatform
// build.gradle.kts
plugins {
    id("cafe.adriel.lyricist") version "1.7.0"
}

// Define strings with @LyricistStrings annotation
@LyricistStrings(languageTag = Locales.EN, default = true)
val EnStrings = Strings(
    greeting = "Hello!",
    farewell = "Goodbye!",
    itemCount = { count ->
        if (count == 1) "$count item" else "$count items"
    },
)

@LyricistStrings(languageTag = Locales.JA)
val JaStrings = Strings(
    greeting = "こんにちは!",
    farewell = "さようなら!",
    itemCount = { count -> "${count}個のアイテム" },
)

// In your Compose UI
@Composable
fun App() {
    // Lyricist provides the strings via CompositionLocal
    ProvideStrings {
        val lyricist = LocalStrings.current
        Text(text = lyricist.greeting)
    }
}

// Switch language at runtime
val lyricist = rememberLyricist(
    defaultLanguageTag = Locales.EN,
)
lyricist.languageTag = Locales.JA  // UI recomposes automatically
Lyricist는 문자열 보간, 람다를 통한 복수형, 중첩 문자열 그룹을 지원해요. Android, iOS(Compose for iOS 사용), Desktop, Web 대상에서 작동해요.
7

XML 문자열용 moko-resources

moko-resources는 Android 방식의 XML 문자열 파일을 기준 원본으로 사용하고 형식 안전한 접근자를 생성해요. commonMain/resources/MR/base/에 영어 문자열을 정의하고 언어마다 로케일 폴더를 추가하세요. 생성된 MR 객체는 컴파일 시 검사되는 접근자를 제공해요.

moko-resources setup
// Using moko-resources for XML-based string management
// build.gradle.kts
plugins {
    id("dev.icerock.mobile.multiplatform-resources") version "0.24.4"
}

multiplatformResources {
    resourcesPackage.set("com.myapp")
}

// commonMain/resources/MR/base/strings.xml (English - default)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="greeting">Hello!</string>
    <string name="farewell">Goodbye!</string>
    <plurals name="item_count">
        <item quantity="one">%d item</item>
        <item quantity="other">%d items</item>
    </plurals>
</resources>

// commonMain/resources/MR/ja/strings.xml (Japanese)
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="greeting">こんにちは!</string>
    <string name="farewell">さようなら!</string>
    <plurals name="item_count">
        <item quantity="other">%d個のアイテム</item>
    </plurals>
</resources>

// Usage in shared Kotlin code
val greeting = MR.strings.greeting.desc()
val items = MR.plurals.item_count.format(count)
moko-resources는 코드를 생성하는 Gradle 플러그인이 필요해요. 'Unresolved reference: MR'이 표시되면 먼저 Gradle 동기화를 실행하세요. IDE에서 MR 접근자를 보려면 코드 생성 단계가 완료되어야 해요.
8

kmp-localechain을 활용한 스마트 로케일 폴백

KMP i18n 라이브러리에는 구성 가능한 폴백 체인이 없어요. pt-BR 번역이 없으면 pt-PT를 완전히 건너뛰고 영어를 표시해요. kmp-localechain은 독립형 메시지 병합 유틸리티로 이 문제를 해결해요. 로케일별 플랫 Map&lt;String, String&gt; 메시지를 받아 폴백 체인 우선순위를 적용한 병합 맵을 반환해요.

LocaleChain usage
// Using kmp-localechain for smart locale fallback
import com.i18nagent.localechain.LocaleChain

// 1. Configure once at app startup
LocaleChain.configure()  // uses built-in fallback chains

// 2. Load your messages as flat maps
val messages = mapOf(
    "en" to mapOf("greeting" to "Hello", "farewell" to "Goodbye"),
    "pt" to mapOf("greeting" to "Olá", "farewell" to "Adeus"),
    "pt-PT" to mapOf("greeting" to "Olá (PT)"),
    "pt-BR" to mapOf("greeting" to "Oi"),
)

// 3. Resolve with chain priority
val resolved = LocaleChain.resolve("pt-BR", messages)
// "greeting" -> "Oi"       (from pt-BR, most specific)
// "farewell" -> "Adeus"    (from pt, next in chain)

// Without LocaleChain, pt-BR users would see English "Goodbye"
// because pt-BR has no "farewell" key.
Custom configuration
// Custom fallback configuration
LocaleChain.configure(
    defaultLocale = "en",
    overrides = mapOf(
        "es-MX" to listOf("es-419", "es"),
        "fr-CA" to listOf("fr"),
    )
)

// Inspect any chain
LocaleChain.chainFor("pt-BR")
// Returns: ["pt-BR", "pt-PT", "pt", "en"]

// Async resolve (lazy loading from network/disk)
val resolved = LocaleChain.resolve("pt-BR") { localeTag ->
    api.fetchMessages(localeTag)  // returns Map<String, String>?
}
kmp-localechain은 플랫 Map&lt;String, String&gt; 맵과 작동해요. 메시지가 중첩됐다면 resolve()에 전달하기 전에 평탄화하세요. 중첩 구조의 딥 머지는 지원하지 않아요.
9

번역 자동화

KMP i18n 설정을 마쳤다면 AI로 번역을 자동화하세요. Kotlin 데이터 클래스, XML 리소스, JSON 등 공유 문자열 파일을 IDE나 CI/CD 파이프라인에서 직접 번역하세요.

Terminal
# Translate your shared string files with i18n Agent
# Works with JSON, XML (moko-resources), or any i18n format

# From your IDE (Claude Code, Cursor, VS Code):
> Translate commonMain/resources/MR/base/strings.xml to Japanese, German, and Spanish

✓ MR/ja/strings.xml created (1.2s)
✓ MR/de/strings.xml created (1.1s)
✓ MR/es/strings.xml created (1.3s)

# Or use the CLI in CI/CD:
npx i18n-agent translate resources/base/strings.xml --lang ja,de,es
번역은 점진적으로 진행하세요. 영어 원문에 새 키를 추가하면 변경된 부분만 번역하세요. 사람이 검토한 번역을 유지하고 전체 파일을 다시 생성할 필요도 없어요.

번역 품질 자동화

i18n-validate를 사용해 누락된 키와 손상된 플레이스홀더를 배포 전에 찾아내세요. 실제 번역이 준비되기 전에 i18n-pseudo의 의사 번역으로 UI를 테스트하세요.

흔한 실수

expect/actual 불일치

commonMain의 모든 expect 선언에는 모든 대상(androidMain, iosMain, jsMain)의 actual 구현이 필요해요. 나중에 새 플랫폼 대상을 추가하면 actual을 제공할 때까지 컴파일러 오류가 발생해요. IDE 빠른 수정으로 스텁을 생성하세요.

하드코딩된 복수형 로직

단수형을 감지하는 데 count == 1을 사용하지 마세요. 프랑스어는 0을 단수로 취급하고 아랍어에는 복수형이 6가지예요. 러시아어는 끝자리가 1, 2-4, 5-20인 숫자에 서로 다른 형식을 사용해요. CLDR을 인식하는 라이브러리(moko-resources)나 로케일별 명시적 람다를 사용하세요.

kmp-localechain의 중첩 Map

kmp-localechain은 플랫 Map&lt;String, String&gt;에서 작동해요. 중첩 맵을 전달하면 폴백 해석이 내부 키를 올바르게 병합하지 못해요. resolve()를 호출하기 전에 "nav.home" 같은 점 표기법 키로 메시지를 평탄화하세요.

moko-resources 추가 후 생성 코드 누락

MR 객체는 Gradle 플러그인이 생성해요. moko-resources 문자열을 추가한 뒤 코드에서 MR.strings.*를 사용하기 전에 Gradle 동기화를 실행하세요. IDE에 계속 오류가 표시되면 Build > Rebuild Project를 시도하세요.

권장 프로젝트 구조

Project Structure
my-kmp-app/
├── shared/
│   ├── build.gradle.kts
│   └── src/
│       ├── commonMain/
│       │   ├── kotlin/com/myapp/i18n/
│       │   │   ├── Strings.kt           # Shared string definitions
│       │   │   ├── StringProvider.kt     # expect fun currentLocale()
│       │   │   └── LocaleSetup.kt       # LocaleChain configuration
│       │   └── resources/MR/            # moko-resources XML (optional)
│       │       ├── base/strings.xml     # English (default)
│       │       ├── ja/strings.xml
│       │       ├── de/strings.xml
│       │       └── es/strings.xml
│       ├── androidMain/
│       │   └── kotlin/com/myapp/i18n/
│       │       └── StringProvider.kt     # actual fun currentLocale()
│       ├── iosMain/
│       │   └── kotlin/com/myapp/i18n/
│       │       └── StringProvider.kt     # actual fun currentLocale()
│       └── jsMain/
│           └── kotlin/com/myapp/i18n/
│               └── StringProvider.kt     # actual fun currentLocale()
├── androidApp/
│   └── src/main/res/values/strings.xml   # Android-specific overrides
├── iosApp/
│   └── iosApp/Localizable.strings        # iOS-specific overrides
└── settings.gradle.kts

지금 i18n Agent 사용해 보기

번역 파일을 여기에 드롭

JSON, YAML, PO, XML, CSV, Markdown, Properties

또는 클릭하여 파일 선택

대상 언어

가입 불필요즉시 견적

자주 묻는 질문