WebAssembly 실전: Rust로 브라우저 성능 한계 돌파하기

프론트엔드

WebAssemblyRust성능 최적화wasm-bindgen브라우저

이 글은 누구를 위한 것인가

  • 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할 수 있어 개발 경험이 우수하다.