WebAssembly 실전: C/C++ 컴파일 파이프라인, 선형 메모리, JS 상호 운용과 성능

이 글의 핵심

WebAssembly는 C/C++/Rust 코드를 브라우저에서 네이티브에 가까운 속도로 실행하는 바이너리 포맷입니다. CPU 바운드 연산에서는 이득이 크지만, JS와 데이터를 주고받는 경계 비용과 번들 크기라는 대가가 있습니다. 동작 원리부터 wasm-pack·Emscripten 빌드, 최적화와 흔한 실수까지 다룹니다.

이 글의 핵심

WebAssembly(WASM)는 웹 브라우저에서 네이티브에 가까운 성능을 목표로 하는 바이너리 포맷입니다. C/C++/Rust로 작성한 코드를 브라우저에서 실행할 수 있게 해 주며, 영상·이미지 처리, 압축·암호화, 게임 엔진, 기존 C/C++ 라이브러리 이식처럼 CPU 바운드 연산이 많은 작업에서 효과가 큽니다. 반대로 JS와 데이터를 주고받는 경계 비용과 번들 크기라는 대가가 있어서, 어디에 쓰느냐가 결과를 좌우합니다.

이 글은 WASM의 동작 원리와 내부 구조(스택 기반 VM, 컴파일 파이프라인, 선형 메모리, GC 제안 현황)를 먼저 설명하고, 이어서 Rust(wasm-pack)와 C/C++(Emscripten)로 직접 빌드해 보는 실습, 번들 크기·SIMD·스레드 최적화, 자주 하는 실수까지 다룹니다. 브라우저에서 AI 모델을 돌리는 응용 사례가 궁금하다면 브라우저에서 AI 모델 돌리기를, Emscripten을 C++ 관점에서 더 자세히 보고 싶다면 WebAssembly 실전: C/C++ 컴파일 파이프라인, 선형 메모리, JS 상호 운용과 성능을 참고하세요.

WebAssembly란?

WebAssembly는 2017년 W3C에서 표준화한 저수준 바이너리 포맷입니다. 여기서 “저수준”이라는 표현은 단순한 마케팅 문구가 아니라 기술적으로 정확한 설명입니다. WASM은 JavaScript처럼 소스 텍스트를 그대로 읽어 실행하는 언어가 아니라, 스택 기반 가상 머신(stack-based virtual machine)을 위한 바이트코드입니다. 실제 CPU 명령어 집합(x86, ARM)을 흉내 내지는 않지만, 레지스터 대신 값 스택에 push/pop을 반복하며 연산을 수행한다는 점에서 JVM 바이트코드나 CPython의 바이트코드와 같은 계열에 속합니다. 다만 WASM은 애초에 “컴파일 타깃”으로 설계되었기 때문에 타입이 완전히 정적이고, 각 함수의 시그니처와 로컬 변수의 개수·타입이 모듈 안에 명시적으로 기록되어 있습니다.

이 점이 WASM과 JavaScript JIT(Just-In-Time) 컴파일의 근본적인 차이입니다. V8이나 SpiderMonkey 같은 JavaScript 엔진도 결국은 기계어로 컴파일해서 실행하지만, JavaScript 소스 자체는 타입이 동적이기 때문에 엔진은 실행 중에 “이 변수가 지금까지 숫자였는지, 문자열이었는지”를 추측(inline cache, hidden class)하고, 그 추측이 틀리면 최적화된 코드를 버리고 다시 인터프리터로 떨어지는 역최적화(deoptimization)가 발생합니다. WASM 모듈은 애초에 타입 추측이 필요 없습니다. 바이너리 안에 i32, i64, f32, f64 같은 타입이 고정되어 있으므로, 브라우저는 파싱과 동시에(스트리밍 컴파일) 안정적으로 기계어를 생성할 수 있고, 실행 중간에 가정이 깨져 최적화를 되돌리는 일이 원천적으로 없습니다. “AOT(Ahead-Of-Time)에 가까운 컴파일”이라고 표현하는 이유가 여기에 있습니다. 엄밀히는 브라우저마다 baseline 컴파일 후 tier-up하는 구조(V8의 Liftoff → TurboFan)를 쓰기도 하지만, 타입이 고정된 입력을 받는다는 점에서 JS의 JIT과는 성격이 다릅니다.

핵심 특징

1. 네이티브급 성능

JavaScript:
- 인터프리터 or JIT 컴파일
- 가비지 컬렉션
- 동적 타입

WebAssembly:
- 미리 컴파일된 바이너리 (브라우저가 기계어로 변환)
- 언어 쪽 할당자가 메모리 관리
- 정적 타입
→ CPU 바운드 코드에서 더 빠르고 성능이 예측 가능함

흔히 “JS보다 10100배 빠르다”는 말이 돌지만, 이는 모든 코드에 적용되는 법칙이 아닙니다. 요즘 JS 엔진은 타입이 안정적인 단순 루프를 상당히 잘 최적화하기 때문에, 그런 코드에서는 WASM과의 차이가 12배 안쪽인 경우도 흔합니다. 격차가 크게 벌어지는 것은 수치 연산·바이트 단위 처리·배열 접근이 많고 JS로는 타입이 자주 흔들리거나 GC 압박이 큰 CPU 바운드 코드입니다. 실제 차이를 만드는 요인은 세 가지입니다. 첫째, 정적 타입 덕분에 매 연산마다 타입 체크·박싱/언박싱이 필요 없습니다. 둘째, 가비지 컬렉션이 없는 언어(C/C++/Rust)로 작성한 WASM은 GC 일시 정지(STW, stop-the-world) 없이 결정론적으로 메모리를 해제합니다. 셋째, 선형 메모리는 하나의 연속된 ArrayBuffer이므로 캐시 지역성이 JS 객체 그래프보다 훨씬 좋습니다. 반대로 DOM 조작, 문자열 처리, 네트워크 I/O처럼 병목이 CPU가 아닌 코드에서는 이 격차가 거의 사라지거나 오히려 WASM이 불리해질 수 있다는 점을 뒤에서 다시 다룹니다.

2. 멀티 언어 지원

