Rust 공유 코어 2편: 경계를 넘는 API를 크게 잡기

여러 플랫폼이 함께 쓰는 계산 엔진을 Rust로 만들면, 엔진 안쪽에 알맞은 자료형과 앱에서 다루기 쉬운 자료형이 다르다는 문제를 만나요. 플랫폼의 편의에 맞추다 보면 core의 설계가 바깥 사정에 끌려가기도 해요.

2편의 질문은 둘이에요. 바깥에서 부르는 함수를 얼마나 크게 잡을지, 그리고 경계를 넘는 값을 어떤 자료형으로 할지예요. 스도쿠를 예로 들어 설명해요.

안쪽의 모델과 바깥의 모델

Rust 안에서는 Grid, Cell, CandidateSet, PuzzleSeed, Difficulty, Move, GameState, ValidationError 같은 타입으로 규칙을 표현해요. enum, pattern matching, ownership, trait을 그대로 쓸 수 있어요.

경계를 넘을 때에는 사정이 달라져요. generic, lifetime, 중첩된 enum, 복잡한 collection, 빌린 참조는 다른 언어로 넘길 때 표현이 달라져요. 생성 도구가 지원한다고 해도, API를 이해하기 쉬운지와 테스트하고 디버깅하기 편한지는 따로 따져야 해요. 바인딩을 만드는 방법은 UniFFI와 wasm-bindgen 문서에 있어요. 호출 단위와 계약을 아래처럼 잡는 것은 그 문서들의 요구가 아니고 제 해석이에요. 이 계약을 실제로 구현해서 실행한 예제와 그 환경은 «예제로 확인한 계약» 절에 있어요.

그래서 역할을 둘로 나눠요.

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

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

core가 바인딩 도구에 의존하지 않도록 설계하고, 도메인 로직은 가능한 한 독립적으로 유지해요. DTO, 직렬화, 오류 변환, 큰 단위의 함수는 바인딩 크레이트에 두기를 권해요.

작은 함수 여러 개보다 큰 함수 몇 개

셀 하나를 다루는 함수를 그대로 바깥에 내놓으면 이런 모양이 돼요.

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

이렇게 하면 경계를 오가는 횟수가 늘어요. 앱이 가진 상태와 엔진이 가진 상태가 어긋날 수 있고, 셀마다 렌더링을 위해 호출하면 흐름을 추적하기도 어려워요.

대신 작업 단위로 내놓아요.

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

액션에 따라 상태가 어떻게 바뀌는지는 엔진이 계산하고, 앱은 돌려받은 값을 화면용 상태로 바꿔요. 호출이 줄고 테스트의 범위가 분명해질 것으로 기대해요. 두 방식을 비교한 실험은 이 글에 들어 있지 않아요.

이 구조는 reducer와 같아요.

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

이 모델은 iOS와 Android의 ViewModel, Web의 state layer와 잘 맞아요. 각 플랫폼이 결과를 UI state로 바꾸면 돼요.

경계를 넘는 자료형

DTO에 쓸 후보는 단순한 것들이에요. string, integer, boolean, 평평한 array, 구분이 분명한 enum, optional 값이고, 필요하면 JSON 문자열을 써요.

JSON을 쓰면 편해지는 점과 비용이 함께 있어요. 상태나 액션을 큰 단위로 넘기기에는 편해요. 그 대신 직렬화 비용이 들고, 컴파일할 때의 타입 안전성이 약해질 수 있어요. 프레임마다 렌더링을 위해 부르는 호출과 사용자가 조작할 때 부르는 호출은 구분해서 판단해요. 사용자 액션 하나를 적용하는 단위라면 직렬화 비용보다 경계가 단순해지는 이득이 더 클 수 있어요. 어느 쪽 비용이 얼마나 큰지 잰 수치는 이 글에 없어요.

JSON으로 계약을 잡으면 이런 모양이에요.

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

앱 쪽에서는 Codable, kotlinx.serialization, Zod나 TypeScript type으로 해석할 수 있고, Rust 쪽에서는 serde로 해석해요. 팀이 JSON schema와 fixture를 함께 보관할 것을 제안해요. 제 생각으로는 문서보다 fixture가 더 정확한 기준이 될 수 있어요.

오류는 값으로 돌려줘요

Rust의 Result<T, E>를 바깥으로 이을 때에는 표현을 일찍 정해야 해요. 오류 처리를 뒤로 미루면 비용이 커져요. 오류를 던질지, result 객체로 돌려줄지, nullable로 나타낼지, callback의 error로 넘길지를 골라요. 저는 도메인 엔진이라면 명시적인 error 객체를 돌려주는 쪽을 선호해요.

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

역할은 이렇게 나눠요. 왜 실패했는지를 표현하는 것은 core의 일이에요. 그 액션을 무시할지, alert를 띄울지, haptic을 줄지, analytics event를 보낼지는 앱이 골라요.

