이 글은 누구를 위한 것인가
- JavaScript 성능 한계를 WASM으로 돌파하려는 프론트엔드 팀
- Rust로 브라우저용 WASM 모듈을 빌드하려는 개발자
- 이미지 처리, 암호화, 파싱 등 CPU 집약 작업을 최적화하려는 팀
들어가며
JavaScript는 JIT 컴파일로 빨라졌지만, CPU 집약적 작업(이미지 처리, 암호화, 파싱)에서는 한계가 있다. WebAssembly는 바이너리 포맷으로 컴파일되어 JavaScript 대비 3-10배 빠른 실행 속도를 제공한다.
이 글은 bluefoxdev.kr의 WebAssembly 실전 가이드 를 참고하여 작성했습니다.
1. WebAssembly 아키텍처
[WASM 빌드 파이프라인]
Rust 코드 → wasm-pack build → .wasm + .js + .d.ts
↓
npm 패키지로 배포 또는
Vite/webpack에서 직접 import
[wasm-bindgen 역할]
Rust ↔ JavaScript 타입 변환 자동화:
- JS의 String ↔ Rust의 String
- JS의 Uint8Array ↔ Rust의 Vec<u8>
- JS의 Array ↔ Rust의 Vec<T>
- JS의 Object ↔ Rust의 JsValue
[WASM 메모리 모델]
선형 메모리 (Linear Memory):
- ArrayBuffer처럼 JS에서 접근 가능
- Rust의 Vec/Box는 WASM 메모리에 할당
- JS ↔ WASM 복사 비용 최소화 방법:
1. TypedArray로 직접 메모리 접근
2. 포인터/길이 전달 후 JS에서 읽기
[성능 최적화 원칙]
1. JS↔WASM 경계 호출 최소화
2. 대용량 데이터는 포인터로 전달
3. SIMD 명령어 활용 (wasm32-simd)
4. Web Worker에서 블로킹 작업 실행
2. Rust → WASM 구현
// Cargo.toml
// [dependencies]
// wasm-bindgen = "0.2"
// js-sys = "0.3"
// web-sys = { version = "0.3", features = ["console"] }
// [lib]
// crate-type = ["cdylib"]
use wasm_bindgen::prelude::*;
// 기본 내보내기
#[wasm_bindgen]
pub fn add(a: u32, b: u32) -> u32 {
a + b
}
// 이미지 처리: 그레이스케일 변환
#[wasm_bindgen]
pub fn to_grayscale(pixels: &[u8]) -> Vec<u8> {
// pixels: RGBA 순서 (4바이트씩)
let mut output = Vec::with_capacity(pixels.len());
for chunk in pixels.chunks(4) {
let r = chunk[0] as f32;
let g = chunk[1] as f32;
let b = chunk[2] as f32;
let a = chunk[3];
// ITU-R BT.601 luminance
let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
output.push(gray);
output.push(gray);
output.push(gray);
output.push(a);
}
output
}
// 큰 배열 정렬 (JS보다 빠름)
#[wasm_bindgen]
pub fn sort_numbers(mut numbers: Vec<f64>) -> Vec<f64> {
numbers.sort_by(|a, b| a.partial_cmp(b).unwrap());
numbers
}
// 구조체 내보내기
#[wasm_bindgen]
pub struct ImageProcessor {
width: u32,
height: u32,
data: Vec<u8>,
}
#[wasm_bindgen]
impl ImageProcessor {
#[wasm_bindgen(constructor)]
pub fn new(width: u32, height: u32) -> Self {
Self {
width,
height,
data: vec![0u8; (width * height * 4) as usize],
}
}
pub fn load_pixels(&mut self, pixels: &[u8]) {
self.data.copy_from_slice(pixels);
}
pub fn apply_blur(&mut self, radius: u32) {
// 가우시안 블러 구현
let _ = radius; // 간략화
}
pub fn get_pixels(&self) -> Vec<u8> {
self.data.clone()
}
// 포인터 반환으로 복사 없이 JS에서 접근
pub fn data_ptr(&self) -> *const u8 {
self.data.as_ptr()
}
pub fn data_len(&self) -> usize {
self.data.len()
}
}
// JavaScript에서 WASM 사용
import init, {
add,
to_grayscale,
sort_numbers,
ImageProcessor
} from './pkg/my_wasm';
// WASM 초기화 (한 번만)
const wasm = await init();
// 기본 함수 호출
console.log(add(1, 2)); // 3
// 이미지 처리
async function processImage(imageData: ImageData): Promise<ImageData> {
const pixels = imageData.data; // Uint8ClampedArray
// Rust 함수 호출 (자동 타입 변환)
const grayPixels = to_grayscale(pixels);
return new ImageData(
new Uint8ClampedArray(grayPixels),
imageData.width,
imageData.height,
);
}
// 포인터로 메모리 공유 (복사 없음)
function processWithPointer(imageData: ImageData) {
const processor = new ImageProcessor(imageData.width, imageData.height);
processor.load_pixels(imageData.data);
processor.apply_blur(3);
// WASM 메모리에서 직접 읽기 (복사 없음!)
const ptr = processor.data_ptr();
const len = processor.data_len();
const wasmMemory = new Uint8Array(wasm.memory.buffer, ptr, len);
// 새 ImageData 생성 (이 시점에만 복사)
return new ImageData(
new Uint8ClampedArray(wasmMemory),
imageData.width,
imageData.height,
);
}
// Web Worker에서 WASM 실행 (메인 스레드 블로킹 방지)
// worker.ts
self.onmessage = async (e: MessageEvent) => {
const { imageData } = e.data;
await init(); // Worker에서 WASM 초기화
const result = to_grayscale(imageData.data);
// Transferable로 복사 없이 전송
self.postMessage({ result }, [result.buffer]);
};
// Vite에서 WASM 설정
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [],
optimizeDeps: {
exclude: ['my-wasm-package'],
},
build: {
target: 'esnext', // WASM top-level await 지원
},
server: {
headers: {
// SharedArrayBuffer 사용 시 필요
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
마무리
WebAssembly의 핵심 이점은 JS↔WASM 경계를 최소화할 때 나타난다. data_ptr()로 포인터를 전달하면 대용량 이미지 데이터를 복사 없이 처리할 수 있다. CPU 집약적 작업은 Web Worker에서 WASM을 실행해 메인 스레드를 해방시킨다. wasm-pack build --target web으로 빌드한 패키지는 Vite에서 일반 npm 패키지처럼 import할 수 있어 개발 경험이 우수하다.