언어주요 도구장점주의할 점
Rustwasm-pack, wasm-bindgen메모리 안전성, 얇은 런타임으로 작은 바이너리, JS 연동 도구가 성숙학습 곡선
C/C++Emscripten기존 코드베이스 재사용, 가장 오래된 생태계libc·파일 시스템 에뮬레이션 때문에 글루 코드가 큼, 메모리 버그는 여전히 런타임 문제
Go표준 컴파일러(GOOS=js GOARCH=wasm), TinyGo익숙한 문법표준 컴파일러는 런타임·GC가 포함돼 결과물이 MB 단위, 작게 만들려면 TinyGo(일부 표준 라이브러리 제약)
AssemblyScriptascTypeScript와 비슷한 문법TS 코드를 그대로 컴파일하는 것은 아니며, 타입과 표준 라이브러리가 다름

Rust가 WASM 쪽에서 자주 추천되는 이유는 성능보다 바이너리 크기와 JS 연동입니다. GC와 큰 런타임이 없으니 결과물이 작고, #[wasm_bindgen] 하나로 JS 쪽 타입 정의(.d.ts)까지 생성됩니다. 반면 이미 검증된 C/C++ 라이브러리(FFmpeg, SQLite, OpenCV 등)를 웹으로 가져오는 것이 목적이라면 Emscripten이 여전히 가장 현실적인 선택입니다.

3. 안전한 샌드박스

  • 브라우저 보안 모델 내에서 실행
  • 메모리 격리
  • 명시적 권한 필요

4. JavaScript와 상호 운용

// JavaScript에서 WASM 호출
const result = wasmModule.add(10, 20);

// WASM에서 JavaScript 호출
wasmModule.alertUser("Hello from WASM!");

컴파일 파이프라인: 소스 코드에서 브라우저 실행까지

WASM 모듈이 브라우저에서 돌아가기까지는 여러 단계의 변환을 거칩니다. 이 과정을 이해하면 “왜 .wat과 .wasm이 따로 존재하는지”, “wasm-pack build를 실행했을 때 실제로 무슨 일이 벌어지는지”를 훨씬 명확하게 파악할 수 있습니다.

flowchart LR
    A["Rust / C++ 소스"] --> B["LLVM IR"]
    B --> C["wasm32 백엔드<br/>(LLVM wasm target)"]
    C --> D[".wasm 바이너리<br/>(모듈)"]
    D -.텍스트 변환.-> E[".wat<br/>(텍스트 포맷)"]
    E -.어셈블.-> D
    D --> F["브라우저: fetch()"]
    F --> G["WebAssembly.compile<br/>(바이트코드 검증+컴파일)"]
    G --> H["WebAssembly.instantiate<br/>(임포트 연결+메모리 할당)"]
    H --> I["실행 가능한 Instance"]

먼저 Rust나 C/C++ 소스는 LLVM IR(중간 표현)로 변환된 뒤, LLVM의 wasm32 백엔드가 이를 .wasm 바이너리로 컴파일합니다. .wasm은 사람이 읽을 수 없는 이진 형식이지만, 표준에는 그와 1:1로 대응하는 텍스트 포맷인 .wat(WebAssembly Text format)도 함께 정의되어 있습니다. .wat은 S-표현식 기반 문법으로, 디버깅하거나 바이너리 구조를 직접 눈으로 확인해야 할 때 wasm2wat, wat2wasm 같은 도구(WABT 툴체인)로 상호 변환합니다. 예를 들어 두 정수를 더하는 함수는 .wat으로 다음과 같이 표현됩니다.

(module
  (func $add (param $a i32) (param $b i32) (result i32)
    local.get $a
    local.get $b
    i32.add)
  (export "add" (func $add)))

local.get으로 로컬 변수를 스택에 push하고, i32.add가 스택 최상단 두 값을 pop해서 더한 뒤 다시 push하는 구조가 바로 앞서 말한 “스택 기반 VM”의 실체입니다. 실무에서 .wat을 직접 작성할 일은 거의 없지만, 번들 크기를 줄이다가 예상치 못한 함수가 남아 있는지 확인하거나 컴파일러 최적화 결과를 검증할 때 유용합니다.

.wasm 파일이 브라우저에 도달하면 두 단계를 거쳐야 실행 가능한 상태가 됩니다. 먼저 WebAssembly.compile()(또는 네트워크 스트림에서 바로 컴파일하는 WebAssembly.compileStreaming())이 바이트코드의 구조적 유효성을 검증하고 기계어로 컴파일해 Module 객체를 만듭니다. 이 검증 단계에서 스택 오버플로우, 타입 불일치, 잘못된 점프 대상 같은 문제를 걸러내므로, WASM은 “신뢰할 수 없는 코드라도 안전하게 실행”할 수 있는 샌드박스 모델을 갖습니다. 그다음 WebAssembly.instantiate()가 이 Module에 실제 메모리(WebAssembly.Memory)를 할당하고, JS 쪽에서 제공하는 임포트 함수들을 연결해 실행 가능한 Instance를 만듭니다. wasm-pack build --target web이 생성하는 hello_wasm.js 글루 코드가 바로 이 compile → instantiate 과정과 임포트/익스포트 연결을 자동화해 주는 래퍼입니다.

WebAssembly 시작하기

1. Rust로 WASM 개발 (권장)

Rust 설치

# Rust 설치
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# wasm32 타겟 추가
rustup target add wasm32-unknown-unknown

# wasm-pack 설치
cargo install wasm-pack

프로젝트 생성

# Rust WASM 프로젝트 생성
cargo new --lib hello-wasm
cd hello-wasm

Cargo.toml 설정

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

[profile.release]
opt-level = "z"     # 크기 우선 최적화 (속도가 더 중요하면 3)
lto = true          # 링크 타임 최적화
codegen-units = 1   # 최적화 기회를 늘리는 대신 빌드는 느려짐
panic = "abort"     # 언와인드 코드 제거

crate-type = ["cdylib"]은 Rust 라이브러리를 다른 언어(여기서는 JS 런타임)가 불러 쓸 수 있는 동적 라이브러리 형태로 빌드하라는 뜻이며, 이게 없으면 .wasm 파일이 만들어지지 않습니다. [profile.release]는 뒤의 “실무 도입 시 고려사항”에서 다룰 번들 크기와 직결되므로 처음부터 넣어 두는 편이 좋습니다. panic = "abort"를 쓰면 패닉 시 스택 되감기 없이 바로 unreachable 트랩으로 끝나므로, 디버깅할 때는 아래에서 설명할 console_error_panic_hook을 함께 쓰는 것이 좋습니다.

Rust 코드 작성

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

