다음에 올 것을 만들어가는 기술에 대한 심층 기사.

WebAssembly가 JavaScript보다 빠르지 않은 순간

WebAssembly가 항상 JavaScript보다 빠른 건 아닙니다. 실제 벤치마크로 TypeScript가 WASM을 이기는 경우와 경계 호출 비용을 살펴봅니다.

국경 검문소에서 멈춰 선 주자와 그 옆을 자유롭게 달려 지나가는 다른 주자

지난달 토요일 하루를 통째로 써서, Rust에서 WASM으로 빌드된 이미지 메타데이터 파서를 TypeScript로 다시 작성했습니다. WASM 버전은 8개월 동안 운영 환경에서 돌아가고 있었어요. 잘 동작했고 불평하는 사람도 없었습니다. 그런데 성능 트레이스를 계속 들여다보다 보니 거슬리는 점이 있었습니다. WASM 파서가 실제 파싱보다 JS와 WASM 경계를 넘나드는 데이터 이동에 더 많은 시간을 쓰고 있었거든요. 그래서 다시 짰습니다. WASM 없이 순수 TypeScript로요. 그 결과 중앙값 페이로드 기준으로 38% 빨라졌습니다. 더 좋은 알고리즘을 쓴 게 아닙니다. 경계를 넘을 때마다 내는 '통행세'를 그만 낸 것뿐입니다.

이 경험은 제 머릿속 전제를 뒤흔들었습니다. 저도 WebAssembly는 무조건 빠른 선택지라고 믿던 개발자 중 하나였거든요. 알고 보니 그 믿음은 우리가 생각하는 것보다 훨씬 자주 틀립니다.

'WASM이 항상 빠르다'는 미신은 이제 버려야 합니다

대부분의 개발자가 가진 머릿속 모델은 이렇습니다. 컴파일 언어를 WASM으로 옮기면 WASM은 네이티브에 가까운 속도로 돌아가고, 따라서 WASM이 JavaScript보다 빠르다는 것이죠. 이 논리의 각 단계는 대체로 맞습니다. 하지만 결론이 따라오지 않아요. 계산 자체가 아니라 그 주변 비용, 즉 데이터를 넣고 결과를 꺼내는 비용과 두 런타임을 나란히 돌릴 때 생기는 오버헤드를 무시하고 있기 때문입니다.

JavaScript 엔진은 정말 미친 수준으로 뛰어납니다. V8, SpiderMonkey, JavaScriptCore는 세계에서 가장 돈 많은 회사들이 수십 년간 투자해 온 최적화의 결과물이에요. 추측성 JIT 컴파일, 인라인 캐싱, 이스케이프 분석, 히든 클래스 전이 같은 기법을 씁니다. 문자열이 많고, 객체가 많고, 콜백이 많은 웹 앱의 전형적인 작업 패턴에서는 최신 JS 엔진이 만들어내는 기계어가 정적 컴파일러 결과와 놀랄 만큼 비슷합니다.

WASM은 그런 최적화를 받지 못합니다. AOT로 미리 컴파일되기 때문에 배포한 것이 그대로 실행됩니다. 예측 가능성 면에서는 장점이지만, 실행 중 패턴에 맞춰 적응하는 JIT과는 달리 런타임 특성에 맞춰 변하지는 못합니다.

경계 넘기 문제: 천 번의 호출로 죽는다

이게 가장 큰 문제입니다. JavaScript에서 WASM으로(또는 그 반대로) 호출할 때마다 오버헤드가 생깁니다. 엔진은 데이터를 마샬링하고, 타입을 검증하고, 실행 컨텍스트를 전환해야 하죠. 한 번 넘는 건 몇 마이크로초로 싸지만, 한 번의 작업 중에 경계를 수천 번 넘는 워크로드는 이 오버헤드에 그대로 무너집니다.