메시지도 나눠요. INVALID_MOVE, PUZZLE_ALREADY_COMPLETED, SEED_OUT_OF_RANGE 같은 안정적인 machine-readable 코드와 사람이 읽는 문구를 분리해요. 한국어와 영어 번역, localization은 앱이 맡아요.

예제로 확인한 계약

이 계약을 작은 예제로 구현해서 2026년 10월 4일에 실행했어요. 예제는 구조만 확인하려고 작게 만들었고, 실제 앱에서 가져온 코드가 아니에요. 환경은 cargo 1.96.0, serde 1.0.229, serde_json 1.0.151이에요. 경계에 놓인 함수는 문자열 셋을 받아 문자열 하나를 돌려줘요. 예제에서는 이 함수를 sudoku-core crate 안에 뒀어요. 앞에서 나눈 구조대로라면 직렬화와 오류 변환은 바인딩 쪽의 일이므로, 이 점은 예제를 줄이려고 설계와 다르게 한 부분이에요. 제대로 나누려면 JSON을 다루는 함수를 바인딩 crate로 옮기거나, 두 바인딩이 함께 쓰는 별도의 crate로 빼야 해요.

pub fn apply_action_json(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
    // 이 예제의 규칙은 environment 를 쓰지 않는다. 형식만 확인한다.
    if let Err(e) = serde_json::from_str::<Environment>(environment_json) {
        return to_json(&fail("invalid_environment", &e.to_string()));
    }
    let result = match (serde_json::from_str(snapshot_json), serde_json::from_str(action_json)) {
        (Ok(snapshot), Ok(action)) => apply_action(&snapshot, &action),
        (Err(e), _) => fail("invalid_snapshot", &e.to_string()),
        (_, Err(e)) => fail("invalid_action", &e.to_string()),
    };
    to_json(&result)
}

입력을 해석하지 못해도 예외를 던지지 않고 같은 모양의 결과를 돌려줘요. fail은 ok가 false이고 error에 코드와 문구를 담은 결과를 만들고, to_json은 serde_json으로 직렬화해요. Node에서 Wasm 패키지로 다섯 가지를 호출한 출력이에요. 성공하는 액션, 충돌하는 액션, 주어진 칸을 바꾸려는 액션, 해석할 수 없는 액션, 잘못된 snapshot의 검증 순서예요. 긴 줄은 150자에서 잘라 …로 표시했어요.

valid   : {"ok":true,"snapshot":"{\"version\":1,\"seed\":0,\"givens\":\"530070000600195000098000060800060003400803001700020006060000280000419005000080079\",\"ce …
conflict: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"conflict","message":"the value conflicts with a row, column, or box"}}
given   : {"ok":false,"snapshot":null,"effects":[],"error":{"code":"given_cell","message":"a given cell cannot be changed"}}
bad json: {"ok":false,"snapshot":null,"effects":[],"error":{"code":"invalid_action","message":"unknown variant `fly`, expected `set_value` or `clear` at line 1  …
validate: {"valid":false,"errors":[{"code":"invalid_snapshot","message":"missing field `version` at line 1 column 2"}]}

이 예제에서는 ok가 true이면 snapshot에 다음 상태의 JSON 문자열이 들어 있고 error가 null이었어요. ok가 false이면 snapshot이 null이고 error가 있었어요. 오류 코드는 conflict, given_cell, out_of_range처럼 소문자로 잡았고, 문구는 영어 한 가지뿐이에요. 번역은 넣지 않았어요.

버전과 fixture로 계약을 지켜요

앱은 App Store와 Play Store에 배포된 뒤에도 기기마다 다른 릴리스가 함께 남아요. Web과 모바일은 갱신되는 속도도 달라요. 그래서 request와 response에 version을 넣고, 버전마다 fixture를 두기를 권해요.

breaking change가 있을 때에는 Rust core, Swift adapter, Kotlin adapter, Web package를 같은 PR에서 점검해요. 생성된 binding의 diff는 크고 읽기 어려울 수 있어요. 그것만 보고 리뷰하기보다 wrapper나 schema로 계약을 확인하기를 권해요. PR 템플릿에는 두 가지를 넣어 둘 수 있어요. 공개 API가 바뀌면 fixture를 추가한다는 기준과, Swift, Kotlin, TypeScript adapter의 테스트가 바뀌었는지를 묻는 항목이에요. request와 response의 fixture는 그대로 계약 테스트가 돼요.

바깥 계약이 단순하면 UniFFI, wasm-bindgen, XCFramework, Android의 .so를 잇는 작업에도 도움이 될 것으로 봐요. 이 API를 UniFFI로 iOS와 Android에서 부르는 방법은 3편에서 이어져요.

여기까지가 설계의 모양이고, 정해지지 않은 것이 남아 있어요. fixture 테스트는 예제에서 Rust와 Node 양쪽으로 실행했어요. Rust 테스트 다섯 개와 Node 테스트 두 개가 통과했어요. Swift와 Kotlin의 adapter 테스트는 만들지 않았어요. ok, snapshot, error의 조합은 예제에서 관찰한 것이고, 계약 문서로 적어 두지는 않았어요.