Rust 공유 코어 6편: Web에서 Wasm 패키지로 쓰기
iOS와 Android에서 쓰는 Rust core의 규칙을 Web에도 적용하려면, 같은 코드를 브라우저에서 돌릴 방법이 있어야 해요. WebAssembly가 그 방법이에요. 브라우저를 위한 저수준 바이너리 형식이고, Rust에서 Web을 대상으로 컴파일할 수 있으며, JavaScript와 함께 써요.
그렇다고 Web 전체를 Rust로 짜자는 것은 아니에요. 6편은 Rust와 Web이 무엇을 나눠 맡을지, 그리고 Next.js 앱에서 Wasm을 언제 어디서 초기화할지를 스도쿠를 예로 설명해요.
Rust가 맡을 일과 Web이 맡을 일
Rust 쪽 후보는 계산 결과와 규칙이 플랫폼마다 같아야 하는 것들이에요. 퍼즐 생성, solver, validation, action reducer, score 계산, daily puzzle의 seed예요.
Web 쪽에 남기기를 권하는 것은 React component, routing, localStorage와 IndexedDB, 키보드 단축키, 포인터 조작, analytics, service worker예요. DOM, fetch, hydration, 접근성, browser storage도 TypeScript와 Web 프레임워크가 맡는 편이 좋아요. Web은 Rust를 호출한 결과로 UI state를 만들어요.
도구 둘과 디렉터리
도구는 둘이 나눠 맡아요. wasm-bindgen은 Rust 함수를 JavaScript에 드러내고 wrapper와 TypeScript declaration을 만들어요. wasm-pack은 crate를 빌드하고 WebAssembly binary, JavaScript glue code, package metadata를 묶어서 npm에서 쓸 수 있는 패키지 형태로 내놓아요. 여기까지는 두 도구의 문서에 있는 내용이고, 아래의 디렉터리 구성과 초기화 방식은 제 해석이에요.
core/sudoku-rs/crates/
sudoku-core/ # 순수 Rust 로직
sudoku-wasm/ # wasm-bindgen 공개 API
packages/
sudoku-wasm/ # wasm-pack output 또는 wrapper package
apps/web/
app/ # Next.js / React 앱
sudoku-wasm이 sudoku-core를 호출해요. Web 앱은 @app/sudoku-wasm을 import하고, Rust 내부의 모델을 직접 보지 않아요. 패키지 단위로 관리하면 의존 관계와 산출물이 놓이는 자리가 드러나요. 제가 예제에서 쓴 버전과 target 옵션은 «예제로 빌드한 Wasm 패키지» 절에 적었어요. wasm-pack의 현재 문서는 출시 전의 기능도 담을 수 있다고 밝히고 있으니, 쓰는 버전에 맞는 내용인지 확인해야 해요.
Next.js에서 초기화할 자리
Next.js는 코드가 서버에서도 브라우저에서도 실행돼요. 브라우저용 모듈을 서버 컴포넌트나 정적 빌드 과정에서 실행하면 오류가 날 수 있어요. 그래서 초기화하는 자리와 시점을 정해 두기를 제안해요.
Client Component
-> dynamic import("@app/sudoku-wasm")
-> init wasm
-> create SudokuEngineClient
Client Component에서 dynamic import를 하고, Wasm을 초기화한 다음 SudokuEngineClient를 만들어요. 다른 방법으로는 state layer에서 처음 쓸 때 초기화하는 lazy initialization이 있어요.
UI가 준비 상태를 알 수 있어야 해요. 관리하기를 제안하는 상태는 다섯이에요.
- wasm loading
- wasm ready
- wasm failed
- engine action pending
- engine action failed
이 상태를 보여 주지 않으면 빈 화면이 뜨거나 버튼을 눌러도 반응이 없을 수 있어요. core가 안정적인지와 별개로, 초기화 과정이 사용자가 느끼는 안정성에 영향을 줘요. 실제 오류 메시지나 재현 절차는 이 글에 싣지 않았어요.
호출은 UI event 단위로
React의 render에서 cell마다 Rust를 부르거나 pointer move마다 Rust로 넘기는 방식은 피하기를 권해요. UI event 하나에 한 번, snapshot과 action을 넘기고 다음 snapshot과 effect를 받는 쪽이 좋아요.
앱이 보는 계약은 메서드 두 개예요.
export interface SudokuEngineClient {
startGame(request: StartGameRequest): Promise<GameSnapshot>;
applyAction(snapshot: GameSnapshot, action: GameAction): Promise<Transition>;
}
이 구조에서 React는 이 클라이언트만 쓰고, 생성된 raw function은 클라이언트 구현 안에서만 불러요. iOS, Android, Web의 SudokuEngineClient는 구현이 달라도 되지만 도메인의 뜻과 앱이 보는 계약은 같게 유지해요. 개념으로는 모바일의 adapter와 같은 자리예요.
타입은 한 번 더 손봐야 할 수 있어요. 생성된 TypeScript declaration만으로는 앱의 도메인 타입을 다 다루기 어려울 수 있어요. 그래서 별도의 wrapper를 두는 편이 좋아요. 이 wrapper에서 Wasm이 돌려준 값을 앱의 타입으로 바꾸고, JSON decoding, schema validation, error code 매핑을 해요.
예제로 빌드한 Wasm 패키지
이 구조를 작은 예제로 빌드해서 Node에서 불러 봤어요. 실제 Web 앱은 아니고, 구조를 확인하는 최소 workspace예요. 쓴 도구는 wasm-pack 0.15.0, wasm-bindgen 0.2.129, Node 24.14.1이고, 2026년 10월 4일에 실행했어요. 바인딩 crate는 코어의 함수를 감싸기만 해요. JSON을 다루는 함수까지 코어에 둔 것은 예제를 줄이려고 2편의 설계와 다르게 한 부분이에요.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn start_game(request_json: &str) -> String {
sudoku_core::start_game_json(request_json)
}
#[wasm_bindgen]
pub fn apply_action(snapshot_json: &str, action_json: &str, environment_json: &str) -> String {
sudoku_core::apply_action_json(snapshot_json, action_json, environment_json)
}
// … validate_snapshot, generate_daily_puzzle도 같은 모양이에요.
빌드 명령은 하나예요. 브라우저가 아니라 Node에서 실행하려고 target을 nodejs로 줬어요. 뒤에 붙은 grep과 sed는 출력에서 완료 줄만 남기고 절대 경로를 줄이려는 것이에요. 이 줄만 따로 실행하면 빌드가 실패해도 파이프의 마지막 명령 때문에 성공처럼 끝날 수 있어요. 실행한 스크립트는 첫머리에 set -euo pipefail을 둬서 그런 실패를 놓치지 않게 했어요.
wasm-pack build crates/sudoku-wasm --target nodejs --out-dir ../../out/wasm-node 2>&1 | grep -E "Done|ready" | sed 's#at .*/out/#at out/#'
출력 디렉터리에는 sudoku_wasm_bg.wasm, sudoku_wasm.js, sudoku_wasm.d.ts, sudoku_wasm_bg.wasm.d.ts, package.json이 생겼어요. .wasm 파일은 142,894바이트였어요. sudoku_wasm.d.ts에 생성된 TypeScript 선언은 이래요. 파일 첫머리의 lint 주석 두 줄은 뺐어요.
export function apply_action(snapshot_json: string, action_json: string, environment_json: string): string;
export function generate_daily_puzzle(request_json: string): string;
export function start_game(request_json: string): string;
export function validate_snapshot(snapshot_json: string): string;
인자와 반환값이 모두 string이에요. 앞에서 적은 대로, 이 문자열을 앱의 타입으로 바꾸고 검증하는 일은 wrapper가 따로 해야 해요. 이 패키지를 Node에서 require로 불러 함수를 호출했고, 공용 fixture를 읽는 테스트 두 개가 통과했어요. 그 테스트는 7편에 실었어요.
하지 않은 것도 적어 둬요. web이나 bundler target으로는 빌드하지 않았어요. 브라우저에서의 초기화, Next.js의 dynamic import, Promise를 돌려주는 client와 wrapper는 만들지 않았어요.
같은 fixture를 두 쪽에서 읽어요
Rust 테스트와 Web 테스트가 같은 fixture를 쓰기를 제안해요. Rust 쪽은 엔진의 출력을 검사하고, Web 쪽은 wrapper가 같은 결과를 맞게 해석하는지 검사해요. 엔진은 맞는데 타입 변환이 틀린 경우를 찾으려는 것이에요.
fixtures/
daily-seed-2026-06-24.json
apply-action-note-toggle.json
invalid-move-conflict.json
파일 이름의 날짜는 예시의 식별자예요. 테스트와 CI를 어떻게 짤지는 다음 편에서 정리해요.
이 구성에서 강조하려는 것은 속도가 아니라 세 플랫폼이 같은 도메인 규칙을 쓴다는 점이에요. 성능을 잰 수치와 초기화 실패를 처리하는 구현은 여기에 없어요. 테스트는 Node에서 fixture 두 개로 실행한 것까지예요. Promise를 돌려주는 인터페이스는 앱이 보는 계약의 예이고, Transition의 필드 정의는 이 글에 없어요.