이런 패턴은 계속 보입니다. 팀이 파서, 변환기, 검증기를 Rust로 짜서 WASM으로 컴파일한 다음, 노드 하나하나, 토큰 하나하나, 단계 하나하나마다 WASM을 호출하는 JavaScript 글루 코드로 감싸는 식이죠. WASM 코어는 단독으로 보면 엄청 빠를 수 있지만, 글루 코드가 그걸 통행료 징수 도로로 바꿔 버립니다.

// The toll road pattern — looks clean, performs terribly
const wasmParser = await initParser();
const ast = wasmParser.parse(source);          // JS -> WASM
for (const node of wasmParser.walkTree(ast)) { // WASM -> JS per node
if (matchesRule(node)) {                     // JS land
wasmParser.transform(node, newValue);      // JS -> WASM per match
}
}
// A 500-line document generates ~10,000 boundary crossings.
// At 2μs each, that's 20ms of pure overhead before any real work.
// Same logic, pure TypeScript — zero boundary tax
const ast = parse(source);
for (const node of walkTree(ast)) {
if (matchesRule(node)) {
transform(node, newValue);
}
}
// V8 JIT-compiles the hot loop. Total time: often under 5ms.

실제 코드베이스에서 이 시나리오를 그대로 벤치마크해 봤습니다. 설정 파일 100개를 묶은 배치를 WASM 버전은 340ms에 처리했고, TypeScript 포트는 195ms에 끝냈습니다. TypeScript가 더 빠른 언어라서 그런 게 아닙니다. 작업이 단 하나의 실행 컨텍스트 밖으로 한 번도 나가지 않았기 때문입니다.

문자열이 WASM 성능을 망가뜨리는 이유

WASM은 선형 메모리, 즉 평평한 바이트 버퍼 위에서 동작합니다. 반면 JavaScript 문자열은 완전히 다른 물건이에요. Latin1, UTF-16, 로프(rope), 콘스 문자열 같은 특수한 내부 형식으로 저장되는 엔진 관리 객체입니다. 문자열을 JS에서 WASM으로 넘기려면 UTF-8로 인코딩하고, 선형 메모리에 공간을 할당하고, 바이트를 복사해야 합니다. 결과 문자열을 꺼내올 때도 또 복사하고 디코딩해야 하고요.

숫자 계산 코드라면 별문제 없습니다. TypedArray를 넣고 숫자를 받으면 되니까요. 하지만 파서, 템플릿 엔진, 검증기, 직렬화기처럼 문자열을 많이 다루는 코드는 문자열 조각 하나하나마다 인코딩-복사-처리-복사-디코딩이라는 세금을 매번 내야 합니다.

// What actually happens when you pass a string to WASM
// (wasm-bindgen generates code like this behind the scenes)
function pushStringToWasm(s: string): [ptr: number, len: number] {
const encoded = new TextEncoder().encode(s); // allocate + encode
const ptr = wasm.__wbindgen_malloc(encoded.length);
new Uint8Array(wasm.memory.buffer).set(encoded, ptr); // copy
return [ptr, encoded.length];
}
function pullStringFromWasm(ptr: number, len: number): string {
const bytes = new Uint8Array(wasm.memory.buffer, ptr, len); // view
return new TextDecoder().decode(bytes.slice()); // copy + decode
}
// A parser that handles 3,000 string fragments per document
// runs this cycle 6,000 times (in + out). That adds up.

우리 WASM 파서를 프로파일링해 보니 전체 실행 시간의 31%가 문자열 직렬화에 쓰였습니다. 파싱도, 트리 구성도 아니었어요. 그저 문자열을 경계 너머로 왔다 갔다 옮기는 시간이었습니다. TypeScript 버전은 그 범주의 작업 자체를 없애 버렸고요.

핫 패스가 문자열 위주라면 WASM은 아마 빠르게 해주는 게 아니라 느리게 만들고 있을 겁니다. 직렬화 오버헤드는 실제로 존재하고, 그 비용은 금방 쌓입니다.

아무도 말하지 않는 JIT 컴파일의 성능 우위

