J.
BLOG
글 목록 구독
기술

Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 1편. 어디까지 Rust로 묶을 것인가

2026.06.24 · 읽기 6분

iOS, Android, Web 앱을 같이 만들다 보면 어느 순간 같은 코드를 세 번 쓰고 있다는 느낌이 든다.

처음에는 괜찮다. 플랫폼마다 UI가 다르고, 저장소가 다르고, 앱 생명주기도 다르다. 그런데 시간이 지나면 문제가 조금 다르게 보인다. 퍼즐 생성 규칙, 상태 전이, 오프라인 검증, 가격 계산, 암호화, 동기화 충돌 해결 같은 도메인 로직까지 플랫폼마다 따로 구현하고 있다면 버그도 세 벌로 난다.

2026년 기준으로 Rust 공통 모듈을 iOS, Android, Web에서 공유하는 방식은 충분히 현실적인 선택지가 됐다. 하지만 여기서 중요한 건 Rust로 앱을 “한 번만” 만들겠다는 접근이 아니다. 오히려 반대에 가깝다. 플랫폼 앱은 각 플랫폼답게 유지하고, 정말 공통이어야 하는 도메인 엔진만 Rust로 분리하는 것이다.

이 시리즈의 첫 글에서는 구현 도구보다 먼저 경계를 잡아보려고 한다. Rust가 어디까지 들어와야 하고, 어디부터는 플랫폼 코드에 남겨야 하는지에 대한 이야기다.

Rust는 앱의 중심이 아니라 도메인 엔진에 가깝다

Rust를 공통 모듈로 쓴다고 하면 앱 구조 전체를 Rust 중심으로 다시 짜야 한다고 생각하기 쉽다. 실제로는 그렇게 가면 복잡도가 빠르게 올라간다.

iOS 앱은 UIKit이나 SwiftUI의 생명주기, MainActor, App Extension, App Store 심사 흐름 안에서 움직인다. Android 앱은 Activity, ViewModel, Compose state, Gradle packaging, Play 정책을 따라야 한다. Web은 routing, hydration, browser storage, bundler, deployment target이 중요하다.

이런 플랫폼 고유 영역까지 Rust가 알기 시작하면 공통 모듈은 장점보다 부담이 커진다. Rust 코어가 화면 상태, analytics event, 저장소 접근, 네트워크 요청, 권한 상태까지 직접 다루기 시작하면 결국 가장 복잡한 플랫폼 계층이 된다.

그래서 나는 Rust의 역할을 “도메인 엔진”으로 제한하는 쪽을 선호한다.

예를 들어 스도쿠 앱이라면 Rust가 맡기 좋은 것은 이런 영역이다.

  • 퍼즐 생성
  • solver
  • 입력 검증
  • note와 value 상태 전이
  • daily puzzle seed 계산
  • 난이도 판정
  • score와 streak 계산
  • replay 가능한 action reducer

반대로 이런 것은 플랫폼에 남기는 편이 낫다.

  • 화면 렌더링
  • 접근성
  • 광고와 결제
  • push notification
  • analytics SDK
  • 로컬 저장소 선택
  • 네트워크 retry 정책
  • 앱 심사와 배포 설정

이 구분은 코드 취향 문제가 아니다. 유지보수 단위의 문제다.

공통화할 수 있는 코드와 공통화하면 안 되는 코드

공통화의 기준은 “세 플랫폼에서 똑같이 생겼는가”가 아니라 “세 플랫폼에서 반드시 같은 결과를 내야 하는가”에 가깝다.

예를 들어 같은 스도쿠 보드와 같은 입력이 들어왔을 때 valid/invalid 판정이 플랫폼마다 다르면 안 된다. 같은 seed로 생성한 daily puzzle도 플랫폼마다 달라지면 안 된다. 이런 로직은 Rust core에 들어갈 가치가 있다.

반대로 같은 데이터를 어떻게 보여줄지는 플랫폼마다 달라도 된다. iOS에서는 UIKit collection view가 자연스러울 수 있고, Android에서는 Compose state로 표현하는 편이 좋을 수 있다. Web에서는 keyboard shortcut과 pointer interaction을 별도로 설계해야 한다. 이것까지 억지로 공유하면 사용자 경험이 플랫폼답지 않아진다.

결국 Rust core에 넣을 코드는 다음 조건을 만족해야 한다.

  • 같은 입력에 대해 같은 출력을 내야 한다.
  • 시간, locale, storage, network 같은 외부 상태에 직접 의존하지 않는다.
  • UI framework를 모른다.
  • 플랫폼 SDK를 모른다.
  • 테스트를 Rust 단독으로 실행할 수 있다.

이 조건을 만족하지 못한다면 Rust에 넣기 전에 한 번 더 의심해보는 편이 좋다.

추천하는 레이어 구조

실무적으로는 세 레이어로 나누는 것이 가장 이해하기 쉽다.

core/
  sudoku-rs/
    crates/
      sudoku-core/      # 순수 Rust 도메인 로직
      sudoku-uniffi/    # iOS/Android 바인딩 표면
      sudoku-wasm/      # WebAssembly 바인딩 표면

apps/
  ios/                  # Swift/UIKit 또는 SwiftUI 앱
  android/              # Kotlin/Compose 앱
  web/                  # TypeScript/React 앱

