Rust WASM Parser를 TypeScript로 재작성해서 3배 빨라진 진짜 이유
요즘 프론트엔드 씬에서 가장 흔하게 들리는 마법의 문장이 있다. “성능이 중요하니까 Rust로 짜서 WASM으로 돌립시다.”
시니어 엔지니어로서 솔직히 말하자면, 이건 전형적인 오버엔지니어링의 함정이다. 나 역시 과거에 이 함정에 빠져 수개월을 낭비한 적이 있고, 최근 OpenUI 팀도 정확히 같은 실수를 경험했다. 그들은 LLM이 생성하는 커스텀 DSL을 React 컴포넌트 트리로 변환하는 파서를 Rust로 작성했다가, 결국 TypeScript로 완전히 재작성하고 나서야 3배 이상의 성능 향상을 얻어냈다.
하지만 이들의 블로그 포스트와 Hacker News의 반응을 자세히 뜯어보면, 단순히 “TypeScript가 Rust보다 빠르다”는 1차원적인 결론이 아님을 알 수 있다. 진짜 문제는 언어가 아니라 경계(Boundary)와 알고리즘에 있었다.
WASM Boundary Tax: 배보다 배꼽이 더 큰 톨게이트
OpenUI의 파서 파이프라인은 Autocloser부터 Mapper까지 총 6단계로 이루어진 꽤 복잡한 구조다. 논리적으로는 Rust의 압도적인 연산 속도를 활용하는 것이 맞아 보인다. 하지만 시스템 아키텍처에서 가장 중요한 것은 개별 컴포넌트의 속도가 아니라 데이터가 이동하는 경로다.
WASM은 JS 엔진과 메모리를 공유하지 않는다. 따라서 JS에서 WASM 파서를 호출할 때마다 다음과 같은 무거운 작업이 강제된다.
- JS 힙의 문자열을 WASM 선형 메모리로 복사 (Allocation + Memcpy)
- Rust에서 빠르게 파싱
- 결과를 JSON 문자열로 직렬화 (
serde_json::to_string()) - JSON 문자열을 다시 JS 힙으로 복사
- V8 엔진이
JSON.parse를 통해 JS 객체로 역직렬화
Rust 자체의 파싱은 눈 깜짝할 사이에 끝났지만, 데이터를 넘겨주고 받는 이 Boundary Tax 가 전체 지연 시간의 대부분을 차지했다.
실패한 최적화: serde-wasm-bindgen의 함정
이 문제를 해결하기 위해 그들이 처음 시도한 방법은 매우 흥미롭다. “JSON 직렬화/역직렬화가 문제라면, Rust에서 곧바로 JS 객체(JsValue)를 반환하면 되지 않을까?”
이를 위해 serde-wasm-bindgen을 도입했지만, 결과는 오히려 30% 더 느려졌다.
이 부분은 내가 2021년에 겪었던 문제와 정확히 일치한다. Rust의 메모리 레이아웃과 JS의 메모리 레이아웃은 완전히 다르다. WASM에서 JS 객체를 직접 생성하려면, 런타임 경계를 넘나드는 미세한 FFI 호출이 수백 번 발생해야 한다. 반면, JSON 문자열을 통째로 넘기는 방식은 단 한 번의 큰 Memcpy로 끝난다. V8 엔진의 C++ 기반 JSON.parse는 초당 기가바이트 단위의 데이터를 처리할 정도로 극도로 최적화되어 있다. 결국 자잘하게 여러 번 경계를 넘는 것보다, 무식하게 큰 덩어리를 한 번에 넘기고 V8의 최적화에 기대는 것이 훨씬 빠르다는 뜻이다.
진짜 해결책 1: 경계를 없애다 (TypeScript 재작성)
결국 그들은 WASM을 포기하고 파서를 순수 TypeScript로 재작성했다. 데이터가 V8 힙 내부에만 머물게 되면서 Boundary Tax가 완전히 사라졌다. 1회성 파싱(One-Shot Parse) 벤치마크 결과, TS 버전은 WASM 버전보다 2.2배에서 4.6배 더 빨랐다.
하지만 Hacker News 커뮤니티의 날카로운 엔지니어들은 이 지표 이면에 숨겨진 진짜 핵심을 짚어냈다.
진짜 해결책 2: O(N²)에서 O(N)으로의 알고리즘 개선
LLM의 응답은 청크 단위로 스트리밍된다. 기존 파서는 새로운 청크가 들어올 때마다 누적된 전체 문자열을 처음부터 다시 파싱했다.
- Chunk 1: 14자 파싱
- Chunk 2: 27자 파싱
- Chunk 3: 45자 파싱
이런 식이면 데이터가 길어질수록 파싱 비용이 기하급수적으로 증가하는 전형적인 O(N²) 문제가 발생한다.
TS로 재작성하면서 그들은 Statement-Level Incremental Caching을 도입했다. Depth-0의 줄바꿈으로 끝나는 문장(Statement)은 더 이상 수정되지 않는다는 점을 이용해, 완성된 AST를 캐싱하고 마지막 미완성 문장만 다시 파싱하도록 구조를 바꾼 것이다.
이 알고리즘 개선 덕분에 스트리밍 환경에서의 총 파싱 비용은 O(N)으로 줄어들었고, 추가로 2.6배에서 3.3배의 성능 향상을 가져왔다.
Hacker News의 반응과 Rewrite Bias
Hacker News 스레드에서는 이 글의 제목이 다소 어그로성(Clickbait)이라는 비판이 많았다.
가장 많은 공감을 받은 의견은 “진짜 승리는 언어의 변경이 아니라 O(N²)을 O(N)으로 바꾼 알고리즘 개선이다”라는 것이었다. 또한, 두 번째로 작성하는 코드는 항상 첫 번째 코드보다 깔끔하고 최적화되기 마련이라는 전형적인 Rewrite Bias 를 지적하는 목소리도 컸다. “이제 그 TS 코드를 다시 Rust로 짜보면 진짜 성능 비교가 될 것”이라는 비꼬는 듯한 댓글은 시니어 엔지니어라면 누구나 쓴웃음을 지으며 공감할 만한 포인트다.
Principal Engineer의 결론
이번 OpenUI의 사례는 우리가 기술 스택을 선택할 때 흔히 저지르는 실수를 완벽하게 보여주는 교훈적인 사례다.
개인적으로 나는 WASM의 가치를 폄하할 생각은 없다. 이미지/비디오 처리, 무거운 암호화 연산, 물리 엔진 등 Compute-bound 작업에서는 WASM이 여전히 최고의 선택이다.
하지만 구조화된 텍스트를 파싱해서 JS 객체로 변환하고 이를 React 렌더러에 넘겨주는 식의 I/O 중심적인 작업에서는 WASM이 오히려 독이 된다. 연산 이득보다 직렬화/역직렬화 비용이 훨씬 크기 때문이다.
성능 문제가 발생했을 때 맹목적으로 “더 빠른 언어”를 찾기 전에 다음 두 가지를 먼저 자문해보자.
- 진짜 병목이 연산 속도인가, 아니면 데이터가 이동하는 I/O 경계인가?
- 언어를 바꾸기 전에 현재 알고리즘의 시간 복잡도(Big-O) 자체를 낮출 방법은 없는가?
대부분의 경우, 정답은 새로운 언어가 아니라 프로파일러와 알고리즘 책 안에 있다.
- Original Article: https://www.openui.com/blog/rust-wasm-parser
- Hacker News Thread: https://news.ycombinator.com/item?id=47461094