J.
BLOG
글 목록 구독
기술

Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 4편. Apple 플랫폼과 XCFramework

2026.07.01 · 읽기 6분

iOS 개발자 입장에서 Rust 공통 모듈 도입은 결국 Xcode가 이해할 수 있는 산출물을 만드는 일로 이어진다.

Rust에서 테스트가 잘 통과해도 iOS 앱에서 링크되지 않으면 실제 제품에는 의미가 없다. simulator에서는 되는데 device에서 안 되거나, 로컬에서는 되는데 CI에서 안 되거나, Swift Package로 묶었을 때 binary artifact checksum이 맞지 않는 문제도 생길 수 있다.

그래서 Apple 플랫폼 통합은 Rust 코드 자체보다 빌드 산출물과 배포 단위를 먼저 이해해야 한다. 이 글은 2026-07-01 기준 Apple Developer 문서, Rust Reference, rustc platform support 문서, UniFFI 문서를 바탕으로 Rust core를 iOS 앱에서 쓰기 위한 XCFramework 중심 흐름을 정리한다.

Rust library를 Apple 플랫폼 산출물로 만든다

Rust는 여러 형태의 library 산출물을 만들 수 있다. 외부 언어에서 링크하기 위한 용도로는 staticlib이나 cdylib 같은 crate type을 보게 된다.

iOS 앱에 Rust core를 넣는 경우에는 static library로 빌드해 XCFramework에 묶는 흐름이 흔하다. device용 aarch64-apple-ios, simulator용 target을 각각 빌드하고, 필요한 header와 module map을 함께 준비한다. 그다음 xcodebuild -create-xcframework로 여러 variant를 하나의 framework bundle로 묶는다.

개념적으로는 이런 흐름이다.

cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim

libdomain_core.a
headers/
module.modulemap

-> DomainCore.xcframework

이 흐름이 해결하는 것은 Xcode가 device와 simulator slice를 구분해 링크할 수 있는 배포 단위를 만드는 일이다. 아직 남는 한계도 있다. target 설치, toolchain version, build profile, output path, header generation 경로가 개발자 Mac과 CI에서 같아야 한다.

산출물 이름보다 중요한 것은 재현성이다. 개발자 Mac에서 되는 명령이 CI에서도 같은 결과를 내야 한다. 그래서 Rust target 설치와 XCFramework 생성은 문서에만 남기지 말고 스크립트로 고정하는 편이 좋다.

XCFramework는 Xcode가 이해하는 경계다

XCFramework는 여러 플랫폼과 아키텍처 variant를 하나로 묶는 binary framework bundle이다. iOS device, iOS simulator, macOS, visionOS처럼 서로 다른 platform slice를 함께 담을 수 있다.

Rust core를 Apple 앱에 넣을 때도 이 형식이 유리하다. Xcode project나 Swift Package에서 binary dependency로 다루기 쉽고, simulator와 device 전환 시에도 Xcode가 올바른 slice를 선택할 수 있다.

다만 XCFramework가 있다고 해서 모든 통합 문제가 끝나는 것은 아니다.

Swift에서 호출하려면 header, module map, generated Swift binding이 같이 맞아야 한다. UniFFI를 사용한다면 Rust library 산출물과 UniFFI가 생성한 Swift 파일의 버전이 맞아야 한다. 이 둘이 어긋나면 compile은 되는데 link 단계에서 깨지거나, runtime에서 symbol mismatch가 날 수 있다.

그래서 XCFramework 생성 스크립트와 UniFFI binding 생성 스크립트는 같은 release unit으로 관리하는 편이 좋다.

Swift Package로 감싸면 배포가 쉬워진다

앱 하나에서만 쓴다면 Xcode project에 직접 XCFramework를 넣어도 된다. 하지만 여러 앱, 샘플, 테스트 target에서 같이 쓴다면 Swift Package로 감싸는 방식이 더 깔끔하다.

Swift Package는 binary target으로 XCFramework를 배포할 수 있다. 앱 프로젝트에서는 package dependency처럼 추가하고, Swift adapter는 별도 target으로 둘 수 있다.

구조는 대략 이렇게 잡을 수 있다.

DomainEnginePackage/
  Package.swift
  Sources/
    DomainEngine/
      DomainEngineClient.swift
      UniFFIAdapter.swift
      GeneratedBindings.swift
  Artifacts/
    DomainCore.xcframework

여기서 Swift 앱이 직접 보는 것은 DomainEngineClient다. GeneratedBindings.swift와 binary target은 package 내부 구현으로 숨긴다.