sudoku-core는 가장 중요한 계층이다. 이 크레이트는 Swift도 Kotlin도 TypeScript도 몰라야 한다. FFI를 위한 타입 타협도 가능하면 여기로 끌고 오지 않는 편이 좋다. 내부 모델은 Rust답게 유지하고, 테스트도 이 계층에 가장 많이 둔다.

sudoku-uniffi는 모바일 바인딩 계층이다. Rust 내부 모델을 Swift/Kotlin에서 다루기 쉬운 형태로 바꾼다. 이 계층에서는 DTO, error 변환, serialization, 공개 함수 이름 같은 외부 표면을 신경 쓴다.

sudoku-wasm은 Web 바인딩 계층이다. wasm-bindgen이나 wasm-pack을 전제로 TypeScript에서 호출하기 좋은 API를 만든다. Web 앱에서 import하기 쉬운 package 형태를 목표로 잡는다.

이렇게 나누면 중요한 장점이 생긴다. Rust core 자체는 바인딩 도구에 덜 묶인다. 나중에 UniFFI의 생성 방식이 바뀌거나, Web 쪽 bundler 구성이 바뀌어도 도메인 규칙은 비교적 안정적으로 남는다.

앱 계층에는 adapter를 둔다

Rust 바인딩을 앱 UI에서 직접 호출하게 만들면 처음에는 편해 보인다. 하지만 조금만 지나면 ViewController, ViewModel, Composable, React Component가 generated binding에 의존하기 시작한다.

이 구조는 변경에 약하다. 바인딩 함수 이름이 바뀌거나, DTO가 조금만 바뀌어도 UI 코드가 넓게 흔들린다.

그래서 각 플랫폼에는 adapter를 하나 더 두는 것이 좋다.

iOS ViewModel
  -> SudokuEngineClient protocol
    -> UniFFI generated binding
      -> Rust core

Android ViewModel
  -> SudokuEngineClient interface
    -> UniFFI generated binding
      -> Rust core

Web state layer
  -> SudokuEngineClient
    -> wasm package
      -> Rust core

여기서 UI는 generated binding을 모른다. 앱이 이해하는 Swift/Kotlin/TypeScript 타입만 본다. 테스트에서는 이 adapter를 fake로 바꿀 수 있다.

좋아 보이지만 팀 단위로 도입할 때는 기준이 필요하다. “generated binding은 앱의 어느 레이어까지 들어올 수 있는가”를 정해두지 않으면, 시간이 지나면서 Rust와 UI 사이의 경계가 다시 흐려진다.

첫 번째 설계 질문

Rust core를 만들기 전에 바로 Cargo workspace부터 만들고 싶을 수 있다. 하지만 그 전에 더 중요한 질문이 있다.

“이 로직은 세 플랫폼에서 반드시 같은 결과를 내야 하는가?”

이 질문에 명확히 답할 수 있는 코드부터 Rust로 옮기는 것이 좋다. 모든 공통 코드 후보를 한 번에 옮기려고 하면 범위가 커진다. 반대로 daily seed 계산, puzzle validation, reducer처럼 결정적인 로직부터 시작하면 성공 확률이 높다.

Rust 공통 모듈의 도입은 기술 선택이기도 하지만, 앱 구조를 다시 정리하는 일이기도 하다. 어디까지 공유하고, 어디부터 플랫폼에 맡길지 정하는 순간부터 유지보수 방향이 결정된다.

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

  • 세 플랫폼에서 중복 구현된 도메인 로직을 목록화한다.
  • 그중 “반드시 같은 결과”가 필요한 로직만 먼저 고른다.
  • Rust core에는 UI, storage, network, analytics, 결제 코드를 넣지 않는다.
  • Rust 내부 모델과 외부 바인딩 모델을 분리한다.
  • iOS, Android, Web 앱에는 각각 adapter 계층을 둔다.
  • generated binding이 UI 레이어까지 직접 퍼지지 않게 기준을 정한다.
  • 첫 번째 마이그레이션 대상은 작고 결정적인 로직으로 잡는다.

마무리

Rust 공통 모듈은 플랫폼 개발을 없애주는 도구가 아니다. 오히려 플랫폼 경계를 더 명확하게 요구한다.

iOS는 iOS답게, Android는 Android답게, Web은 Web답게 만든다. Rust는 그 아래에서 같은 규칙을 보장하는 도메인 엔진으로 둔다. 이 기준이 잡히면 이후 UniFFI, XCFramework, Android .so, WebAssembly 같은 구현 선택도 훨씬 현실적으로 판단할 수 있다.

다음 글에서는 Rust core를 외부에서 호출하기 위한 FFI API를 어떻게 설계하면 좋은지 정리해보겠다.

출처

  • Rust Reference - Linkage

    • Rust 라이브러리를 외부 언어와 연결할 때 고려해야 하는 crate type과 linkage 개념을 확인했다.
  • Mozilla UniFFI - The UniFFI user guide

    • Rust 라이브러리를 Swift, Kotlin 등 외부 언어에서 호출할 수 있게 하는 바인딩 생성 흐름을 참고했다.
  • MDN Web Docs - WebAssembly

    • WebAssembly가 JavaScript와 함께 실행되는 Web 플랫폼의 compilation target이라는 기본 전제를 확인했다.
광고 Coupang Partners

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

#Rust#iOS#Android#WebAssembly
이전 글 iOS 런타임 폰트 등록, CTFontManagerRegisterFontsForURL 실무 정리
© 2026 진재명 · blog.jaemyeong.com iOS 소프트웨어 엔지니어 · 부산