WASM의 AOT 모델은 성능이 예측 가능하고 일관된다는 뜻입니다. 어떤 경우엔 아주 좋죠. 하지만 JavaScript의 JIT 모델에서는 V8이 코드가 실행되는 것을 지켜보다가, 실제로 흘러 들어오는 데이터에 맞춰 최적화된 기계어를 생성합니다. 시간이 지나면 JIT으로 컴파일된 코드가 말도 안 되게 빨라질 수 있어요.

객체 형태(shape) 특화를 예로 들어 보죠. 같은 속성을 같은 순서로 가진 객체들의 배열을 처리하면, V8은 이 단일 형태(monomorphic) 패턴을 감지하고 고정된 메모리 오프셋으로 속성에 접근하는 기계어를 만듭니다. 해시 테이블 조회도, 타입 검사도 없어요. 동적인 JavaScript에서 사실상 C 구조체 수준의 성능이 나오는 셈입니다.

// V8 loves this pattern — all objects share one hidden class
interface Token {
kind: number;   // numeric enum, not string
start: number;
end: number;
flags: number;
}
// Flat array of uniform objects = V8's happy place
const tokens: Token[] = [];
for (let i = 0; i < source.length; ) {
const kind = classifyChar(source.charCodeAt(i));
const start = i;
i = scanToken(source, i, kind);
tokens.push({ kind, start, end: i, flags: 0 });
}
// After a few hundred iterations, V8 compiles this to
// machine code with fixed-offset property access. Fast.

WASM은 이걸 할 수 없습니다. 성능 특성이 컴파일 시점에 고정되어 있거든요. 타이트하고 예측 가능한 수치 루프라면 그걸로 충분합니다. Rust 컴파일러가 이미 충분히 최적화해 주니까요. 하지만 웹 앱의 전형적인 복잡하고, 다형적이고, 콜백이 많은 코드에서는 JIT이 런타임에 특화할 수 있다는 점이 확실한 장점입니다.

번들 크기와 콜드 스타트: 잊기 쉬운 지표들

처리량만이 성능 지표는 아닙니다. 사용자는 로드 시간, 상호작용 지연, 첫 결과가 나오기까지의 시간을 체감하죠. WASM은 이 세 가지 모두에서 실질적인 비용이 있습니다.

  • wasm-bindgen을 쓴 Rust WASM 모듈은 보통 gzip 압축 기준 100KB에서 1.5MB 정도입니다. 같은 기능의 TypeScript를 minify하고 gzip하면 보통 10~40KB입니다.
  • WASM 모듈은 실행되기 전에 네이티브 코드로 컴파일되어야 합니다. 스트리밍 컴파일이 도움은 되지만, 브라우저가 해야 할 작업은 여전히 남아 있습니다.
  • JavaScript 엔진은 지연 파싱을 합니다. 함수는 호출되기 전까지 컴파일되지 않아요. WASM은 컴파일 비용 전체를 처음에 한꺼번에 치릅니다.
  • V8의 코드 캐시 덕분에 재방문 시에는 JS 파싱을 통째로 건너뜁니다. WASM 컴파일 캐싱도 존재하지만 아직 덜 성숙했습니다.

우리 프로젝트의 실제 비교 결과를 보겠습니다. WASM 파서 번들은 gzip 기준 380KB였고, TypeScript 대체본은 26KB였습니다. 좋은 연결 환경이라면 별거 아니죠. 하지만 모든 성능 리뷰마다 시뮬레이션하는 3G 스로틀링 환경에서는 WASM 버전이 로드 시간을 1.8초나 더 늘렸습니다. 이건 반올림 오차가 아니라, 사용자가 머무르느냐 떠나느냐를 가르는 차이입니다.

콜드 스타트도 문제였습니다. 중급 폰에서 WASM 모듈을 컴파일하고 인스턴스화하는 데 약 110ms가 걸렸습니다. TypeScript 버전은 20ms가 안 돼서 상호작용이 가능했어요. 사용자가 하루에도 수십 번씩 찾는 도구라면 이 차이가 쌓여서 실제 불편함이 됩니다.

