콘텐츠로 이동

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, 특정 운영체제 없음”이라는 뜻이에요.

Terminal window
rustup target add wasm32-unknown-unknown

다음으로 wasm-pack을 설치해요. wasm-pack은 크레이트를 컴파일하고, wasm-bindgen을 실행하고, 필요한 모든 것을 한 폴더에 모아 줘요. 공식 설치 프로그램을 쓰거나, Cargo로 빌드하면 돼요.

Terminal window
# wasm-pack 웹사이트의 Linux / macOS 설치 프로그램
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# 또는 Rust가 설치된 어떤 시스템에서든
cargo install wasm-pack --locked

Wasm 모듈은 main이 있는 프로그램이 아니라 라이브러리예요. 하나 만들어 볼게요.

Terminal window
cargo new --lib hello-wasm
cd 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로 네이티브에서 실행돼요. 테스트를 참고하세요.)

크레이트 폴더에서 다음을 실행하세요.

Terminal window
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.json

hello-wasm의 하이픈(-)이 파일 이름에서는 밑줄(_)로 바뀌었어요. wasm-pack build는 기본적으로 릴리스(최적화) 빌드를 만들어요. 이것저것 실험하는 동안에는 --dev를 붙이면 최적화하지 않는 대신 더 빨리 빌드돼요.

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이 설치되어 있다면 이게 가장 빨라요.

Terminal window
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-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 = true
codegen-units = 1

스스로에게 솔직해지고, 측정하세요. 함수 두 개짜리 저희 예제에서는 이 설정이 도움이 되지 않았어요. 파일이 22,817바이트로 오히려 조금 더 커졌거든요. 이런 설정은 더 큰 크레이트에서 효과를 보는 경우가 많아요. 또 format!이나 패닉 메시지 같은 것도 코드를 끌어들이기 때문에, 이런 걸 피한 크레이트는 훨씬 작아질 수 있어요.

몇 가지 예를 각 회사가 직접 쓴 글과 함께 소개할게요(모두 영어).

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로 컴포넌트를 작성할 수 있어요.

  • Leptos (영어): 서버에서 페이지를 렌더링할 수도 있는, 빠르고 현대적인 프레임워크예요.
  • Yew (영어): 컴포넌트 기반의 성숙한 프레임워크예요.
  • 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