Rust 공유 코어 3편: UniFFI 생성 코드는 adapter 뒤에
Rust로 만든 공통 기능을 iOS와 Android에서 부르려면 Swift 쪽과 Kotlin 쪽에 연결 계층이 있어야 해요. C ABI와 플랫폼별 wrapper를 손으로 만드는 방법도 있어요. 작은 실험에서는 고를 만하지만, 앱이 커지고 API가 늘어나면 관리 부담이 커질 것으로 예상해요.
UniFFI는 이 계층을 생성해 주는 도구예요. 생성은 도구가 하지만, 생성된 코드를 앱 안의 어디에 둘지는 도구가 정해 주지 않아요. 3편은 그 자리를 정하는 이야기예요.
UniFFI가 해 주는 일과 해 주지 않는 일
이 절에서 UniFFI가 무엇을 생성하는지는 문서에 있는 내용이에요. 규칙이 어긋날 위험이 준다는 판단과 도구가 정해 주지 않는 것에 대한 설명, 그리고 그 뒤의 절에 나오는 adapter 구조와 생성 파일의 관리 방식은 제 해석이에요.
UniFFI는 공개하기로 한 Rust 함수와 타입을 바탕으로 Swift와 Kotlin의 호출 코드를 만들어요. 만들어진 코드는 C와 호환되는 FFI를 거쳐 Rust 라이브러리로 이어져요. API를 고친 뒤에는 다시 생성하면 되므로, 언어마다 wrapper를 손으로 유지하지 않아도 돼요. 두 플랫폼이 같은 도메인 함수를 쓰게 되므로 규칙이 어긋날 위험이 줄 것으로 봐요. Swift용과 Kotlin용은 같은 Rust API에서 나온 별개의 산출물이에요.
도구가 정해 주지 않는 것도 있어요. 어떤 함수를 공개할지, DTO를 어떤 모양으로 할지, 오류를 어떻게 표현할지, 생성된 타입을 앱의 어디까지 드러낼지는 앱을 만드는 쪽이 정해요. Rust 내부의 모델을 그대로 공개하면 모바일에서 쓰기 불편해질 수 있어요.
생성 코드는 adapter 뒤에 둬요
생성된 API를 ViewModel이나 ViewController가 직접 부르고 생성된 타입이 앱 전체에 퍼지면, Rust API를 고칠 때마다 두 플랫폼의 UI까지 고쳐야 할 수 있어요. 그래서 플랫폼마다 얇은 adapter를 둬요.
iOS
SudokuEngineClient protocol
-> UniFFISudokuEngineClient
-> generated Swift binding
Android
SudokuEngineClient interface
-> UniFFISudokuEngineClient
-> generated Kotlin binding
앱은 iOS의 protocol과 Android의 interface만 써요. 생성 코드에 닿는 것은 UniFFISudokuEngineClient뿐이에요. ViewModel에는 생성된 타입을 넘기지 않고, Swift에서는 자체 DTO나 domain type을, Kotlin에서는 data class를 넘겨요.
이 구조에서는 ViewModel의 단위 테스트에 fake SudokuEngineClient를 넣을 수 있어요. Rust를 실제로 부르는 integration test는 따로 구성해요.
호출 단위와 타입
렌더링할 때마다 여러 번 부르는 방식보다 사용자 액션마다 한 번 처리하는 방식을 권해요. 현재 상태와 액션을 넘기고, 바뀐 상태를 한 번에 돌려받아요.
JSON 문자열을 주고받는 형태로 적은 API의 예예요.
start_game(request_json: String) -> String
apply_action(snapshot_json: String, action_json: String) -> String
validate_snapshot(snapshot_json: String) -> String
JSON을 꼭 써야 하는 것은 아니에요. UniFFI의 record, enum, sequence를 쓸 수도 있어요. 다만 공개하는 타입이 복잡해지면 두 언어에서 의미를 똑같이 유지하기가 어려워질 수 있어요.
저는 처음에는 간단한 DTO와 fixture로 시작하는 쪽을 선호해요. 측정해서 성능 문제가 드러난 뒤에 타입을 잘게 나눠요. FFI 설계부터 복잡하게 만들어도 실제 병목은 다른 곳에 있을 수 있어요. 두 방식의 성능을 비교한 수치는 이 글에 없어요.
플랫폼마다 챙길 것
UniFFI를 쓴다고 해서 두 앱의 구조가 같아지지는 않아요. adapter 안쪽의 실행 방식은 그 플랫폼의 런타임에 맞게 골라요.
iOS에서는 Swift concurrency, MainActor, UIKit과 SwiftUI의 상태 갱신을 고려해요. CPU를 많이 쓰는 호출은 main thread 밖에서 처리하는 것이 좋아요. adapter가 background queue나 async 경계를 관리하고, 화면 갱신은 main actor로 돌아와서 해요. Rust 결과물을 XCFramework로 묶는 과정은 4편에서 다뤄요.
ViewModel scope, coroutine dispatcher, Compose의 상태 갱신, 네이티브 라이브러리를 로드하는 순서는 Android에서 점검할 것들이에요. Rust의 .so를 로드하지 못하면 앱을 시작할 때나 처음 호출할 때 오류가 날 수 있어요. ABI마다 제대로 패키징됐는지도 확인해야 해요. Kotlin과 네이티브 코드의 연결 개념은 Android의 JNI 안내를 참고해요.
예제에서 생성하고 호출해 본 것
이 구조를 작은 예제로 만들어서 2026년 10월 4일에 바인딩을 생성하고 호출해 봤어요. 실제 앱의 코드가 아니고 구조를 확인하려는 최소 workspace예요. 환경은 macOS 27.0.1, cargo 1.96.0, uniffi 0.29.5, Swift 6.4예요. 바인딩 crate는 코어의 함수를 감싸서 내보내기만 해요. JSON을 해석하고 직렬화하는 함수까지 코어 crate에 둔 것은 예제를 줄이려고 2편의 설계와 다르게 한 부분이에요.
uniffi::setup_scaffolding!();
#[uniffi::export]
pub fn start_game(request_json: String) -> String {
sudoku_core::start_game_json(&request_json)
}
#[uniffi::export]
pub fn apply_action(snapshot_json: String, action_json: String, environment_json: String) -> String {
sudoku_core::apply_action_json(&snapshot_json, &action_json, &environment_json)
}
// … validate_snapshot, generate_daily_puzzle도 같은 모양이에요.
위의 API 예와 달리 apply_action의 인자가 셋이에요. 2편의 계약대로 environment_json을 더했어요. 바인딩은 빌드한 라이브러리에서 생성해요. uniffi-bindgen은 따로 설치하지 않고 같은 crate의 실행 파일로 뒀어요.
cargo build -q -p sudoku-uniffi
# …
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language swift --out-dir out/swift
cargo run -q -p sudoku-uniffi --bin uniffi-bindgen -- generate \
--library target/debug/libsudoku_uniffi.dylib --language kotlin --no-format --out-dir out/kotlin
Kotlin 쪽에 --no-format을 준 것은 이 Mac에 ktlint가 없어서예요. 옵션 없이 생성하면 ktlint를 찾지 못했다는 경고가 나왔어요. -q는 cargo의 출력을 줄이는 옵션이에요. 생성된 파일은 넷이에요.
kotlin/uniffi/sudoku_uniffi/sudoku_uniffi.kt
swift/sudoku_uniffi.swift
swift/sudoku_uniffiFFI.h
swift/sudoku_uniffiFFI.modulemap
Swift 파일은 514줄, Kotlin 파일은 1107줄이었어요. Swift에서는 함수 이름이 startGame(requestJson:), applyAction(snapshotJson:actionJson:environmentJson:)처럼 바뀌어요. 생성된 Swift 파일과 호출 코드를 swiftc로 함께 컴파일하고 Rust 라이브러리를 링크해서 macOS에서 실행했어요. 호출 코드의 일부예요.
let env = #"{"locale":"ko-KR"}"#
let snapshot = startGame(requestJson: #"{"seed":0}"#)
// …
print("validate:", validateSnapshot(snapshotJson: snapshot))
// …
let conflict = applyAction(snapshotJson: snapshot, actionJson: #"{"type":"set_value","row":0,"col":2,"value":5}"#, environmentJson: env)
print("conflict:", conflict)
이 부분의 출력이에요.
validate: {"valid":true,"errors":[]}
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
Swift에서 부른 함수가 Rust의 결과를 문자열로 돌려준 것까지 확인했어요. 이 실행은 iOS가 아니라 macOS 호스트에서 한 것이고, adapter와 protocol은 만들지 않았어요.
생성된 파일을 저장소에 넣을지
선택지는 둘이에요.
- 생성된 Swift와 Kotlin 파일을 저장소에 넣어요. IDE에서 찾아보기 쉽고 PR에서 API가 어떻게 바뀌었는지 보기 좋아요. 생성 코드의 큰 diff가 리뷰에 부담이 될 수 있어요.
- 빌드할 때 생성해요. Rust가 유일한 원본으로 남아요. 새로 clone한 환경과 CI에서 생성 절차를 재현해야 하고, 개발 환경마다 UniFFI CLI나 Rust toolchain의 버전이 다르면 빌드가 불안정해질 수 있어요.
앱 프로젝트의 초기, 특히 iOS와 Xcode의 연결이 아직 자리 잡지 않았다면 저장소에 넣는 쪽도 고려할 만해요. 이때는 생성 명령을 기록해 두고, CI에서 다시 생성했을 때 diff가 없는지 검사하는 것이 좋아요. 라이브러리가 중심인 프로젝트이거나 변경량이 부담스럽다면 빌드할 때 생성하는 쪽을 고려해요. 어느 쪽도 모든 프로젝트에 맞는 정책은 아니에요. 어느 쪽을 고르든 CI에서 binding 생성을 재현하기를 권해요.
생성 파일을 어떻게 관리할지를 포함해서, API의 경계, 타입, 오류 표현은 프로젝트마다 정해야 해요. 이 글의 선택지는 권고예요. 예제에서 확인한 것은 생성과 macOS에서의 Swift 호출까지이고, Kotlin 바인딩을 컴파일해서 Android에서 부르는 것과 iOS 기기에서의 실행은 확인하지 않았어요.