타입스크립트에서의 WebAssembly

타입스크립트에서의 WebAssembly

이 글에서는 타입스크립트에서의 WebAssembly를 설명합니다.

타입스크립트와 WebAssembly를 통합하는 실용적이고 이해하기 쉬운 방법을 설명합니다.

YouTube Video

타입스크립트에서의 WebAssembly

WebAssembly(Wasm)는 브라우저 내에서 거의 네이티브 속도로 동작하는 바이너리 형식 실행 환경입니다. 타입스크립트에서 Wasm을 호출함으로써 계산 집약적인 처리와 C/C++이나 Rust로 작성된 기존 네이티브 라이브러리를 효율적으로 활용할 수 있습니다.

기본 실행 흐름

여기에서는 Wasm의 기본 실행 흐름을 설명합니다. 타입스크립트(또는 브라우저)가 .wasm 파일을 가져와 인스턴스화한 후 내보낸 함수를 호출합니다.

    1. AssemblyScript, Rust, C++ 등으로 .wasm 바이너리를 생성하거나, 기존의 것을 준비합니다.
    1. 타입스크립트(또는 브라우저)에서 .wasm 파일을 가져와 동기적으로 또는 비동기적으로 인스턴스화합니다.
    1. 내보낸 함수를 호출하고, 필요에 따라 WebAssembly.Memory를 사용해 메모리를 공유합니다.

WebAssembly.instantiateStreaming

다음으로, Wasm 파일을 로드하고 내보낸 함수를 호출하는 기본 예제를 보여드리겠습니다. 브라우저가 instantiateStreaming을 지원해야 합니다.

다음 코드는 서버에서 simple.wasm 파일을 가져와서 add 함수를 호출하는 예제입니다.

 1// TypeScript: load-and-call.ts
 2// Fetch and instantiate a wasm module and call its exported `add` function.
 3async function run() {
 4  const response = await fetch('http://localhost:3000/simple.wasm');
 5  // Use instantiateStreaming when available for efficiency.
 6  const { instance } = await WebAssembly.instantiateStreaming(response, {});
 7  // @ts-ignore
 8  const result = instance.exports.add(2, 3);
 9  console.log('2 + 3 =', result);
10}
11run().catch(console.error);
  • Wasm 내부의 함수는 instance.exports에 저장됩니다.
  • 타입스크립트는 타입 정보를 받지 않으므로 @ts-ignore를 사용하거나 직접 타입 정의를 작성해야 합니다.

AssemblyScript를 사용하는 워크플로우

AssemblyScript는 타입스크립트와 유사한 문법으로 Wasm을 작성할 수 있어 타입스크립트 개발자에게 친숙한 선택지입니다. 여기서는 간단한 함수를 준비하고, 이를 .wasm.d.ts로 빌드한 뒤, TypeScript에서 호출합니다.

1// assembly/index.ts (AssemblyScript)
2// npm install --save-dev assemblyscript
3
4// Export a simple function to add two integers.
5export function add(a: i32, b: i32): i32 {
6  return a + b;
7}
  • asc(AssemblyScript 컴파일러)를 사용하면 .wasm 파일과 선택적으로 타입 정의 파일인 .d.ts 파일을 생성할 수 있습니다. 로컬에서 시도하려면 npm으로 assemblyscript를 설치하고 빌드하세요.
1# build commands
2# npm install --save-dev assemblyscript
3npx asc assembly/index.ts   -o build/simple.wasm   -t build/simple.wat   --bindings esm   --exportTable   --sourceMap
4
5# optionally generate d.ts with --exportRuntime or use as-bind / loader tools

타입스크립트 측에서 가져오고 호출하는 예제입니다.

1// ts client that loads AssemblyScript-generated wasm
2async function runAssemblyScript() {
3  const res = await fetch('http://localhost:3000/build/simple.wasm');
4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
5  // @ts-ignore
6  console.log('AssemblyScript add:', instance.exports.add(10, 7));
7}
8runAssemblyScript().catch(console.error);
  • AssemblyScript는 메모리 모델과 문자열을 신중하게 다루어야 하지만, 기본적인 수치 계산에는 매우 쉽게 사용할 수 있습니다.

Rust + wasm-bindgen(강력하고 널리 사용되는 선택지)

