JavaScript Promise Integration(JSPI) API는 외부 기능에 대해 ‘동기식’ 접근을 전제로 작성된 WebAssembly 애플리케이션이, 실제로는 ‘비동기’인 환경에서도 매끄럽게 동작하도록 해준다.
JavaScript Promise Integration(JSPI) API는 외부 기능에 대해 동기식 접근을 전제로 작성된 WebAssembly 애플리케이션이, 실제로는 _비동기_인 환경에서도 매끄럽게 동작하도록 해준다.
이 글에서는 JSPI API의 핵심 기능이 무엇인지, 어떻게 접근하는지, 이를 대상으로 소프트웨어를 어떻게 개발하는지, 그리고 직접 시험해 볼 수 있는 예시를 소개한다.
비동기 API는 작업의 시작 과 해결(완료) 을 분리해서 동작하며, 후자는 첫 단계 이후 어느 시점에 발생한다. 무엇보다 중요한 점은, 애플리케이션이 작업을 시작한 뒤에도 실행을 계속한다는 것과, 작업이 끝났을 때 알림을 받는다는 것이다.
예를 들어 fetch API를 사용하면 웹 애플리케이션이 URL에 연관된 내용을 가져올 수 있다. 하지만 fetch 함수는 가져온 결과를 곧바로 반환하지 않고, 대신 Promise 객체를 반환한다. fetch 응답과 원래 요청 사이의 연결은 그 Promise 객체에 콜백 을 붙임으로써 다시 설정된다. 콜백 함수는 응답을 검사하고 (물론 데이터가 있다면) 데이터를 수집할 수 있다.
반면 많은 경우 C/C++(및 기타 여러 언어) 애플리케이션은 원래 동기식 API를 기준으로 작성된다. 예를 들어 Posix의 read 함수는 I/O 작업이 완료될 때까지 끝나지 않는다. 즉, read 함수는 읽기가 끝날 때까지 블록(block) 된다.
하지만 브라우저의 메인 스레드를 블록하는 것은 허용되지 않으며, 많은 환경은 동기식 프로그래밍을 잘 지원하지 않는다. 그 결과 “사용하기 쉬운 단순한 API”를 원하는 애플리케이션 개발자의 요구와, I/O를 비동기 코드로 구성해야 하는 더 넓은 생태계 사이에 불일치가 생긴다. 이는 특히 포팅 비용이 큰 기존 레거시 애플리케이션에서 큰 문제가 된다.
JSPI는 동기식 애플리케이션과 비동기 웹 API 사이의 간극을 메우는 API다. JSPI는 비동기 웹 API 함수가 반환하는 Promise 객체를 가로채고 WebAssembly 애플리케이션을 중단(suspend) 시킨다. 비동기 I/O 작업이 완료되면 WebAssembly 애플리케이션을 재개(resume) 한다. 이를 통해 WebAssembly 애플리케이션은 직선형(일자형) 코드로 비동기 작업을 수행하고 그 결과를 처리할 수 있다.
중요하게도, JSPI를 사용하기 위해 WebAssembly 애플리케이션 자체에 필요한 변경은 매우 적다.
JSPI는 JavaScript 호출에서 반환되는 Promise 객체를 가로채고 WebAssembly 애플리케이션의 메인 로직을 중단함으로써 동작한다. 그리고 이 Promise 객체에 콜백을 붙여, 브라우저의 이벤트 루프 태스크 러너가 해당 콜백을 호출할 때 중단된 WebAssembly 코드를 재개한다.
또한 WebAssembly export는 원래 export가 반환하던 값 대신 Promise 객체를 반환하도록 리팩터링된다. 이 Promise 객체가 WebAssembly 애플리케이션이 반환하는 값이 된다. WebAssembly 코드가 중단되면[1] export된 Promise 객체가 WebAssembly로 들어간 호출의 반환값으로 반환된다.
export Promise는 원래 호출이 완료되면 resolve된다. 원래 WebAssembly 함수가 일반 값을 반환하면 export Promise는 그 값(자바스크립트 객체로 변환된 값)으로 resolve되며, 예외가 발생하면 export Promise는 reject된다.
이 동작은 WebAssembly 모듈 인스턴스화 단계에서 import와 export를 래핑(wrapping) 함으로써 가능해진다. 함수 래퍼는 일반적인 비동기 import에 중단 동작을 추가하고, 중단을 Promise 객체 콜백으로 라우팅한다.
WebAssembly 모듈의 모든 export와 import를 반드시 래핑할 필요는 없다. 실행 경로가 비동기 API 호출을 포함하지 않는 export는 래핑하지 않는 편이 낫다. 마찬가지로 WebAssembly 모듈의 모든 import가 비동기 API 함수로 연결되는 것도 아니므로, 그런 import도 래핑하지 말아야 한다.
물론 이를 가능하게 하는 내부 메커니즘이 상당히 많지만[2], JSPI는 JavaScript 언어나 WebAssembly 자체를 변경하지 않는다. JSPI의 동작은 JavaScript와 WebAssembly 사이 경계에만 국한된다.
웹 애플리케이션 개발자 관점에서는, 결과적으로 JavaScript로 작성된 다른 async 함수들과 유사한 방식으로 async 함수/Promise 세계에 참여하는 코드가 만들어진다. WebAssembly 개발자 관점에서는, 동기식 API를 사용해 애플리케이션을 만들면서도 웹의 비동기 생태계에 참여할 수 있게 된다.
WebAssembly 모듈을 중단/재개할 때 사용하는 메커니즘은 본질적으로 상수 시간(constant time)이므로, JSPI 사용 비용이 높을 것으로 예상하지 않는다. 특히 변환(transform) 기반 접근들과 비교하면 더욱 그렇다.
비동기 API 호출이 반환한 Promise 객체를 WebAssembly로 전달하기 위해 상수량의 작업이 필요하다. 마찬가지로 Promise가 resolve되면 상수 시간 오버헤드로 WebAssembly 애플리케이션을 재개할 수 있다.
하지만 브라우저의 다른 Promise 스타일 API들과 마찬가지로, WebAssembly 애플리케이션이 중단될 때마다 브라우저의 태스크 러너에 의해 다시 “깨워질” 때까지 실행되지 않는다. 이는 WebAssembly 계산을 시작한 JavaScript 코드의 실행 자체가 브라우저로 반환되어야 함을 의미한다.
JavaScript에는 비동기 계산을 표현하는 잘 발달된 메커니즘이 이미 있다. Promise 객체와 async 함수 표기법이 그것이다. JSPI는 이것들과 잘 통합되도록 설계되었지만, 이를 대체하려는 것은 아니다.
특히 JSPI를 사용해 JavaScript 코드를 중단시키는 것은 허용되지 않는다.
JSPI는 현재 W3C WebAssembly WG에서 phase 4이다. 이는 W3C Wasm CG에서 명세가 표결을 거쳤으며 사실상 표준화되었음을 의미한다. 또한 Chrome 137 및 Firefox 139에서 사용할 수 있다.
JSPI는 Linux, MacOS, Windows, ChromeOS의 Chrome에서 Intel 및 Arm 플랫폼(64비트와 32비트 모두)에서 사용할 수 있다.
아래에서는 Emscripten을 사용해 JSPI를 사용하는 C/C++ WebAssembly 모듈을 생성하는 방법을 보여준다. 애플리케이션이 다른 언어를 사용하거나(예: Emscripten 미사용) 하는 경우, 제안서(proposal)를 읽고 API가 어떻게 동작하는지 살펴보는 것을 권한다.
실제로 어떻게 동작하는지 보기 위해 간단한 예제를 해보자. 아래 C 프로그램은 피보나치를 놀라울 정도로 나쁜 방식으로 계산한다. JavaScript에게 덧셈을 시키고, 더 나쁘게는 JavaScript Promise 객체로 덧셈을 하게 한다:[3]
long promiseFib(long x) { if (x == 0) return 0; if (x == 1) return 1; return promiseAdd(promiseFib(x - 1), promiseFib(x - 2));}// promise an additionEM_ASYNC_JS(long, promiseAdd, (long x, long y), { return Promise.resolve(x+y);});
promiseFib 함수 자체는 피보나치 함수의 단순한 재귀 버전이다. (우리 관점에서) 흥미로운 부분은 두 피보나치 하위 결과를 더하는 promiseAdd의 정의인데, 여기서 JSPI를 사용한다!
우리는 Emscripten 매크로 EM_ASYNC_JS를 사용해 C 프로그램 본문 안에 JavaScript 함수로 promiseAdd를 작성한다. JavaScript에서 덧셈은 보통 Promise를 수반하지 않으므로, Promise를 구성해 강제로 Promise를 사용하게 만든다.
EM_ASYNC_JS 매크로는 Promise 결과를 일반 함수처럼 접근할 수 있도록 JSPI를 이용하는 데 필요한 모든 글루(glue) 코드를 생성한다.
작은 데모를 컴파일하려면 Emscripten의 emcc 컴파일러를 사용한다:[4]
emcc -O3 badfib.c -o b.html -s JSPI
이 명령은 프로그램을 컴파일하여 로드 가능한 HTML 파일(b.html)을 만든다. 여기서 가장 특별한 커맨드라인 옵션은 -s JSPI다. 이는 Promise를 반환하는 JavaScript import와 인터페이스하기 위해 JSPI를 사용하는 코드를 생성하도록 하는 옵션이다.
생성된 b.html을 Chrome에서 로드하면 다음과 유사한 출력을 볼 수 있다:
fib(0) 0μs 0μs 0μs
fib(1) 0μs 0μs 0μs
fib(2) 0μs 0μs 3μs
fib(3) 0μs 0μs 4μs
…
fib(15) 0μs 13μs 1225μs
이는 처음 15개의 피보나치 수와, 피보나치 수 하나를 계산하는 데 걸린 평균 시간을 마이크로초 단위로 나열한 것이다. 각 줄의 세 시간 값은 순서대로 “순수 WebAssembly 계산”, “혼합 JavaScript/WebAssembly 계산”, “중단(suspend)이 발생하는 버전의 계산”에 걸린 시간을 의미한다.
fib(2)가 Promise 접근을 포함하는 가장 작은 계산임에 유의하자. 그리고 fib(15)를 계산할 때까지 promiseAdd 호출이 약 1000번 발생한다. 이는 JSPI가 적용된 함수의 실제 비용이 약 1μs 정도임을 시사한다. 단순히 정수 두 개를 더하는 것보다는 훨씬 비싸지만, 외부 I/O 함수 접근에 보통 필요한 밀리초 단위 시간보다는 훨씬 작다.
다음 예제에서는 JSPI의 다소 놀라운 활용법을 살펴본다. 바로 코드를 동적으로 로딩하는 것이다. 필요한 코드를 담은 모듈을 fetch로 가져오되, 해당 함수가 처음 호출될 때까지 로딩을 미루는 아이디어다.
fetch 같은 API는 본질적으로 비동기이지만, 우리는 애플리케이션의 임의 위치(특히 아직 존재하지 않는 함수를 호출하는 도중)에서 이를 호출할 수 있어야 한다. 따라서 JSPI가 필요하다.
핵심 아이디어는 동적으로 로딩될 함수를 스텁(stub)으로 대체하는 것이다. 이 스텁은 먼저 누락된 함수 코드를 로딩하고, 자신을 로딩된 코드로 교체한 뒤, 원래 인자들로 새로 로딩된 코드를 호출한다. 이후의 호출은 로딩된 함수로 직접 들어간다. 이 전략은 코드를 동적으로 로딩하는 사실상 투명한(transparent) 접근을 가능하게 한다.
로드할 모듈은 매우 단순하며 42를 반환하는 함수를 포함한다:
// This is a simple provider of forty-two#include <emscripten.h>EMSCRIPTEN_KEEPALIVE long provide42(){ return 42l;}
이 코드는 p42.c 파일에 있고, Emscripten으로 어떤 ‘추가 기능(extras)’도 빌드하지 않도록 컴파일한다:
emcc p42.c -o p42.wasm --no-entry -Wl,--import-memory
EMSCRIPTEN_KEEPALIVE 접두는 함수 provide42가 코드 내부에서 사용되지 않더라도 제거되지 않도록 보장하는 Emscripten 매크로다. 이로써 우리가 동적으로 로딩하려는 함수를 포함하는 WebAssembly 모듈이 만들어진다.
p42.c 빌드에 추가한 -Wl,--import-memory 플래그는 메인 모듈과 동일한 메모리에 접근할 수 있도록 하기 위한 것이다.[5]
코드를 동적으로 로딩하기 위해 표준 WebAssembly.instantiateStreaming API를 사용한다:
WebAssembly.instantiateStreaming(fetch('p42.wasm'));
이 표현식은 fetch로 컴파일된 Wasm 모듈을 찾고, WebAssembly.instantiateStreaming으로 fetch 결과를 컴파일하고 인스턴스화된 모듈을 생성한다. fetch와 WebAssembly.instantiateStreaming 모두 Promise를 반환하므로, 결과를 단순히 꺼내서 필요한 함수를 추출할 수 없다. 대신 EM_ASYNC_JS 매크로를 사용해 이를 JSPI 스타일 import로 감싼다:
EM_ASYNC_JS(fooFun, resolveFun, (), { console.log('loading promise42'); LoadedModule = (await WebAssembly.instantiateStreaming(fetch('p42.wasm'))).instance; return addFunction(LoadedModule.exports['provide42']);});
여기서 console.log 호출에 주목하자. 로직이 올바른지 확인하는 데 사용한다.
addFunction은 Emscripten API의 일부지만, 런타임에 사용 가능하도록 emcc에 필수 의존성임을 알려야 한다. 다음 줄에서 이를 수행한다:
EM_JS_DEPS(funDeps, "$addFunction")
동적으로 코드를 로딩하는 상황에서는 불필요한 로딩을 피하고 싶다. 이 경우 provide42를 이후에 호출할 때 재로딩이 일어나지 않게 하고 싶다. C에는 이를 위해 사용할 수 있는 간단한 기능이 있다. provide42를 직접 호출하지 않고, 트램펄린(trampoline)을 통해 호출한다. 트램펄린은 함수를 로딩하게 한 뒤, 실제로 함수를 호출하기 직전에 자기 자신을 우회(bypass)하도록 바꾼다. 적절한 함수 포인터를 사용해 이렇게 할 수 있다:
extern fooFun get42;long stub(){ get42 = resolveFun(); return get42();}fooFun get42 = stub;
프로그램의 나머지 관점에서 호출하려는 함수는 get42다. 초기 구현은 stub이며, stub는 실제 함수를 로딩하기 위해 resolveFun을 호출한다. 로딩이 성공하면 get42가 새로 로딩된 함수를 가리키게 바꾸고, 그 함수를 호출한다.
메인 함수는 get42를 두 번 호출한다:[6]
int main() { printf("first call p42() = %ld\n", get42()); printf("second call = %ld\n", get42());}
브라우저에서 이를 실행하면 다음과 같은 로그가 나온다:
loading promise42
first call p42() = 42
second call = 42
get42는 실제로 두 번 호출되지만, loading promise42 줄은 한 번만 나타나는 점에 유의하자.
이 예제는 JSPI가 예상치 못한 방식으로도 사용될 수 있음을 보여준다. 코드를 동적으로 로딩하는 것은 Promise를 만드는 것과는 꽤 거리가 있어 보인다. 또한 WebAssembly 모듈을 동적으로 링크하는 다른 방법들도 있으며, 이 예제가 그 문제의 결정적 해법을 제시하려는 의도는 아니다.
이 새로운 기능으로 여러분이 무엇을 만들어낼지 매우 기대된다! W3C WebAssembly Community Group의 repo에서 토론에 참여해 달라.
badfib 전체 목록 ##include <stdio.h>#include <stdlib.h>#include <time.h>#include <emscripten.h>typedef long (testFun)(long, int);#define microSeconds (1000000)long add(long x, long y) { return x + y;}// Ask JS to do the additionEM_JS(long, jsAdd, (long x, long y), { return x + y;});// promise an additionEM_ASYNC_JS(long, promiseAdd, (long x, long y), { return Promise.resolve(x+y);});__attribute__((noinline))long localFib(long x) { if (x==0) return 0; if (x==1) return 1; return add(localFib(x - 1), localFib(x - 2));}__attribute__((noinline))long jsFib(long x) { if (x==0) return 0; if (x==1) return 1; return jsAdd(jsFib(x - 1), jsFib(x - 2));}__attribute__((noinline))long promiseFib(long x) { if (x==0) return 0; if (x==1) return 1; return promiseAdd(promiseFib(x - 1), promiseFib(x - 2));}long runLocal(long x, int count) { long temp = 0; for(int ix = 0; ix < count; ix++) temp += localFib(x); return temp / count;}long runJs(long x,int count) { long temp = 0; for(int ix = 0; ix < count; ix++) temp += jsFib(x); return temp / count;}long runPromise(long x, int count) { long temp = 0; for(int ix = 0; ix < count; ix++) temp += promiseFib(x); return temp / count;}double runTest(testFun test, int limit, int count){ clock_t start = clock(); test(limit, count); clock_t stop = clock(); return ((double)(stop - start)) / CLOCKS_PER_SEC;}void runTestSequence(int step, int limit, int count) { for (int ix = 0; ix <= limit; ix += step){ double light = (runTest(runLocal, ix, count) / count) * microSeconds; double jsTime = (runTest(runJs, ix, count) / count) * microSeconds; double promiseTime = (runTest(runPromise, ix, count) / count) * microSeconds; printf("fib(%d) %gμs %gμs %gμs %gμs\n",ix, light, jsTime, promiseTime, (promiseTime - jsTime)); }}EMSCRIPTEN_KEEPALIVE int main() { int step = 1; int limit = 15; int count = 1000; runTestSequence(step, limit, count); return 0;}
u42.c와 p42.c 목록 #u42.c C 코드는 동적 로딩 예제의 메인 부분을 나타낸다:
#include <stdio.h>#include <emscripten.h>typedef long (*fooFun)();// promise a functionEM_ASYNC_JS(fooFun, resolveFun, (), { console.log('loading promise42'); LoadedModule = (await WebAssembly.instantiateStreaming(fetch('p42.wasm'))).instance; return addFunction(LoadedModule.exports['provide42']);});EM_JS_DEPS(funDeps, "$addFunction")extern fooFun get42;long stub() { get42 = resolveFun(); return get42();}fooFun get42 = stub;int main() { printf("first call p42() = %ld\n", get42()); printf("second call = %ld\n", get42());}
p42.c 코드는 동적으로 로딩되는 모듈이다.
#include <emscripten.h>EMSCRIPTEN_KEEPALIVE long provide42() { return 42l;}