How we made a 9 MB WebAssembly module feel faster without removing a single byte
9MB WebAssembly 모듈, 바이트를 줄이지 않고 더 빠르게 느끼게 만든 방법
Recraft Studio는 9.2MB CanvasKit WebAssembly 모듈을 더 일찍 요청하고 React Suspense 대기열에 묶인 작업을 앞당겨 캔버스 첫 렌더 시간을 15~19% 줄였습니다. 파일 크기는 그대로였지만, 실험군 약 1만8천 명을 대상으로 측정한 결과 p90에서 약 1.9초 빨라졌습니다.
- 주제
AI 요약
이미지 편집 서비스 Recraft Studio는 프로젝트 편집기에서 캔버스가 처음 그려지는 시점이 p75 기준 약 8.5초라는 사실을 성능 계측으로 발견했습니다. 편집기가 내려받는 데이터는 최적화 전 16.75MB, 이후 16.71MB로 거의 같았습니다. 차이는 9.2MB짜리 WebAssembly 렌더링 엔진 CanvasKit을 언제 요청하느냐에 있었습니다.
병목은 다운로드 시작 시점이었습니다
기존 로딩 순서는 초기 JavaScript 청크 파싱, 실시간 저장소 연결과 프로젝트 데이터 수신, 기본 스타일 요청, CanvasKit 다운로드와 초기화, 폰트 로딩, 캔버스 첫 렌더였습니다. CanvasKit 파일 주소는 HTML을 보낼 때 이미 알 수 있었지만, 실제 요청은 여러 네트워크 왕복과 React 트리 탐색이 끝난 뒤에야 시작했습니다.
원인 중 하나는 React Suspense였습니다. 편집기 상위 컴포넌트가 프로젝트 데이터를 기다리며 멈추면 하위 컴포넌트가 아직 실행되지 않았습니다. 기본 스타일 요청도 프로젝트 데이터가 도착한 뒤 React 트리에서 차례를 기다렸습니다. Stage 컴포넌트에서는 Skia 초기화 훅이 먼저 대기 상태에 들어가 폰트 요청을 막았습니다. 결과적으로 파일 주소를 아는 시점보다 CanvasKit 요청이 훨씬 늦었습니다.
저자는 9.2MB 파일을 브라우저가 컴파일하는 데 걸리는 시간은 주된 문제가 아니라고 설명합니다. WebAssembly 스트리밍 컴파일은 다운로드와 함께 진행되고, 캐시에서 인스턴스를 만드는 데는 5~10ms가 걸렸습니다. 병목은 CPU보다 네트워크였고, 해결책은 다운로드를 일찍 시작하는 것이었습니다.
요청을 앞당기고 작업을 병렬화했습니다
편집기 페이지의 HTML에 preload 링크를 추가해 CanvasKit과 기본 폰트를 브라우저가 즉시 요청하게 했습니다. 폰트 URL을 코드에서 가져오려 하면 652KB짜리 fonts.json이 페이지 번들에 들어가므로 URL은 문자열로 직접 지정했습니다. 이어 initSkia()를 모듈 최상위로 옮겨 Skia와 폰트 초기화를 나란히 시작했습니다. 폰트 초기화는 Suspense에서 던지는 방식 대신 멱등성이 있는 메모이즈 Promise로 바꿔, 미리 시작한 요청과 React 경로의 요청이 충돌하지 않게 했습니다. 기본 스타일 요청도 Suspense 밖에서 먼저 시작하도록 옮겼습니다. 로컬 추적에서는 해당 요청이 약 890ms 뒤에서 약 15ms 뒤로 당겨졌습니다.
CanvasKit 요청은 문서와 함께 첫 요청 묶음에서 시작해 이전보다 약 1.5초 빨라졌습니다. 실제 실험은 약 5일 동안 각 그룹 약 1만8천 명을 대상으로 진행했습니다. Skia 로드 시간은 p50·p75·p90에서 각각 19%·18%·19% 줄었고, 캔버스 첫 렌더는 18%·17%·17% 단축됐습니다. p90 기준 개선 폭은 약 1.9초였습니다. 반면 더 많은 리소스를 일찍 받으면서 로딩 표시 시점은 p75에서 123ms, 약 10% 늦어졌습니다.
페이지에 따라 preload와 prefetch를 나눴습니다
프로젝트 목록에서 CanvasKit을 preload하면 편집기를 열지 않는 방문자도 리소스를 내려받아 요청 경쟁이 생겼습니다. 현재 페이지에서 곧 쓸 리소스에는 preload를, 다음 페이지에서 쓸 리소스에는 prefetch를 적용했습니다. 목록 페이지에서는 prefetch를 사용하고 편집기에서는 preload를 유지했습니다.
로딩 표시가 늦어진 비용은 코드 분할과 프로젝트 카드에 마우스를 올렸을 때의 편집기 청크 prefetch로 줄였습니다. Next.js는 경로에서 직접 아는 청크는 미리 받지만, 동적 import 안에 중첩된 청크까지 따라가지는 않았습니다. 그래서 마우스 진입 시 편집기 모듈을 직접 import하도록 구현했습니다. 코드 분할은 전체 사용자에게 적용하고, hover prefetch만 실험군에 넣었습니다. 그 결과 로딩 표시 시점은 p50에서 239ms에서 36ms로, p75에서 631ms에서 193ms로, p90에서 1,680ms에서 708ms로 줄었습니다.
dev.to 반응
- @kyisaiah47 — 편집기가 전혀 열리지 않는 방문에서도 preload가 CanvasKit을 내려받게 되는 문제는 어떻게 막나요?
- @bra31k — 제가 이해한 게 맞다면 preload는 프로젝트 페이지에서만 작동합니다. 프로젝트 목록 같은 다른 페이지에서는 prefetch만 합니다. 새 사용자는 모두 프로젝트 페이지에서 진행하는 온보딩을 거칩니다. 프로젝트 페이지와 CanvasKit은 이제 앱에서 중요한 부분이며, 거의 모든 사용자가 무언가를 만들려고 편집기에 들어옵니다. 그래도 목록만 둘러보고 프로젝트를 열지 않는 사람은 다운로드 비용을 부담합니다. 감수하기로 한 절충입니다.
- @anh_nguynvn_0478e614ba — 파일 크기를 바꾸지 않고 9MB짜리 WebAssembly 모듈의 사용자 경험을 개선한 점은 현실적인 접근입니다. 브라우저에서 무거운 이미지 처리 라이브러리를 다룰 때 비슷한 문제를 겪었습니다. 파일을 줄이는 것보다 모듈의 로드와 실행 시점을 관리하는 편이 나을 때도 있습니다. 전체 다운로드가 끝날 때까지 기다리는 대신 스트리밍 인스턴스화를 쓰거나 작업을 Web Worker로 나누면 캔버스가 멈추지 않습니다. 계산 속도 자체보다 사용자가 상호작용을 시작하기까지의 지연을 줄이는 일이 더 중요할 때가 많습니다.
- @bra31k — 감사합니다. 제 경험도 같습니다. 파일 크기가 아니라 요청 시점이 문제였습니다. 스트리밍 컴파일은 이미 적용되어 있었고, 다운로드를 일찍 시작한 것이 개선을 만들었습니다. Worker는 다음 단계로 흥미로운 생각입니다.
- @koda2026 — 현재 페이지에 쓸 preload와 다음 페이지를 위한 prefetch의 차이를 많은 앱이 잘못 다뤄 큰 리소스 경쟁을 일으킵니다. 정말 인상적인 부분은 initSkia()를 모듈 범위로 올려 React Suspense 트리를 우회한 점입니다. 저는 바로 이런 상황을 피하려고 엄격한 ‘프레임워크 없음’ 규칙으로 AI 코딩 멘토 Koda를 만듭니다. 저가형 Android 기기에서 3G를 대상으로 하면 가상 DOM이 마운트될 때까지 중요한 요청을 기다리는 건 UX에 치명적입니다. 브라우저가 JS 파싱과 함께 9.2MB WASM 스트리밍 컴파일을 시작하게 한 접근이 훌륭합니다. 느린 모바일 네트워크에서 인증 핸드셰이크 중 인스턴스화가 병목이 되거나 메인 스레드가 멈추지는 않았나요? Amplitude A/B 테스트도 엄격하게 진행했네요. 바이트 하나 줄이지 않고 p90을 19% 개선한 300줄은 대단한 프런트엔드 엔지니어링입니다.
- @bra31k — 감사합니다. 컴파일은 문제가 아니었습니다. 스트리밍 컴파일은 다운로드와 함께 메인 스레드 밖에서 진행되고, 캐시에서 인스턴스를 만드는 데는 5~10ms가 걸립니다. 인증 핸드셰이크 중 메인 스레드는 대부분 유휴 상태입니다. 다만 해당 추적은 빠른 네트워크에서 진행했습니다. 저가형 Android 기기에서 3G로 프로파일링하지는 않았습니다. 나중에 확인해보겠습니다. Koda도 살펴보겠습니다.
- @koda2026 — 스트리밍 컴파일이 메인 스레드에서 잘 처리된다니 좋네요. 저가형 Android 기기와 3G에서 프로파일링하면 결과를 꼭 듣고 싶습니다. Koda를 살펴봐 주셔서 감사합니다.
원문: dev.to / 번역·요약: Trawling