Skip to content

Kotlin 멀티플랫폼 프로젝트 구조의 기본

Kotlin Multiplatform를 사용하면 여러 플랫폼 간에 코드를 공유할 수 있습니다. 이 글에서는 공유 코드의 제약 사항, 공유 코드와 플랫폼별 코드 부분을 구별하는 방법, 그리고 이 공유 코드가 작동하는 플랫폼을 지정하는 방법을 설명합니다.

또한, 공통 코드(common code), 타겟(targets), 플랫폼별 및 중간 소스 세트(platform-specific and intermediate source sets), 테스트 통합(test integration)과 같은 Kotlin Multiplatform 프로젝트 설정의 핵심 개념을 학습하게 됩니다. 이는 향후 멀티플랫폼 프로젝트를 설정하는 데 도움이 될 것입니다.

여기서 제시하는 모델은 Kotlin에서 사용되는 모델에 비해 단순화되었습니다. 하지만 이 기본적인 모델은 대부분의 경우에 적합할 것입니다.

공통 코드

공통 코드(Common code) 는 여러 플랫폼 간에 공유되는 Kotlin 코드입니다.

간단한 "Hello, World" 예시를 살펴보겠습니다.

kotlin
fun greeting() {
    println("Hello, Kotlin Multiplatform!")
}

플랫폼 간에 공유되는 Kotlin 코드는 일반적으로 commonMain 디렉터리에 위치합니다. 코드 파일의 위치는 이 코드가 컴파일되는 플랫폼 목록에 영향을 미치므로 중요합니다.

Kotlin 컴파일러(Kotlin compiler)는 소스 코드를 입력으로 받아 플랫폼별 바이너리(platform-specific binaries) 세트를 결과물로 생성합니다. 멀티플랫폼 프로젝트를 컴파일할 때, 동일한 코드에서 여러 바이너리를 생성할 수 있습니다. 예를 들어, 컴파일러는 동일한 Kotlin 파일에서 JVM .class 파일과 네이티브 실행 파일(native executable files)을 생성할 수 있습니다.

Common code

모든 Kotlin 코드가 모든 플랫폼으로 컴파일될 수 있는 것은 아닙니다. Kotlin 컴파일러는 공통 코드에서 플랫폼별 함수나 클래스를 사용하는 것을 방지합니다. 왜냐하면 이 코드는 다른 플랫폼으로 컴파일될 수 없기 때문입니다.

예를 들어, 공통 코드에서 java.io.File 종속성(dependency)을 사용할 수 없습니다. 이것은 JDK의 일부이지만, 공통 코드는 네이티브 코드(native code)로도 컴파일되며, 네이티브 코드에서는 JDK 클래스를 사용할 수 없기 때문입니다.

Unresolved Java reference

공통 코드에서는 Kotlin Multiplatform 라이브러리를 사용할 수 있습니다. 이러한 라이브러리는 여러 플랫폼에서 다르게 구현될 수 있는 공통 API를 제공합니다. 이 경우, 플랫폼별 API는 추가적인 부분으로 작용하며, 공통 코드에서 이러한 API를 사용하려고 하면 오류가 발생합니다.

예를 들어, kotlinx.coroutines는 모든 타겟을 지원하는 Kotlin Multiplatform 라이브러리이지만, fun CoroutinesDispatcher.asExecutor(): Executor와 같이 kotlinx.coroutines의 동시성 프리미티브(concurrent primitives)를 JDK 동시성 프리미티브로 변환하는 플랫폼별 부분도 가지고 있습니다. 이 추가 API 부분은 commonMain에서 사용할 수 없습니다.

타겟

타겟(Targets)은 Kotlin이 공통 코드를 컴파일하는 플랫폼을 정의합니다. 예를 들어, JVM, JS, Android, iOS 또는 Linux가 될 수 있습니다. 이전 예시는 공통 코드를 JVM 및 네이티브 타겟으로 컴파일했습니다.

