wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.
**WebAssembly(Wasm)**는 브라우저와 서버에서 실행되는 바이너리 명령어 형식입니다. JavaScript의 대체가 아니라 보완으로, CPU 집약적 작업을 네이티브에 가까운 속도로 실행합니다.
Rust는 WebAssembly의 최고 지원 언어 중 하나입니다. 가비지 컬렉터가 없어 Wasm 바이너리에 런타임을 포함할 필요가 없고, 결과적으로 더 작고 빠른 Wasm 모듈을 생성합니다.
| 영역 | 예시 | 장점 |
|---|---|---|
| 브라우저 | 이미지 처리, 암호화, 게임 | JS 대비 10-100배 빠른 연산 |
| 서버리스 | Spin, Cloudflare Workers | 1ms 미만 콜드 스타트 |
| 플러그인 시스템 | Envoy 필터, Figma 플러그인 | 안전한 샌드박스 실행 |
| 공유 로직 | 검증, 계산 | 서버+클라이언트 코드 재사용 |
# wasm-pack 설치
cargo install wasm-pack
# 프로젝트 생성
cargo new --lib wasm-utils
cd wasm-utils[package]
name = "wasm-utils"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }wasm-bindgen은 Rust와 JavaScript 간의 인터페이스를 자동 생성합니다.
use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};
// JavaScript에서 호출 가능한 함수
#[wasm_bindgen]
pub fn validate_email(email: &str) -> bool {
// 간단한 이메일 검증 로직
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let domain_parts: Vec<&str> = parts[1].split('.').collect();
!parts[0].is_empty() && domain_parts.len() >= 2 && domain_parts.iter().all(|p| !p.is_empty())
}
#[wasm_bindgen]
pub fn calculate_price(base_price: f64, quantity: u32, discount_percent: f64) -> f64 {
let subtotal = base_price * quantity as f64;
let discount = subtotal * (discount_percent / 100.0);
(subtotal - discount).max(0.0)
}
// 구조체를 JS로 노출
#[wasm_bindgen]
#[derive(Serialize, Deserialize)]
pub struct ValidationResult {
valid: bool,
errors: Vec<String>,
}
#[wasm_bindgen]
impl ValidationResult {
#[wasm_bindgen(getter)]
pub fn valid(&self) -> bool {
self.valid
}
#[wasm_bindgen(getter)]
pub fn errors(&self) -> Vec<String> {
self.errors.clone()
}
}
#[wasm_bindgen]
pub fn validate_user_input(name: &str, email: &str, age: u32) -> ValidationResult {
let mut errors = Vec::new();
if name.is_empty() || name.len() > 100 {
errors.push("이름은 1-100자여야 합니다".to_string());
}
if !validate_email(email) {
errors.push("유효한 이메일 주소를 입력하세요".to_string());
}
if age < 1 || age > 150 {
errors.push("나이는 1-150 사이여야 합니다".to_string());
}
ValidationResult {
valid: errors.is_empty(),
errors,
}
}# npm 패키지로 빌드
wasm-pack build --target web --out-dir pkg
# 생성 파일:
# pkg/wasm_utils_bg.wasm — Wasm 바이너리
# pkg/wasm_utils.js — JS 글루 코드
# pkg/wasm_utils.d.ts — TypeScript 타입 정의
# pkg/package.json — npm 패키지 메타데이터<script type="module">
import init, { validate_email, calculate_price, validate_user_input } from './pkg/wasm_utils.js';
async function main() {
await init();
// 이메일 검증
console.log(validate_email('user@example.com')); // true
console.log(validate_email('invalid')); // false
// 가격 계산
const price = calculate_price(10000, 3, 15);
console.log(`최종 가격: ${price}원`); // 25500원
// 사용자 입력 검증
const result = validate_user_input('Alice', 'alice@example.com', 25);
console.log(`유효: ${result.valid}`);
console.log(`오류: ${result.errors}`);
}
main();
</script>wasm-pack의 --target web 옵션은 ES 모듈로 빌드합니다. 번들러(Webpack, Vite 등)를 사용하는 경우 --target bundler를, Node.js에서 사용하는 경우 --target nodejs를 지정합니다.
**WASI(WebAssembly System Interface)**는 Wasm 모듈이 파일 시스템, 네트워크 등 시스템 리소스에 접근할 수 있게 하는 표준 인터페이스입니다.
rustup target add wasm32-wasip1fn main() {
println!("WASI에서 실행 중!");
// 환경 변수 접근
if let Ok(value) = std::env::var("MY_CONFIG") {
println!("설정: {}", value);
}
// 파일 시스템 접근 (샌드박스 내에서)
let data = std::fs::read_to_string("/data/input.txt")
.unwrap_or_else(|_| "파일 없음".to_string());
println!("데이터: {}", data);
}# 빌드
cargo build --target wasm32-wasip1 --release
# Wasmtime으로 실행
wasmtime target/wasm32-wasip1/release/myapp.wasm
# 파일 시스템 디렉토리 매핑
wasmtime --dir /data::./local_data target/wasm32-wasip1/release/myapp.wasmSpin은 Fermyon이 개발한 WebAssembly 서버리스 프레임워크입니다. Wasm 모듈을 HTTP 핸들러로 실행하며, 콜드 스타트가 1ms 미만입니다.
# Spin CLI 설치
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
# Rust HTTP 핸들러 프로젝트 생성
spin new -t http-rust my-spin-app
cd my-spin-appuse spin_sdk::http::{IntoResponse, Request, Response};
use spin_sdk::http_component;
use serde::Serialize;
#[derive(Serialize)]
struct HealthResponse {
status: String,
runtime: String,
}
#[http_component]
fn handle_request(req: Request) -> anyhow::Result<impl IntoResponse> {
let path = req.uri().path();
match path {
"/health" => {
let body = serde_json::to_string(&HealthResponse {
status: "healthy".to_string(),
runtime: "spin-wasm".to_string(),
})?;
Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.body(body)
.build())
}
"/api/validate" => {
// 요청 본문에서 데이터 추출
let body = req.body();
let input: serde_json::Value = serde_json::from_slice(body)?;
let name = input["name"].as_str().unwrap_or("");
let valid = !name.is_empty() && name.len() <= 100;
let response = serde_json::json!({
"valid": valid,
"field": "name",
});
Ok(Response::builder()
.status(200)
.header("content-type", "application/json")
.body(response.to_string())
.build())
}
_ => {
Ok(Response::builder()
.status(404)
.body("Not Found")
.build())
}
}
}spin_manifest_version = 2
[application]
name = "my-spin-app"
version = "0.1.0"
[[trigger.http]]
route = "/..."
component = "my-spin-app"
[component.my-spin-app]
source = "target/wasm32-wasip1/release/my_spin_app.wasm"
[component.my-spin-app.build]
command = "cargo build --target wasm32-wasip1 --release"# 빌드 및 실행
spin build
spin up
# 테스트
curl http://localhost:3000/health
curl -X POST http://localhost:3000/api/validate \
-H "Content-Type: application/json" \
-d '{"name": "Alice"}'Spin의 Wasm 핸들러는 요청당 새 인스턴스를 생성하지만, Wasm 모듈의 인스턴스화는 마이크로초 단위로 이루어집니다. 이는 컨테이너 기반 서버리스(AWS Lambda 등)의 수백 밀리초 콜드 스타트와 비교하면 압도적인 차이입니다.
Rust의 강력한 장점 중 하나는 동일한 비즈니스 로직을 서버와 클라이언트에서 재사용할 수 있다는 것입니다.
workspace/
Cargo.toml # 워크스페이스 루트
shared/ # 공유 비즈니스 로직
Cargo.toml
src/lib.rs
server/ # Axum 서버
Cargo.toml
src/main.rs
wasm-client/ # 브라우저 Wasm
Cargo.toml
src/lib.rs[workspace]
members = ["shared", "server", "wasm-client"]
resolver = "2"use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
pub items: Vec<OrderItem>,
pub coupon_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderItem {
pub product_id: String,
pub quantity: u32,
pub unit_price: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderSummary {
pub subtotal: f64,
pub discount: f64,
pub tax: f64,
pub total: f64,
}
// 이 함수는 서버와 브라우저 모두에서 동일하게 실행됨
pub fn calculate_order(order: &Order) -> OrderSummary {
let subtotal: f64 = order
.items
.iter()
.map(|item| item.unit_price * item.quantity as f64)
.sum();
let discount = match order.coupon_code.as_deref() {
Some("WELCOME10") => subtotal * 0.10,
Some("VIP20") => subtotal * 0.20,
_ => 0.0,
};
let taxable = subtotal - discount;
let tax = taxable * 0.10; // 10% 부가세
OrderSummary {
subtotal,
discount,
tax,
total: taxable + tax,
}
}
pub fn validate_order(order: &Order) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
if order.items.is_empty() {
errors.push("주문 항목이 비어 있습니다".to_string());
}
for (i, item) in order.items.iter().enumerate() {
if item.quantity == 0 {
errors.push(format!("항목 {}의 수량은 0보다 커야 합니다", i + 1));
}
if item.unit_price < 0.0 {
errors.push(format!("항목 {}의 가격은 음수일 수 없습니다", i + 1));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}서버에서는 이 공유 모듈을 직접 의존성으로 사용하고, 브라우저용 Wasm 모듈에서는 wasm-bindgen으로 감싸서 JS에 노출합니다.
Wasm 바이너리 크기는 로딩 시간에 직접 영향을 미칩니다. 최적화 설정을 적용합니다.
[profile.release]
opt-level = "z" # 크기 최적화 (속도보다 크기 우선)
lto = true # 링크 타임 최적화
codegen-units = 1 # 단일 코드 생성 유닛 (더 나은 최적화)
strip = true # 디버그 심볼 제거
panic = "abort" # 패닉 시 unwind 대신 abort (바이너리 크기 감소)# wasm-opt로 추가 최적화 (binaryen 패키지)
wasm-opt -Oz -o output.wasm input.wasm
# 크기 확인
ls -lh pkg/*.wasm
# 크기 분석
cargo install twiggy
twiggy top pkg/wasm_utils_bg.wasm| 최적화 단계 | 예상 크기 |
|---|---|
| 기본 릴리스 빌드 | ~200KB |
| opt-level="z" + LTO | ~100KB |
| wasm-opt -Oz 적용 | ~70KB |
| 불필요 기능 제거 후 | ~30-50KB |
opt-level = "z"는 크기를 최소화하지만 실행 속도가 약간 느려질 수 있습니다. 성능이 중요한 경우 opt-level = "s" (크기 최적화이지만 z보다 덜 공격적) 또는 opt-level = 3 (속도 최적화)을 사용하세요.
이 장에서는 Rust와 WebAssembly의 결합을 다루었습니다.
다음 장에서는 시리즈의 마지막으로 실전 프로젝트를 진행합니다. 지금까지 배운 모든 내용을 종합하여 프로덕션 수준의 Rust 백엔드 API를 처음부터 끝까지 구축합니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Axum, SQLx, Tokio를 조합한 프로덕션 수준의 REST API를 처음부터 구축합니다. JWT 인증, CRUD, 미들웨어, 테스트, Docker 배포까지 총정리합니다.
Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.
Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.