#[wasm_bindgen]
pub struct Calculator {
    value: f64,
}

#[wasm_bindgen]
impl Calculator {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Calculator {
        Calculator { value: 0.0 }
    }

    pub fn add(&mut self, x: f64) {
        self.value += x;
    }

    pub fn get_value(&self) -> f64 {
        self.value
    }
}

빌드

# 브라우저에서 ES 모듈로 바로 불러올 수 있게 빌드 (기본이 릴리스 빌드)
wasm-pack build --target web

# 디버그 빌드 (최적화 없음, 빌드 빠름)
wasm-pack build --dev --target web

# 결과물:
# pkg/
#   ├── hello_wasm.js        # JS 글루 코드 (init + export 래퍼)
#   ├── hello_wasm_bg.wasm   # WASM 바이너리
#   ├── hello_wasm.d.ts      # TypeScript 타입 정의
#   └── package.json         # npm 패키지로 배포할 때 사용

wasm-pack build는 옵션을 주지 않으면 이미 릴리스 프로필로 빌드합니다. 인터넷의 예제 중에는 --release를 붙여야 최적화된다고 설명하는 경우가 있는데, 개발 중 빠른 빌드가 필요할 때 붙이는 쪽은 --dev입니다.

--target은 글루 코드가 어떤 방식으로 로드될지를 결정하므로 사용하는 환경에 맞춰야 합니다.

  • --target web: <script type="module">에서 직접 import하고 await init()으로 초기화합니다. 번들러 없이 테스트할 때 가장 간단합니다.
  • --target bundler(기본값): Webpack·Vite 같은 번들러가 .wasm import를 처리한다고 가정합니다. Webpack 5라면 experiments: { asyncWebAssembly: true }가, Vite라면 vite-plugin-wasm 같은 플러그인이 필요합니다.
  • --target nodejs: Node.js에서 require로 동기 로드합니다.

타깃을 잘못 고르면 빌드는 성공하는데 런타임에 import 오류가 나므로, “로컬에선 되는데 번들러에 넣으니 안 된다”는 문제는 먼저 이 옵션을 확인합니다.

HTML에서 사용

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Hello WASM</title>
</head>
<body>
  <h1>WebAssembly Example</h1>
  <button id="btn">Run WASM</button>
  <div id="result"></div>

  <script type="module">
    import init, { add, greet, Calculator } from './pkg/hello_wasm.js';

    async function run() {
      // WASM 초기화
      await init();

      document.getElementById('btn').addEventListener('click', () => {
        // 함수 호출
        const sum = add(10, 20);
        const greeting = greet('Alice');
        
        // 클래스 사용
        const calc = new Calculator();
        calc.add(5);
        calc.add(10);
        const value = calc.get_value();

        document.getElementById('result').innerHTML = `
          Sum: ${sum}<br>
          Greeting: ${greeting}<br>
          Calculator: ${value}
        `;
      });
    }

    run();
  </script>
</body>
</html>

이 예제에서 입문자가 가장 자주 막히는 부분은 세 가지입니다.

await init()을 빠뜨리는 경우. init()은 .wasm 파일을 fetch해서 컴파일·인스턴스화하는 비동기 함수입니다. Promise를 기다리지 않고 add()를 바로 부르면 내부 인스턴스가 아직 없어서 오류가 납니다. 이벤트 핸들러를 init()이 끝난 뒤에 등록하는 위 구조가 그래서 중요합니다. 또 이 HTML은 file://로 열면 fetch가 막히므로 npx serve .나 python -m http.server 같은 로컬 서버로 열어야 합니다.

.wasm을 다른 경로(CDN 등)에 두는 경우. 기본적으로 글루 코드는 자기 옆의 hello_wasm_bg.wasm을 찾습니다. 다른 위치에서 받으려면 경로를 명시합니다. 최신 wasm-bindgen에서는 문자열을 바로 넘기는 방식이 deprecated 경고를 내므로 객체 형태로 넘깁니다.

await init({ module_or_path: 'https://cdn.example.com/hello_wasm_bg.wasm' });

Rust 구조체 객체를 해제하지 않는 경우. new Calculator()로 만든 JS 객체는 실제로는 WASM 선형 메모리 안의 Rust 값을 가리키는 포인터 래퍼입니다. JS 변수를 버려도 WASM 쪽 메모리는 JS GC가 직접 회수하지 않습니다. 생성된 클래스에는 free() 메서드가 있으므로, 반복해서 만들고 버리는 객체라면 다 쓴 뒤 calc.free()를 호출해야 메모리가 계속 늘어나지 않습니다. 환경에 따라 FinalizationRegistry로 자동 해제가 붙기도 하지만, 호출 시점이 보장되지 않으므로 여기에 의존하지 않는 편이 안전합니다.


C/C++로 WASM 개발

Emscripten 설치

# Emscripten 설치
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk

./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

C 코드 작성

// hello.c
#include <emscripten.h>
#include <stdio.h>

// JavaScript에서 호출 가능
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
    return a + b;
}

EMSCRIPTEN_KEEPALIVE
double fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main() {
    printf("Hello from WebAssembly!\n");
    return 0;
}

컴파일

# WASM으로 컴파일
emcc hello.c -O2 -o hello.html \
  -s EXPORTED_FUNCTIONS='["_main","_add","_fibonacci"]' \
  -s EXPORTED_RUNTIME_METHODS='["cwrap"]'

# 결과물:
# hello.html
# hello.js
# hello.wasm

예전 예제에서 자주 보이는 -s WASM=1은 현재 Emscripten의 기본값이라 붙일 필요가 없습니다. EXPORTED_FUNCTIONS를 직접 지정하면 기본 export 목록을 덮어쓰므로, main을 실행해야 한다면 _main도 함께 적어야 합니다. C 함수 이름 앞의 밑줄(_add)은 Emscripten이 C 심볼을 내보낼 때 붙이는 규칙입니다.

JavaScript에서 사용

<script src="hello.js"></script>
<script>
Module.onRuntimeInitialized = () => {
  // C 함수 래핑
  const add = Module.cwrap('add', 'number', ['number', 'number']);
  const fibonacci = Module.cwrap('fibonacci', 'number', ['number']);

  console.log(add(10, 20)); // 30
  console.log(fibonacci(10)); // 55
};
</script>

버퍼·문자열 전달과 Embind

