Rust로 작성해 WASM으로 컴파일했던 openui-lang 파서를 TypeScript로 포팅하면서 경계(interop) 오버헤드를 제거해 호출당 최대 4.6배, 스트리밍 총 비용을 최대 3.3배 개선한 과정과 벤치마크, 그리고 WASM이 실제로 도움이 되는 경우를 정리합니다.
OpenUI LangPlaygroundAPI ReferenceBlog
On this page
파이프라인WASM 경계 세금시도한 해결: JSON 왕복 생략벤치마크: JSON 문자열 vs 직접 JsValue (1000회 실행, 호출당 µs)진짜 해결: 경계를 완전히 제거벤치마크 방법: 원샷 파싱결과: 원샷 파싱 (중앙값 µs, 1000회)알고리즘 문제: O(N²) 스트리밍해결: 문장 단위 점진 캐싱벤치마크 방법: 전체 스트림 총 파싱 비용결과: 전체 스트림 총 파싱 비용 (모든 청크의 중앙값 µs)두 TS 숫자가 달라 보이는 이유요약WASM이 실제로 도움이 되는 경우핵심 요점
Rust WASM 파서를 TypeScript로 다시 작성했더니, 3배 더 빨라졌습니다
Thesys Engineering Team·Fri Mar 13 2026
우리는 openui-lang 파서를 Rust로 만들고 WASM으로 컴파일했습니다. 논리는 타당했습니다: Rust는 빠르고, WASM은 브라우저에서 네이티브에 가까운 속도를 제공하며, 우리 파서는 상당히 복잡한 다단계 파이프라인이기 때문입니다. 이런 걸 Rust로 하고 싶지 않을 이유가 있을까요?
알고 보니 우리는 잘못된 부분을 최적화하고 있었습니다.
openui-lang 파서는 LLM이 출력한 커스텀 DSL을 React 컴포넌트 트리로 변환합니다. 스트리밍되는 모든 청크마다 실행되므로 지연 시간이 매우 중요합니다. 파이프라인은 여섯 단계로 구성됩니다:
autocloser → lexer → splitter → parser → resolver → mapper → ParseResult
id = expression 문장으로 자릅니다OutputNode 형식으로 변환합니다WASM 파서를 호출할 때마다 Rust 코드 자체가 아무리 빨라도 반드시 지불해야 하는 오버헤드가 있습니다:
JS world WASM world
────────────────────────────────────────────────────────
wasmParse(input)
│
├─ copy string: JS heap → WASM linear memory (allocation + memcpy)
│
│ Rust parses ✓ fast
│ serde_json::to_string() ← serialize result
│
├─ copy JSON string: WASM → JS heap (allocation + memcpy)
│
JSON.parse(jsonString) ← deserialize result
│
return ParseResult
Rust 파싱 자체는 한 번도 느린 부분이 아니었습니다. 오버헤드는 전부 경계에서 발생했습니다: 문자열을 안으로 복사하고, 결과를 JSON 문자열로 직렬화하고, JSON 문자열을 밖으로 복사한 뒤, V8이 다시 JS 객체로 역직렬화하는 과정입니다.
자연스러운 질문은 이것이었습니다: WASM이 JSON 직렬화 단계를 건너뛰고 JS 객체를 직접 반환하면 어떨까? 우리는 정확히 이 일을 해주는 serde-wasm-bindgen을 통합했습니다. Rust struct를 JsValue로 변환해 직접 반환합니다.
결과는 30% 더 느림이었습니다.
이유는 이렇습니다. JS는 WASM 선형 메모리 안에 있는 Rust struct의 바이트를 네이티브 JS 객체로 읽을 수 없습니다. 두 런타임은 메모리 레이아웃이 완전히 다릅니다. Rust 데이터로부터 JS 객체를 구성하려면 serde-wasm-bindgen이 Rust 데이터를 실제 JS 배열/객체로 재귀적으로 실체화(materialise)해야 하고, 이는 parse() 호출마다 런타임 경계를 넘나드는 많은 미세 변환을 수반합니다.
이를 JSON 방식과 비교해 보세요: serde_json::to_string()은 경계를 전혀 넘지 않는 순수 Rust에서 실행되어 문자열 하나를 만들고, memcpy 한 번으로 JS 힙으로 복사한 뒤, V8의 네이티브 C++ JSON.parse가 단일 최적화 패스로 처리합니다. 작고 많은 작업들보다, 더 적고 더 큰(그리고 더 최적화된) 작업이 이깁니다.
| Fixture | JSON 왕복 | serde-wasm-bindgen | 변화 |
|---|---|---|---|
| simple-table | 20.5 | 22.5 | -9% 느림 |
| contact-form | 61.4 | 79.4 | -29% 느림 |
| dashboard | 57.9 | 74.0 | -28% 느림 |
우리는 이 변경을 즉시 되돌렸습니다.
우리는 전체 파서 파이프라인을 TypeScript로 포팅했습니다. 동일한 6단계 아키텍처, 동일한 ParseResult 출력 형태 — WASM도 없고 경계도 없으며, 전부 V8 힙에서 실행됩니다.
측정 대상: 완성된 출력 문자열에 대한 단일 parse(completeString) 호출. 호출당 파서 비용을 분리해 측정합니다.
실행 방법: JIT을 안정화하기 위해 30회 워밍업을 수행한 뒤, performance.now()(µs 정밀도)를 사용해 1000회 타이밍을 측정했습니다. 중앙값을 보고합니다. Fixture는 실제 LLM이 생성한 컴포넌트 트리이며, 각 형식의 실제 스트리밍 문법으로 직렬화된 것입니다.
Fixture:
simple-table — root + 3개 컬럼과 5개 로우를 가진 Table 하나 (~180 chars)contact-form — root + 6개 입력 필드 + 제출 버튼을 가진 폼 레이아웃 (~400 chars)dashboard — root + 사이드바 내비 + 3개 메트릭 카드 + 차트 + 데이터 테이블 (~950 chars)| Fixture | TypeScript | WASM | 향상 |
|---|---|---|---|
| simple-table | 9.3 | 20.5 | 2.2x |
| contact-form | 13.4 | 61.4 | 4.6x |
| dashboard | 19.4 | 57.9 | 3.0x |
WASM을 제거하면서 호출당 비용은 해결했지만, 스트리밍 아키텍처에는 더 깊은 비효율이 여전히 남아 있었습니다.
파서는 모든 LLM 청크마다 호출됩니다. 순진한 접근은 청크를 누적한 뒤 매번 전체 문자열을 처음부터 다시 파싱하는 것입니다:
Chunk 1: parse("root = Root([t") → 14 chars
Chunk 2: parse("root = Root([tbl])\ntbl = T") → 27 chars
Chunk 3: parse(full_accumulated_string) → ...
1000자 출력이 20자 청크로 전달되는 경우: 50번의 parse 호출이 누적 총 ~25,000자를 처리합니다. 청크 개수에 대해 O(N²)입니다.
깊이 0에서의 개행으로 끝나는 문장은 불변입니다 — LLM은 다시 돌아와 그것을 수정하지 않습니다. 우리는 완료된 문장 AST를 캐시하는 스트리밍 파서를 추가했습니다:
State: { buf, completedEnd, completedSyms, firstId }
On each push(chunk):
1. Scan buf from completedEnd for depth-0 newlines
2. For each complete statement found: parse + cache AST → advance completedEnd
3. Pending (last, incomplete) statement: autoclose + parse fresh
4. Merge cached + pending → resolve + map → return ParseResult
완료된 문장은 다시 파싱되지 않습니다. 청크마다 다시 파싱되는 것은 오직 끝에 붙어 있는 진행 중인 문장 하나뿐입니다. O(N²) 대신 O(total_length)입니다.
측정 대상: 완전한 문서 하나에 대해 모든 청크 호출에 걸쳐 누적되는 총 파싱 오버헤드. 원샷 벤치마크와는 다릅니다. 단일 호출이 아니라, 실제 스트림 동안 발생하는 모든 파싱 호출의 합을 측정합니다. 이것이 실제 사용자가 체감하는 반응성에 영향을 주는 수치입니다.
실행 방법: 문서를 20자 청크로 재생합니다. 각 청크는 parse()(순진한 방식) 또는 push()(점진 방식) 호출을 트리거합니다. 모든 호출의 총 시간을 기록합니다. 전체 스트림 재생을 100회 수행하고 중앙값을 취했습니다.
| Fixture | 순진한 TS (매 청크 전체 재파싱) | 점진 TS (완료분 캐시) | 향상 |
|---|---|---|---|
| simple-table | 69 | 77 | 없음 (단일 문장, 캐시 이점 없음) |
| contact-form | 316 | 122 | 2.6x |
| dashboard | 840 | 255 | 3.3x |
simple-table fixture는 단일 문장입니다 — 캐시할 것이 없어서 두 접근이 사실상 동일합니다. 이점은 문장 수가 늘어날수록 커집니다. 문서의 더 많은 부분이 캐시되어 각 청크에서 건너뛰게 되기 때문입니다.
원샷 표에서는 contact-form이 13.4µs로 나오지만, 스트리밍 표에서는 316µs(순진)로 나옵니다. 이는 모순이 아니라, 측정 대상이 다르기 때문입니다:
parse() 한 번의 비용parse() 호출 총 비용(청크 1은 20자 파싱, 청크 2는 40자 파싱, ..., 청크 20은 400자 파싱 — 이렇게 커지는 호출들의 누적 합)| 접근 | 호출당 비용 | 전체 스트림 총합 | 비고 |
|---|---|---|---|
| WASM + JSON 왕복 | 20-61µs | 기준선 | 매 호출마다 복사 오버헤드 |
| WASM + serde-wasm-bindgen | 22-79µs | +9-29% 느림 | 내부적으로 수백 번의 경계 교차 |
| TypeScript (순진한 재파싱) | 9-19µs | 69-840µs | 경계 없음, 하지만 O(N²) 스트리밍 |
| TypeScript (점진) | 9-19µs | 69-255µs | 경계 없음 + O(N) 스트리밍 |
최종 결과: 호출당 2.2-4.6배 더 빠르고, 스트리밍 총 비용은 2.6-3.3배 더 낮아졌습니다.
이번 경험은 WASM의 올바른 사용 사례에 대한 우리의 생각을 더 명확하게 해주었습니다:
✅ 상호 운용이 최소인 계산 바운드 작업: 이미지/비디오 처리, 암호학, 물리 시뮬레이션, 오디오 코덱. 큰 입력 → 스칼라 출력 또는 제자리(in-place) 변경. 경계를 드물게 넘습니다.
✅ 이식 가능한 네이티브 라이브러리: C/C++ 라이브러리(SQLite, OpenCV, libpng)를 전체 JS 재작성 없이 브라우저로 가져오기.
❌ 구조화된 텍스트를 JS 객체로 파싱: 어떤 방식이든 직렬화 비용을 치르게 됩니다. 파싱 계산은 충분히 빨라서 V8의 JIT이 Rust의 이점을 없애버립니다. 경계 오버헤드가 지배적입니다.
❌ 작은 입력에 대해 자주 호출되는 함수: 스트림당 50번 호출되고 계산이 5µs 걸린다면, 경계 비용을 상쇄할 수 없습니다.
구현 언어를 선택하기 전에 실제로 시간이 어디에 쓰이는지 프로파일링하라. 우리에게 비용은 계산에 있었던 적이 없고, 항상 WASM-JS 경계를 넘는 데이터 전송에 있었습니다.
serde-wasm-bindgen을 통한 "직접 객체 전달"은 더 싸지 않다. Rust에서 JS 객체를 필드별로 구성하는 것은 JSON 문자열 한 번 전송하는 것보다 경계 교차가 더 적은 것이 아니라 더 많습니다. 경계 교차는 단일 FFI 호출 내부에서 보이지 않게 발생합니다.
알고리즘 복잡도 개선이 언어 수준 최적화를 압도한다. 스트리밍 케이스에서 O(N²)에서 O(N)으로 바꾼 것이, WASM에서 TypeScript로 전환한 것보다 더 큰 실용적 영향을 주었습니다.
WASM과 JS는 힙을 공유하지 않는다. WASM에는 평평한 선형 메모리(WebAssembly.Memory)가 있어 JS가 이를 원시 바이트로 읽을 수는 있지만, 그 바이트는 Rust의 내부 레이아웃(포인터, enum 판별자, 정렬 패딩 등)이며 JS 런타임에는 완전히 불투명합니다. 변환은 항상 필요하고, 항상 비용이 듭니다.