Kotlin 타겟(Kotlin target) 은 컴파일 타겟을 설명하는 식별자(identifier)입니다. 이는 생성된 바이너리의 형식, 사용 가능한 언어 구성(language constructions) 및 허용되는 종속성을 정의합니다.

타겟은 플랫폼(platforms)으로도 지칭될 수 있습니다. 지원되는 타겟의 전체 목록은 여기에서 확인할 수 있습니다.

Kotlin이 특정 타겟에 대한 코드를 컴파일하도록 지시하려면 먼저 타겟을 _선언_해야 합니다. Gradle에서는 kotlin {} 블록 내에서 사전 정의된 DSL 호출을 사용하여 타겟을 선언합니다.

kotlin
kotlin {
    jvm() // Declares a JVM target
    iosArm64() // Declares a target that corresponds to 64-bit iPhones
}

이러한 방식으로 각 멀티플랫폼 프로젝트는 지원되는 타겟 세트를 정의합니다. 빌드 스크립트에서 타겟을 선언하는 방법에 대한 자세한 내용은 계층적 프로젝트 구조(Hierarchical project structure) 섹션을 참조하세요.

jvmiosArm64 타겟이 선언되면, commonMain의 공통 코드는 이 타겟들로 컴파일됩니다.

Targets

특정 타겟으로 어떤 코드가 컴파일될지 이해하려면, 타겟을 Kotlin 소스 파일에 첨부된 레이블로 생각할 수 있습니다. Kotlin은 이 레이블을 사용하여 코드를 컴파일하는 방법, 생성할 바이너리, 그리고 해당 코드에서 허용되는 언어 구성 및 종속성을 결정합니다.

greeting.kt 파일을 .js로도 컴파일하려면 JS 타겟을 선언하기만 하면 됩니다. 그러면 commonMain의 코드는 JS 타겟에 해당하는 추가 js 레이블을 받아, Kotlin이 .js 파일을 생성하도록 지시합니다.

Target labels

이것이 Kotlin 컴파일러가 선언된 모든 타겟으로 컴파일된 공통 코드와 작동하는 방식입니다. 플랫폼별 코드를 작성하는 방법을 배우려면 소스 세트를 참조하세요.

소스 세트

Kotlin 소스 세트(Kotlin source set) 는 자체 타겟, 종속성 및 컴파일러 옵션을 가진 소스 파일의 집합입니다. 이는 멀티플랫폼 프로젝트에서 코드를 공유하는 주요 방법입니다.

멀티플랫폼 프로젝트의 각 소스 세트는 다음을 가집니다.

  • 주어진 프로젝트에서 고유한 이름을 가집니다.
  • 일반적으로 소스 세트의 이름을 가진 디렉터리에 저장되는 소스 파일 및 리소스 집합을 포함합니다.
  • 이 소스 세트의 코드가 컴파일되는 타겟 집합을 지정합니다. 이 타겟들은 이 소스 세트에서 사용 가능한 언어 구성 및 종속성에 영향을 미칩니다.
  • 자체 종속성 및 컴파일러 옵션을 정의합니다.

Kotlin은 여러 개의 사전 정의된 소스 세트를 제공합니다. 그중 하나는 commonMain으로, 모든 멀티플랫폼 프로젝트에 존재하며 선언된 모든 타겟으로 컴파일됩니다.

Kotlin Multiplatform 프로젝트에서는 src 내부의 디렉터리 형태로 소스 세트와 상호 작용합니다. 예를 들어, commonMain, iosMain, jvmMain 소스 세트를 가진 프로젝트는 다음과 같은 구조를 가집니다.

Shared sources

Gradle 스크립트에서는 kotlin.sourceSets {} 블록 내부에서 이름으로 소스 세트에 접근합니다.

kotlin
kotlin {
    // Targets declaration:
    // …

    // Source set declaration:
    sourceSets {
        commonMain {
            // Configure the commonMain source set
        }
    }
}

commonMain 외에 다른 소스 세트는 플랫폼별 소스 세트 또는 중간 소스 세트가 될 수 있습니다.

플랫폼별 소스 세트