숫자가 아닌 데이터는 선형 메모리를 거쳐야 합니다. JS에서 Module._malloc으로 공간을 잡고 바이트를 복사한 뒤 포인터를 넘기고, 끝나면 Module._free로 돌려줍니다.

const data = new Uint8Array(pixels);               // RGBA 바이트
const ptr = Module._malloc(data.length);
Module.HEAPU8.set(data, ptr);                      // JS → Wasm 메모리 복사
Module._grayscale(ptr, width, height);             // C++가 제자리에서 수정
const result = Module.HEAPU8.slice(ptr, ptr + data.length);  // 복사본으로 꺼냄
Module._free(ptr);

_malloc·_free도 EXPORTED_FUNCTIONS에 넣어야 하고, 최근 Emscripten은 HEAPU8 같은 뷰를 기본으로 Module에 노출하지 않으므로 -s EXPORTED_RUNTIME_METHODS='["cwrap","HEAPU8"]'처럼 명시해야 합니다. 결과를 subarray로 꺼내면 Wasm 메모리를 가리키는 뷰라서 _free 이후나 메모리가 자란 뒤에는 내용이 바뀌거나 무효가 되므로, 보관할 값은 slice로 복사해 둡니다. 문자열을 C++에서 malloc으로 만들어 반환하는 경우도 주의가 필요합니다. cwrap의 반환 타입을 'string'으로 두면 문자열은 복사해 오지만 원래 버퍼는 해제하지 않아 호출마다 누수가 생기므로, 반환 타입을 'number'로 받아 UTF8ToString(ptr)(이것도 EXPORTED_RUNTIME_METHODS에 추가)으로 읽은 뒤 직접 _free(ptr)하는 편이 안전합니다. 할당과 해제를 한쪽 언어로 모으는 규칙을 정해 두는 것이 누수를 막는 가장 확실한 방법입니다.

C++ 클래스를 그대로 쓰고 싶다면 Embind가 타입 변환을 대신해 줍니다.

#include <emscripten/bind.h>
class Calculator {
public:
    int add(int a, int b) const { return a + b; }
};
EMSCRIPTEN_BINDINGS(calculator) {
    emscripten::class_<Calculator>("Calculator")
        .constructor<>()
        .function("add", &Calculator::add);
}
emcc calculator.cpp -o calculator.mjs -lembind -O3 -s MODULARIZE=1 -s EXPORT_ES6=1 -s EXPORT_NAME=createModule
import createModule from './calculator.mjs';
const Module = await createModule();
const calc = new Module.Calculator();
console.log(calc.add(2, 3));  // 5
calc.delete();                // Embind 객체는 JS GC가 회수하지 않으므로 직접 해제

MODULARIZE를 켜면 전역 Module 대신 팩토리 함수를 내보내 번들러와 함께 쓰기 쉽고, await createModule()가 초기화 완료까지 기다려 주므로 onRuntimeInitialized 콜백이 필요 없습니다. 개발 중에는 -g -O0 -s ASSERTIONS=2로 소스맵과 런타임 검사를 켜고, 배포 빌드는 -O3(크기가 중요하면 -Oz)에 ASSERTIONS=0으로 나누어 두면 디버깅과 파일 크기를 함께 챙길 수 있습니다.


선형 메모리 모델과 JS-WASM 데이터 교환의 실제 제약

WASM 인스턴스는 자신만의 선형 메모리(linear memory)를 가집니다. 이는 JavaScript의 ArrayBuffer로 그대로 노출되는, 처음부터 끝까지 주소가 연속된 하나의 거대한 바이트 배열입니다. C/Rust 코드 입장에서는 이 메모리가 곧 힙(heap)이자 스택이고, malloc으로 할당한 포인터는 결국 이 ArrayBuffer 안의 오프셋(정수 인덱스)일 뿐입니다. 문제는 여기서 시작됩니다. WASM 함수는 정수(i32/i64)와 부동소수점(f32/f64)만 인자로 주고받을 수 있고, 문자열이나 객체 같은 복합 타입을 직접 전달할 방법이 없습니다. JavaScript의 string, Object, Array는 WASM 선형 메모리 바깥, 즉 JS 엔진의 힙에 존재하기 때문입니다.

flowchart TD
    A["JS: 문자열 'Alice'"] --> B["TextEncoder.encode()<br/>UTF-8 바이트로 변환"]
    B --> C["WASM 선형 메모리에<br/>malloc 후 바이트 복사"]
    C --> D["포인터+길이를<br/>WASM 함수 인자로 전달"]
    D --> E["WASM 함수 실행<br/>(Rust/C 로직)"]
    E --> F["결과를 선형 메모리에<br/>다시 기록"]
    F --> G["JS: 포인터+길이 읽어<br/>TextDecoder.decode()"]
    G --> H["JS: 최종 문자열/객체"]

즉 greet(name: &str) 같은 단순해 보이는 함수 하나를 호출하기 위해, 실제로는 (1) JS 문자열을 TextEncoder로 UTF-8 바이트 배열로 바꾸고, (2) 그 바이트를 WASM 선형 메모리 안의 빈 공간에 malloc으로 할당한 뒤 복사하고, (3) 그 시작 포인터와 길이라는 정수 두 개만 WASM 함수에 넘기고, (4) WASM이 처리한 결과를 다시 메모리에 써넣으면, (5) JS가 그 포인터·길이를 읽어 TextDecoder로 문자열을 복원하는 다단계 과정이 필요합니다. 이 글 앞부분의 greet() 예제가 마치 JS 함수처럼 자연스럽게 호출되는 것처럼 보이는 이유는, wasm-bindgen이 이 인코딩·디코딩·메모리 복사·포인터 관리 로직을 컴파일 타임에 자동 생성해 hello_wasm.js 글루 코드 안에 숨겨주기 때문입니다. 즉 “wasm-bindgen이 편하다”는 말은 이 지루하고 실수하기 쉬운 마샬링(marshalling) 코드를 대신 짜준다는 뜻이지, WASM이 원래 문자열을 직접 주고받을 수 있다는 뜻이 아닙니다.

