Rust 공유 코어 1편: 무엇을 공유하고 어디서 끊을지
세 플랫폼(iOS, Android, Web)을 함께 만들면 로직이 세 벌 생겨요. UI, 저장 방식, 수명주기가 플랫폼마다 다른 것은 자연스러워요. 문제는 퍼즐 규칙, 상태 변화, 오프라인 판정, 가격 계산, 암호 처리, 동기화 충돌 처리까지 세 번 구현할 때예요. 그러면 결함도 플랫폼마다 따로 관리해야 해요.
Rust로 로직을 공유하는 방식은 2026년 기준으로 실용적인 선택이라고 봐요. 다만 앱 전체를 합치는 것이 아니고, 공통 규칙을 맡을 엔진을 뽑아내는 데 초점을 둬요. 시리즈의 첫 편인 이 글은 무엇을 공유하고 어디서 경계를 그을지를 다뤄요.
여기에 적은 것은 설계 제안이에요. 스도쿠는 설명을 위한 예시이고, 특정 앱에서 측정한 결과나 벤치마크는 들어 있지 않아요. 다만 이 구조가 실제로 빌드되는지는 2026년 10월 4일에 작은 예제로 확인했고, 그 결과와 도구의 버전을 «직접 만들어 본 구조» 절에 실었어요. Rust의 linkage, UniFFI, WebAssembly 문서는 각 도구가 무엇을 하는지를 설명하고, 층을 나누는 방식과 공유의 기준은 제 해석이에요.
Rust가 전부 맡으면 복잡해지는 이유
플랫폼마다 따라야 할 제약이 있어요.
- iOS: UIKit과 SwiftUI의 수명주기, MainActor, App Extension, App Store 심사
- Android: Activity, ViewModel, Compose state, Gradle packaging, Play 정책
- Web: routing, hydration, browser storage, bundler, 배포 대상
Rust 쪽이 UI 상태, analytics event, 저장소 I/O, 통신, 권한까지 맡으면 이 제약들도 함께 떠안게 돼요. 그래서 무엇을 공유할지는 유지보수 책임을 어떻게 나눌지로 정해요.
엔진에 넣을 것과 앱에 둘 것
스도쿠를 예로 들면 엔진 후보는 이런 것들이에요.
- 퍼즐 생성과 solver
- 사용자 입력의 판정
- note와 value의 변경
- daily puzzle의 seed 계산
- 난이도 평가
- score와 streak 계산
- action을 다시 재생할 수 있는 reducer
앱에 남길 것은 렌더링, 접근성, 광고, 결제, push notification, analytics SDK, 저장소 선택, 통신 재시도, 심사와 배포 구성이에요.
기준은 코드가 비슷하게 생겼는지가 아니에요. 결과가 같아야 하는지예요. 같은 보드와 같은 입력에 대한 valid와 invalid 판정이 플랫폼마다 같아야 하고, 같은 seed에서 나오는 daily puzzle이 같아야 해요.
표현은 달라도 돼요. iOS에서는 UIKit의 collection view가 자연스러울 수 있고, Android에서는 Compose state가 잘 맞을 수 있어요. Web의 키보드 단축키와 포인터 조작은 따로 설계해야 해요. 지나치게 많이 공유하면 플랫폼에 맞는 조작 경험을 잃을 수 있어요.
코어에 넣을 후보는 네 조건으로 걸러요.
- 입력만으로 결과가 정해져요.
- 현재 시간, locale, storage, network에 직접 묶이지 않아요.
- UI framework와 플랫폼 SDK를 알지 못해요.
- Rust만으로 테스트할 수 있어요.
조건을 채우지 못하는 후보는 다시 검토해요. daily seed나 streak처럼 시간과 관련된 후보에 시간 값을 어떻게 넘길지는 이 글에서 다루지 않아요.
세 층으로 나눈 구조
도메인, 바인딩, 앱의 세 층을 제안해요.
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는 Rust 내부의 모델을 중심에 둬요. Swift, Kotlin, TypeScript나 FFI의 표현 제약을 가능한 한 들이지 않고, 테스트를 여기에 모아요.
sudoku-uniffi는 모바일을 위한 변환 경계예요. Swift와 Kotlin에 맞춘 DTO, error 매핑, serialization, 공개 함수의 이름을 관리해요.
sudoku-wasm은 wasm-bindgen이나 wasm-pack을 쓰는 Web 쪽 호출 경계예요. TypeScript API와, 앱에서 import할 package를 내놓는 것이 목표예요.
이렇게 나누면 코어가 바인딩 도구에 덜 묶여요. UniFFI가 코드를 생성하는 방식이나 Web bundler 설정이 달라지더라도, 도메인 규칙 쪽은 크게 흔들리지 않을 것으로 기대해요.
앱은 생성된 코드를 직접 부르지 않아요
ViewController, ViewModel, Composable, React Component가 생성된 코드에 직접 묶이면, 함수 이름이나 DTO가 바뀔 때 UI 전체가 영향을 받아요. 그래서 앱 안에 중간 계층을 둬요.
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
SudokuEngineClient는 iOS에서는 protocol, Android에서는 interface이고, Web에서는 state layer와 wasm package 사이에 놓이는 클라이언트예요. UI에 내놓는 값은 그 앱이 쓰는 언어의 타입이에요. 테스트할 때에는 adapter를 fake로 바꿔 끼울 수 있어요. 생성된 binding을 어느 층까지 써도 되는지는 팀의 규칙으로 적어 둘 필요가 있어요.
직접 만들어 본 구조
위의 세 층을 그대로 Cargo workspace로 만들어 봤어요. 구조를 확인하려고 만든 최소 예제이고, 실제 앱의 코드가 아니에요. sudoku-core에는 시작, 액션 적용, 검증, 일일 퍼즐 생성 네 가지 규칙을 넣었고, sudoku-uniffi와 sudoku-wasm은 그 함수를 감싸기만 해요. 환경은 macOS 27.0.1, Xcode 27.0, cargo 1.96.0이에요.
crate마다 직접 의존하는 것을 cargo tree --depth 1로 뽑으면 이래요.
sudoku-core v0.1.0
├── serde v1.0.229
└── serde_json v1.0.151
sudoku-uniffi v0.1.0
├── sudoku-core v0.1.0
└── uniffi v0.29.5
sudoku-wasm v0.1.0
├── sudoku-core v0.1.0
└── wasm-bindgen v0.2.129
코어는 serde와 serde_json에만 의존하고, 바인딩 crate 두 개가 코어를 불러요. 코어에서 바인딩 쪽으로 가는 의존이 없다는 것을 이 출력으로 확인했어요. 다만 예제의 코어는 JSON 직렬화(serde_json)까지 맡고 있어요. 위에서 바인딩 층의 일로 나눈 것을 예제에서는 줄여서 코어에 둔 것이에요.
코어의 테스트는 cargo test -p sudoku-core로 돌려요. 시뮬레이터나 브라우저 없이 끝나요. 전체 출력 가운데 테스트 다섯 개가 실행된 구간만 옮겼고, 앞뒤에 있는 0개짜리 결과 줄은 뺐어요.
running 5 tests
test same_seed_gives_same_snapshot ... ok
test given_cell_is_rejected ... ok
test snapshot_validation_reports_errors_as_values ... ok
test daily_puzzle_depends_only_on_the_date ... ok
test fixtures_match_expected ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
테스트 다섯 개는 같은 seed가 같은 결과를 내는지, 주어진 칸을 바꾸려는 액션이 거부되는지, 일일 퍼즐이 날짜 문자열에만 의존하는지를 봐요. 이 workspace에서 iOS, Android, Web용 산출물을 만든 과정은 이 시리즈의 3편부터 7편에 나눠 실었어요.
옮기는 순서
- 플랫폼마다 중복된 도메인 로직의 목록을 만들어요.
- 그 가운데 결과가 같아야 하는 후보를 골라요.
- 코어가 UI, storage, network, analytics, 결제에 의존하지 못하게 막아요.
- 내부 모델과 바인딩 모델을 나눠요.
- 플랫폼마다 adapter를 두고, 생성된 코드에 접근하는 규칙을 정해요.
- 작고 결정적인 로직부터 옮겨요.
처음 옮기기 좋은 후보는 daily seed 계산, puzzle validation, reducer예요. Cargo workspace를 짜기 전에 공유가 정말 필요한지부터 판단해요. 후보를 한꺼번에 옮기면 범위가 커져요.
확인 방법으로는 Rust만으로 도는 테스트와 앱 adapter의 fake를 제안해요. Rust 테스트는 위 예제에서 실행했어요. 앱 adapter의 fake는 만들어 보지 않았고, 성능 수치는 이 글에 없어요.
이 구조로도 플랫폼 개발과 심사, 배포의 책임은 그대로 남아요. 팀이 규칙을 지키지 않으면 나눠 놓은 층 사이에 의존이 다시 생길 수 있어요. 도입이 수월해지고 안정적이 될 것이라는 기대를 뒷받침하는 측정 수치는 이 글에 싣지 않았어요. 경계를 정한 뒤에는 UniFFI, XCFramework, Android의 .so, WebAssembly를 어떻게 쓸지 검토하게 되고, 다음 편에서는 바깥에서 부르는 FFI API의 설계를 다뤄요.