2024년 초, 우리 팀(나 포함 다섯 명)은 브라우저에서 실시간 영상 필터링 기능을 만들어야 했다. JavaScript Canvas API로 프레임마다 픽셀을 처리하니 고사양 맥북에서도 30fps 근처를 맴돌았다. 데드라인은 6주 후였고, 다시 설계할 여유는 없었다.
그때 WebAssembly가 머릿속에 떠올랐다.
“C++이나 Rust로 작성된 코드가 네이티브에 가까운 속도로 브라우저에서 돌아간다.” 당시엔 마법 같은 말이었다. 그래서 2주를 꼬박 투자해서 직접 테스트했다. 2026년인 지금은 그때보다 생태계가 훨씬 성숙했다. WASI 0.2가 안정화됐고, GC 지원은 Chrome 119 이후 모든 주요 브라우저에서 기본 활성화됐으며, Component Model 스펙도 실용 단계에 접어들었다.
이 글은 그 2년간의 경험을 정리한 것이다. 어디서 진짜 빛을 발하고, 어디서 실망스러웠는지 — 측정치 있는 케이스 위주로.
첫 번째 마이그레이션 — 그리고 예상 못 했던 복병
영상 필터링 케이스 얘기부터 하자. Rust로 그레이스케일 변환과 엣지 감지 필터를 구현하고, wasm-pack build --target web으로 컴파일했다. JavaScript 버전과 동일한 입력으로 벤치마크를 돌렸더니:
- JavaScript (Canvas ImageData): 평균 47ms / 프레임
- WebAssembly (Rust): 평균 11ms / 프레임
약 4배 차이. 60fps가 가능해졌다. 여기까진 기대한 대로였다.
그런데 여기서 예상 못 한 문제가 나왔다. JavaScript에서 WebAssembly 함수로 데이터를 넘길 때, 특히 큰 ArrayBuffer를 다룰 때 — 메모리 복사 비용이 생각보다 컸다. 1920×1080 프레임 하나가 약 8MB인데, 이걸 Wasm 메모리로 복사하는 데만 3-4ms가 추가로 붙었다. 처음엔 “왜 이렇게 느리지?” 하고 한참 헤맸다. Rust 코드 최적화 문제인 줄 알고 이틀을 날렸다.
알고 보니 SharedArrayBuffer를 쓰면 이 복사를 줄일 수 있었는데, 서버에서 Cross-Origin-Opener-Policy와 Cross-Origin-Embedder-Policy 헤더를 설정해야 했다. 서버 설정을 건드리기 싫었던 우리 팀에게는 꽤 번거로운 장벽이었다. 결국 헤더를 추가했고, 그 이후론 안정적으로 돌아갔다.
여기서 얻은 교훈은 하나다. WebAssembly가 빠르다는 말은 맞지만, JS↔Wasm 경계를 넘나드는 비용은 별개로 계산해야 한다. 데이터를 한 번 넘기고 Wasm 안에서 오래 처리하는 작업에서만 진짜 이득이 난다.
실제로 JavaScript보다 확실히 빠른 세 가지 케이스
측정치가 있거나 직접 써본 케이스만 썼다.
이미지·영상 처리
픽셀 단위 연산처럼 루프가 무겁고 메모리 접근 패턴이 예측 가능한 작업은 WebAssembly가 잘 맞는다. 내가 직접 쓴 건 이미지 리사이징이었는데, sharp 라이브러리의 Wasm 포트를 쓰니 JavaScript 기반 jimp 대비 약 6배 빨랐다. ffmpeg.wasm도 2025년 기준으로 꽤 성숙해서, 브라우저에서 MP4를 트랜스코딩하는 걸 실제 서비스에 적용한 팀들이 여럿 있다.
// Rust로 작성한 그레이스케일 변환
// wasm-pack으로 컴파일 후 JS에서 바로 import 가능
#[wasm_bindgen]
pub fn to_grayscale(pixels: &mut [u8]) {
for chunk in pixels.chunks_mut(4) {
// 단순 평균이 아니라 luminance 기반 가중 평균
// 사람 눈이 초록에 더 민감하다는 특성을 반영
let gray = (chunk[0] as f32 * 0.299
+ chunk[1] as f32 * 0.587
+ chunk[2] as f32 * 0.114) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
// alpha(chunk[3])는 건드리지 않음
}
}
암호화 및 해시 연산
회사 내부 도구 중에 클라이언트 사이드에서 파일을 SHA-256으로 해싱해서 무결성을 확인하는 기능이 있었다. 대용량 파일(100MB+)을 다루는 경우였는데, JavaScript로 구현했을 때 메인 스레드가 3-4초 동안 블로킹됐다. Web Worker를 쓰면 UI는 살릴 수 있지만 속도 자체는 그대로였다.
Rust의 sha2 크레이트를 Wasm으로 컴파일하니, 같은 파일 해싱이 약 1.8초로 줄었다. 여기에 SIMD 명령어를 활성화하면(-C target-feature=+simd128) 100MB 기준 1.1초까지 내려갔다. 체감 차이가 컸다. 사용자가 업로드 버튼 누르고 결과를 기다리는 시간이 4초에서 1초로 줄면 그게 그냥 “빠르다”가 아니라 앱이 완전히 다르게 느껴진다.
파서와 컴파일러
이건 직접 만든 게 아니라 오픈소스를 통해 확인한 케이스다. Biome(구 Rome)이 Wasm 타겟을 지원하면서 브라우저 환경에서도 빠른 린팅이 가능해졌다. AST 파싱처럼 CPU 집약적이고 순수 계산 위주인 작업에서 WebAssembly는 설득력 있는 선택이다.
기대에 못 미쳤던 두 가지 케이스
솔직히 처음엔 WebAssembly를 거의 만능 도구처럼 생각했다. 두 가지 케이스에서 제대로 틀렸다.
DOM 조작은 여전히 JavaScript가 맞다
“WebAssembly로 React를 대체할 수 있는가?”라는 글을 당시에 너무 많이 읽었던 것 같다. Yew(Rust 기반 UI 프레임워크)로 실험해봤는데, 결과가 별로였다. WebAssembly는 DOM에 직접 접근할 수 없다. 모든 DOM 조작이 JavaScript를 거쳐야 하기 때문에, UI처럼 DOM 호출이 잦은 작업에선 그 오버헤드가 계속 쌓인다.
단순한 카운터 예시에서 Yew가 Svelte보다 느렸다. 이건 WebAssembly 자체의 문제라기보다 잘못된 사용 사례 선택의 문제지만 — 당시엔 좀 실망스러웠던 건 사실이다. Rust까지 배워가며 시도했는데 기본 카운터가 더 느리다니.
번들 크기 — 아직 완전히 해결 안 됨
Rust로 작성한 비교적 간단한 유틸리티를 Wasm으로 빌드했는데, 최적화 없이 나온 .wasm 파일이 800KB가 넘었다. wasm-opt로 최적화하고 gzip 압축을 적용하면 150KB 정도까지 줄어들지만, 모바일 저사양 네트워크 환경에서는 여전히 초기 로딩에 영향을 준다.
WebAssembly.instantiateStreaming이 표준화돼서 스트리밍 컴파일이 가능하고 툴체인도 많이 개선됐다. 그래도 번들 크기는 여전히 계산해야 할 변수다. JavaScript로 충분한 걸 굳이 Wasm으로 만들면 오히려 사용자 경험을 해친다. 나는 이 교훈을 직접 겪고 나서야 받아들였다.
브라우저 밖에서 더 흥미로운 것들: WASI와 Component Model
그런데 — 나는 2025년 중반쯤부터 WebAssembly의 진짜 흥미로운 부분이 브라우저 밖에 있다는 걸 깨닫기 시작했다.
WASI 0.2가 2024년 초에 공식 릴리스됐고, 이제 꽤 안정적이다. 파일 시스템, 네트워킹, 소켓 — 운영체제와 상호작용하는 표준 인터페이스가 생겼다는 뜻이다. 하나의 Wasm 바이너리가 Linux, macOS, Windows, 엣지 런타임 어디서든 돌아간다. Docker 이미지보다 훨씬 가볍고, 언어 런타임 의존성도 없다.
우리 팀이 실험해본 케이스는 플러그인 시스템이었다. 사용자가 업로드한 코드를 격리된 환경에서 실행해야 하는 시나리오였는데, Docker 컨테이너는 무겁고 Node.js의 vm 모듈은 격리가 충분하지 않았다. wasmtime을 서버 런타임으로 쓰고 각 플러그인을 독립된 Wasm 인스턴스로 실행하는 방식을 시도했는데, 방향 자체는 맞았다. 확신하긴 이르지만, 이 패턴은 멀티테넌트 SaaS 플랫폼에서 꽤 잘 맞는다고 생각한다.
Component Model은 아직 실험적이지만 방향이 옳다. 기존 Wasm 모듈은 서로 메모리를 공유하거나 복잡한 바인딩 없이는 조합하기 어려웠다. Component Model은 이걸 WIT(WebAssembly Interface Types) 파일로 해결하려 한다.
// WIT 인터페이스 정의 예시 — 플러그인이 구현해야 할 계약을 명시
package my-plugin:core;
interface transform {
record image-buffer {
data: list<u8>,
width: u32,
height: u32,
}
// 서로 다른 언어로 작성된 컴포넌트가 이 인터페이스를 통해 통신
apply-filter: func(input: image-buffer) -> image-buffer;
}
world plugin {
export transform;
}
지금 당장 프로덕션에 쓸 수 있는 건 아니다. 2027년 즈음이면 이야기가 달라질 것 같다는 게 내 판단이다.
그래서 언제 써야 하는가 — 내가 쓰는 기준
2년 넘게 여러 케이스를 직접 다뤄보고 나서, 나는 새 기능을 구현할 때 딱 하나의 질문으로 필터링한다: “이 기능이 JavaScript로는 물리적으로 불가능하거나, 가능해도 사용자 경험을 해칠 만큼 느린가?”
그 답이 ‘예’일 때만 WebAssembly를 고려한다.
구체적으로는 세 가지 케이스에서 실제로 쓸 만하다고 본다. 픽셀 처리, 오디오 처리, 암호화처럼 CPU 집약적이고 순수 계산 위주인 작업. 이미 C/C++/Rust로 작성된 성숙한 라이브러리를 브라우저에 포팅해야 할 때(처음부터 재작성하는 것보다 포팅이 훨씬 현실적이다). 그리고 WASI가 주는 플랫폼 독립성과 격리가 필요한 서버 사이드 플러그인 시나리오.
반대로 피해야 할 케이스도 명확하다. DOM 조작이 중심인 UI 로직엔 쓰지 않는다 — 구조적으로 맞지 않는다. JavaScript로 충분히 구현 가능한 기능도 건드리지 않는다(번들 크기만 늘어나고 유지보수가 복잡해진다). 팀에 Rust나 C++ 경험이 없다면 더 조심해야 한다. 학습 비용이 생각보다 크다. 나도 Rust를 처음 배울 때 borrow checker에 막혀서 단순한 픽셀 루프 작성하는 데 하루를 날린 적 있다.
WebAssembly가 JavaScript를 대체할 거냐는 질문을 자주 받는다. 아니다, 적어도 가까운 미래엔 아니다. Figma가 렌더링 엔진에 Wasm을 쓰면서 나머지 UI 로직은 여전히 JavaScript/TypeScript로 관리하는 것처럼, 두 기술은 대체 관계가 아니라 보완 관계다. 그 경계를 어디에 그을지가 핵심이다.
그 외엔 TypeScript, 그리고 필요하면 Worker 스레드로 충분하다.