객체나 배열처럼 더 복잡한 구조를 넘길 때는 문제가 커집니다. 뒤에서 볼 serde_wasm_bindgen::from_value / to_value는 JS 객체의 필드를 하나씩 읽어 Rust 구조체로 옮기는 방식이라, 편리하지만 호출할 때마다 변환 비용이 붙습니다. 대량의 숫자 배열(이미지 픽셀, 오디오 샘플)은 &[u8], &mut [u8], &[f64] 같은 슬라이스로 받는 편이 훨씬 빠릅니다. 이때 wasm-bindgen이 하는 일은 타입 배열 전체를 선형 메모리로 한 번 복사(&mut이면 끝난 뒤 다시 원래 배열로 한 번 복사)하는 것입니다. 요소 하나하나를 변환하는 것보다 훨씬 싸지만, 엄밀히 말하면 복사가 없는 것은 아닙니다. 반대로 Vec<u8>이나 Vec<f64>를 반환하면 JS 쪽에서는 일반 배열이 아니라 Uint8Array·Float64Array 같은 새 타입 배열 복사본을 받습니다.

복사 자체를 없애려면, WASM 쪽에 버퍼를 할당해 두고 그 포인터를 JS에 알려 준 뒤, JS가 new Uint8Array(memory.buffer, ptr, len)으로 선형 메모리 위에 직접 뷰를 만들어 데이터를 쓰게 해야 합니다. 이 방식은 가장 빠르지만, WASM 메모리가 늘어나면(memory.grow) 기존 ArrayBuffer가 분리(detach)되어 미리 만들어 둔 뷰가 전부 무효가 된다는 함정이 있습니다. 뷰는 캐시하지 말고 쓸 때마다 다시 만드는 습관이 필요합니다.

이 번거로움의 근본 원인은 WASM이 가비지 컬렉션을 지원하지 않는 언어들을 1급 대상으로 설계되었기 때문입니다. GC가 없으므로 WASM 런타임은 “이 포인터가 아직 살아 있는 객체를 가리키는지”를 알 방법이 없고, 결국 메모리 관리를 전적으로 C/Rust 쪽 할당자(allocator)에 위임합니다. 이 한계를 해소하기 위해 W3C WebAssembly 커뮤니티 그룹은 WASM GC 제안(Garbage Collection proposal)을 표준화 중이며, Chrome/Firefox는 이미 구조체·배열 타입을 선형 메모리 밖에서 직접 다루는 struct/array 참조 타입을 지원하기 시작했습니다. WASM GC가 성숙하면 Kotlin, Dart, Java(Kotlin/Wasm, Dart의 dart2wasm) 같은 GC 기반 언어가 지금의 수동 글루 코드 없이도 훨씬 가벼운 결과물을 만들어낼 수 있지만, 2026년 현재도 C/C++/Rust 생태계만큼 성숙하지는 않았고, Emscripten·wasm-bindgen 기반 워크플로가 여전히 실무 표준입니다.


실전 예제: 이미지 필터

Rust로 이미지 처리

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn grayscale(data: &mut [u8], width: u32, height: u32) {
    for i in (0..data.len()).step_by(4) {
        let r = data[i] as f32;
        let g = data[i + 1] as f32;
        let b = data[i + 2] as f32;
        
        // 그레이스케일 변환
        let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
        
        data[i] = gray;
        data[i + 1] = gray;
        data[i + 2] = gray;
    }
}

#[wasm_bindgen]
pub fn blur(data: &mut [u8], width: u32, height: u32) {
    let w = width as usize;
    let h = height as usize;
    // 원본으로 초기화: 가장자리 픽셀과 알파 채널이 0(투명)으로 지워지지 않도록
    let mut output = data.to_vec();
    
    // 간단한 박스 블러 (3x3, 가장자리 1픽셀은 원본 유지)
    for y in 1..h-1 {
        for x in 1..w-1 {
            let idx = (y * w + x) * 4;
            
            for c in 0..3 {
                let mut sum = 0u32;
                for dy in -1i32..=1 {
                    for dx in -1i32..=1 {
                        let ny = (y as i32 + dy) as usize;
                        let nx = (x as i32 + dx) as usize;
                        let i = (ny * w + nx) * 4 + c;
                        sum += data[i] as u32;
                    }
                }
                output[idx + c] = (sum / 9) as u8;
            }
        }
    }
    
    data.copy_from_slice(&output);
}

두 함수 모두 이미지 전체를 한 번에 받아 처리한다는 점이 핵심입니다. 픽셀마다 process_pixel(r, g, b) 같은 WASM 함수를 호출하는 구조로 만들면, 계산 자체보다 JS↔WASM 경계를 넘는 호출 비용과 작은 반환값의 복사 비용이 더 커져서 오히려 JS보다 느려질 수 있습니다. 블러에서 output을 따로 두는 이유도 있습니다. 같은 버퍼를 읽으면서 덮어쓰면 이미 블러된 이웃 픽셀을 다시 평균 내게 되어 결과가 한쪽으로 번집니다.

JavaScript에서 사용

<!DOCTYPE html>
<html>
<head>
  <title>Image Filter</title>
</head>
<body>
  <input type="file" id="fileInput" accept="image/*">
  <canvas id="canvas"></canvas>
  <br>
  <button id="grayscale">Grayscale</button>
  <button id="blur">Blur</button>

  <script type="module">
    import init, { grayscale, blur } from './pkg/image_filter.js';

    let imageData;
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d');

    // WASM 초기화
    await init();

    // 이미지 로드
    document.getElementById('fileInput').addEventListener('change', (e) => {
      const file = e.target.files[0];
      const img = new Image();
      
      img.onload = () => {
        canvas.width = img.width;
        canvas.height = img.height;
        ctx.drawImage(img, 0, 0);
        imageData = ctx.getImageData(0, 0, img.width, img.height);
      };
      
      img.src = URL.createObjectURL(file);
    });

    // 그레이스케일 적용
    document.getElementById('grayscale').addEventListener('click', () => {
      if (!imageData) return;
      
      const data = imageData.data;
      const start = performance.now();
      
      // WASM 호출
      grayscale(data, canvas.width, canvas.height);
      
      const end = performance.now();
      console.log(`Grayscale: ${end - start}ms`);
      
      ctx.putImageData(imageData, 0, 0);
    });

    // 블러 적용
    document.getElementById('blur').addEventListener('click', () => {
      if (!imageData) return;
      
      const data = imageData.data;
      const start = performance.now();
      
      // WASM 호출
      blur(data, canvas.width, canvas.height);
      
      const end = performance.now();
      console.log(`Blur: ${end - start}ms`);
      
      ctx.putImageData(imageData, 0, 0);
    });
  </script>
</body>
</html>

