J.
BLOG
글 목록 구독
기술

Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 2편. FFI 경계와 API 설계

2026.06.26 · 읽기 6분

Rust 공통 모듈을 만들 때 가장 쉽게 놓치는 부분은 API 경계다.

Rust 내부에서는 좋은 모델이 Swift, Kotlin, TypeScript에서 그대로 좋은 모델이 아닐 수 있다. 반대로 플랫폼 언어에서 편한 타입을 Rust 내부까지 끌고 들어오면 core가 지저분해진다. 그래서 FFI 경계는 별도의 설계 대상이다.

이번 글에서는 Rust core를 외부 앱에서 호출하기 위한 API 표면을 어떻게 잡으면 좋은지 정리한다. 핵심은 단순하다. Rust 내부 모델은 Rust답게 두고, 외부로 내보내는 API는 플랫폼 개발자가 편하게 쓰도록 따로 설계해야 한다.


내부 모델과 공개 모델을 분리한다

Rust core 안에서는 도메인 모델을 풍부하게 표현하는 것이 좋다.

예를 들어 스도쿠 엔진이라면 Grid, Cell, CandidateSet, PuzzleSeed, Difficulty, Move, GameState, ValidationError 같은 타입을 충분히 둘 수 있다. Rust의 enum, pattern matching, ownership, trait을 활용하면 도메인 규칙을 꽤 선명하게 표현할 수 있다.

하지만 이 모델을 그대로 Swift, Kotlin, TypeScript로 노출하는 것은 다른 문제다.

FFI 경계에는 언어별 타입 시스템 차이가 있다. generic, lifetime, nested enum, complex collection, borrowed reference 같은 개념은 외부 언어에서 자연스럽지 않다. 바인딩 생성기가 일부를 처리해주더라도 앱 개발자가 읽고 테스트하고 디버깅하기 쉬운 API가 되리라는 보장은 없다.

그래서 공개 모델은 따로 잡는 편이 낫다.

sudoku-core
  - Rust 내부 도메인 모델
  - Rust 테스트
  - 순수 계산 로직

sudoku-uniffi / sudoku-wasm
  - 외부 공개 DTO
  - serialization
  - error 변환
  - coarse-grained 함수

여기서 중요한 것은 sudoku-core가 바인딩 도구를 몰라도 되게 만드는 것이다. UniFFI를 쓰든 wasm-bindgen을 쓰든, 내부 도메인 로직은 가능한 한 독립적으로 유지한다.


FFI API는 굵게 가져간다

처음에는 Rust 함수를 세밀하게 나누고 싶어진다.

get_cell(row, col)
set_value(row, col, value)
toggle_note(row, col, digit)
is_conflict(row, col)
get_candidates(row, col)

이런 API는 객체 내부를 조금씩 조작하는 느낌이라 익숙하다. 하지만 FFI 경계에서는 대체로 좋지 않다. 호출 횟수가 늘어나고, Swift/Kotlin/TypeScript 쪽 상태와 Rust 쪽 상태가 서로 어긋날 가능성이 커진다. 화면 렌더링 중 셀마다 Rust를 호출하는 구조라면 디버깅도 어려워진다.

공통 엔진은 “로컬 서비스”처럼 호출하는 편이 낫다.

start_game(request) -> GameSnapshot
apply_action(snapshot, action) -> TransitionResult
validate_snapshot(snapshot) -> ValidationReport
generate_daily_puzzle(request) -> PuzzleEnvelope

하나의 사용자 액션에 대해 Rust가 다음 상태를 계산해서 돌려준다. 플랫폼 앱은 그 결과를 받아 UI state로 변환한다. 이렇게 하면 FFI 호출은 줄고, 테스트 단위는 선명해진다.

실무적으로는 action reducer 형태가 잘 맞는 경우가 많다.

현재 상태 + 사용자 액션 + 설정
  -> 다음 상태 + 효과 + 에러 또는 경고

이 모델은 iOS ViewModel, Android ViewModel, Web state layer와도 잘 맞는다. 각 플랫폼은 reducer 결과를 자신의 UI state로 바꾸면 된다.


DTO는 의도적으로 단순하게 둔다

FFI 경계의 DTO는 똑똑할 필요가 없다. 오히려 단순한 편이 좋다.

나는 외부 공개 DTO에서 다음 타입을 우선한다.

  • string
  • integer
  • boolean
  • flat array
  • 명확한 enum
  • optional value
  • 필요할 경우 JSON string

JSON string은 완벽한 답은 아니다. 성능 비용이 있고, compile-time type safety가 약해질 수 있다. 하지만 coarse-grained API에서 state 전체나 action 전체를 넘기는 정도라면 꽤 현실적인 선택이다. 특히 Swift, Kotlin, TypeScript에서 각자 native decoding을 하기 쉽다는 장점이 있다.

중요한 것은 JSON을 어디에 쓰는지다. 렌더링 중 매 frame마다 호출되는 함수에 JSON을 쓰면 당연히 부담이 된다. 반대로 “사용자 액션 하나를 적용한다”는 단위라면 JSON serialization 비용보다 경계의 단순함이 더 큰 이득일 수 있다.

