Rust와 WebAssembly
WebAssembly(줄여서 Wasm이라고 많이 불러요)를 쓰면 Rust 같은 언어로 짠 코드를 웹 브라우저 안에서 JavaScript와 나란히 실행할 수 있어요. 브라우저 안의 두 번째 엔진이라고 생각하면 돼요. 페이지를 움직이는 건 여전히 JavaScript지만, 이미지 필터, 물리 계산, 큰 파일 파싱처럼 무거운 일이 생기면 빠르게 컴파일된 Rust 코드에게 넘길 수 있어요.
이 페이지에서는 작은 Rust 라이브러리를 만들고, Wasm으로 컴파일해서, 웹 페이지에서 호출해 볼 거예요. 여기 나오는 내용은 Rust Playground에서는 실행되지 않아요(브라우저와 추가 도구가 필요해요). 그래서 대부분의 예제는 여러분의 컴퓨터에서 실행할 평범한 코드 블록이에요. 하지만 순수 Rust 부분은 바로 실행해 볼 수 있어요.
WebAssembly란 무엇이고, 왜 Rust와 잘 맞을까
섹션 제목: “WebAssembly란 무엇이고, 왜 Rust와 잘 맞을까”WebAssembly는 프로그램을 담는 작은 바이너리 형식이에요. 모든 주요 브라우저가 JavaScript와 같은 보안 샌드박스 안에서, 네이티브에 가까운 속도로 실행할 수 있어요. 브라우저 전용도 아니에요. 서버와 엣지 플랫폼에서도 Wasm을 실행해요.
Rust는 Wasm에서 가장 인기 있는 언어 중 하나인데, 이유가 몇 가지 있어요.
- 가비지 컬렉터도, 큰 런타임도 없어요. Rust는 코드와 함께 추가 장치를 실어 보낼 필요가 없어서,
.wasm파일이 작아질 수 있어요. - 도구가 훌륭해요.
wasm-bindgen이 Rust와 JavaScript를 잇는 연결 코드(glue code)를 대신 써 주고,wasm-pack은 명령 하나로 모든 것을 빌드해요. - 안전하고 빨라요. 데스크톱에서와 똑같은 메모리 안전성과 성능을 누릴 수 있어요.
도구 준비하기
섹션 제목: “도구 준비하기”먼저 Rust에게 Wasm 타깃(target)으로 컴파일할 수 있다고 알려 줘요. 타깃은 “코드가 실행될 기계의 종류”예요. wasm32-unknown-unknown은 “32비트 Wasm, 특정 운영체제 없음”이라는 뜻이에요.
rustup target add wasm32-unknown-unknown다음으로 wasm-pack을 설치해요. wasm-pack은 크레이트를 컴파일하고, wasm-bindgen을 실행하고, 필요한 모든 것을 한 폴더에 모아 줘요. 공식 설치 프로그램을 쓰거나, Cargo로 빌드하면 돼요.
# wasm-pack 웹사이트의 Linux / macOS 설치 프로그램curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# 또는 Rust가 설치된 어떤 시스템에서든cargo install wasm-pack --lockedWasm용 Rust 라이브러리 작성하기
섹션 제목: “Wasm용 Rust 라이브러리 작성하기”Wasm 모듈은 main이 있는 프로그램이 아니라 라이브러리예요. 하나 만들어 볼게요.
cargo new --lib hello-wasmcd hello-wasm그다음 Cargo.toml을 고쳐요. crate-type 줄은 Cargo에게 .wasm에 맞는 라이브러리(cdylib)를 만들라고 알려 주고, wasm-bindgen은 Rust와 JavaScript를 이어 주는 크레이트예요.
[package]name = "hello-wasm"version = "0.1.0"edition = "2021"
[lib]crate-type = ["cdylib", "rlib"]
[dependencies]wasm-bindgen = "0.2"이제 src/lib.rs에 함수 두 개를 작성해요. #[wasm_bindgen] 속성(attribute)은 각 함수를 “JavaScript에서 호출할 수 있음”으로 표시해요.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn add(a: i32, b: i32) -> i32 { a + b}
#[wasm_bindgen]pub fn greet(name: &str) -> String { format!("안녕하세요, {name}! 이 문자열은 Rust에서 만들었어요.")}필요한 Rust 코드는 이게 전부예요. 둘 다 평범한 함수라는 점을 눈여겨보세요. JavaScript 문자열을 &str로 바꾸고, 반환된 String을 다시 JavaScript 문자열로 바꾸는 일은 wasm-bindgen이 알아서 해 줘요.
먼저 네이티브로 로직 테스트하기
섹션 제목: “먼저 네이티브로 로직 테스트하기”시간을 많이 아껴 주는 습관이 하나 있어요. 실제 작업은 평범한 Rust 함수에 두고, 평소 쓰는 컴퓨터에서 cargo test나 간단한 main으로 테스트하는 거예요. 브라우저에서 디버깅하는 건 터미널에서 디버깅하는 것보다 느리거든요. 속성만 뺀 같은 로직은 어디서든 실행돼요.
fn add(a: i32, b: i32) -> i32 { a + b}
fn greet(name: &str) -> String { format!("안녕하세요, {name}! 이 문자열은 Rust에서 만들었어요.")}
fn main() { println!("{}", greet("Ferris")); println!("2 + 3 = {}", add(2, 3));}실행 결과:
안녕하세요, Ferris! 이 문자열은 Rust에서 만들었어요.2 + 3 = 5로직이 맞다는 걸 확인하고 나면, #[wasm_bindgen] 래퍼는 브라우저로 통하는 얇은 문일 뿐이에요. (#[test]로 표시한 테스트도 cargo test로 네이티브에서 실행돼요. 테스트를 참고하세요.)
wasm-pack으로 빌드하기
섹션 제목: “wasm-pack으로 빌드하기”크레이트 폴더에서 다음을 실행하세요.
wasm-pack build --target web--target web은 “webpack 같은 번들러 없이, 브라우저가 <script type="module"> 태그로 바로 불러올 수 있는 파일을 만들어 줘”라는 뜻이에요. 저희가 실행했을 때 새로 생긴 pkg/ 폴더에는 이런 파일이 들어 있었어요.
pkg/├── hello_wasm_bg.wasm 컴파일된 Rust 코드├── hello_wasm.js Wasm을 불러오고 각 함수를 감싸는 JavaScript 연결 코드├── hello_wasm.d.ts 함수들의 TypeScript 타입├── hello_wasm_bg.wasm.d.ts└── package.jsonhello-wasm의 하이픈(-)이 파일 이름에서는 밑줄(_)로 바뀌었어요. wasm-pack build는 기본적으로 릴리스(최적화) 빌드를 만들어요. 이것저것 실험하는 동안에는 --dev를 붙이면 최적화하지 않는 대신 더 빨리 빌드돼요.
JavaScript에서 호출하기
섹션 제목: “JavaScript에서 호출하기”Cargo.toml 옆에 index.html을 만드세요(그러면 pkg/가 바로 옆에 있게 돼요).
<!doctype html><html lang="ko"><head> <meta charset="utf-8"> <title>Rust가 보내는 인사</title></head><body> <p id="message">불러오는 중…</p>
<script type="module"> import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // .wasm 파일을 내려받고 시작해요
const text = greet("Ferris"); const sum = add(2, 3); document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`; </script></body></html>기본 내보내기(default export)인 init()이 .wasm 파일을 내려받고 시작해요. 그다음부터 add와 greet는 평범한 JavaScript 함수예요.
브라우저는 file:// 주소에서 모듈을 불러오지 않기 때문에, 작은 로컬 웹 서버가 필요해요. Python이 설치되어 있다면 이게 가장 빨라요.
python3 -m http.server 8080브라우저에서 http://localhost:8080을 열어 보세요. 페이지에 이렇게 표시돼요.
안녕하세요, Ferris! 이 문자열은 Rust에서 만들었어요. 2 + 3 = 5이 문장은 브라우저 안에서 실행되는 Rust가 만든 거예요. 🦀
경계를 싸게 넘는 것과 비싸게 넘는 것
섹션 제목: “경계를 싸게 넘는 것과 비싸게 넘는 것”Rust와 JavaScript는 메모리를 같은 방식으로 공유하지 않아요. Wasm 모듈에는 자기만의 메모리 블록이 있고, 값은 두 쪽 사이를 오가야 해요. 공짜로 오가는 값도 있고, 복사 비용이 드는 값도 있어요.
| Rust 타입 | JavaScript에서 | 비용 |
|---|---|---|
i32, u32, f64, bool |
number, boolean |
저렴해요: 그대로 전달돼요 |
&str, String |
string |
복사하고, UTF-16을 UTF-8로(또는 반대로) 변환해요 |
&[u8], Vec<u8> |
Uint8Array |
Wasm 메모리 안으로(또는 밖으로) 복사해요 |
&mut [u8] |
Uint8Array |
안으로 복사했다가, 호출이 끝나면 다시 밖으로 복사해요 |
그래서 황금률은 이거예요. 호출 횟수는 적게, 한 번에 많은 일을 하세요. 이미지 전체를 처리하려고 Rust를 한 번 호출하는 건 빨라요. 픽셀마다 한 번씩, Rust를 백만 번 호출하면 대부분의 시간을 경계를 넘는 데 써 버려요.
예를 들어 볼게요. 이 함수는 픽셀 값이 담긴 버퍼 전체를 한 번의 호출로 밝게 만들어요.
use wasm_bindgen::prelude::*;
/// 모든 픽셀을 더 밝게 만들어요. JS에서는 Uint8Array를 넘겨요.#[wasm_bindgen]pub fn brighten(pixels: &mut [u8], amount: u8) { for p in pixels.iter_mut() { *p = p.saturating_add(amount); }}JavaScript에서는 await init() 뒤에 Uint8Array를 넘겨서 brighten(pixels, 20)처럼 호출하면 되고, 배열은 그 자리에서 바뀌어요. 반복문 자체는 순수 Rust라서 네이티브로 테스트할 수 있어요.
fn brighten(pixels: &mut [u8], amount: u8) { for p in pixels.iter_mut() { // saturating_add는 오버플로하지 않고 255에서 멈춰요 *p = p.saturating_add(amount); }}
fn main() { let mut pixels = [10, 100, 250]; brighten(&mut pixels, 20); println!("{pixels:?}");}실행 결과:
[30, 120, 255]250 + 20이 프로그램이 죽거나 값이 처음으로 돌아가는(wrap-around) 대신 255가 된 것을 보세요. saturating_add는 최댓값에서 멈춰요. 색을 다룰 때 딱 원하는 동작이죠.
.wasm 파일을 작게 유지하기
섹션 제목: “.wasm 파일을 작게 유지하기”.wasm 파일은 한 바이트도 빠짐없이 내려받아야 페이지에서 쓸 수 있어요. 몇 가지 팁을 소개할게요.
- 배포할 때는 항상 릴리스 빌드를 쓰세요. 저희의 작은 예제(영어 원문 버전)에서
wasm-pack build --dev는 58,591바이트짜리.wasm파일을 만들었고, 기본 릴리스 빌드는 21,530바이트였어요. (wasm-opt를 끈 상태로 저희 컴퓨터에서 잰 숫자예요. 여러분의 숫자는 다를 거예요.) - wasm-pack이
wasm-opt를 실행하게 두세요. 릴리스 모드에서 wasm-pack은 기본적으로 Binaryen의wasm-opt최적화 도구를 결과물에 실행해요. 보통 크기가 더 줄어요. - 서버에서 압축을 켜세요. 21,530바이트였던 저희 파일은 gzip을 거치니 8,464바이트가 됐어요.
- 크기에 초점을 맞춘 설정을 시도해 보고, 측정하세요.
[profile.release]opt-level = "z" # 크기를 줄이도록 최적화해요 ("s"는 조금 덜 강한 버전이에요)lto = truecodegen-units = 1스스로에게 솔직해지고, 측정하세요. 함수 두 개짜리 저희 예제에서는 이 설정이 도움이 되지 않았어요. 파일이 22,817바이트로 오히려 조금 더 커졌거든요. 이런 설정은 더 큰 크레이트에서 효과를 보는 경우가 많아요. 또 format!이나 패닉 메시지 같은 것도 코드를 끌어들이기 때문에, 이런 걸 피한 크레이트는 훨씬 작아질 수 있어요.
실제로 Wasm을 쓰는 곳
섹션 제목: “실제로 Wasm을 쓰는 곳”몇 가지 예를 각 회사가 직접 쓴 글과 함께 소개할게요(모두 영어).
- Figma는 C++로 만든 디자인 편집기를 WebAssembly로 컴파일했고, 로딩 시간을 3배 줄였다고 발표했어요. (Rust는 아니지만, 큰 앱에서 Wasm이 무엇을 할 수 있는지 잘 보여 줘요.)
- Cloudflare Workers는
workers-rs크레이트를 통해 Rust를 지원해요. Rust 코드가 Wasm으로 컴파일되어 전 세계의 Cloudflare 서버에서 실행돼요. - Shopify Functions는 스토어를 커스터마이즈하는 WebAssembly 모듈이고, Shopify는 이를 위한 가장 성능 좋은 언어로 Rust를 추천해요.
- 1Password의 브라우저 확장 프로그램은 페이지 자동 채우기 기능을 WebAssembly로 옮겼고, 적어도 두 배는 빨라졌다고 발표했어요.
자주 하는 실수
섹션 제목: “자주 하는 실수”crate-type = ["cdylib"]을 빠뜨리기. 이게 없으면 wasm-pack은 바로 멈춰요. 다행히 에러 메시지가 무엇을 추가해야 하는지 정확히 알려 줘요.
Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]crate-type = ["cdylib", "rlib"]“WebAssembly로 컴파일하려면 crate-type이 cdylib이어야 하니, Cargo.toml에 다음을 추가하라”는 뜻이에요.
index.html을 더블클릭해서 열기. 페이지는 “불러오는 중…“에서 멈추고, (Chrome의) 브라우저 콘솔에는 이렇게 나와요.
Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.CORS 정책 때문에 file:// 주소에서는 스크립트를 불러올 수 없다는 메시지예요. 해결 방법은 python3 -m http.server 같은 로컬 웹 서버로 폴더를 제공하고, 대신 http://localhost 주소를 여는 거예요.
await init() 전에 함수 호출하기. Wasm 모듈이 아직 로드되지 않아서, 연결 코드가 호출할 대상이 없어요. 저희가 init() 줄을 지웠더니 Chrome에 Cannot read properties of undefined (reading '__wbindgen_free')라는 에러가 나왔어요. 항상 await init()부터 하세요.
다음 단계: Rust로 만드는 웹 앱 전체
섹션 제목: “다음 단계: Rust로 만드는 웹 앱 전체”JavaScript에서 Rust 함수 몇 개를 호출하는 것만으로도 훌륭한 출발이에요. 웹 앱 전체를 Rust로 만들고 싶다면 다음 프레임워크를 살펴보세요. 둘 다 Wasm으로 컴파일되고, React와 조금 비슷하게 Rust로 컴포넌트를 작성할 수 있어요.
- WebAssembly는 브라우저(와 서버)에서 실행할 수 있는 빠르고 안전한 바이너리 형식이에요. Rust는 가비지 컬렉터가 없어서 Wasm으로 잘 컴파일돼요.
rustup target add wasm32-unknown-unknown으로 타깃을 추가하고,wasm-pack을 설치하세요.- 함수에
#[wasm_bindgen]을 붙이고,crate-type = ["cdylib", "rlib"]을 설정하고,wasm-pack build --target web으로 빌드해요. - JavaScript에서는 먼저
await init()을 하고 나서 함수를 호출하세요. 페이지는file://가 아니라http://로 제공해야 해요. - 숫자는 경계를 싸게 넘지만, 문자열과 슬라이스는 복사돼요. 그러니 호출은 적게, 한 번에 많은 일을 하게 하세요.
- 릴리스 빌드를 압축해서 배포하고, 크기 관련 설정은 측정해 본 뒤에 믿으세요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. 브라우저용 WebAssembly 타깃을 Rust에 추가하는 명령은 무엇일까요?
2. #[wasm_bindgen] 속성은 무슨 일을 하나요?
3. 페이지가 "불러오는 중…"에서 멈추고, 콘솔에 file:/// 주소에 대한 CORS 에러가 나와요. 어떻게 고치면 될까요?
4. JavaScript에서 Rust로 넘길 때 가장 비용이 적게 드는 것은 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- Rust를 WebAssembly로 컴파일하기 - MDN: wasm-pack 프로젝트를 만들고 실행해 보는 단계별 튜토리얼이에요.
- The wasm-bindgen Guide (영어): 지원하는 모든 타입, 브라우저 API를 호출하는 방법, 그리고 많은 예제가 있어요.
- wasm-pack 문서 (영어):
build의 모든 옵션, 타깃, 프로필을 다뤄요. - wasm32-unknown-unknown - The rustc book (영어): 이 타깃이 무엇을 지원하고 어떤 제한이 있는지 설명해요.
- 웹어셈블리 - MDN: Rust와 상관없이, Wasm이 무엇이고 브라우저가 어떻게 실행하는지 설명해요.