웹 어셈블리를 이용한 자바스크립트 모듈 | [object Object]

June 6, 2018

웹 어셈블리를 이용한 자바스크립트 모듈

이전까지 사용하고 있던 node.js 네이티브 모듈 기반의 철자 검사기를 개선하는 작업을 하면서 주요 모듈을 WebAssmebly 기반으로 구현하게 되었다. 네이티브 모듈과 새 모듈 모두 널리 알려진 철자 검사기인 Hunspell을 기반으로 하고 있는 것은 동일하지만 WebAssembly기반으로 구현한 덕분에 브라우저에서도 빠른 속도로 철자 검사를 수행할 수 있게 되었다는 점에 만족하고 있다. C/C++ 기반의 코드를 WebAssembly를 통해 자바스크립트 환경에서 사용할 수 있도록 만들면서 고려했던 점과 개선해야 하겠다고 생각한 점들을 두서없이 간단하게 정리해 본다.

WebAssembly를 이용해 구현한 모듈은 철자 검사기인 Hunspell의 wasm 바인딩인 Hunspell-asm과 구글이 Chromium에서 사용중인 언어 감지기인 CLD3의 바인딩인 Cld3-asm이며, Electron기반의 어플리케이션에서 좀 더 쉽게 사용할 수 있도록 Electron-hunspell 또한 공개되어 있다.

빌드 도구와 설정

언어에 따라 상이하지만 현재 최우선 지원 순위로 두고 있는 C/C++을 기준으로 WebAssembly 바이너리를 빌드할 수 있는 방법은 몇 가지가 있다. 그 가운데 별 망설임없이 잘 알려진 emscripten을 이용해 코드를 빌드했다. 기존 코드에서 사용중인 makefile을 거의 그대로 이용할 수 있다는 점에서부터, emscripten에서 지원하는 기능들을 이용하면 자바스크립트와 C/C++간 자료 교환이 훨씬 수월해진다는 점이 컸다. 예를 들어 Hunspell에서 철자 제안 목록을 가져오는 C 함수의 경우 아래와 같이 문자열의 배열 포인터를 반환하는데,

int Hunspell_suggest(Hunhandle* pHunspell, char*** slst, const char* word);

emscripten에서 지원하는 포인터 변환 함수인 getValue/Pointer_stringify등을 이용하면 간단하게 값을 가져올 수 있었다. 특히 node.js 기반과 같은 실행 환경에서 시스템에 접근할 수 있는 방법도 더불어 제공하고 있다.

바이너리 빌드는 자바스크립트 빌드 스크립트에 포함하지 않고 별도로 빌드하고 있다. node-pre-gyp와 유사한 방식으로 자바스크립트 모듈을 필요한 버전을 패키징 시에 다운로드 해서 사용하고 있지만 node.js 네이티브 모듈과는 달리 각 플랫폼에 맞게 별도로 빌드할 필요가 없기때문에 docker 이미지를 이용해서 빌드할 수 있는 것이 장점이다. Webpack이나 기타 번들러 혹은 자바스크립트 모듈에서 바로 네이티브 코드를 불러 사용하는 방식의 로더들이 만들어지고 있고 장기적으로는 그와 같은 방향으로 자바스크립트와 WebAssembly 모듈의 통합이 이루어질 것이라고 보지만 Hunspell이나 Cld의 경우 기존 코드의 makefile 및 기타 설정을 그대로 재사용하는 쪽을 택했다.

node.js vs. 브라우저, 혹은 isomorphic

Hunspell-asm과 Cld3-asm 모두 브라우저와 node.js 및 Electron에서 동작할 수 있게 작성되었다. 생성된 바이너리는 실행 환경이 WebAssembly만 지원하면 되기 때문에 어디서든 사용할 수 있지만, 바이너리를 불러오는 방법과 라이브러리의 성격에 따른 지원에서 다소 차이를 보인다.

Emscripten의 기본 설정으로 빌드를 마치면 실제 WebAssembly 바이너리인 xxxx.wasm 과 Emscripten이 제공하는 함수들이 포함된 자바스크립트 파일인 xxxx.js가 생성된다. 이 자바스크립트 모듈이 바이너리를 불러서 컴파일하는 등의 처리를 담당하는데 내부적으로 실행 환경 정의를 이미 해 두고 있기 때문에 require/fetch를 필요에 따라 사용하고 있다. Electron과 같이 조금 특이하게 브라우저 환경에서 require 및 node 관련 변수가 정의되어 있거나, 원격 주소가 아닌 로컬에서 파일을 불러오는 경우라면 fetch로 바이너리를 불러 올 수 없기 때문에 SINGLE_FILE 설정을 이용해 자바스크립트 안에 바이너리를 내장하는 방법을 사용하게 되었다.

대신 SINGLE_FILE을 사용하면 생성된 바이너리를 base64로 자바스크립트 안에 저장해버리기 때문에 자바스크립트를 불러오는 데 걸리는 시간이 길어지고 바이너리를 필요할 때 지연해서 불러올 수 없게 된다. 동시에 몇 가지 WebAssembly를 불러올 때 사용하는 최적화를 사용할 수 없게 된다는 점도 고려할 필요가 있다. 이를테면 WebAssembly.instantiateStreaming()과 같이 바이너리를 다운로드받으면서 동시에 초기화하거나, 컴파일 된 모듈을 캐시에 저장해 컴파일 시간을 단축시키는 등의 방법이다. 특히 두 번째를 위해 작성한 모듈을 변경하는 작업을 다시 고려하고 있다.

