# Rust 공통 모듈을 크로스플랫폼에서 공유하기 - 6편. WebAssembly로 Web에 배포하기

> Rust 공통 모듈을 WebAssembly로 빌드해 Web 앱의 도메인 엔진으로 쓰는 구조를 wasm-bindgen, wasm-pack, browser 초기화 경계, FFI 계약, fixture 공유 기준으로 정리합니다.

- Canonical: https://blog.jaemyeong.com/posts/rust-shared-core-06-web-wasm/
- Published: 2026.07.24
- Category: 기술
- Tags: #Rust, #WebAssembly, #wasm-bindgen, #wasm-pack, #TypeScript

iOS와 Android에서 Rust core를 공유했다면 Web에서도 같은 로직을 쓰고 싶어진다.

Web에서는 Rust를 WebAssembly로 빌드하는 방식이 자연스럽다. wasm-bindgen은 Rust와 JavaScript 사이의 상호작용을 도와주고, wasm-pack은 Rust crate를 npm 생태계에서 사용할 수 있는 형태로 묶어준다.

하지만 WebAssembly를 도입한다고 해서 Web 앱 전체가 Rust 중심으로 바뀌는 것은 아니다. DOM, routing, browser storage, fetch, hydration, accessibility는 여전히 TypeScript와 Web framework 쪽이 자연스럽다. Rust는 계산과 규칙의 일관성이 중요한 부분을 맡는 편이 좋다.

이번 글에서는 Rust core를 Web에서 어떻게 패키지처럼 다루면 좋은지 정리한다.

## wasm은 Web 앱의 한 모듈이다

WebAssembly는 modern browser에서 실행되는 low-level binary format이다. Rust 같은 언어를 Web target으로 컴파일할 수 있고, JavaScript와 함께 실행된다.

여기서 중요한 표현은 “함께 실행된다”다. wasm이 JavaScript를 대체한다고 보면 설계가 어긋난다. 실제 Web 앱에서는 TypeScript가 UI와 browser API를 다루고, wasm module은 특정 계산을 맡는 구조가 더 자연스럽다.

스도쿠 앱을 예로 들면 Rust/Wasm이 맡기 좋은 영역은 이렇다.

- 퍼즐 생성
- solver
- validation
- action reducer
- score 계산
- daily puzzle seed

반대로 다음 영역은 TypeScript에 남기는 편이 좋다.

- React component
- routing
- localStorage 또는 IndexedDB 접근
- keyboard shortcut
- pointer interaction
- analytics
- service worker

즉 Web에서도 원칙은 모바일과 같다. Rust는 도메인 엔진이고, Web 앱은 그 엔진을 호출해 UI state를 구성한다.

## wasm-bindgen과 wasm-pack의 역할

wasm-bindgen은 Rust와 JavaScript 사이의 고수준 상호작용을 가능하게 한다. Rust 함수를 JavaScript에서 호출할 수 있게 하고, 필요한 wrapper와 TypeScript declaration을 생성하는 흐름을 제공한다.

wasm-pack은 한 단계 더 앱 개발자에게 가까운 도구다. Rust crate를 빌드해 WebAssembly binary, JavaScript glue code, package metadata를 만들어준다. 결과물을 npm package처럼 다룰 수 있다.

실무 구조는 이렇게 잡을 수 있다.

```text
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` crate는 `sudoku-core`를 호출한다. Web 앱은 직접 Rust 내부 모델을 알지 않고, npm package처럼 `@app/sudoku-wasm`을 import한다.

이 구조의 장점은 Web 앱 관점에서 의존성이 명확하다는 점이다. Rust build output이 앱 내부 어딘가에 숨어 있지 않고, package 단위로 관리된다.

## 초기화 경계를 분리한다

Web에서 wasm을 쓸 때 가장 조심해야 할 부분은 초기화 시점이다.

Next.js 같은 framework에서는 server environment와 browser environment가 섞인다. browser 전용 wasm module을 서버 컴포넌트나 static build 시점에 바로 실행하려고 하면 문제가 생길 수 있다.

그래서 wasm engine을 사용하는 코드는 browser boundary 안에 두는 편이 안전하다.

```text
Client Component
  -> dynamic import("@app/sudoku-wasm")
  -> init wasm
  -> create SudokuEngineClient
```