WASM이 실제로 이기는 경우 (그리고 써야 할 때)

WASM이 나쁜 기술이라는 인상을 주고 싶지는 않습니다. WASM은 훌륭한 기술이고, 확실한 적정 영역이 있어요. 문제는 사람들이 그 영역 밖에서 쓰고 나서 왜 느려졌는지 궁금해한다는 겁니다.

WASM이 이기는 경우는 계산이 무겁고, 경계 호출은 적고, 데이터가 숫자 위주일 때입니다. 픽셀 버퍼에 대한 이미지 필터, 암호 해시, 물리 시뮬레이션, 오디오 DSP, 비디오 코덱 같은 것들이죠. 큰 데이터 덩어리를 선형 메모리에 밀어 넣고, WASM이 타이트한 루프에서 계산하게 한 다음, 결과만 꺼내 옵니다. 들어갈 때 한 번, 나올 때 한 번, 그 사이에 엄청난 계산이 있는 구조예요. 바로 여기서 WASM이 빛납니다.

결정적인(deterministic) 성능이 필요할 때도 WASM이 유리합니다. JIT 컴파일은 강력하지만 예측이 어렵습니다. JS 함수는 처음 몇 번의 호출이 인터프리터로 돌고, 그다음 베이스라인 컴파일을 거치고, 운이 좋으면 최적화 컴파일까지 갑니다. 첫 호출부터 일관된 지연 시간이 필요하다면(게임 루프, 실시간 오디오, 금융 계산 등) WASM의 사전 컴파일이 그걸 보장해 줍니다.

그리고 기존 네이티브 코드베이스를 옮기는 경우라면 WASM이 확실히 맞는 선택입니다. WASM 오버헤드를 피하려고 SQLite를 JavaScript로 다시 짜는 사람은 없어야 합니다. 그건 미친 짓이에요. 기존 C 코드에는 수십 년의 최적화가 담겨 있고, 그걸 그대로 재현할 방법은 없으니까요.

실용적인 결정 프레임워크: WASM일까, TypeScript일까?

프로젝트 세 개에서 이 작업을 해 보고 나서, 간단한 체크리스트로 정리했습니다. 완벽하진 않지만, 덕분에 잘못된 선택을 두 번이나 피할 수 있었어요.

  1. 경계 호출 횟수를 세세요. WASM 모듈이 JS를 호출하거나(또는 그 반대로) 한 번의 작업에서 수백 번을 넘는다면, 아마 TypeScript가 이길 겁니다. 확실히 하려면 프로파일링해 보세요.
  2. 데이터 타입을 보세요. 주로 숫자와 타입드 배열인가요? 그렇다면 WASM이 잘 맞습니다. 주로 문자열, 객체, 콜백인가요? 그렇다면 JS에 머무르세요.
  3. 시작 비용을 측정하세요. 코드가 페이지 로드 시 실행되거나 사용자 클릭에 반응한다면 WASM의 컴파일 오버헤드가 중요합니다. 오래 살아 있는 워커에서 돈다면 시작 비용은 시간이 지나며 분산되어 사라집니다.
  4. 번들 예산을 확인하세요. 이미 번들 크기로 고생하고 있다면 400KB짜리 WASM 모듈을 추가하는 순간 아프게 됩니다.
  5. 개발 경험(DX) 비용을 고려하세요. WASM은 Rust나 C++ 빌드 시스템, 소스맵의 복잡성, 더 어려운 디버깅을 가져옵니다. 성능 이득이 미미하다면 그 복잡성은 가치가 없어요.
  6. 실제 데이터로 벤치마크하세요. 마이크로 벤치마크는 거짓말을 합니다. 오버헤드 패턴은 실제 운영 규모의 입력과 현실적인 호출 패턴에서만 드러납니다.

재작성에서 배운 것: 전과 후

파서 마이그레이션의 실제 수치를 공개하겠습니다. 합성 벤치마크가 아니라, 50줄에서 2,000줄 사이의 실제 문서 코퍼스 200개를 처리한 결과입니다.