이 섹션에서는 Rust로 Wasm을 작성하고 wasm-bindgen을 사용하여 JavaScript 또는 TypeScript와 연동하는 워크플로우를 설명합니다. 여기서는 간단한 피보나치 함수를 예로 들어, 생성된 모듈을 ES 모듈로 가져오는 방법을 설명합니다.

Rust 측에서 wasm-bindgen을 사용하여 함수를 내보냅니다.

 1// src/lib.rs (Rust)
 2// install wasm-pack from https://drager.github.io/wasm-pack/installer/
 3use wasm_bindgen::prelude::*;
 4
 5// Export a function to JavaScript using wasm-bindgen.
 6#[wasm_bindgen]
 7pub fn fib(n: u32) -> u32 {
 8    if n <= 1 { return n; }
 9    let mut a = 0;
10    let mut b = 1;
11    for _ in 2..=n {
12        let tmp = a + b;
13        a = b;
14        b = tmp;
15    }
16    b
17}
  • wasm-pack 또는 wasm-bindgen CLI로 빌드하면 타입스크립트용 타입 정의와 JS 래퍼가 생성되어 ESM으로 바로 가져올 수 있습니다.
1# build with wasm-pack
2# install wasm-pack from https://drager.github.io/wasm-pack/installer/
3wasm-pack build --target nodejs --out-dir pkg

타입스크립트 측에서는 pkg에서 ES 모듈을 가져와서 사용합니다.

 1// Node.js: import WASM module built with --target web
 2// import init, { fib } from '../pkg/my_wasm_module.js';
 3// Node.js: import WASM module built with --target nodejs
 4import wasm from '../pkg/my_wasm_module.js';
 5
 6async function run() {
 7  //await init();   // --target web
 8  console.log('fib(10)=', wasm.fib(10));
 9}
10
11run().catch(console.error);
  • wasm-pack은 JavaScript 래퍼와 .d.ts 타입 정의 파일을 생성하므로 타입스크립트에서 쉽게 사용할 수 있습니다. wasm-pack 명령어의 --target 옵션에 web을 지정할 경우, 비동기 초기화가 필요하다는 점에 유의하세요.

실제 메모리 공유 예제: 배열 전달 및 처리(저수준 방식)

대용량 데이터를 Wasm과 주고받을 때, 효율적인 데이터 교환을 위해 ArrayBuffer 공유가 중요합니다. 여기서는 AssemblyScript를 예시로 들지만, Rust의 wasm-bindgen도 같은 원리가 적용됩니다.

AssemblyScript 측에서 메모리에 데이터를 쓸 수 있는 내보낸 함수를 준비합니다. 예를 들어, 배열의 각 요소를 제곱하는 함수는 다음과 같습니다.

 1// assembly/array_ops.ts (AssemblyScript)
 2// Square values in place in the wasm linear memory starting at `ptr` for `len` elements.
 3export function square_in_place(ptr: usize, len: i32): void {
 4  // Treat memory as a pointer to 32-bit integers.
 5  for (let i = 0; i < len; i++) {
 6    let offset = ptr + (i << 2); // i * 4 bytes
 7    let value = load<i32>(offset);
 8    store<i32>(offset, value * value);
 9  }
10}

AssemblyScript에서 사용하는 메모리 설정을 지정하려면, 다음과 같은 asconfig.json 파일을 준비합니다.

1{
2  "options": {
3    "memoryBase": 0,
4    "importMemory": false,
5    "initialMemory": 1,
6    "maximumMemory": 10
7  }
8}
1 npx asc assembly/array_ops.ts   -o build/array_ops.wasm   -t build/array_ops.wat   --bindings esm   --exportTable   --sourceMap
  • 이 함수를 호출하려면 ArrayBuffer를 Wasm 메모리 공간에 복사하고 포인터를 전달해야 합니다.

