Prologue: 브라우저에서 포토샵이 돌아가는 세상
2017년에 처음 "WebAssembly가 브라우저에서 네이티브에 가까운 성능을 낸다"는 말을 들었을 때 반신반의했다. 그러다 Figma가 웹 기반인데 Adobe XD보다 빠르다는 걸 체감했고, Google Earth Web이 생각보다 부드럽게 돌아가는 걸 보면서 "어, 이게 진짜 되는 거였구나" 싶었다.
최근엔 Adobe가 Photoshop Web 버전을 WASM으로 돌리고, AutoCAD Web도 등장했다. 예전엔 "무거운 것들은 데스크톱에서" 했는데, 이제 그 경계가 무너지고 있어.
그래서 직접 파봤다. WASM이 뭔지, 어떻게 만드는지, 언제 쓰면 좋은지, 그리고 실제로 Rust 코드를 브라우저에서 실행하는 방법까지.
WebAssembly란 정확히 무엇인가?
WASM은 언어가 아니다
흔한 오해가 있어. "WebAssembly로 코딩한다"고 말하는 건 틀린 표현이야. WASM은 컴파일 타겟이야. 실행 형식(execution format)이라고도 해.
Rust 코드 → Rust 컴파일러 → WebAssembly 바이너리 (.wasm)
C/C++ 코드 → Emscripten → WebAssembly 바이너리 (.wasm)
Go 코드 → Go 컴파일러 → WebAssembly 바이너리 (.wasm)
AssemblyScript → asc → WebAssembly 바이너리 (.wasm)
.wasm 파일은 바이너리 형식의 저수준 명령어 집합이야. CPU가 직접 이해하는 기계어는 아니지만, 브라우저의 WASM 런타임이 거의 기계어에 가까운 속도로 실행해.
JavaScript와 무엇이 다른가?
JavaScript 실행 과정:
1. JS 소스 코드 다운로드
2. 파싱 (AST 생성)
3. 인터프리테이션 또는 JIT 컴파일
4. 최적화 (충분히 실행되면 핫 패스 최적화)
5. 실행
WebAssembly 실행 과정:
1. .wasm 바이너리 다운로드
2. 디코딩 (이미 바이너리 형식)
3. JIT 컴파일 (파싱 없음, 타입 추론 없음)
4. 실행
WASM은 타입 정보가 컴파일 시점에 이미 결정되어 있어. JS처럼 런타임에 타입을 추론하거나, 함수가 여러 타입으로 호출될 때 최적화를 폐기하는 일이 없어. 덕분에 실행 속도가 일관되고 예측 가능해.
WASM이 아닌 것
흔한 오해들:
- JS를 대체하는 게 아니다: WASM은 DOM에 직접 접근 못 해. UI는 여전히 JavaScript가 담당.
- 항상 빠른 게 아니다: JS ↔ WASM 경계를 자주 넘으면 오버헤드가 커져. 단순 작업은 JS가 더 빨 수 있어.
- 쉽게 만들 수 있는 게 아니다: 툴체인이 복잡하고, 메모리 관리를 직접 해야 할 때가 있어.
언제 WASM을 써야 하는가?
좋은 사용 사례
| 카테고리 | 구체적 예시 |
|---|---|
| 이미지/비디오 처리 | 필터 적용, 코덱, 이미지 압축 |
| 암호화/해시 | SHA, AES, bcrypt, ECDSA |
| 물리 시뮬레이션 | 게임 엔진, CAD, 구조 해석 |
| 오디오 처리 | DSP, 코덱, 이펙트 |
| 과학 계산 | ML 추론, 통계, 수치해석 |
| 레거시 코드 이식 | C/C++ 라이브러리 웹 포팅 |
나쁜 사용 사례
- DOM 조작 (JS에서 직접 하는 게 훨씬 빠름)
- 간단한 문자열 처리
- 네트워크 요청 처리
- 일반적인 비즈니스 로직
핵심 판단 기준: CPU 집약적 계산이 오래 걸려서 UI가 블로킹되는가? → WASM 고려 그냥 JS 최적화나 Web Worker로도 해결되는가? → WASM 불필요
Rust로 WASM 모듈 만들기
왜 Rust인가?
| 언어 | WASM 지원 | 메모리 안전 | GC | 파일 크기 |
|---|---|---|---|---|
| Rust | 최고 | 컴파일 타임 | 없음 | 작음 |
| C/C++ | 좋음 | 수동 | 없음 | 작음 |
| Go | 좋음 | 런타임 | 있음 | 큼 |
| AssemblyScript | 좋음 | 없음 | 있음 | 중간 |
Rust는 가비지 컬렉터가 없어서 WASM 바이너리 크기가 작고, wasm-pack이라는 공식 툴체인이 훌륭해서 많이 써.
환경 설정
# Rust 설치 (없다면)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# wasm-pack 설치
cargo install wasm-pack
# wasm32-unknown-unknown 타겟 추가
rustup target add wasm32-unknown-unknown
새 프로젝트 생성
# wasm-pack 템플릿으로 생성
cargo new --lib image-processor
cd image-processor
Cargo.toml 설정:
[package]
name = "image-processor"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = [
"console",
"ImageData",
]}
js-sys = "0.3"
getrandom = { version = "0.2", features = ["js"] }
[dev-dependencies]
wasm-bindgen-test = "0.3"
[profile.release]
opt-level = 3
lto = true
첫 번째 WASM 함수 작성
// src/lib.rs
use wasm_bindgen::prelude::*;
// #[wasm_bindgen]으로 JS에서 호출 가능하게 표시
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
// 브라우저 console.log 호출
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}
// 피보나치 수열 (CPU 집약적 예시)
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
console_log!("Computing fibonacci({})", n);
if n <= 1 {
return n as u64;
}
let mut a: u64 = 0;
let mut b: u64 = 1;
for _ in 2..=n {
let c = a + b;
a = b;
b = c;
}
b
}
// 이미지 그레이스케일 처리 (실용적 예시)
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
// pixels는 RGBA 형식: [R, G, B, A, R, G, B, A, ...]
for i in (0..pixels.len()).step_by(4) {
let r = pixels[i] as f32;
let g = pixels[i + 1] as f32;
let b = pixels[i + 2] as f32;
// 인간 시각 가중치 적용
let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
pixels[i] = gray;
pixels[i + 1] = gray;
pixels[i + 2] = gray;
// Alpha(pixels[i + 3])는 변경 없음
}
}
빌드
# 웹 번들러용 빌드 (webpack, vite 등)
wasm-pack build --target bundler
# Node.js용
wasm-pack build --target nodejs
# 번들러 없이 직접 웹에서
wasm-pack build --target web
# 개발용 (최적화 없음, 빠른 빌드)
wasm-pack build --dev --target bundler
빌드 결과물:
pkg/
image_processor_bg.wasm ← 실제 WASM 바이너리
image_processor.js ← JS 바인딩 glue code
image_processor.d.ts ← TypeScript 타입 정의
package.json
JavaScript에서 WASM 호출하기
기본 사용법
// wasm-pack이 생성한 모듈 import
import init, { add, fibonacci, grayscale } from './pkg/image_processor.js';
async function main() {
// WASM 모듈 초기화 (바이너리 로드 및 컴파일)
await init();
// 이제 함수 호출 가능
console.log(add(2, 3)); // 5
console.log(fibonacci(40)); // 102334155
}
main();
이미지 처리 실전 예시
import init, { grayscale } from './pkg/image_processor.js';
async function applyGrayscale(imageElement) {
await init();
// Canvas로 이미지 픽셀 데이터 추출
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = imageElement.width;
canvas.height = imageElement.height;
ctx.drawImage(imageElement, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// WASM 함수 호출 - pixels는 Uint8ClampedArray
// wasm-bindgen이 자동으로 WASM 메모리로 복사
grayscale(imageData.data);
// 결과를 다시 Canvas에 그리기
ctx.putImageData(imageData, 0, 0);
return canvas.toDataURL();
}
React에서 사용하기
import { useState, useEffect, useCallback } from 'react';
import type { InitOutput } from './pkg/image_processor';
// WASM 모듈을 한 번만 초기화
let wasmInit: Promise<InitOutput> | null = null;
async function getWasm() {
if (!wasmInit) {
const { default: init, grayscale, fibonacci } = await import('./pkg/image_processor.js');
wasmInit = init();
await wasmInit;
return { grayscale, fibonacci };
}
await wasmInit;
const { grayscale, fibonacci } = await import('./pkg/image_processor.js');
return { grayscale, fibonacci };
}
function ImageProcessor() {
const [processing, setProcessing] = useState(false);
const [result, setResult] = useState<string | null>(null);
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setProcessing(true);
try {
const { grayscale } = await getWasm();
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
// CPU 집약적 작업을 WASM으로
const startTime = performance.now();
grayscale(imageData.data);
console.log(`WASM 처리 시간: ${performance.now() - startTime}ms`);
ctx.putImageData(imageData, 0, 0);
const blob = await canvas.convertToBlob();
setResult(URL.createObjectURL(blob));
} finally {
setProcessing(false);
}
}, []);
return (
<div>
<input type="file" accept="image/*" onChange={handleFileUpload} />
{processing && <p>처리 중...</p>}
{result && <img src={result} alt="Grayscale result" />}
</div>
);
}
WASM 메모리 모델 이해하기
선형 메모리
WASM은 **선형 메모리(linear memory)**를 사용해. 연속된 바이트 배열이야.
// WASM 메모리에 직접 접근
const memory = new WebAssembly.Memory({
initial: 1, // 64KB 페이지 1개
maximum: 10, // 최대 640KB
});
// ArrayBuffer로 접근
const buffer = new Uint8Array(memory.buffer);
buffer[0] = 42; // 직접 메모리 쓰기
wasm-bindgen의 메모리 관리
wasm-bindgen을 쓰면 대부분의 메모리 관리가 자동화돼:
// Rust에서 큰 데이터를 반환할 때
#[wasm_bindgen]
pub fn process_large_data(input: &[u8]) -> Vec<u8> {
// Vec<u8>은 자동으로 WASM 메모리에서 JS로 복사됨
input.iter().map(|&x| x.wrapping_add(1)).collect()
}
// JS에서 받을 때
const result = process_large_data(inputData);
// result는 JS의 Uint8Array
// 내부적으로 WASM 메모리에서 복사된 데이터
메모리 복사 최소화 (성능 최적화)
데이터를 JS ↔ WASM 사이에서 자주 복사하면 성능이 떨어져. 이럴 때는:
use wasm_bindgen::prelude::*;
use js_sys::Uint8Array;
// WASM 메모리 내에서 직접 작업하는 방식
#[wasm_bindgen]
pub struct ImageBuffer {
data: Vec<u8>,
}
#[wasm_bindgen]
impl ImageBuffer {
pub fn new(size: usize) -> ImageBuffer {
ImageBuffer {
data: vec![0u8; size],
}
}
// JS에서 포인터를 받아서 직접 접근
pub fn as_ptr(&self) -> *const u8 {
self.data.as_ptr()
}
pub fn as_mut_ptr(&mut self) -> *mut u8 {
self.data.as_mut_ptr()
}
pub fn len(&self) -> usize {
self.data.len()
}
pub fn process(&mut self) {
// 복사 없이 내부에서 처리
for byte in self.data.iter_mut() {
*byte = byte.wrapping_add(1);
}
}
}
// JS에서 WASM 메모리에 직접 쓰기
const { memory } = await import('./pkg/image_processor_bg.wasm');
const buffer = ImageBuffer.new(1024 * 1024); // 1MB
// WASM 메모리에 직접 접근 (복사 없음)
const wasmMemory = new Uint8Array(memory.buffer, buffer.as_ptr(), buffer.len());
wasmMemory.set(inputData); // JS 데이터를 WASM 메모리에 직접 씀
// 처리 (복사 없음)
buffer.process();
// 결과 읽기
const result = new Uint8Array(wasmMemory);
JS vs WASM 성능 비교
실제 벤치마크: 이미지 필터
// JavaScript 구현
function jsGrayscale(pixels) {
for (let i = 0; i < pixels.length; i += 4) {
const gray = 0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2];
pixels[i] = gray;
pixels[i + 1] = gray;
pixels[i + 2] = gray;
}
}
// 1920x1080 이미지 (약 8MB 픽셀 데이터)
// Chrome V8 JIT 최적화 후: ~20-30ms
// WASM: ~8-12ms
| 작업 | JS (JIT 최적화 후) | WASM | 비율 |
|---|---|---|---|
| 이미지 그레이스케일 (1080p) | 25ms | 10ms | ~2.5x |
| 1M 피보나치 | 15ms | 3ms | ~5x |
| AES-256 암호화 (1MB) | 45ms | 8ms | ~5.6x |
| JSON 파싱 (10MB) | 150ms | 오히려 느림 | — |
| DOM 업데이트 100회 | 10ms | 불가 | — |
중요: WASM이 "항상 빠른 것"이 아니야. CPU 집약적 수치 계산에서는 확실히 빠르지만, I/O 작업이나 DOM 조작에서는 의미 없어.
실제 WASM 사용 사례
Figma
Figma의 렌더링 엔진이 C++로 작성되어 있고, Emscripten으로 WASM으로 컴파일돼. 벡터 그래픽 계산, 레이아웃 엔진, 렌더링이 모두 WASM 안에서 동작해. JavaScript는 UI 컨트롤과 WASM 엔진 사이의 인터페이스 역할만 해.
Google Earth Web
3D 지형 렌더링, 위성 이미지 처리, 물리 기반 대기 효과 등이 WASM으로 실행돼. C++ 클라이언트 코드베이스를 웹으로 이식한 결과야.
Adobe Photoshop Web
2021년에 공개된 Photoshop Web은 C/C++ 코드를 Emscripten으로 WASM 컴파일했어. Canvas API와 통합해서 레이어, 마스크, 필터 등 핵심 기능을 브라우저에서 실행.
직접 쓸 수 있는 WASM 라이브러리
// ffmpeg.wasm: 브라우저에서 비디오 변환
import { createFFmpeg, fetchFile } from '@ffmpeg/ffmpeg';
const ffmpeg = createFFmpeg({ log: true });
await ffmpeg.load();
ffmpeg.FS('writeFile', 'input.mp4', await fetchFile(videoFile));
await ffmpeg.run('-i', 'input.mp4', 'output.gif');
const data = ffmpeg.FS('readFile', 'output.gif');
// @tensorflow/tfjs-backend-wasm: ML 추론 가속
import * as tf from '@tensorflow/tfjs';
import '@tensorflow/tfjs-backend-wasm';
await tf.setBackend('wasm');
const model = await tf.loadLayersModel('model.json');
const result = model.predict(inputTensor);
// sharp (Node.js): 이미지 처리 WASM 버전
// sqlite-wasm: 브라우저에서 SQLite
// pdfjs: PDF 렌더링
WASM과 Web Workers 조합
무거운 WASM 연산도 메인 스레드를 블로킹할 수 있어. Web Worker와 조합하면 UI가 멈추지 않아.
// worker.js
import init, { heavy_computation } from './pkg/my_wasm.js';
let initialized = false;
self.onmessage = async (e) => {
if (!initialized) {
await init();
initialized = true;
}
const { id, data } = e.data;
const result = heavy_computation(data);
self.postMessage({ id, result });
};
// main.js
const worker = new Worker('./worker.js', { type: 'module' });
function runWasmInWorker(data) {
return new Promise((resolve) => {
const id = Math.random();
worker.postMessage({ id, data });
worker.onmessage = (e) => {
if (e.data.id === id) {
resolve(e.data.result);
}
};
});
}
// UI 블로킹 없이 WASM 실행
const result = await runWasmInWorker(largeDataset);
언제 WASM을 쓰고 언제 그냥 JS를 쓸까?
결정 플로우차트
CPU 집약적 작업인가?
├── NO → 그냥 JavaScript
└── YES → UI가 블로킹되는가?
├── NO → 잘 최적화된 JS로 충분
└── YES → Web Worker로 해결되는가?
├── YES → Web Worker + JS
└── NO (복잡한 수치계산, 기존 C/C++ 라이브러리 이식 등)
→ WASM 고려
실무 가이드라인
WASM을 쓸 때:
- 이미지/비디오/오디오 처리
- 암호화 연산
- 게임 물리 엔진
- ML 모델 추론 (TensorFlow.js WASM backend)
- 기존 C/C++/Rust 라이브러리 웹 이식
WASM을 안 써도 될 때:
- 일반 비즈니스 로직
- API 호출 및 데이터 변환
- 폼 처리, 라우팅
- "그냥 빠를 것 같아서"
정리
WebAssembly는 "JavaScript를 죽이는 기술"이 아니야. JS를 보완하는 기술이야. DOM은 여전히 JS가 담당하고, WASM은 CPU가 힘들어하는 계산을 대신 처리해.
핵심 요약:
- WASM은 컴파일 타겟, 언어가 아님
- Rust + wasm-pack이 현재 최고의 WASM 개발 경험
- CPU 집약적 계산에서 JS 대비 2-6배 성능
- JS ↔ WASM 경계 최소화가 성능 최적화의 핵심
- Web Worker와 조합해서 UI 블로킹 방지
- Figma, Google Earth, Adobe Photoshop Web이 이미 검증
브라우저에서 무거운 계산이 필요한 상황이 생기면, 이제 "이건 웹에서 못 해"라고 포기하지 않아도 돼.