Metric                  | Rust/WASM    | TypeScript   | Delta
------------------------|--------------|--------------|--------
Median parse time       | 23.1ms       | 14.4ms       | -38%
P99 parse time          | 58.3ms       | 30.7ms       | -47%
Bundle size (gzip)      | 380KB        | 26KB         | -93%
Cold start              | 142ms        | 18ms         | -87%
Boundary crossings/doc  | ~12,000      | 0            | -100%
String serialization    | 31% of time  | 0%           | eliminated
Debug/profile ease      | painful      | native       | huge win

TypeScript 재작성은 단순한 직접 포팅이 아니었습니다. V8 옵티마이저와 잘 맞도록 AST 표현을 다시 설계했어요. 단형 객체 형태, 문자열 태그 대신 숫자 열거형, 포인터가 많은 트리 대신 평탄한 배열 저장 방식을 썼습니다. Rust 컴파일러가 그 수준의 세부 사항을 알아서 처리해 주기 때문에 Rust에서는 이런 최적화를 고민할 일이 거의 없습니다. 하지만 JavaScript에서는 JIT에 반쯤 맞춰 줘야 합니다.

// AST node design optimized for V8's hidden class system
// Every node has identical shape = monomorphic access = fast
const NODE_HEADING = 1;
const NODE_PARAGRAPH = 2;
const NODE_CODE = 3;
const NODE_LIST = 4;
interface ASTNode {
type: number;       // numeric, not string — cheaper comparisons
start: number;      // byte offset
end: number;
parent: number;     // index into nodes array, not a reference
firstChild: number; // -1 if none
nextSibling: number; // -1 if none
flags: number;      // bitfield for boolean props
}
// Pre-allocate and reuse — minimal GC pressure
const pool: ASTNode[] = new Array(1024);
let poolIdx = 0;
function allocNode(type: number, start: number, end: number, parent: number): number {
const i = poolIdx++;
pool[i] = { type, start, end, parent, firstChild: -1, nextSibling: -1, flags: 0 };
return i;
}

핵심 교훈이 뭐냐고요? 가장 빠른 코드는 가장 빠른 언어로 쓴 코드가 아닙니다. 런타임과 싸우지 않고 함께 일하는 코드가 가장 빠른 코드입니다. 우리 Rust 코드는 훌륭한 Rust 코드였어요. 하지만 WASM으로 컴파일되어 JavaScript 애플리케이션에 들어가자, 매 순간 플랫폼과 싸우고 있었습니다.

추측은 그만두고, 측정을 시작하세요

저는 지금도 WASM을 씁니다. 제가 JavaScript로 짤 수 있는 어떤 것보다 4배 빠른 WASM 기반 이미지 처리 파이프라인이 있어요. 원시 픽셀 버퍼를 다루고 경계 호출은 전혀 없기 때문입니다. 적합한 도구를 적합한 일에 쓰는 거죠.

하지만 성능이 필요할 때 WASM을 기본값으로 삼는 습관은 버렸습니다. 지금 제 기본 방식은 먼저 TypeScript 버전을 만들고, 실제 데이터로 측정한 뒤, 프로파일러가 필요하다고 말할 때만 WASM에 손을 대는 것입니다. 그것도 실제로 느린 특정 핫 패스만요. 모듈 전체도, 기능 전체도 아닙니다. 사전 컴파일의 혜택을 진짜로 받을 수 있는 타이트한 계산 루프만 옮깁니다.

TypeScript로 작성하고, 측정하고, 충분히 빠르면 배포하세요. 충분히 빠르지 않다면 핫 루프만, 오직 핫 루프만 WASM으로 옮기세요. 그러면 코드는 줄고, 번들은 작아지고, 애플리케이션은 더 빨라집니다.

웹 플랫폼은 강력한 실행 모델 두 가지를 제공합니다. 둘 다 활용하세요. 다만 직감이 그렇다고 말하는 곳이 아니라, 실제로 도움이 되는 곳에서 쓰세요.