공통 코드만 사용하는 것이 편리하더라도, 항상 가능한 것은 아닙니다. commonMain의 코드는 선언된 모든 타겟으로 컴파일되며, Kotlin은 그곳에서 플랫폼별 API를 사용하는 것을 허용하지 않습니다.

네이티브(native) 및 JS 타겟을 가진 멀티플랫폼 프로젝트에서 commonMain의 다음 코드는 컴파일되지 않습니다.

kotlin
// commonMain/kotlin/common.kt
// 공통 코드에서는 컴파일되지 않음
fun greeting() {
    java.io.File("greeting.txt").writeText("Hello, Multiplatform!")
}

해결책으로, Kotlin은 플랫폼별 소스 세트(platform-specific source sets)를 생성하며, 이는 플랫폼 소스 세트(platform source sets)라고도 불립니다. 각 타겟은 해당 타겟만을 위해 컴파일되는 해당 플랫폼 소스 세트를 가집니다. 예를 들어, jvm 타겟은 JVM으로만 컴파일되는 해당 jvmMain 소스 세트를 가집니다. Kotlin은 이러한 소스 세트에서 플랫폼별 종속성(platform-specific dependencies)을 사용하는 것을 허용합니다. 예를 들어, jvmMain에서는 JDK를 사용할 수 있습니다.

kotlin
// jvmMain/kotlin/jvm.kt
// `jvmMain` 소스 세트에서 Java 종속성을 사용할 수 있습니다
fun jvmGreeting() {
    java.io.File("greeting.txt").writeText("Hello, Multiplatform!")
}

특정 타겟으로의 컴파일

특정 타겟으로의 컴파일은 여러 소스 세트와 함께 작동합니다. Kotlin이 멀티플랫폼 프로젝트를 특정 타겟으로 컴파일할 때, 해당 타겟으로 레이블이 지정된 모든 소스 세트를 수집하여 이들로부터 바이너리를 생성합니다.

jvm, iosArm64, js 타겟을 포함하는 예시를 살펴보겠습니다. Kotlin은 공통 코드를 위한 commonMain 소스 세트와 특정 타겟을 위한 해당 jvmMain, iosArm64Main, jsMain 소스 세트를 생성합니다.

Compilation to a specific target

JVM으로 컴파일하는 동안, Kotlin은 "JVM"으로 레이블이 지정된 모든 소스 세트, 즉 jvmMaincommonMain을 선택합니다. 그런 다음 이들을 함께 JVM 클래스 파일로 컴파일합니다.

Compilation to JVM

Kotlin이 commonMainjvmMain을 함께 컴파일하기 때문에, 결과 바이너리에는 commonMainjvmMain 모두의 선언이 포함됩니다.

멀티플랫폼 프로젝트를 사용할 때 다음 사항을 기억하세요.

  • Kotlin이 코드를 특정 플랫폼으로 컴파일하도록 하려면, 해당 타겟을 선언하세요.
  • 코드를 저장할 디렉터리 또는 소스 파일을 선택하려면, 먼저 어떤 타겟 간에 코드를 공유할지 결정하세요.
    • 코드가 모든 타겟 간에 공유되는 경우, commonMain에 선언해야 합니다.
    • 코드가 하나의 타겟에만 사용되는 경우, 해당 타겟의 플랫폼별 소스 세트(예: JVM의 경우 jvmMain)에 정의해야 합니다.
  • 플랫폼별 소스 세트에 작성된 코드는 공통 소스 세트의 선언에 접근할 수 있습니다. 예를 들어, jvmMain의 코드는 commonMain의 코드를 사용할 수 있습니다. 그러나 그 반대는 성립하지 않습니다: commonMainjvmMain의 코드를 사용할 수 없습니다.
  • 플랫폼별 소스 세트에 작성된 코드는 해당 플랫폼 종속성을 사용할 수 있습니다. 예를 들어, jvmMain의 코드는 Guava 또는 Spring과 같은 Java 전용 라이브러리를 사용할 수 있습니다.

중간 소스 세트