이 방식의 장점은 앱 코드가 Rust 통합 세부사항을 덜 알게 된다는 점이다. Xcode project에 build phase script를 계속 추가하는 방식보다 dependency 경계가 선명해진다.

하지만 trade-off도 있다. binary artifact를 어디에 둘지, remote zip으로 배포한다면 checksum을 어떻게 관리할지, source package와 binary version을 어떻게 맞출지 결정해야 한다. 사내 앱이라면 repository 내부 artifact로 시작하고, 안정화 이후 별도 release artifact로 분리하는 방식이 현실적일 수 있다.

Xcode와 CI에서 같은 경로를 재현해야 한다

Apple 플랫폼 통합에서 가장 흔한 문제는 로컬과 CI의 빌드 경로가 다르다는 것이다.

로컬 개발자는 이미 Rust target을 설치해두었고, 예전에 만든 XCFramework가 남아 있을 수 있다. CI는 매번 fresh clone에서 시작한다. 이 차이를 무시하면 “내 Mac에서는 되는데 CI에서 깨지는” 문제가 반복된다.

그래서 다음 명령은 하나의 스크립트로 묶는 편이 좋다.

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

이 스크립트가 해결하는 것은 빌드 순서와 입력값을 한 곳에 모으는 일이다. 아직 해결하지 못하는 것도 있다. Xcode build phase에서 매 build마다 Rust를 다시 빌드하면 개발 속도가 느려질 수 있다. 로컬에서는 inputs/outputs와 cache를 잡고, CI에서는 clean build를 우선하는 식으로 나누는 편이 현실적이다.

생성물을 커밋하지 않는다면 더 엄격해야 한다. fresh clone에서 Xcode project를 열고 build했을 때 필요한 산출물이 자동으로 만들어져야 한다. 그렇지 않으면 새로운 팀원이 첫 빌드부터 막힌다.

Swift adapter는 필수에 가깝다

iOS 앱 코드가 UniFFI generated binding을 직접 호출하게 만들면 초기 구현은 빠르다. 하지만 시간이 지나면 앱 전체가 바인딩 구조에 묶인다.

Swift adapter를 두면 경계가 훨씬 좋아진다.

protocol SudokuEngineClient {
    func startGame(request: StartGameRequest) throws -> GameSnapshot
    func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}

이 protocol 뒤에 UniFFI 구현체를 둔다. ViewModel은 protocol만 알고, generated binding은 adapter 내부에서만 사용한다.

이 구조가 해결하는 것은 앱 코드와 generated binding의 결합을 줄이는 일이다. Rust 호출이 무거우면 adapter에서 async API로 감싸고, UI 업데이트는 MainActor에서 처리하게 만들 수 있다. 테스트에서는 fake client를 넣어 ViewModel을 검증할 수 있다.

결국 iOS 앱에서 중요한 건 Rust를 호출할 수 있느냐만이 아니다. Rust 호출이 앱의 구조를 흐리지 않게 만드는 것이 더 중요하다.

그래서 무엇부터 보면 좋을까

  • Rust crate type을 Apple 플랫폼 통합에 맞게 정한다.
  • device와 simulator target을 모두 빌드하는 스크립트를 만든다.
  • XCFramework 생성 과정을 로컬과 CI에서 같은 명령으로 재현한다.
  • UniFFI Swift binding과 Rust binary 산출물의 버전을 함께 관리한다.
  • Swift Package binary target으로 감쌀지, Xcode project에 직접 넣을지 결정한다.
  • generated binding은 Swift adapter 내부에 숨긴다.
  • ViewModel은 Swift protocol만 바라보게 한다.
  • fresh clone에서 Xcode build가 재현되는지 반드시 확인한다.

마무리

Apple 플랫폼에서 Rust를 쓰는 일은 Rust 자체보다 Xcode 통합이 더 큰 비중을 차지한다.

XCFramework는 이 경계를 다루기 좋은 배포 단위다. 하지만 XCFramework만 만들었다고 끝은 아니다. Swift binding, adapter, Xcode build phase, Swift Package, CI 재현성까지 같이 봐야 실제 앱에서 안정적으로 쓸 수 있다.

다음 글에서는 Android에서 Rust .so를 패키징할 때 확인해야 하는 ABI, Gradle, NDK, 16 KB page size 문제를 정리해보겠다.

출처

광고 Coupang Partners

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

#Rust#iOS#Xcode#SwiftPM#FFI
이전 글 automationmodetool, Xcode UI 테스트의 Automation Mode 암호 프롬프트 정리
© 2026 진재명 · blog.jaemyeong.com iOS 소프트웨어 엔지니어 · 부산