성능 벤치마크

JavaScript vs WASM: 직접 재 보기

벤치마크 글에서 흔히 보는 “재귀 피보나치가 10배 빨라졌다” 같은 수치는 그대로 믿기 어렵습니다. 재귀 피보나치는 정수 덧셈과 함수 호출뿐이라 V8이 매우 잘 최적화하는 코드이고, 실제로 재 보면 JS와 WASM의 차이가 크지 않은 경우가 많습니다. 차이는 환경·브라우저·입력 크기에 따라 달라지므로, 자신의 워크로드로 직접 재는 것이 유일하게 믿을 만한 방법입니다.

import init, { grayscale } from './pkg/image_filter.js';
await init();

// JS 버전: WASM 버전과 같은 알고리즘
function grayscaleJS(data) {
  for (let i = 0; i < data.length; i += 4) {
    const g = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
    data[i] = data[i + 1] = data[i + 2] = g;
  }
}

function bench(label, fn, runs = 20) {
  fn(); // 워밍업: JIT 최적화와 WASM tier-up이 끝나도록 한 번 먼저 실행
  const start = performance.now();
  for (let i = 0; i < runs; i++) fn();
  console.log(label, ((performance.now() - start) / runs).toFixed(2), 'ms');
}

const pixels = new Uint8ClampedArray(2048 * 2048 * 4).map(() => Math.random() * 255);
bench('JS  ', () => grayscaleJS(pixels.slice()));
bench('WASM', () => grayscale(pixels.slice(), 2048, 2048));

측정할 때 주의할 점이 몇 가지 있습니다. 첫째, 워밍업 없이 첫 실행만 재면 JS는 인터프리터 단계, WASM은 baseline 컴파일 단계의 성능이 섞여 나옵니다. 둘째, WASM 쪽 측정값에는 타입 배열을 선형 메모리로 복사하고 되돌리는 비용이 포함됩니다. 실제 서비스에서도 그 비용을 내야 하므로 빼지 않고 재는 것이 공정합니다. 셋째, 한 번 호출로 끝나는 작은 입력에서는 경계 비용이 계산보다 커서 WASM이 불리하게 나올 수 있습니다. 이렇게 재 보고 나서 차이가 목표 성능을 넘길 만큼 크다면 그때 WASM을 도입하는 것이 순서입니다.


WASM과 JavaScript 상호 운용

JavaScript 함수 호출 (Rust)

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    // JavaScript 함수 선언
    fn alert(s: &str);
    
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

#[wasm_bindgen]
pub fn greet_with_alert(name: &str) {
    let message = format!("Hello, {}!", name);
    log(&message);
    alert(&message);
}

JavaScript 객체 전달

JS 객체를 Rust 구조체로 받으려면 serde와 serde-wasm-bindgen을 의존성에 추가해야 합니다.

[dependencies]
wasm-bindgen = "0.2"
serde = { version = "1", features = ["derive"] }
serde-wasm-bindgen = "0.6"
use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
pub struct User {
    name: String,
    age: u32,
}

#[wasm_bindgen]
pub fn process_user(val: JsValue) -> Result<JsValue, JsValue> {
    // JavaScript 객체 → Rust 구조체 (필드가 없거나 타입이 다르면 Err)
    let user: User = serde_wasm_bindgen::from_value(val)?;
    
    // 처리
    let processed = User {
        name: user.name.to_uppercase(),
        age: user.age + 1,
    };
    
    // Rust 구조체 → JavaScript 객체
    Ok(serde_wasm_bindgen::to_value(&processed)?)
}

unwrap()으로 처리하면 JS에서 잘못된 객체를 넘겼을 때 Rust가 패닉하고, 브라우저 콘솔에는 원인 없이 unreachable executed만 남습니다. Result<_, JsValue>를 반환하면 wasm-bindgen이 Err를 JS 예외로 바꿔 던져 주므로, JS 쪽에서 try/catch로 어떤 필드가 문제였는지 메시지를 받을 수 있습니다.

// JavaScript (init()은 이미 끝났다고 가정)
import { process_user } from './pkg/myapp.js';

const user = { name: 'alice', age: 25 };
const result = process_user(user);
console.log(result); // { name: 'ALICE', age: 26 }

WASM의 실제 사용 사례

1. Figma (디자인 도구)

  • C++로 작성된 렌더링 엔진을 WASM으로 포팅
  • 브라우저에서 네이티브급 성능

2. Google Earth

  • C++로 작성된 3D 엔진을 WASM으로 이식
  • 플러그인 없이 브라우저에서 실행

3. Photoshop Web

  • 이미지 처리 엔진을 WASM으로 최적화
  • 복잡한 필터를 실시간 처리

4. Unity 게임

  • Unity 게임을 WASM으로 내보내기
  • 웹 브라우저에서 3D 게임 실행

5. FFmpeg.wasm

  • 비디오 인코딩/디코딩을 브라우저에서
  • 서버 없이 클라이언트에서 처리

툴체인이 실제로 하는 일: Emscripten과 wasm-pack

Emscripten과 wasm-pack은 겉으로는 “명령어 하나로 WASM을 만들어 주는 도구”처럼 보이지만, 내부적으로 하는 일은 상당히 다릅니다. 이 차이를 알아두면 빌드 오류를 만났을 때 어느 계층에서 문제가 생겼는지 훨씬 빠르게 짚어낼 수 있습니다.

Emscripten은 사실상 “웹을 새로운 운영체제처럼 취급하는” 컴파일러 툴체인입니다. emcc는 내부적으로 Clang/LLVM을 호출해 C/C++를 .wasm으로 컴파일하지만, 그것만으로는 printf, malloc, 파일 시스템 접근 같은 표준 C 런타임이 동작하지 않습니다. 브라우저에는 파일 시스템도, stdout도 없기 때문입니다. 그래서 Emscripten은 자체 libc 구현(주로 musl 기반)과 가상 파일 시스템(MEMFS/IDBFS), 그리고 OpenGL 호출을 WebGL로 바꿔주는 에뮬레이션 레이어까지 함께 제공합니다. emcc hello.c -o hello.html을 실행했을 때 .wasm뿐 아니라 상당한 크기의 hello.js 글루 코드가 함께 생성되는 이유가 이것입니다. 이 글루 코드가 위에서 설명한 compile/instantiate 호출, 가상 파일 시스템 초기화, cwrap/ccall 같은 타입 변환 헬퍼를 모두 대신 처리해 줍니다. 즉 Emscripten은 “기존 C/C++ 애플리케이션을 최소한으로 수정해서 웹으로 이식”하는 데 최적화된 도구입니다.

