자체 호스팅 Convex 확장: TypeScript Convex 함수를 Wasm으로 AOT 컴파일하기
/ 19분 분량
목차
저는 Convex 쿼리 및 뮤테이션을 WebAssembly로 컴파일하고, 동반되는 convex-backend의 변경 사항을 통해 convex-backend의 Tokio 런타임에서 Wasmtime fibers를 사용하여 Wasm으로 컴파일된 Convex 함수를 실행하는 convex-wasm-compiler를 개발했습니다.
TLDR 결과: 쿼리와 뮤테이션이 평균적으로 2배 빠르며, 꼬리 지연 시간(tails)은 3배 작습니다. 거주 실행 환경(V8 context 대 Wasm environment)에 서버에서 유지되는 메모리가 80% 감소했습니다.
이 변경 사항 이전에 Convex 함수의 빠른 실행을 위해서는 광범위한 V8 context 캐싱 및 재사용이 필요했습니다. V8 context를 재사용하지 않으면, 각 Convex 함수는 로드된 모듈이나 JIT(Just-In-Time) 워밍업이 전혀 되지 않은 새로운 context에서 시작합니다. 이 과정에서 함수 실행에 필요한 애플리케이션 모듈을 로드하고 함수의 JS 코드를 해석하는 작업이 시작됩니다. 비교적 간단한 Convex 함수조차도 평균 100~150ms의 실행 시간을 보였습니다.
애플리케이션이 성장하고 모듈이 커질수록 문제는 더욱 심각해집니다. Convex components는 통제되지 않는 모듈 성장을 해결하지만, 그 자체로 충분한 오버헤드와 복잡성을 추가하여 많은 애플리케이션에 있어 순(net) 마이너스 요인이라고 생각합니다. 특히 서브시스템 소유권이나 API 안정성 등에 대한 우려가 없는 단일 개발자 또는 몇몇 개발자가 개발하는 앱의 경우 더욱 그렇습니다. 저는 내 글에서 “장엄한 모놀리스(majestic monolith)” 스타일의 Convex 개발과 컴포넌트 중심 개발 방식에 대한 저의 입장을 논의했습니다.
모든 모듈을 로드하고 TurboFan을 통해 함수의 코드가 네이티브 코드로 컴파일된, 재사용되고 워밍업된 V8 context로 Convex 함수를 실행하면, 해당 함수가 읽는 데이터베이스 행들이 이미 메모리에 캐시되어 있다는 전제 하에, Convex 함수의 실행 시간이 극적으로 감소하는 경우가 많습니다. 예를 들어 100150ms에서 1015ms까지 떨어집니다.
따라서 V8 컨텍스트 재사용은 빠르고 효율적인 Convex 앱에 필수적이었습니다. 하지만 여기에는 두 가지 문제가 있습니다.
첫째, 실행의 99% 이상이 V8 컨텍스트를 재사용하도록 보장하려면, convex-backend 런타임의 각 V8 isolate는 하나 이상의 컨텍스트를 유지해야 합니다. 즉, convex-backend에 의해 특정 V8 isolate에서 자주 실행되도록 할당되는 모든 모듈(기술적으로는 모듈 초기화 그룹) 각각에 대해 별도의 컨텍스트가 필요합니다. 제가 자체 호스팅하는 convex-backend의 앱 구성에서는 현재 최대 6+1개의 컨텍스트를 가진 TinyLFU 캐시를 사용하고 있으며 (자체 호스팅 Convex 구성을 게시한 이후 5+1개에서 증가), 총 400개의 캐시된 컨텍스트를 사용합니다. 이는 제 애플리케이션의 피크 부하가 동시에 실행되는 400개의 Convex 함수 실행보다 훨씬 낮음에도 불구하고 이루어지는 것입니다. 이러한 상대적인 비효율성의 주된 이유는 V8 컨텍스트가 V8 isolate에 고정되어 있어 서로 공유할 수 없기 때문입니다.
각 V8 컨텍스트는 약 35MB의 메모리를 차지하며, 애플리케이션이 성장함에 따라 이 크기는 더 커집니다. 두 달 전에는 제 애플리케이션에서 평균 V8 컨텍스트 유지 메모리가 30MB라고 보고한 바 있습니다.
결과적으로 이러한 컨텍스트들은 저의 convex-backend 서버에서 약 14GB의 메모리를 차지합니다.
V8 컨텍스트 재사용의 두 번째 문제는 Convex 앱이 배포될 때 일부 캐시된 컨텍스트가 무효화된다는 것입니다. 이는 수백 개의 V8 컨텍스트가 재(再)생성되고 재워밍되는 과정에서 서버 전반에 걸쳐 대규모 모듈 로딩 및 컴파일 활동을 유발할 수 있으며, 이 과정은 약 1시간 동안 지속될 수 있습니다. V8 isolate는 서로 최적화된 기계 코드를 공유하지 않습니다. 심지어 직렬화된 바이트코드가 캐시되더라도, 모듈 초기화는 여전히 각 V8 컨텍스트별로 독립적으로 발생합니다.
실질적인 V8 컨텍스트 무효화(context invalidation)가 발생하는지 여부는 앱 배포 시 어떤 모듈이 업데이트되었는지에 따라 달라집니다. 하지만 만약 배포가 전체 서버 부하의 거대한 부분을 차지하는 '핫'한 Convex 함수를 포함하는 핵심 모듈들을 정확히 업데이트한다면 (따라서 이들 각 모듈은 여러 다른 V8 아이솔레이트(isolates)의 TinyLFU 캐시 내에 많은 V8 컨텍스트와 연결되어 있습니다), 모든 V8 컨텍스트 중 상당 부분이 폐기되고 재초기화가 필요할 수 있습니다.
이러한 V8 컨텍스트 재활성화(re-warming) 물결은 Convex 앱 배포가 유발하는 또 다른 물결과 일치합니다. 바로 업데이트된 모듈의 쿼리에 대한 캐시된 쿼리 결과가 무효화되고, 심지어 읽기 세트(read set)의 데이터가 변경되지 않았더라도 클라이언트에서 해당 업데이트된 모듈의 쿼리로 보내는 반응형 구독(reactive subscriptions)이 앱 배포 시 재실행되기 때문입니다. 따라서 서버는 단순히 코드를 컴파일하고 모듈을 로드하는 것뿐만 아니라, 정상 상태(steady state)보다 더 많은 쿼리를 실행해야 합니다.
실제적으로 이러한 배포 후 작업 물결은 일정 시간 동안 쿼리 및 뮤테이션이 느리게 작동하게 만들 수 있으며, 과부하가 심각할 경우 일부 실행 건수가 손실될 수도 있습니다.
이는 V8 아이솔레이트에서 Convex 함수를 실행하는 것과 관련된 보다 일반적인 문제이며, V8 컨텍스트 재사용에만 국한된 문제는 아닙니다. convex-backend는 FUNRUN_ISOLATE_ACTIVE_THREADS 변수를 통해 활성으로 실행될 수 있는 V8 아이솔레이트 워커의 수를 제한합니다 (즉, 데이터베이스 읽기나 쓰기와 같은 런타임 종속성 또는 기타 I/O에 의해 차단되지 않는 경우). 이 변수는 convex-backend 컨테이너에 할당된 CPU 한계치 또는 그보다 약간 낮은 값으로 설정되어야 합니다.
하지만 이러한 스레드들은 대부분 (서버의 최대 지속 처리량(maximum sustained throughput)을 크게 제한하지 않는다는 전제 하에) convex-backend 자체 Tokio 런타임과 분리하기 어렵습니다. 이 Tokio 런타임은 RUNTIME_WORKER_THREADS 변수를 통해 OS 스레드 수를 설정하며, 또는 V8 컨텍스트 내에서 컴파일 및 가비지 컬렉션(garbage collection)을 담당하는 V8 엔진 자체의 백그라운드 스레드들 (이들의 개수는 V8_THREADS 변수를 통해 설정됨)과도 분리하기 어렵습니다.
제 서버 설정에서 convex-backend의 CPU 제한은 28.5 CPUs이며, FUNRUN_ISOLATE_ACTIVE_THREADS=28,
RUNTIME_WORKER_THREADS=28, 그리고 V8_THREADS=16으로 설정되어 있습니다. 또한, 저는 각각 1~2개의 활성 스레드를 가진 여러 Node 실행 풀을 가지고 있습니다. 이는 이론적으로 컨테이너 내 프로세스 전반에 걸쳐 28.5 CPUs로 제한된 환경에서 최소 75개의 활성 OS 스레드가 존재할 수 있음을 의미합니다.
이러한 수준의 과다 할당(oversubscription)은 나쁘지 않지만, 결코 최적이라고는 거리가 멉니다. 지난주 동안 V8 Convex 쿼리 및 Mutation 실행이 서버에서 활성화되었을 때, 가장 많은 경합도가 발생했던 5분 간의 CPU 압력은 29%에 달했습니다. 물론 이것은 애플리케이션 배포 시점 무렵에 발생한 일입니다.
이러한 CPU 압력이 가져온 실질적인 결과는 서버가 바쁠 때 (애플리케이션 배포 때문이든, 아니면 단순히 자연적으로 부하가 증가했기 때문이든), Convex 함수 실행 속도가 적정 부하 상태일 때보다 느려진다는 것입니다. 이는 데이터베이스 읽기/쓰기가 느려지는 영향과는 별개로 발생합니다 (Convex 아래에 있는 MySQL이나 PostgreSQL 같은 데이터베이스 자체도 더 높은 트랜잭션 부하 기간 동안 더 높은 CPU 압력을 보이거나, 컨테이너가 제한될 수 있습니다). 또한, 쿼리 결과 캐시 변동(query results cache churn), 인덱스 캐시 변동(index cache churn), 그리고 V8 Context 캐시 변동(V8 context cache churn)도 증가합니다.
핫 함수 실행을 Convex의 Tokio 런타임에 임베딩된 Wasmtime과 같은 WebAssembly (Wasm) 런타임으로 옮기는 것은 위에서 설명한 문제들에 대한 명백한 대응책입니다. Convex 팀은 Wasm 런타임이 2026년 초에 예정되어 있다고 발표했습니다. 지난 8월에는 convex/convex-backend 소스 트리에 Wasm 런타임이 추가되었으나, 다음 날 제거되었고 그 이후로 다시 나타난 적이 없습니다. 당시 구현된 것은 Wasm으로 컴파일된 QuickJS 인터프리터를 사용했기 때문에, Convex 함수들은 자체적으로 컴파일되는 것이 아니라 Wasm 기반에서 해석(interpreted)되었습니다. 따라서 해당 구현은 매우 느렸으며 프로덕션용으로는 적합하지 않았습니다.
(8월 이후 다양한 Q&A 세션에서 Convex 리더십에 의해 전달된 바와 같이) 일반적인 관념은 개발자들이 WebAssembly 런타임의 성능 이점을 얻기 위해, Wasm 타겟을 가진 컴파일 언어(주로 Rust)로 Convex 함수를 작성할 것이라는 것입니다.
저는 Convex 팀이 Wasm 런타임 의제를 가속화할 계획을 알기 전인 7월 말에, 일반적인 Convex 함수(TypeScript로 작성됨)를 WebAssembly 바이트코드로 변환하는 컴파일러 개발을 시작했습니다. (8월 이전에는 Jamie Turner가 Wasm 런타임이 아마도 2027년에나 도착할 것이라고만 언급했었습니다.)
지난 거의 3개월에 걸친 전체 개발 캠페어 동안, Wasmtime 런타임을 convex-backend에 임베딩하는 것은 총 노력의 약 10% 정도를 차지하는 비교적 사소한 부분이었습니다. 다시 말해, convex-backend에 Wasm 런타임을 추가하는 것 자체는 비교적 쉬웠으며, 저는 처음부터 이미 어느 정도 명확하게 알고 있었습니다 (비록 제가 문제의 다른 부분들을 극적으로 과소평가했고, 심지어 convex-backend의 Wasm 런타임이 전체 노력의 50%일 것이라고 생각했지만, 실제로는 단지 10%에 불과했으며 프로젝트 전체는 제가 처음에 예상했던 것보다 다섯 배나 오래 걸렸습니다).
따라서, 실제로 제 Convex 애플리케이션의 일부를 Rust로 다시 작성하는 것은 그때 이미 고려되고 있던 일이었습니다. 저는 여러 가지 이유가 복합적으로 작용하여 그 경로를 택하지 않았습니다:
- 생산 환경의 Convex 코드만 약 350k LOC(라인)에 달하고, 여기에 Node.js에서 실행되는 시스템의 다른 (비-Convex) 부분들과 상당한 로직 공유가 이루어진 경우 (이러한 부분들을 포함하면 프로덕션 코드 크기는 0.5M LOC를 초과함), 이 애플리케이션을 다시 작성하는 것은 기존 Convex 함수들을 WebAssembly로 컴파일하는 것만큼이나 막막하게 느껴졌습니다.
돌이켜보면, TypeScript에서 Rust로 0.5M LOC(라인)을 다시 작성하는 것이 3개월짜리 컴파일러 개발 프로젝트보다 시간이 적게 걸렸을 수도 있지만, 당시에는 그렇게 예상하지 못했습니다. 반면에 이 프로젝트 자체에도 제가 시도해보지 않았기 때문에 지금은 알 수 없는 상당한 위험들이 있었을 것이고, 기능 제공 흐름(feature delivery flow)에 확실히 차질을 빚었을 것입니다.
저는 Rust 컴파일 시간에 대해 불안했습니다. 그리고 그로 인해 발생할 수 있는 개발 루프(development loop) 지연도요. 이 부분에 대해서는 어느 정도 스스로가 옳았다고 느낍니다. 왜냐하면 그때 이후로, 제가 convex-backend에 추가한 네이티브 레지던트 기능(native resident functionality)을 통해 애플리케이션의 일부를 완전히 Rust로 작성했기 때문입니다. 이 부분은 Convex action이나 Convex query/mutation이 아닙니다. 이것은 Convex 앱과 함께 배포되는 백그라운드 Rust 프로세스이며, loopback을 통해 자체적으로 Convex 쿼리와 mutation을 호출합니다. 애플리케이션의 이 부분은 단지 30k 라인의 Rust로 이루어져 있지만, 코드가 변경될 때마다 느린 Rust 컴파일 시간의 고통을 느낍니다.
저는 나중에 Convex 팀의 공식 API로 대체될 수 있는 (예: ctx.db.query(), insert(), defineSchema() 등의 등가물인) Rust용 Convex 프로그래밍 API를 만들고 싶지 않았습니다. 더 일반적으로는, 미래의 Convex 개발과 상충하거나 경쟁하기보다는 보완할 잠재력을 가진 무언가를 하고 싶었습니다. — 결국 컴파일러를 만드는 것이 저에게는 Rust 재작성보다 더 흥미롭게 느껴졌습니다! Convex 애플리케이션 코드는 변경되지 않았습니다. 여전히 convex-js SDK를 사용하는 TypeScript입니다.
convex-wasm-compiler는 소스 분석(source analysis), JavaScript 번들링(JavaScript bundling), 그리고 네이티브 컴파일을 조정합니다. 이는 Convex 함수 등록을 식별하고 내보낸 라우트(exported routes)를 해당 소스와 연결하기 위해 tsrs를 사용하며, esbuild...
의존성을 해결하고 애플리케이션의 TypeScript를 번들링된 JavaScript로 변환합니다. 이의 Rust 분석기는 Oxc를 사용하여 소스 및 의존성 그래프에서 안전하지 않은 유지 상태(unsafe retained state)를 검사하여, 초기화된 모듈이 요청 전반에 걸쳐 재사용될 수 있는지 확인합니다. 이러한 분석 결과는 진입(admission)과 패키징을 안내하며, esbuild가 방출한 JavaScript가 실행 가능한 애플리케이션 코드를 제공합니다.
컴파일러는 그런 다음 해당 JavaScript를 애플리케이션 모듈로 준비하고, Convex의 호스트 작업(host operations)에 대한 브릿지를 추가합니다 (여기서 “호스트”란 데이터베이스 읽기 및 쓰기와 같은 convex-backend의 런타임 작업을 의미하며, “게스트”는 애플리케이션 함수 실행을 의미합니다). 그리고 (Static) Hermes를 호출합니다. 그 shermes 컴파일러는 C 코드를 방출하며, 이는 Emscripten의 Clang/LLVM 툴체인에 의해 Hermes 런타임 및 Convex의 네이티브 어댑터와 함께 컴파일됩니다. wasm-ld가 이 구성 요소들을 연결하고(link), Binaryen이 결과적인 Core WebAssembly를 최적화합니다. 연결된 Hermes 런타임은 Wasm 게스트 내부에서 JavaScript 객체, 문자열, Promise, 예외, 그리고 가비지 컬렉션을 제공합니다.
Static Hermes는 JavaScript 비동기 함수(async functions)를 Promise로 백업되는 재개 가능한 상태 기계(resumable state machines)로 컴파일합니다. Hermes 패치는 핵심 Promise 메커니즘을 C++로 이동시킵니다: 이는 가비지 컬렉션된 JSPromise를 도입합니다.