
WebAssembly 실제: 브라우저에서 네이티브 성능 끌어내기
Figma와 Google Earth는 왜 브라우저에서도 빠를까? WebAssembly의 실체와 Rust로 WASM 모듈을 만들어 JavaScript에서 호출하는 전 과정을 파헤쳐봤다.

Figma와 Google Earth는 왜 브라우저에서도 빠를까? WebAssembly의 실체와 Rust로 WASM 모듈을 만들어 JavaScript에서 호출하는 전 과정을 파헤쳐봤다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

HTTP는 무전기(오버) 방식이지만, 웹소켓은 전화기(여보세요)입니다. 채팅과 주식 차트가 실시간으로 움직이는 기술적 비밀.

2017년에 처음 "WebAssembly가 브라우저에서 네이티브에 가까운 성능을 낸다"는 말을 들었을 때 반신반의했다. 그러다 Figma가 웹 기반인데 Adobe XD보다 빠르다는 걸 체감했고, Google Earth Web이 생각보다 부드럽게 돌아가는 걸 보면서 "어, 이게 진짜 되는 거였구나" 싶었다.
최근엔 Adobe가 Photoshop Web 버전을 WASM으로 돌리고, AutoCAD Web도 등장했다. 예전엔 "무거운 것들은 데스크톱에서" 했는데, 이제 그 경계가 무너지고 있어.
그래서 직접 파봤다. WASM이 뭔지, 어떻게 만드는지, 언제 쓰면 좋은지, 그리고 실제로 Rust 코드를 브라우저에서 실행하는 방법까지.
흔한 오해가 있어. "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 실행 과정:
1. JS 소스 코드 다운로드
2. 파싱 (AST 생성)
3. 인터프리테이션 또는 JIT 컴파일
4. 최적화 (충분히 실행되면 핫 패스 최적화)
5. 실행
WebAssembly 실행 과정:
1. .wasm 바이너리 다운로드
2. 디코딩 (이미 바이너리 형식)
3. JIT 컴파일 (파싱 없음, 타입 추론 없음)
4. 실행
WASM은 타입 정보가 컴파일 시점에 이미 결정되어 있어. JS처럼 런타임에 타입을 추론하거나, 함수가 여러 타입으로 호출될 때 최적화를 폐기하는 일이 없어. 덕분에 실행 속도가 일관되고 예측 가능해.
흔한 오해들:
| 카테고리 | 구체적 예시 |
|---|---|
| 이미지/비디오 처리 | 필터 적용, 코덱, 이미지 압축 |
| 암호화/해시 | SHA, AES, bcrypt, ECDSA |
| 물리 시뮬레이션 | 게임 엔진, CAD, 구조 해석 |
| 오디오 처리 | DSP, 코덱, 이펙트 |
| 과학 계산 | ML 추론, 통계, 수치해석 |
| 레거시 코드 이식 | C/C++ 라이브러리 웹 포팅 |
핵심 판단 기준: CPU 집약적 계산이 오래 걸려서 UI가 블로킹되는가? → WASM 고려 그냥 JS 최적화나 Web Worker로도 해결되는가? → WASM 불필요
| 언어 | 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
// 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
// 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();
}
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은 선형 메모리(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을 쓰면 대부분의 메모리 관리가 자동화돼:
// 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);
// 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 조작에서는 의미 없어.
Figma의 렌더링 엔진이 C++로 작성되어 있고, Emscripten으로 WASM으로 컴파일돼. 벡터 그래픽 계산, 레이아웃 엔진, 렌더링이 모두 WASM 안에서 동작해. JavaScript는 UI 컨트롤과 WASM 엔진 사이의 인터페이스 역할만 해.
3D 지형 렌더링, 위성 이미지 처리, 물리 기반 대기 효과 등이 WASM으로 실행돼. C++ 클라이언트 코드베이스를 웹으로 이식한 결과야.
2021년에 공개된 Photoshop Web은 C/C++ 코드를 Emscripten으로 WASM 컴파일했어. Canvas API와 통합해서 레이어, 마스크, 필터 등 핵심 기능을 브라우저에서 실행.
// 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 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);
CPU 집약적 작업인가?
├── NO → 그냥 JavaScript
└── YES → UI가 블로킹되는가?
├── NO → 잘 최적화된 JS로 충분
└── YES → Web Worker로 해결되는가?
├── YES → Web Worker + JS
└── NO (복잡한 수치계산, 기존 C/C++ 라이브러리 이식 등)
→ WASM 고려
WASM을 쓸 때:
WASM을 안 써도 될 때:
WebAssembly는 "JavaScript를 죽이는 기술"이 아니야. JS를 보완하는 기술이야. DOM은 여전히 JS가 담당하고, WASM은 CPU가 힘들어하는 계산을 대신 처리해.
핵심 요약:브라우저에서 무거운 계산이 필요한 상황이 생기면, 이제 "이건 웹에서 못 해"라고 포기하지 않아도 돼.