Rust 공유 코어 4편: iOS에 XCFramework로 전달하기
공용 Rust core를 iOS 앱에서 쓰려면 Rust 쪽 테스트가 통과하는 것만으로는 부족해요. 그것이 앱에서 링크가 된다는 보장은 아니에요. 시뮬레이터와 기기에서 결과가 다르거나, 개발 머신과 CI에서 다르거나, Swift Package의 바이너리 체크섬이 맞지 않는 문제가 생길 수 있어요.
4편은 Rust 바이너리와 Swift 호출 계층을 Apple 앱에 전달하는 과정을 Apple Developer, Rust Reference, rustc, UniFFI 문서로 정리해요. 이 문서들은 2026년 10월 4일에 다시 열어 확인했어요.
정적 라이브러리를 XCFramework로 묶어요
다른 언어에서 연결할 Rust 산출물의 crate type에는 staticlib과 cdylib이 있어요(Rust Reference의 linkage). iOS에서는 정적 라이브러리를 XCFramework로 포장하는 방식을 써요.
기기용과 시뮬레이터용을 따로 빌드하고, 헤더와 module map을 준비한 뒤 xcodebuild -create-xcframework로 합쳐요. 이 흐름을 개념적으로 적으면 이래요.
cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim
libdomain_core.a
headers/
module.modulemap
-> DomainCore.xcframework
타깃의 지원 범위는 rustc의 Apple iOS 안내에 있어요. 위 블록은 실행한 명령과 출력이 아니고, 입력과 산출물의 관계를 보여 주는 그림이에요.
XCFramework는 플랫폼과 아키텍처별 variant를 하나에 담을 수 있어요. iOS 기기, 시뮬레이터, macOS, visionOS가 그 예예요. Xcode 프로젝트나 Swift Package의 바이너리 의존성으로 쓰면, Xcode가 빌드 대상에 맞는 slice를 선택할 수 있어요.
재현하려면 조건을 맞춰야 해요. 타깃의 설치 상태, toolchain 버전, profile, 출력 위치, 헤더를 생성하는 위치를 개발 머신과 CI에서 같게 하기를 권해요. 타깃 설치와 패키징은 스크립트에 넣기를 권해요. 제가 예제를 빌드한 환경의 값은 «예제로 묶어 본 XCFramework» 절에 적었어요.
헤더, module map, 생성된 Swift 바인딩은 서로 맞아야 해요. UniFFI가 내놓은 코드와 Rust 라이브러리의 버전이 다르면 링크에 실패하거나 실행 중에 심벌 문제가 생길 수 있어요. 그래서 두 생성 작업을 같은 릴리스 단위로 관리하기를 제안해요.
예제로 묶어 본 XCFramework
위의 흐름을 작은 예제로 실제로 돌려 봤어요. 일부러 작게 만든 예제이고, 실제 앱은 아니에요. 실행한 날은 2026년 10월 4일이고, 환경은 macOS 27.0.1, Xcode 27.0, cargo 1.96.0, uniffi 0.29.5예요. Rust 타깃 aarch64-apple-ios와 aarch64-apple-ios-sim은 미리 설치돼 있었어요. 바인딩 crate의 crate-type에는 staticlib을 넣었어요.
헤더와 module map은 UniFFI가 Swift 바인딩과 함께 생성한 것을 썼어요. 생성된 sudoku_uniffiFFI.modulemap을 module.modulemap이라는 이름으로 복사했어요. 아래는 실행한 스크립트에서 이 과정에 해당하는 줄이에요. 마지막의 sed는 출력에 찍히는 절대 경로를 줄이려는 것이고, out/host는 3편의 Swift 호출에 쓴 디렉터리예요.
mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
-library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
-library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
-output out/SudokuCore.xcframework | sed 's#: .*/out/#: out/#'
출력과 만들어진 디렉터리예요.
xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a
Info.plist에는 ios-arm64와 ios-arm64-simulator 두 항목이 들어 있었어요. 정적 라이브러리는 각각 약 31 MB였어요. strip을 하지 않은 release 빌드의 크기예요.
여기까지가 확인한 범위예요. 이 XCFramework를 실제로 가져다 쓸 수 있는지도 한 단계 더 확인했어요. binaryTarget으로 XCFramework를 가리키고 생성된 Swift 바인딩을 타깃에 넣은 Swift Package를 만들어, xcodebuild build로 iOS 시뮬레이터용과 기기용을 빌드했고 둘 다 성공했어요. UniFFI가 생성한 module map은 고치지 않고 그대로 썼어요. Xcode 27.0에서 본 결과이고, 다른 Xcode 버전에서는 따로 확인해야 해요. 시뮬레이터에서 앱을 실행하는 것과 xcodebuild test는 해 보지 않았어요. 생성된 Swift 바인딩으로 Rust를 부르는 것은 macOS에서만 확인했고, 그 기록은 3편에 있어요.
Swift Package로 전달하기
XCFramework가 담는 것과 xcodebuild -create-xcframework의 사용법, Rust의 crate type과 타깃은 문서에 있어요. module map을 준비하는 일, Xcode가 slice를 고르는 동작, 버전이 어긋날 때의 증상은 이번에 확인한 문서에서 찾지 못했어요. 앞 절에서 권한 스크립트화와 릴리스 단위 관리, 그리고 이 절부터 나오는 패키지 구성과 CI 운영은 문서가 정해 주지 않는 것이고 제 해석이에요.
앱이 하나라면 XCFramework를 프로젝트에 직접 추가해도 돼요. 앱이 여럿이거나 샘플과 테스트 타깃이 있다면 Swift Package를 쓰는 쪽을 제안해요. binary target과 Swift adapter를 위한 타깃을 나눌 수 있어요.
DomainEnginePackage/
Package.swift
Sources/
DomainEngine/
DomainEngineClient.swift
UniFFIAdapter.swift
GeneratedBindings.swift
Artifacts/
DomainCore.xcframework
앱에 공개하는 접점은 DomainEngineClient예요. 생성 코드와 바이너리는 내부 구현으로 취급해요. 이렇게 하면 앱이 Rust 연결에 대해 알아야 할 것이 줄어요. 프로젝트의 build phase에 스크립트를 쌓는 것보다 의존 관계가 분명해져요.
배포에서 정할 것도 있어요. artifact를 어디에 둘지, 원격 ZIP의 checksum을 어떻게 관리할지, 소스 패키지의 버전과 바이너리의 버전을 어떻게 맞출지예요. 사내에서만 쓴다면 저장소 안에 artifact를 두고 시작해서, 안정된 뒤에 릴리스 파일로 분리하는 방법이 있어요.
로컬과 CI가 같은 순서로 빌드하게
Apple 쪽 통합에서는 Rust 함수를 부르는 일보다 Swift 계층, 패키지, build phase, CI를 관리하는 일의 비중이 크다고 봐요.
로컬에는 이미 설치한 타깃과 예전에 만든 산출물이 남아 있을 수 있어요. CI는 새로 clone한 상태에서 시작한다고 가정해요. 경로가 달라서 생기는 문제도 있어요. 그래서 입력과 순서를 한곳에서 관리하기를 권해요.
install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
이 목록은 자동화에 넣을 단계이고, 그대로 실행되는 셸 스크립트가 아니에요.
build phase에서 매번 Rust를 컴파일하면 개발할 때 반복 시간이 늘어날 수 있어요. 로컬에서는 inputs와 outputs, cache를 설정하고, CI는 clean build를 중심으로 운영하기를 권해요. 얼마나 늘어나는지 잰 시간은 이 글에 없어요.
생성 파일을 커밋하지 않는 구성이라면 조건이 하나 더 붙어요. 새로 clone해서 Xcode 프로젝트를 열고 빌드하는 과정에서 필요한 산출물이 자동으로 만들어져야 해요. 그래야 새로 온 사람의 첫 빌드가 실패하지 않아요.
Swift protocol 뒤에 UniFFI 구현을 둬요
UniFFI가 생성한 API를 앱에서 바로 쓰면 시작하기는 쉬워요. 길게 보면 앱이 의존하는 범위가 넓어질 수 있어요. 그래서 Swift protocol 뒤에 UniFFI 구현을 둬요. 제안하는 protocol의 모양이에요.
protocol SudokuEngineClient {
func startGame(request: StartGameRequest) throws -> GameSnapshot
func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}
이 protocol에 ViewModel이 의존하고, 생성된 API는 adapter 안에서만 써요. 무거운 Rust 작업은 adapter에서 async API로 감싸고, UI 갱신은 MainActor에서 처리하도록 만들 수 있어요. fake client를 넣으면 ViewModel만 따로 검사할 수 있어요. 같은 구조를 Android에서 .so로 전달하는 방법(ABI, Gradle, NDK, 16 KB page size)은 5편에 있어요.
확인할 것은 셋이에요. 기기와 시뮬레이터를 모두 빌드해 봐요. 로컬과 CI가 같은 명령을 쓰는지 봐요. 새로 clone한 상태에서 Xcode 빌드가 되는지 봐요. 이 가운데 예제에서 해 본 것은 기기용과 시뮬레이터용 빌드예요. 소스만 새 디렉터리에 복사해서 같은 스크립트를 처음부터 다시 돌리는 것도 해 봤고, 끝까지 통과했어요. CI에서 돌리는 것과 Xcode 프로젝트의 빌드는 확인하지 않았어요.