또는 Web 앱의 state layer에서 lazy initialization을 관리할 수 있다. 중요한 것은 UI가 wasm 초기화 상태를 알 수 있어야 한다는 점이다. 초기화 중, 실패, 준비 완료 상태를 명확히 다루지 않으면 사용자는 빈 화면이나 멈춘 버튼을 보게 된다.

실제 앱에서는 이런 상태가 필요하다.

- wasm loading
- wasm ready
- wasm failed
- engine action pending
- engine action failed

Rust core가 안정적이어도 Web 초기화 경험이 나쁘면 사용자에게는 앱이 불안정해 보인다.

## FFI 경계는 Web에서도 굵게 가져간다

Web에서도 FFI API를 너무 잘게 쪼개면 좋지 않다.

React render 중에 cell마다 wasm 함수를 호출하거나, pointer move마다 Rust를 호출하는 구조는 피하고 싶다. UI event 하나에 대해 현재 snapshot과 action을 넘기고, 다음 snapshot과 effect를 받는 방식이 낫다.

예를 들어 TypeScript에서는 이런 형태로 감싼다.

```ts
export interface SudokuEngineClient {
  startGame(request: StartGameRequest): Promise<GameSnapshot>;
  applyAction(snapshot: GameSnapshot, action: GameAction): Promise<Transition>;
}
```

내부 구현은 wasm package를 호출한다. React component는 이 client만 알고, wasm-bindgen이 생성한 raw function은 모른다.

이 구조는 모바일 adapter와도 개념적으로 맞아 떨어진다. iOS의 `SudokuEngineClient`, Android의 `SudokuEngineClient`, Web의 `SudokuEngineClient`가 같은 도메인 의미를 갖게 된다. 구현은 플랫폼마다 다르지만 앱이 기대하는 계약은 같다.

## TypeScript 타입과 fixture를 같이 관리한다

wasm-pack이 TypeScript declaration을 만들어주더라도, 앱 도메인 타입을 그대로 맡겨두기에는 부족할 수 있다.

Web 앱에서는 Rust/Wasm output을 앱 내부 type으로 변환하는 wrapper를 두는 편이 좋다. 이 wrapper에서 JSON decoding, schema validation, error code mapping을 처리한다.

특히 Rust core와 Web 앱 사이에는 fixture가 중요하다.

```text
fixtures/
  daily-seed-2026-06-24.json
  apply-action-note-toggle.json
  invalid-move-conflict.json
```

같은 fixture를 Rust test와 Web test에서 같이 쓰면 좋다. Rust에서는 engine output을 검증하고, Web에서는 TypeScript wrapper가 같은 output을 올바르게 해석하는지 검증한다.

이렇게 하면 “Rust core는 맞는데 Web wrapper가 타입을 잘못 해석한” 문제를 잡기 쉽다.

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

- WebAssembly를 Web 앱 전체의 대체재가 아니라 도메인 엔진 모듈로 본다.
- Rust/Wasm에는 계산과 규칙 일관성이 중요한 로직만 넣는다.
- wasm-bindgen 공개 API는 `sudoku-wasm` 같은 별도 crate에 둔다.
- wasm-pack output은 npm package처럼 관리한다.
- Next.js 같은 framework에서는 browser-only 초기화 경계를 분리한다.
- UI에는 loading, ready, failed 상태를 명확히 제공한다.
- wasm raw function은 TypeScript adapter 내부에 숨긴다.
- Rust fixture와 Web fixture를 공유해 같은 결과를 검증한다.

## 마무리

Web에서 Rust core를 쓰는 일은 성능 최적화만의 문제가 아니다. iOS, Android와 같은 도메인 규칙을 Web에서도 보장하기 위한 구조적 선택에 가깝다.

다만 Web은 Web답게 남겨야 한다. React, routing, browser API, hydration 경계는 TypeScript가 다루고, Rust/Wasm은 결정적인 도메인 계산을 맡는다. 이 경계가 선명하면 wasm은 부담이 아니라 꽤 좋은 공통 엔진 배포 단위가 된다.

다음 글에서는 이 구조가 실제로 유지되도록 테스트와 CI를 어떻게 잡아야 하는지 정리해보겠다.

## 출처

- [MDN Web Docs - WebAssembly](https://developer.mozilla.org/en-US/docs/WebAssembly)
- [Rust and WebAssembly - The wasm-bindgen Guide](https://rustwasm.github.io/docs/wasm-bindgen/)
- [Rust and WebAssembly - The wasm-pack Book](https://rustwasm.github.io/docs/wasm-pack/)