Hunspell의 경우 이에 더해 파일 시스템 접근에 대한 고려가 필요했다. 철자 검사기를 생성하는 C 함수의 정의를 보면 사전 파일에 대한 경로를 받아 사전을 읽어 들인다. WebAssembly 또한 자바스크립트 런타임에서 동작하기 때문에 당연히 파일 시스템에 바로 접근할 수 있는 권한이 없다.

Hunhandle* Hunspell_create(const char* affpath, const char* dpath);

40여개에 가까운 사전을 모두 탑재할 수는 없었기 때문에 바이너리를 빌드할 때 필요한 데이터 파일을 같이 패키징하는 방법을 사용할 수는 없었다. 대신 Emscripten이 제공하는 가상 파일 시스템을 이용했다.

node.js에서는 실제 파일 시스템을 마운트하고 파일을 직접 읽을 수 있도록 하고

FS.mount(FS.filesystems.NODEFS, { root: path.resolve(dirPath) }, mountedDirPath);

브라우저의 경우 MEMFS를 이용해 가상 경로를 만들고 다운로드 받은 사전 데이터를 ArrayBufferView로 저장한다.

FS.writeFile(mountedFilePath, contents, { encoding: 'binary' });

성능과 관련해 고려할 점

WebAssembly와 관련한 이야기 중에 일부는 믿을 수 없을 만큼 극적인 성능 향상을 언급하는 경우가 있다. 철자 검사기의 경우 이전에 사용하던 구현체가 이미 자바스크립트가 아닌 node.js 기반의 네이티브 모듈이었기 때문에 기대치는 높지 않았지만, 상대적으로 만족할 만한 결과를 얻을 수 있었다. 다만 이는 네이티브 모듈을 사용하는 방식, Electron의 렌더러 프로세스에서 node.js 모듈을 사용하는 등의 요소를 감안하지 않은 단순 비교라는 점을 유의할 필요가 있다.

위가 이전 버전, 아래는 Hunspell-asm

더불어 Hunspell / cld 둘 다 연산 과정에서 자바스크립트와의 데이터 교환이 거의 필요없다는 점이 많은 도움이 되었다. WebAssembly의 메모리 영역에서 자바스크립트에 생성된 데이터에 직접 접근이 힘들고, 대부분의 C 인터페이스가 포인터를 이용하기 때문에 값을 전달하거나 돌려받을 때 부하가 발생한다. 수정할 철자를 제안받는 Hunspell_suggest함수를 보면

int Hunspell_suggest(Hunhandle* pHunspell, char*** slst, const char* word);

문자열의 배열을 내부적으로는 std::vector<std::string>에 저장하고 그 포인터 값을 반환하고 있다. 자바스크립트에서 이 값을 다시 읽기 위해서는 배열을 다시 순회하면서 각 포인터값을 Pointer_stringify를 통해 문자열로 다시 가져오는 작업을 거쳐야 한다. 반대로 이야기하면 WebAssembly로 작성된 함수와 자바스크립트 사이에 잦은 상태 교환이 필요하다면 그만큼 성능 향상의 폭이 줄어들 수 밖에 없다는 이야기가 된다.

수행 속도와 더불어 브라우저를 주로 목표로 삼는 자바스크립트에서 중요한 것 가운데 하나가 모듈의 크기 / 초기화 속도이다. 이론적으로는 같은 코드를 구현했을경우 바이너리 포맷인 WebAssembly가 더 작은 크기를 가지게 되지만, 실제 WebAssembly로 빌드한 바이너리는 예상보다 더 큰 경우가 많다. Emscripten의 자바스크립트를 포함한 크기지만 hunspell-asm의 경우 약 800KB, cld3-asm의 경우는 거의 930KB로 둘 다 1MB에 가깝다. 이는 C/C++ 코드가 사용하는 라이브러리들 때문인데 위의 Hunspell_suggest처럼 Hunspell이 사용하는 std::vectorstd::string같은 자료형에서부터 깊게는 malloc과 같은 함수들까지 WebAssembly의 빌드 결과물에 포함되기 때문이다. WebAssembly 지원을 목표 상위에 두고 있는 Rust처럼 아예 메모리 할당 함수까지 새로 구현하는 방법은 C 기반의 코드에서는 쉽지 않겠지만 WebAssembly를 위해 새로 코드를 작성할 경우 의존성을 최대한 줄일 필요가 있다.

아직까지 WebAssembly의 메모리 영역은 GC를 수행하지 않는다는 점은 매우 중요하다. Hunspell의 사전과 같이 메모리 영역에 불러온 자료부터 검사기 자체까지 직접 메모리를 해제해 주어야 한다. 특히 C++기반의 코드에서 embind를 사용할 경우 클래스 소멸자도 자동으로 호출되지 않는 등 예외적인 상황이 많기 때문에 주의를 기울여야 한다.

OJ Kwon