Kotlin Multiplatform으로 만든 공유 모듈을 iOS 프레임워크로 내보내면, Kotlin 코드가 Objective-C 헤더를 거쳐 Swift에 노출됩니다. 이 변환 과정에서 Kotlin 언어의 여러 기능이 Objective-C가 표현할 수 있는 범위로 깎여 나갑니다. suspend 함수는 콜백 기반 함수로 바뀌고, Flow는 제네릭이 지워진 불투명한 타입이 되고, sealed class의 when 분기 완전성도 Swift switch에서는 보장되지 않습니다. SKIE(Swift Kotlin Interface Enhancer)는 컴파일러 플러그인으로 이 생성 과정에 개입해서, Swift 쪽에 더 자연스러운 API를 만들어줍니다.

왜 필요한가

공유 모듈에 아래와 같은 함수가 있다고 하면,

// shared/src/commonMain/kotlin/ItemRepository.kt
class ItemRepository(private val api: ItemApi) {
    suspend fun fetchItems(): List<Item> = api.getItems()

    fun observeItems(): Flow<List<Item>> = api.itemsFlow()
}

SKIE 없이 기본 Kotlin/Native Obj-C 익스포트만 쓰면 Swift 쪽 호출부는 이렇게 생깁니다.

repository.fetchItems { items, error in
    if let error {
        print("failed: \(error)")
        return
    }
    guard let items else { return }
    // items 사용
}

observeItems()가 반환하는 타입은 Kotlinx_coroutines_coreFlow라는 제네릭이 지워진 타입이라, 그대로는 for await 같은 Swift 문법을 쓸 수 없습니다. 실무에서는 보통 Flow를 감싸는 CommonFlow 같은 Swift 래퍼 클래스를 직접 만들어서 Combine이나 클로저로 다시 노출시키는 방식으로 우회했습니다. 함수 하나 추가할 때마다 이 래퍼 코드도 같이 늘어나는 게 불편했습니다.

핵심 개념

SKIE는 별도의 Kotlin 코드 수정 없이, 프레임워크를 빌드하는 시점에 개입해서 Swift에 노출되는 시그니처 자체를 바꿔줍니다. suspend 함수는 async/await로, FlowAsyncSequence로, sealed class는 Swift enum처럼 exhaustive하게 분기할 수 있는 형태로 바뀝니다. 적용 방법은 공유 모듈의 Gradle 플러그인 목록에 한 줄 추가하는 것으로 끝납니다.

// shared/build.gradle.kts
plugins {
    kotlin("multiplatform")
    id("co.touchlab.skie") version "0.10.14"
}

kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach {
        it.binaries.framework {
            baseName = "Shared"
        }
    }
}

기존 commonMain 코드는 그대로 두고, 프레임워크를 다시 빌드하면 생성되는 Swift 인터페이스만 바뀝니다.

실전 예시

같은 ItemRepository를 SKIE 적용 후 Swift에서 호출하면 이렇게 됩니다.

Task {
    do {
        let items = try await repository.fetchItems()
        // items 사용
    } catch {
        print("failed: \(error)")
    }
}

Task {
    for await items in repository.observeItems() {
        // Flow가 값을 emit할 때마다 실행
    }
}

커스텀 래퍼 클래스 없이 async/awaitfor await를 바로 쓸 수 있습니다. sealed class도 마찬가지로 개선됩니다.

// commonMain
sealed class LoadState {
    object Loading : LoadState()
    data class Success(val items: List<Item>) : LoadState()
    data class Error(val message: String) : LoadState()
}

SKIE 없이는 Swift switch에서 default 분기를 강제로 넣어야 컴파일이 됩니다. LoadState에 새 케이스를 추가해도 default가 조용히 그걸 삼켜버려서, 새 케이스 처리를 빠뜨렸는지 컴파일러가 알려주지 못합니다. SKIE를 적용하면 Swift switch가 실제로 exhaustive하게 체크되어서, 케이스를 하나 빠뜨리면 Swift 컴파일 에러로 바로 드러납니다.

switch loadState {
case .loading:
    ProgressView()
case .success(let state):
    // state.items 사용
case .error(let state):
    // state.message 사용
}
// 새 케이스가 추가되면 이 switch가 컴파일 에러로 알려줌

장단점 정리

장점

  • suspend/Flow를 감싸는 Swift 래퍼 클래스를 직접 만들 필요가 없어져서, 공유 모듈에 함수를 추가할 때마다 Swift 쪽 보일러플레이트가 함께 늘어나는 문제가 사라짐
  • sealed class가 Swift에서도 exhaustive하게 체크되어, 케이스 추가 시 빠뜨린 처리가 런타임이 아니라 컴파일 타임에 드러남
  • Kotlin 소스 코드는 그대로 두고 Gradle 설정만 추가하면 적용되어서, 마이그레이션 비용이 낮음

단점

  • 컴파일러 플러그인이 프레임워크 빌드 과정에 끼어들기 때문에, 클린 빌드 시간이 늘어남
  • Xcode에서 실제로 생성된 Objective-C 헤더를 열어보면 여전히 원본 그대로라, 문제가 생기면 SKIE가 만드는 별도의 Swift 인터페이스 문서를 따로 확인해야 함
  • Kotlin 버전과 SKIE 버전 간 호환 매트릭스가 있어서, Kotlin을 업그레이드할 때마다 SKIE 버전도 같이 맞춰야 함

Swift 쪽에서 코루틴과 Flow를 다루는 코드가 프로젝트 전반에 반복적으로 등장한다면, 그 보일러플레이트를 걷어내는 비용 대비 효과가 큽니다. 반대로 iOS 타겟에서 공유 모듈을 아주 얇게만 쓰는 경우라면, 빌드 시간 증가분을 감수할 만큼 이득이 크지 않을 수 있습니다.