Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 3편. UniFFI로 iOS와 Android 연결하기
Rust core를 iOS와 Android에서 같이 쓰려면 결국 Swift와 Kotlin이 Rust 함수를 호출할 수 있어야 한다.
직접 C ABI를 설계하고 Swift wrapper와 Kotlin/JNI wrapper를 손으로 작성하는 방법도 가능하다. 작은 실험이라면 그렇게 해도 된다. 하지만 앱이 커지고 API가 늘어나면 wrapper 유지보수 비용이 생각보다 빨리 커진다.
UniFFI는 이 지점을 해결하기 위한 도구다. Rust 라이브러리에서 외부로 노출할 API를 정의하고, Swift와 Kotlin 바인딩을 생성한다. 모바일 앱 입장에서는 Rust 엔진을 Swift/Kotlin API처럼 호출할 수 있게 된다.
이번 글에서는 UniFFI를 도입할 때 어떤 구조로 바라보면 좋은지 정리한다.
UniFFI가 해주는 일
UniFFI의 핵심은 Rust API를 여러 언어 바인딩으로 생성해주는 것이다. Rust 함수나 타입을 외부에 노출하고, 생성된 Swift/Kotlin 코드는 C-compatible FFI 계층을 통해 Rust 라이브러리와 통신한다.
실무적으로 좋은 점은 명확하다.
Swift wrapper와 Kotlin wrapper를 각각 손으로 유지하지 않아도 된다. Rust API 변경이 있을 때 바인딩을 다시 생성하면 된다. iOS와 Android가 같은 Rust 함수를 호출하므로 도메인 규칙이 플랫폼마다 갈라질 가능성도 줄어든다.
하지만 UniFFI가 모든 설계를 대신해주는 것은 아니다.
어떤 함수를 외부에 노출할지, 어떤 DTO를 쓸지, 에러를 어떻게 표현할지, generated binding을 앱 어디까지 노출할지는 여전히 앱 팀이 결정해야 한다. 특히 Rust 내부 모델을 그대로 공개하면 Swift와 Kotlin 코드가 불편해질 수 있다.
그래서 UniFFI는 “아키텍처”가 아니라 “바인딩 생성 도구”로 보는 편이 안전하다. 앱 구조의 중심은 여전히 adapter와 domain boundary에 있어야 한다.
추천 구조: generated binding은 숨긴다
UniFFI를 도입할 때 가장 피하고 싶은 구조는 ViewModel이나 ViewController가 generated binding을 직접 호출하는 것이다.
처음에는 간단하다. 함수 하나를 호출하고 결과를 받으면 된다. 하지만 generated type이 앱 전역에 퍼지는 순간 변경 비용이 커진다. Rust API를 조금만 바꿔도 Swift UI 코드와 Kotlin UI 코드가 같이 흔들린다.
그래서 각 플랫폼에는 얇은 adapter를 둔다.
iOS
SudokuEngineClient protocol
-> UniFFISudokuEngineClient
-> generated Swift binding
Android
SudokuEngineClient interface
-> UniFFISudokuEngineClient
-> generated Kotlin binding
이 구조에서 앱의 ViewModel은 generated binding을 모른다. Swift에서는 앱 내부 DTO나 domain type만 보고, Android에서는 Kotlin data class만 본다. generated binding은 adapter 내부 구현 세부사항이 된다.
이 방식은 테스트에도 유리하다. ViewModel 테스트에서는 fake SudokuEngineClient를 넣으면 된다. Rust 엔진을 실제로 호출하는 integration test는 별도로 둔다.
Rust API는 모바일에서 호출하기 쉬워야 한다
UniFFI를 쓴다고 해서 Rust API가 자동으로 좋은 모바일 API가 되는 것은 아니다.
모바일 앱에서 중요한 것은 호출 경계다. 화면이 그려질 때마다 Rust를 자주 호출하는 구조는 피하는 편이 좋다. 사용자 액션 하나가 들어왔을 때 Rust에 현재 상태와 action을 넘기고, 다음 상태를 한 번에 받는 모델이 낫다.
예를 들어 이런 API가 더 안정적이다.
start_game(request_json: String) -> String
apply_action(snapshot_json: String, action_json: String) -> String
validate_snapshot(snapshot_json: String) -> String
물론 모든 프로젝트가 JSON string을 써야 하는 것은 아니다. UniFFI가 지원하는 record, enum, sequence를 사용할 수도 있다. 다만 외부 공개 타입이 복잡해질수록 Swift/Kotlin 양쪽에서 같은 의미로 유지하기 어려워진다.
개인적으로는 초기에는 단순한 DTO와 fixture를 우선한다. 성능 문제가 실제로 확인되면 그때 더 세밀한 타입으로 바꾼다. 성능을 이유로 처음부터 FFI 표면을 복잡하게 만들면, 정작 병목은 다른 곳에 있을 가능성도 크다.
iOS와 Android의 차이를 인정해야 한다
UniFFI는 양쪽 바인딩을 만들어주지만, iOS와 Android 앱 구조가 같아지는 것은 아니다.
iOS에서는 Swift concurrency, MainActor, UIKit/SwiftUI state update 흐름을 고려해야 한다. Rust 호출이 CPU 비용이 크다면 main thread에서 직접 호출하지 않는 편이 좋다. adapter에서 background queue나 async boundary를 정리하고, UI 업데이트는 main actor로 돌아오게 해야 한다.
Android에서는 ViewModel scope, coroutine dispatcher, Compose state update, native library loading 순서를 봐야 한다. Rust .so가 제대로 로드되지 않으면 앱 시작 시점이나 첫 호출 시점에 실패할 수 있다. ABI별 packaging도 같이 확인해야 한다.
즉 UniFFI의 generated binding은 공통일 수 있지만, 앱에 붙이는 방식은 플랫폼답게 달라져야 한다.
이 지점에서 adapter가 다시 중요해진다. generated binding은 같아도 adapter 내부 구현은 iOS와 Android의 런타임 특성에 맞춰 다르게 가져갈 수 있다.
generated code는 빌드 산출물로 볼지 결정해야 한다
UniFFI binding을 생성하면 Swift/Kotlin 파일이 생긴다. 이 파일을 커밋할지, 빌드 때마다 생성할지는 팀마다 선택이 갈린다.
생성물을 커밋하면 Xcode나 Android Studio에서 바로 탐색하기 쉽고, PR diff로 API 변화를 볼 수 있다. 반대로 generated code diff가 크고 노이즈가 될 수 있다.
빌드 때마다 생성하면 source of truth가 Rust 쪽에 명확히 남는다. 대신 fresh clone에서 반드시 생성 스크립트가 동작해야 한다. CI도 같은 경로를 재현해야 한다. 개발자 환경에 UniFFI CLI나 Rust toolchain 버전 차이가 있으면 빌드가 흔들릴 수 있다.
어느 쪽이든 기준이 필요하다.
나는 앱 프로젝트에서는 초기에 생성물을 커밋하는 편도 나쁘지 않다고 본다. 특히 iOS/Xcode 통합이 아직 안정되지 않은 단계라면 generated Swift 파일을 눈으로 볼 수 있는 장점이 있다. 대신 생성 명령을 문서화하고, CI에서 “재생성했을 때 diff가 없는지” 확인하면 좋다.
라이브러리 성격이 강하거나 generated code 노이즈가 너무 크다면 빌드 생성 방식이 더 맞을 수 있다.
그래서 무엇부터 보면 좋을까
- UniFFI를 아키텍처가 아니라 바인딩 생성 도구로 본다.
- Rust 내부 모델을 그대로 Swift/Kotlin에 노출하지 않는다.
- generated binding은 iOS/Android adapter 내부에 숨긴다.
- ViewModel과 UI는 앱 내부 DTO만 보게 한다.
- CPU 비용이 큰 Rust 호출은 main thread에서 직접 실행하지 않는다.
- Android에서는 native library loading과 ABI packaging을 같이 확인한다.
- generated code를 커밋할지, 빌드 때 생성할지 팀 기준을 정한다.
- CI에서 binding generation을 반드시 재현한다.
마무리
UniFFI는 Rust core를 iOS와 Android에서 공유하게 해주는 꽤 실용적인 도구다. 하지만 도구가 경계를 대신 설계해주지는 않는다.
중요한 것은 generated binding을 앱 전체에 퍼뜨리지 않는 것이다. Swift와 Kotlin 앱은 각자 플랫폼다운 구조를 유지하고, Rust 엔진은 adapter 뒤에 숨긴다. 그렇게 해야 Rust core가 바뀌어도 UI와 앱 구조가 과하게 흔들리지 않는다.
다음 글에서는 iOS 개발자 관점에서 Rust 산출물을 Xcode가 이해할 수 있는 XCFramework로 묶는 흐름을 정리해보겠다.
출처
-
Mozilla UniFFI - The UniFFI user guide
- UniFFI가 Rust 라이브러리에서 Swift/Kotlin 바인딩을 생성하는 방식과 기본 사용 흐름을 참고했다.
-
- Android에서 Kotlin/Java 코드와 native library가 상호작용하는 JNI 경계의 기본 개념을 확인했다.
-
- Rust 라이브러리를 외부 언어에서 사용할 수 있는 library crate type과 linkage 개념을 확인했다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.