반면 wasm-pack은 Rust 생태계에 맞춰 훨씬 가볍게 동작합니다. Rust는 애초에 표준 라이브러리가 no_std 환경(운영체제 없는 임베디드 환경)까지 지원하도록 설계되어 있어서, wasm32-unknown-unknown 타깃으로 컴파일할 때 Emscripten 같은 두꺼운 런타임 에뮬레이션이 필요 없습니다. wasm-pack이 실제로 하는 일은 (1) cargo build --target wasm32-unknown-unknown으로 .wasm을 생성하고, (2) wasm-bindgen CLI를 호출해 소스에 붙은 #[wasm_bindgen] 매크로 정보를 바탕으로 앞서 설명한 JS↔WASM 마샬링 글루 코드(hello_wasm.js, .d.ts 타입 선언)를 생성하고, (3) 필요하면 wasm-opt(Binaryen 프로젝트)로 최종 바이너리를 최적화·경량화하는 것입니다. #[wasm_bindgen] 매크로는 컴파일 타임에 함수 시그니처를 분석해서, 문자열·슬라이스·구조체 등 각 타입에 맞는 인코딩/디코딩 코드를 자동으로 생성해 준다는 점에서, 이 글에서 설명한 선형 메모리 마샬링 문제를 사람이 직접 짜지 않아도 되게 해주는 코드 생성기라고 이해하면 정확합니다.

실무 도입 시 고려사항

WASM 도입을 검토할 때 실무에서 가장 자주 부딪히는 문제는 성능이 아니라 번들 크기와 디버깅입니다.

