Kotlin suspend가 Swift async로, KMP Swift export 붙여본 자리

얼마 전에 Kotlin 2.4 스위프트 익스포트(Swift export)를 붙여놓고 let user = try await shared.fetchUser() 한 줄을 봤을 때, 드디어 ObjC 접두사 지옥에서 벗어났구나 싶었습니다. 그런데 화면을 닫아봤더니 이렇더군요.
코틀린(Kotlin) 쪽 코루틴이 멀쩡히 살아서 네트워크 호출을 끝까지 돌리고 있었어요. iOS 화면은 이미 사라졌는데 백그라운드에선 일을 계속하고 있던 거죠. 시그니처만 예뻐졌지, 그 밑은 제가 생각한 것과 달랐습니다.
ObjC 헤더 경유, KMP-iOS 연동이 어색했던 이유
기존 코틀린 멀티플랫폼(KMP) 방식은 공유 모듈을 ObjC 프레임워크로 내보냈습니다. 그래서 iOS 쪽에서 보면 KotlinInt, KotlinArray 같은 접두사 타입이 줄줄이 따라붙었죠. Xcode 자동완성을 띄우면 Kotlinx_coroutines... 같은 잔재가 섞여서 눈이 피곤했어요.
suspend 함수도 마찬가지였습니다. Swift 5.5 이후로 async/await처럼 호출할 수 있게 자동으로 감싸주긴 하는데, 그 속은 사실 콜백(Completion Handler)이었거든요. 겉만 비동기처럼 보이고 안은 그냥 콜백 래핑이었던 셈이지요.

Kotlin 2.4 Swift export 설정과 suspend async 매핑
Kotlin 2.4부터는 Gradle 설정에 swiftExport { } 블록을 선언하면 됩니다. 그러면 스위프트 패키지 매니저(SPM)로 바로 붙일 수 있는 Swift 모듈이 생성돼요. 자동으로 Package.swift까지 만들어주니까, ObjC 프레임워크를 일일이 엮던 시절보다 설정이 훨씬 간결합니다.
다만 자동 생성된 모듈명이 기존 타깃과 부딪히는 경우가 있어서, 모듈명 충돌만 처음에 한 번 잡아주면 됩니다.
매핑 결과는 꽤 깔끔해졌어요.
suspend fun fetchUser(): User는 Swiftfunc fetchUser() async throws -> User로 매핑됩니다.sealed class는 Swiftenum으로 바뀌어서, 분기 처리가 한결 자연스러워집니다.- 호출부에는
KotlinInt같은 접두사가 안 붙고, IDE 자동완성도 네이티브처럼 떠줍니다.
그래서 iOS 개발자 입장에선 래퍼 코드 한 줄 없이 let user = try await shared.fetchUser() 로 공유 모듈을 직접 부를 수 있게 됐습니다. 단순히 데이터 받아오는 호출 위주라면 추가 라이브러리 없이도 충분하다고 봅니다.

코루틴 취소 전파, KMP Swift export의 진짜 함정
진짜 문제는 여기서부터예요. Swift 쪽 Task를 취소해도 그게 코틀린 코루틴까지 전달이 안 됩니다. 화면을 닫아서 Task를 cancel 했는데, 정작 코틀린 코루틴은 멀쩡히 살아서 작업을 끝까지 돌려요.
이게 왜 무섭냐면, 사용자가 리스트 화면을 빠르게 들락날락하면 취소됐어야 할 네트워크 호출이 차곡차곡 쌓이거든요. 자원 누수가 조용히 누적되는 거죠. 취소뿐 아니라 디스패처(Dispatcher)나 코루틴 컨텍스트, Task Local 정보도 코틀린-스위프트 경계를 넘으면서 다 사라집니다.
거기에 아직 알파(Alpha) 단계라 제네릭이나 컬렉션 타입 매핑도 불안정해요. sealed class가 enum으로 바뀌긴 하지만, switch 완전성(exhaustiveness)이 보장되지 않아서 default 케이스를 빼먹으면 런타임에서 새는 분기가 생길 수 있습니다. Kotlin 2.4.0에서도 스위프트 익스포트는 여전히 알파 단계로 알려져 있어요.

단순 모듈은 공식 export, 복잡한 비동기는 SKIE로
그래서 Flow 변환 자체는 2.4 공식 export에서도 AsyncSequence로 일부 지원되긴 하지만, 양방향 취소 전파까지는 여전히 안 되니 그 부분이 라이브러리 몫입니다. 대표적인 게 터치랩(Touchlab)의 SKIE인데요. 컴파일러 플러그인으로 프레임워크를 후처리해서 진짜 네이티브 Swift 코드를 만들어줍니다.
SKIE를 붙이면 Swift Task를 취소했을 때 코틀린 코루틴도 같이 취소되고, 반대로 코틀린에서 취소되면 Swift 쪽에 CancellationError가 올라옵니다. 양방향으로 취소가 흐르는 거죠. 참고로 같은 취소라도 suspend 함수는 CancellationError를 던지는 방식이고, Flow는 루프를 스킵하는 방식이라 처리가 조금 다릅니다. 게다가 콜백 래핑이 아니라 실제 async 함수를 생성하고, Flow를 Swift AsyncSequence로 바꿔줘서 for await 구문으로 받을 수 있어요. 메인 스레드가 아니어도 suspend 호출이 된다는 것도 큰 장점입니다.
어노테이션 기반으로 가고 싶다면 KMP-NativeCoroutines라는 선택지도 있습니다. suspend 함수랑 Flow에 어노테이션을 달면 Swift async/await, AsyncSequence, 심지어 Combine의 Publisher로도 노출되고 취소 전파까지 지원한다고 해요.
물론 이런 컴파일러 플러그인은 빌드 복잡도랑 시간을 늘립니다. 나중에 공식 export가 안정화되면 라이브러리에 묶인 코드를 다시 손봐야 하는 부담도 있고요.
제가 이번에 직접 붙여보고 내린 결론은 이원화입니다. 취소가 중요하지 않은 단순 데이터 호출 모듈은 공식 스위프트 익스포트로 가볍게 가고, 양방향 취소나 Flow 스트림이 얽히는 복잡한 비동기 화면은 SKIE를 얹는 거죠. 시그니처가 예뻐진 것에 혹해서 전부 공식 export로 밀어붙였다가는, 저처럼 닫힌 화면 뒤에서 코루틴이 혼자 돌고 있는 광경을 보게 되실 수 있습니다.
지금 KMP로 iOS를 붙이고 계신 분이라면, 질문은 딱 하나로 좁혀집니다. 내 화면을 닫는 순간 코틀린 코루틴이 정말 멈추는지, 그 한 줄을 직접 찍어보셨나요? 거기서 답이 갈리는 것 같아요.