간단한 멀티플랫폼 프로젝트는 일반적으로 공통 코드와 플랫폼별 코드만 가집니다. commonMain 소스 세트는 선언된 모든 타겟 간에 공유되는 공통 코드를 나타냅니다. jvmMain과 같은 플랫폼별 소스 세트는 해당 타겟으로만 컴파일되는 플랫폼별 코드를 나타냅니다.

실제로, 더 세분화된 코드 공유가 필요한 경우가 많습니다.

모든 최신 Apple 기기와 Android 기기를 타겟팅해야 하는 예시를 살펴보겠습니다.

kotlin
kotlin {
    androidTarget()
    iosArm64()   // 64비트 iPhone 기기
    macosArm64() // 최신 Apple Silicon 기반 Mac
    watchosX64() // 최신 64비트 Apple Watch 기기
    tvosArm64()  // 최신 Apple TV 기기  
}

그리고 모든 Apple 기기를 위한 UUID를 생성하는 함수를 추가할 소스 세트가 필요하다고 가정해 봅시다.

kotlin
import platform.Foundation.NSUUID

fun randomUuidString(): String {
    // Apple별 API에 접근하려 합니다
    return NSUUID().UUIDString()
}

이 함수를 commonMain에 추가할 수 없습니다. commonMain은 Android를 포함한 모든 선언된 타겟으로 컴파일되지만, platform.Foundation.NSUUID는 Android에서는 사용할 수 없는 Apple별 API입니다. commonMain에서 NSUUID를 참조하려고 하면 Kotlin이 오류를 표시합니다.

이 코드를 각 Apple별 소스 세트인 iosArm64Main, macosArm64Main, watchosX64Main, tvosArm64Main에 복사하여 붙여넣을 수도 있습니다. 하지만 이와 같이 코드를 중복하는 것은 오류가 발생하기 쉬우므로 권장되지 않습니다.

이 문제를 해결하기 위해 중간 소스 세트(intermediate source sets) 를 사용할 수 있습니다. 중간 소스 세트는 프로젝트의 모든 타겟은 아니지만 일부 타겟으로 컴파일되는 Kotlin 소스 세트입니다. 중간 소스 세트는 계층적 소스 세트(hierarchical source sets) 또는 단순히 계층(hierarchies)이라고도 불립니다.

Kotlin은 기본적으로 일부 중간 소스 세트를 생성합니다. 이 특정 경우에, 결과 프로젝트 구조는 다음과 같습니다.

Intermediate source sets

여기서 하단의 여러 색 블록은 플랫폼별 소스 세트입니다. 명확성을 위해 타겟 레이블은 생략되었습니다.

appleMain 블록은 Apple별 타겟으로 컴파일된 코드를 공유하기 위해 Kotlin이 생성한 중간 소스 세트입니다. appleMain 소스 세트는 Apple 타겟으로만 컴파일됩니다. 따라서 Kotlin은 appleMain에서 Apple별 API 사용을 허용하며, 여기에 randomUUID() 함수를 추가할 수 있습니다.

Kotlin이 기본적으로 생성하고 설정하는 모든 중간 소스 세트를 찾고, Kotlin이 기본적으로 필요한 중간 소스 세트를 제공하지 않는 경우 어떻게 해야 하는지에 대한 자세한 내용은 계층적 프로젝트 구조를 참조하세요.

특정 타겟으로 컴파일하는 동안, Kotlin은 해당 타겟으로 레이블이 지정된 모든 소스 세트(중간 소스 세트 포함)를 가져옵니다. 따라서 iosArm64 플랫폼 타겟으로 컴파일하는 동안 commonMain, appleMain, iosArm64Main 소스 세트에 작성된 모든 코드가 결합됩니다.

Native executables

일부 소스 세트에 소스가 없어도 괜찮습니다. 예를 들어, iOS 개발에서는 일반적으로 iOS 기기에는 특화되지만 iOS 시뮬레이터에는 특화되지 않은 코드를 제공할 필요가 없습니다. 따라서 iosArm64Main은 거의 사용되지 않습니다.