번들 크기: Rust wasm32-unknown-unknown + wasm-bindgen 조합은 “Hello World” 수준에서도 릴리스 빌드 기준 수십 KB에서 시작하고, 여기에 serde, 정규식, 부동소수점 포맷팅 같은 의존성이 붙으면 수백 KB까지 쉽게 늘어납니다. Emscripten은 C 런타임과 가상 파일 시스템까지 포함하므로 기본적으로 더 무겁습니다. 이를 줄이는 실전 절차는 (1) Cargo.toml에 [profile.release] lto = true, opt-level = "z", panic = "abort"를 설정해 링크 타임 최적화와 크기 우선 최적화를 켜고, (2) wasm-opt -Oz로 후처리 최적화를 한 번 더 돌리고(wasm-pack은 릴리스 빌드에서 자동으로 실행하며, 별도로 쓰려면 Binaryen을 설치하거나 npm install -g wasm-opt), (3) twiggy top pkg/*_bg.wasm 같은 도구로 어떤 함수가 용량을 차지하는지 확인해 무거운 의존성(포맷팅, 정규식 등)을 걷어내고, (4) Brotli/Gzip 압축을 서버에서 적용하는 순서로 진행합니다. 예전 자료에서 권하던 wee_alloc 경량 할당자는 유지보수가 중단되었고 메모리 누수 이슈가 보고되어 있으므로, 새 프로젝트에서는 기본 할당자를 그대로 쓰는 편이 낫습니다. 절감 폭이 수 KB 수준이라 위험을 감수할 이유가 크지 않습니다. 이렇게 해도 WASM 파일 자체는 캐시가 가능하고 다운로드 후 파싱·컴파일이 JS 파싱보다 빠른 경우가 많으므로, “초기 로딩이 조금 늦더라도 이후 연산이 압도적으로 빠른” 워크로드에는 여전히 유리합니다.

디버깅: WASM 실행 중 패닉이나 크래시가 나면 브라우저 콘솔에는 스택 트레이스 대신 unreachable executed 같은 의미 없는 메시지만 뜨는 경우가 흔합니다. Rust는 console_error_panic_hook 크레이트를 추가하고 초기화 함수(예: #[wasm_bindgen(start)]가 붙은 함수)에서 console_error_panic_hook::set_once()를 한 번 호출해 두면 패닉 메시지와 발생 위치를 JS 콘솔로 볼 수 있고, Emscripten은 -g 플래그와 DWARF 디버그 정보를 통해 Chrome DevTools의 “C/C++ DevTools Support (DWARF)” 확장으로 소스 레벨 디버깅을 지원합니다. 그래도 JS 디버깅과 비교하면 손이 훨씬 많이 가므로, 프로덕션 배포 전 로컬에서 충분히 유닛 테스트(Rust는 wasm-bindgen-test, C++는 네이티브 빌드로 먼저 검증 후 이식)를 돌려두는 습관이 필수적입니다.

“진짜 WASM이 필요한가”라는 질문: 실무에서 가장 흔한 실수는 단순히 “느린 것 같다”는 이유로 WASM 도입을 검토하는 것입니다. 프로파일러로 병목을 먼저 확인했을 때 병목이 DOM 리플로우, 불필요한 리렌더링, 네트워크 왕복이라면 WASM은 전혀 도움이 되지 않습니다. 반대로 프로파일러가 가리키는 병목이 순수 CPU 연산(반복문, 수치 계산, 바이트 단위 처리)이고, 이미 Float64Array/Int32Array 같은 타입 배열과 루프 최적화까지 적용한 JS로도 목표 성능에 못 미친다면, 그때 비로소 WASM 도입이 정당화됩니다. Figma나 FFmpeg.wasm처럼 애초에 C/C++로 작성된 대규모 코드베이스를 재사용해야 하는 경우도 마찬가지로 WASM이 명확히 유리한 사례입니다. 반면 새 프로젝트를 처음부터 WASM으로 설계하는 것은, 툴체인·디버깅·빌드 복잡도 증가를 상쇄할 만큼 CPU 바운드 연산이 많을 때만 권장됩니다.


성능을 더 끌어내기: SIMD, 스레드, 파일 크기

벤치마크에서 기대만큼 빨라지지 않는 경우, 원인은 WASM 자체보다 JS↔WASM 경계를 너무 자주 넘는 것인 경우가 많습니다. 픽셀 하나마다 WASM 함수를 호출하면 호출 비용과 데이터 복사가 계산보다 커지므로, 이미지 전체를 선형 메모리에 한 번 복사해 두고 한 번의 호출로 처리하게 설계해야 합니다. 그다음 단계가 SIMD와 스레드입니다.

SIMD는 128비트 레지스터로 f32 네 개를 한 번에 더하는 식의 벡터 연산이며, 주요 브라우저가 모두 지원합니다. Rust는 -C target-feature=+simd128로 빌드하면 컴파일러가 루프를 자동 벡터화하기도 하고, std::arch::wasm32의 f32x4_add 같은 인트린직으로 직접 쓸 수도 있습니다. Emscripten은 -msimd128을 붙입니다. SIMD를 지원하지 않는 환경을 고려해야 한다면 SIMD 빌드와 일반 빌드를 두고 기능 감지 후 골라 로드합니다.

멀티스레딩은 Web Worker들이 SharedArrayBuffer로 같은 선형 메모리를 공유하는 방식이라, 보안 정책상 페이지가 cross-origin isolated 상태여야 합니다. 서버가 Cross-Origin-Opener-Policy: same-origin과 Cross-Origin-Embedder-Policy: require-corp 헤더를 보내지 않으면 SharedArrayBuffer is not defined 에러가 납니다. COEP를 켜면 CORP/CORS 헤더가 없는 외부 이미지·스크립트·광고가 로드되지 않으므로, 스레드가 꼭 필요한 페이지에만 적용하는 편이 현실적입니다.

emcc worker.cpp -O3 -msimd128 -pthread -s PTHREAD_POOL_SIZE=4 -o out.js

파일 크기는 첫 로딩 시간에 그대로 반영됩니다. 릴리스 빌드(-O3, --release)는 기본이고, Binaryen의 wasm-opt -Oz로 한 번 더 줄이며(wasm-pack은 자동으로 실행), C++이라면 -fno-exceptions -fno-rtti로 쓰지 않는 기능을 뺍니다. Rust는 Cargo.toml의 [profile.release]에 opt-level = "z", lto = true를 주면 효과가 큽니다. 서버에서 Brotli로 압축해 전송하고, WebAssembly.instantiateStreaming을 쓰면 다운로드와 컴파일이 동시에 진행됩니다.

자주 만나는 에러

  • expected magic word / MIME 타입 에러: 서버가 .wasm을 application/wasm으로 보내지 않으면 instantiateStreaming이 실패합니다. Nginx라면 types { application/wasm wasm; }를 추가합니다.
  • memory access out of bounds: 초기 메모리보다 큰 데이터를 다루는 경우입니다. Emscripten은 -s ALLOW_MEMORY_GROWTH=1로 메모리가 자라도록 허용합니다. 다만 메모리가 커지면 기존 ArrayBuffer 뷰가 무효화되므로, JS 쪽에서 HEAPU8 같은 뷰를 캐시해 두었다면 성장 후 다시 만들어야 합니다.
  • Module._myFunction is not a function: C++ 함수는 이름 맹글링과 데드 코드 제거 때문에 사라지기 쉽습니다. extern "C"와 EMSCRIPTEN_KEEPALIVE를 붙이거나 -s EXPORTED_FUNCTIONS='["_myFunction"]'로 명시합니다.
  • Cannot read properties of undefined (reading '__wbindgen_malloc') 류의 오류: wasm-bindgen 글루 코드가 아직 초기화되지 않은 인스턴스에 접근한 경우입니다. --target web 빌드에서 await init() 전에 export 함수를 호출하지 않았는지 확인합니다.
  • wasm-bindgen 스키마 버전 불일치: wasm-bindgen CLI를 직접 설치해 쓰는 경우, Cargo.toml(정확히는 Cargo.lock)의 wasm-bindgen 크레이트 버전과 CLI 버전이 다르면 빌드 단계에서 버전이 맞지 않는다는 오류가 납니다. CLI를 lockfile과 같은 버전으로 설치(cargo install wasm-bindgen-cli --version <버전>)하면 해결됩니다. wasm-pack은 이 버전을 자동으로 맞춰 줍니다.

WASM의 한계

WASM이 적합하지 않은 경우

  1. DOM 조작: JavaScript가 훨씬 빠름 — WASM은 DOM API에 직접 접근할 수 없어 결국 JS를 거쳐야 하고, 그 호출 경계(imports/exports)를 넘나들 때마다 오버헤드가 붙습니다.
  2. 간단한 UI 로직: 오버헤드가 더 큼 — 모듈 로딩·컴파일·인스턴스화 비용이 실제 연산 시간보다 클 수 있습니다.
  3. 네트워크 I/O: 병목이 네트워크이므로 이점 없음 — fetch 응답을 기다리는 시간은 WASM이든 JS든 동일합니다.
  4. 작은 계산: WASM 로딩 시간이 더 오래 걸림 — 수 밀리초짜리 연산이라면 모듈을 내려받고 초기화하는 비용이 이득을 상쇄합니다.

WASM이 유용한 경우

  1. 계산 집약적 작업: 암호화, 압축, 수학 연산 — 반복문이 많고 타입이 고정된 순수 함수형 코드일수록 유리합니다.
  2. 이미지/영상 처리: 필터, 변환, 렌더링 — 대량의 바이트 배열을 선형 메모리에서 직접 조작해 복사 비용을 줄일 수 있습니다.
  3. 게임 엔진: 물리 엔진, 충돌 감지 — 프레임마다 반복되는 수치 연산에서 GC 일시 정지가 없다는 장점이 극대화됩니다.
  4. 레거시 코드 포팅: C/C++ 라이브러리를 웹에서 사용 — 처음부터 다시 짜는 대신 검증된 코드베이스를 그대로 재사용할 수 있습니다.

WASM을 도입할지 판단하는 기준

프로파일러가 순수 CPU 연산을 병목으로 가리킬 때 도입을 검토하고, DOM 조작이나 네트워크가 병목이라면 JS 쪽을 먼저 최적화하는 편이 낫습니다. WASM 함수는 숫자만 직접 주고받기 때문에 문자열과 객체는 선형 메모리로 복사·변환되며, 작은 함수를 루프 안에서 자주 호출하는 구조라면 이 경계 비용이 계산 속도 이득을 상쇄할 수 있습니다. 그래서 실제 효과는 “호출은 굵게, 데이터는 한 번에” 넘기는 구조로 바꿀 수 있는지에 달려 있고, 이득의 크기도 워크로드마다 다르므로 자신의 데이터로 직접 재 보는 것이 가장 확실합니다.


같이 보면 좋은 글