예를 들어 이런 식이다.

apply_action(
  snapshot_json: String,
  action_json: String,
  environment_json: String
) -> transition_json: String

보기에는 투박하지만 장점이 있다. Swift, Kotlin, TypeScript 쪽에서는 각자 Codable, kotlinx.serialization, Zod나 TypeScript type으로 해석할 수 있다. Rust 쪽에서는 serde로 내부 모델에 매핑한다.

팀 단위라면 JSON schema나 fixture를 같이 관리하는 것도 좋다. API 문서보다 fixture가 더 정확할 때가 많다.


에러 모델은 초기에 정해야 한다

FFI 경계에서 에러 처리는 뒤로 미루면 비용이 커진다.

Rust 내부에서는 Result<T, E>가 자연스럽다. 하지만 외부 언어에서는 에러가 thrown error인지, result object인지, nullable인지, callback error인지에 따라 사용성이 달라진다.

개인적으로는 앱 도메인 엔진에서는 error object를 명시적으로 돌려주는 방식을 선호한다.

TransitionResult
  - ok: Boolean
  - snapshot: String?
  - effects: [Effect]
  - error: EngineError?

이 구조는 UI와도 잘 맞는다. 실패한 action을 무시할지, alert를 띄울지, haptic만 줄지, analytics event를 남길지는 플랫폼 앱이 결정한다. Rust core는 “무엇이 잘못됐는지”를 명확히 표현하면 된다.

에러 코드는 사람이 읽는 메시지와 분리하는 편이 좋다. Rust가 한국어/영어 사용자 메시지를 직접 만들기 시작하면 localization이 꼬인다. Rust는 INVALID_MOVE, PUZZLE_ALREADY_COMPLETED, SEED_OUT_OF_RANGE 같은 안정적인 code를 주고, 문구는 플랫폼 앱이 처리한다.


버전 호환성도 API 설계의 일부다

앱이 App Store와 Play Store에 배포되면 사용자 기기에 여러 버전이 동시에 존재한다. Web은 비교적 빠르게 갱신되지만, 모바일 앱은 그렇지 않다.

Rust core API를 바꿀 때도 이 현실을 봐야 한다.

가능하면 request와 response에 version을 둔다. fixture도 버전별로 보관한다. breaking change가 필요하면 Rust core, Swift adapter, Kotlin adapter, Web package가 같은 PR에서 같이 바뀌는지 확인해야 한다.

특히 generated binding은 diff가 크고 읽기 어려울 수 있다. 그래서 사람이 리뷰해야 하는 API는 별도 wrapper나 schema 파일로 남겨두는 것이 좋다. generated code만 보고 API 변경을 리뷰하는 것은 피곤하다.

좋아 보이지만 팀 단위로 도입할 때는 코드 리뷰 기준이 필요하다. “Rust core public API가 바뀌면 어떤 fixture를 추가해야 하는가”, “Swift/Kotlin/TypeScript adapter 테스트가 같이 바뀌었는가” 같은 기준을 PR 템플릿에 넣는 것도 방법이다.


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

  • Rust 내부 모델과 외부 공개 DTO를 분리한다.
  • FFI API는 cell 단위가 아니라 action 단위로 굵게 설계한다.
  • Swift, Kotlin, TypeScript가 모두 다루기 쉬운 타입만 공개한다.
  • JSON string을 쓸 경우 성능 경로와 비성능 경로를 구분한다.
  • 에러 코드는 안정적인 machine-readable code로 설계한다.
  • 사용자 문구와 localization은 플랫폼 앱에 남긴다.
  • request/response fixture를 만들어 API 계약을 테스트한다.
  • generated binding이 아니라 adapter와 schema를 리뷰 대상으로 둔다.

마무리

Rust 공통 모듈의 API는 Rust 개발자만을 위한 API가 아니다. 실제로 매일 호출하는 사람은 Swift, Kotlin, TypeScript 코드다.

그래서 FFI 경계에서는 Rust다운 정교함보다 플랫폼 개발자가 이해하기 쉬운 단순함이 더 중요할 때가 많다. 내부는 Rust답게, 외부는 앱 개발자가 쓰기 쉽게. 이 분리를 잘해두면 이후 UniFFI, wasm-bindgen, XCFramework, Android .so 통합이 훨씬 덜 흔들린다.

다음 글에서는 이 API를 iOS와 Android에서 실제로 호출하기 위해 UniFFI를 어떻게 바라보면 좋은지 정리해보겠다.


출처

광고 Coupang Partners

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

#Rust#iOS#Android#WebAssembly#FFI
이전 글 Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 1편. 어디까지 Rust로 묶을 것인가 다음 글 Mac에서 Windows로 보낸 한글 파일명이 깨지는 이유: 자소 분리(NFD) 현상과 convmv 해결책
© 2026 진재명 · blog.jaemyeong.com iOS 소프트웨어 엔지니어 · 부산