Apple 기기 및 시뮬레이터 타겟

Kotlin Multiplatform를 사용하여 iOS 모바일 애플리케이션을 개발할 때, 일반적으로 iosMain 소스 세트와 함께 작업합니다. 일반적으로 ios 타겟의 플랫폼별 소스 세트라고 생각할 수 있지만, 단일 ios 타겟은 존재하지 않습니다. 대부분의 모바일 프로젝트는 최소 두 가지 타겟이 필요합니다.

  • 기기 타겟(Device target) 은 iOS 기기에서 실행될 수 있는 바이너리를 생성하는 데 사용됩니다. 현재 iOS용 기기 타겟은 iosArm64 하나뿐입니다.
  • 시뮬레이터 타겟(Simulator target) 은 컴퓨터에서 실행되는 iOS 시뮬레이터용 바이너리를 생성하는 데 사용됩니다. Apple silicon Mac 컴퓨터를 사용한다면, 시뮬레이터 타겟으로 iosSimulatorArm64를 선택하세요. Intel 기반 Mac 컴퓨터를 사용한다면 iosX64를 사용하세요.

iosArm64 기기 타겟만 선언하면 로컬 머신에서 애플리케이션과 테스트를 실행하고 디버그할 수 없습니다.

iosArm64Main, iosSimulatorArm64Main, iosX64Main과 같은 플랫폼별 소스 세트는 일반적으로 비어 있습니다. iOS 기기와 시뮬레이터를 위한 Kotlin 코드는 대개 동일하기 때문입니다. 이들 모두 간에 코드를 공유하려면 iosMain 중간 소스 세트만 사용해도 됩니다.

동일한 원리가 Mac 이외의 다른 Apple 타겟에도 적용됩니다. 예를 들어, Apple TV용 tvosArm64 기기 타겟과 Apple silicon 및 Intel 기반 기기의 Apple TV 시뮬레이터용 tvosSimulatorArm64, tvosX64 시뮬레이터 타겟이 있다면, 이들 모두를 위해 tvosMain 중간 소스 세트를 사용할 수 있습니다.

테스트 통합

실제 프로젝트에서는 주요 프로덕션 코드와 함께 테스트도 필요합니다. 이것이 기본적으로 생성되는 모든 소스 세트에 MainTest 접미사가 붙는 이유입니다. Main은 프로덕션 코드를 포함하고, Test는 이 코드에 대한 테스트를 포함합니다. 둘 사이의 연결은 자동으로 설정되며, 테스트는 추가 구성 없이 Main 코드가 제공하는 API를 사용할 수 있습니다.

Test에 해당하는 소스 세트 역시 Main과 유사한 소스 세트입니다. 예를 들어, commonTestcommonMain에 해당하는 소스 세트이며 선언된 모든 타겟으로 컴파일되어 공통 테스트를 작성할 수 있도록 합니다. jvmTest와 같은 플랫폼별 테스트 소스 세트는 플랫폼별 테스트(예: JVM별 테스트 또는 JVM API가 필요한 테스트)를 작성하는 데 사용됩니다.

공통 테스트를 작성할 소스 세트 외에도, 멀티플랫폼 테스트 프레임워크가 필요합니다. Kotlin은 @kotlin.Test 어노테이션과 assertEquals, assertTrue와 같은 다양한 검증 메서드를 제공하는 기본 kotlin.test 라이브러리를 제공합니다.

각 플랫폼의 해당 소스 세트에서 일반적인 테스트처럼 플랫폼별 테스트를 작성할 수 있습니다. 주요 코드와 마찬가지로, 각 소스 세트별로 플랫폼별 종속성을 가질 수 있습니다. 예를 들어, JVM용 JUnit과 iOS용 XCTest가 있습니다. 특정 타겟에 대한 테스트를 실행하려면 <targetName>Test 태스크를 사용하세요.

멀티플랫폼 앱 테스트 튜토리얼에서 멀티플랫폼 테스트를 생성하고 실행하는 방법을 알아보세요.

다음 단계