아래는 타입스크립트에서 WebAssembly.Memory를 이용해 데이터를 복사하고 함수를 호출하는 예제입니다.

 1// TypeScript: use memory to pass array to wasm
 2async function runArrayOps() {
 3  const res = await fetch('http://localhost:3000/build/array_ops.wasm');
 4  const { instance } = await WebAssembly.instantiateStreaming(res, {});
 5  // @ts-ignore
 6  const memory: WebAssembly.Memory = instance.exports.memory;
 7  // Create a view into wasm memory.
 8  const i32View = new Int32Array(memory.buffer);
 9
10  // Example data
11  const input = new Int32Array([1, 2, 3, 4]);
12  // Choose an offset (in i32 elements) to copy data to (simple example: at index 0).
13  const offset = 0;
14  i32View.set(input, offset);
15
16  // Call wasm function: ptr in bytes, len in elements
17  // @ts-ignore
18  instance.exports.square_in_place(offset * 4, input.length);
19
20  // Read back result
21  const result = i32View.slice(offset, offset + input.length);
22  console.log('squared:', result);
23}
24runArrayOps().catch(console.error);
  • memory.buffer는 공유 선형 메모리이며, 복사를 최소화하면 처리 속도가 그만큼 빨라집니다. 참고로, 포인터는 바이트 단위 위치를 나타내고 TypedArray는 요소 수로 관리되므로, 이 차이에 주의해야 합니다.

타입 안전 처리를 위해: 타입스크립트 타입 정의 준비

Wasm의 내보내기는 자바스크립트 객체이므로, 타입스크립트 측에서 타입 정의를 제공하면 개발이 쉬워집니다. 아래는 간단한 타입 정의 파일 예시입니다.

다음은 수동으로 만들 수 있는 최소한의 simple.d.ts 타입 정의입니다.

1// simple.d.ts
2export function add(a: number, b: number): number;
3export const memory: WebAssembly.Memory;
  • 이 파일을 tsconfig.jsontypeRoots에 두거나 declare module을 사용하면 타입 체크가 가능합니다. wasm-pack.d.ts 파일을 자동으로 생성하므로, 이를 활용하는 것이 편리합니다.

런타임 초기화 패턴: 동기 vs 비동기

Wasm 모듈은 I/O(가져오기)와 컴파일이 필요하므로 비동기 초기화가 일반적입니다. 하지만 WebAssembly.Module을 미리 캐시해 두고 동기적으로 인스턴스화하는 패턴도 있습니다.

아래는 WebAssembly를 비동기적으로 초기화하는 기본 코드 구조입니다. 실제 프로젝트에서는 이 패턴을 권장합니다.

1// async init pattern
2async function initWasm(url: string) {
3  const res = await fetch(url);
4  const { instance, module } = await WebAssembly.instantiateStreaming(res, {});
5  return instance;
6}
  • 비동기 초기화는 에러 핸들링 및 지연 로딩을 유연하게 구현할 수 있어 실제 개발에서 가장 편리합니다. 또한, wasm-pack이 생성한 코드에는 초기화를 위한 init() API가 포함되어 있으므로, 이 흐름에 익숙해지면 작업이 순조롭게 진행됩니다.

실전 성능 고려사항

성능을 대폭 개선하기 위한 몇 가지 주의점을 소개합니다. 타입스크립트와 WebAssembly를 조합할 때 다음의 최적화 팁을 참고하세요.

  • 함수 호출이 매우 빈번할 경우, JavaScript와 Wasm 사이의 호출 오버헤드가 병목이 될 수 있습니다. 최대한 데이터를 일괄 처리하여 한 번에 처리하는 것을 권장합니다.
  • 메모리 할당과 복사는 처리 부하를 증가시킵니다. 공유 버퍼와 포인터를 활용해 이러한 연산을 최소화하세요.
  • 부동 소수점 숫자 처리에 주의하세요. 타입스크립트에서는 number 타입이 되지만, Wasm 쪽 타입과 맞추면 정확하게 다룰 수 있습니다.

요약

타입스크립트와 WebAssembly를 조합하면 브라우저에서 거의 네이티브에 가까운 성능을 얻을 수 있습니다. 이는 특히 계산 집약적인 작업이나 기존 네이티브 자산을 활용하고자 할 때 효과적입니다. 이 조합은 웹 애플리케이션의 성능을 향상시키고자 할 때 매우 강력한 선택지입니다.

위의 기사를 보면서 Visual Studio Code를 사용해 우리 유튜브 채널에서 함께 따라할 수 있습니다. 유튜브 채널도 확인해 주세요.

YouTube Video