3-5. 배포(WebAssembly)

NAPI-RS와 Craby로 Node.js와 React Native 환경을 설정한 다음, 마지막으로 Web 환경을 설정해야 했다.
목표는 웹 브라우저에서도 러스트 코드를 실행하는 것이었고, 선택한 게 WebAssembly였다.

왜 WebAssembly?

초기에는 WebAssembly를 고려하지 않았다.
NAPI-RS 문서에서 WebAssembly 지원이 가능하다고 나와있긴 했지만, 안정적이지 않을 것 같아서 나중에 추가하려고 생각했다.

그리고 Rust로 모든 플랫폼에 제공할 수 있다는 것이 원래 목표였으므로, Rust Wasm 빌드는 이전에 Yew 블로그를 만들어 본 적도 있으니 가장 우선순위를 낮췄었다.

그래서 NAPI-RS의 WebAssembly 빌드를 확인해봤는데, 생각보다 간단했다.
NAPI-RS가 wasm32-wasi 타겟으로 빌드할 수 있다는게 실험적 기능으로 추가되어 있었기 때문이다.

초기 설정 과정

NAPI-RS는 napi build --target wasm32-wasip1-threads 명령어로 WebAssembly 빌드를 지원한다.
하지만 모노레포 환경에서 제대로 작동하지 않는 경우가 많았다.

초기 커밋을 보면 packages/hwpjs/package.json에 WebAssembly 빌드 설정이 추가되었고, @napi-rs/wasm-runtime을 의존성으로 추가했다.

package.jsonnapi.targetswasm32-wasip1-threads를 추가했다:

{
  "napi": {
    "binaryName": "hwpjs",
    "targets": [
      "x86_64-pc-windows-msvc",
      "x86_64-apple-darwin",
      "aarch64-apple-darwin",
      "wasm32-wasip1-threads"
    ]
  }
}

그리고 빌드 스크립트도 추가했다:

{
  "scripts": {
    "build:web:wasm": "napi build --target wasm32-wasip1-threads --release --package hwpjs-napi --output-dir dist"
  }
}

하지만 실제로 빌드해보니 여러 문제가 있었다:

  • 빌드된 .wasm 파일이 제대로 로드되지 않음
  • 브라우저에서 Buffer 같은 Node.js 전용 API 사용 불가
  • Vite 같은 번들러에서 WebAssembly 파일 처리 필요
  • 빌드된 WASM 파일의 이름과 위치가 예상과 달랐음

WASM 빌드 위치 문제와 커스텀 스크립트

NAPI-RS로 WASM을 빌드하면 기본적으로 hwpjs.wasm이라는 이름으로 dist 디렉토리에 생성된다.
하지만 실제로 사용할 때는 hwpjs.wasm32-wasi.wasm이라는 이름이 필요했다.

그리고 NAPI-RS가 생성하는 파일 구조도 예상과 달랐다:

  • 빌드된 파일: dist/hwpjs.wasm
  • 필요한 파일명: dist/hwpjs.wasm32-wasi.wasm
  • 디버그 파일: dist/hwpjs.debug.wasm (이건 삭제해야 함)

그래서 빌드 후 파일명을 변경하는 커스텀 스크립트를 만들었다:

// scripts/rename-wasm.ts
import { renameSync, existsSync, unlinkSync } from 'fs';
import { join } from 'path';

const distDir = join(process.cwd(), 'dist');

// WASM 파일명 변경
const wasmSrc = join(distDir, 'hwpjs.wasm');
const wasmDest = join(distDir, 'hwpjs.wasm32-wasi.wasm');
const wasmDebug = join(distDir, 'hwpjs.debug.wasm');

if (existsSync(wasmSrc)) {
  // 목적지 파일이 이미 있으면 먼저 삭제
  if (existsSync(wasmDest)) {
    unlinkSync(wasmDest);
  }
  renameSync(wasmSrc, wasmDest);
  console.log('✓ Renamed hwpjs.wasm to hwpjs.wasm32-wasi.wasm');
}

// hwpjs.debug.wasm 삭제
if (existsSync(wasmDebug)) {
  unlinkSync(wasmDebug);
  console.log('✓ Removed hwpjs.debug.wasm');
}

그리고 package.jsonpostbuild:node:all 스크립트에 이 스크립트를 추가했다:

{
  "scripts": {
    "build:web:wasm": "napi build --target wasm32-wasip1-threads --release --package hwpjs-napi --output-dir dist",
    "postbuild:node:all": "bun run scripts/rename-wasm.ts"
  }
}

이렇게 하면 모든 플랫폼 빌드가 끝난 후 자동으로 WASM 파일명이 변경되고, 디버그 파일이 삭제됐다.

왜 이런 스크립트가 필요한가?

NAPI-RS의 WASM 빌드는 실험적 기능이라서, 파일명이나 위치가 표준화되지 않았다.
그래서 빌드 후 처리 단계에서 필요한 형태로 변환해야 했다.

그냥 대충 나오는 결과물을 보고 내 입맛에 바꾼 거라, 실제 내가 문서를 제대로 읽지 않은건지, 아니면 아직 실험적 기능이라 그런건지 잘 모르겠다.
NAPI-RS의 WASM 지원이 안정화되면 이런 작업이 필요 없을 수도 있고, 당장 내가 사용법을 이해 못해서일 수도 있지만, 시간을 쏟고 싶지 않고 일단 잘 동작되는 방법을 찾았기에 더 이상 해당 문제에 집중하지 않았다.

Web 예제 앱 커스텀 설정

Web 예제 앱은 Vite를 사용했는데, WebAssembly 파일을 지원하도록 설정해야 했다.

1. Vite 설정

vite.config.ts에서 워크스페이스 모듈을 사용할 수 있도록 alias를 설정하고, .wasm 파일을 정적 에셋으로 처리하도록 설정했다:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@ohah/hwpjs': path.resolve(__dirname, '../../packages/hwpjs'),
    },
  },
  assetsInclude: ['**/*.wasm'],
});

이렇게 하면 Vite가 워크스페이스 내의 패키지를 찾을 수 있고, .wasm 파일을 번들에 포함시킬 수 있었다.

2. Buffer Polyfill - 삽질의 연속

WebAssembly 모듈이 Node.js의 Buffer를 사용하는데, 브라우저에는 Buffer가 없다.
그래서 커스텀 Buffer polyfill을 만들어야 했다.

하지만 이게 생각보다 삽질이었다.

첫 번째 시도: 라이브러리 안에 포함

처음에는 라이브러리 자체에서 Buffer polyfill을 제공하려고 했다.
browser.jswasi-browser.js 같은 파일에 Buffer polyfill 코드를 넣으면, 사용자가 별도로 설정하지 않아도 될 것 같았다.
라이브러리를 import하면 자동으로 Buffer polyfill이 설정되어서, 사용자 경험이 좋을 거라고 생각했다.

하지만 이건 실패했다.
WASM 모듈이 로드되기 전에 Buffer가 설정되어야 하는데, 모듈 내부에서 설정하면 이미 늦었다.
라이브러리 파일이 import되는 시점에는 이미 WASM 모듈이 초기화되기 시작했고, Buffer를 찾지 못해서 크래시가 발생했다.

WASM 실행 순서가 꼬여서, 라이브러리 안에 Buffer polyfill을 포함하는 건 불가능했다.
결국 사용자가 직접 Buffer polyfill을 설정해야 하는 구조가 됐다.

두 번째 시도: HTML 파일에 인라인 스크립트

그래서 index.html<head>에 인라인 스크립트로 Buffer polyfill을 넣었다:

<head>
  <script>
    // Buffer polyfill - 모든 모듈 로드 전에 실행
    if (typeof globalThis.Buffer === 'undefined') {
      globalThis.Buffer = Uint8Array;
      globalThis.Buffer.from = function (data) {
        if (data instanceof Uint8Array) return data;
        if (Array.isArray(data)) return new Uint8Array(data);
        return new Uint8Array(data);
      };
      globalThis.Buffer.isBuffer = function (obj) {
        return obj instanceof Uint8Array;
      };
    }
  </script>
</head>

이렇게 하면 HTML이 파싱될 때 가장 먼저 실행되니까, 모든 모듈 로드 전에 Buffer가 설정될 거라고 생각했다.

하지만 이것도 문제가 있었다:

  • HTML에 비즈니스 로직이 들어가는 게 깔끔하지 않음
  • TypeScript 타입 체크를 받을 수 없음
  • 유지보수가 어려움

세 번째 시도: React 컴포넌트에서 설정

그래서 App.tsx에서 useEffect로 Buffer를 설정하려고 했다.
하지만 이것도 실패했다.
React 컴포넌트가 마운트되는 시점은 이미 모듈이 로드된 후였다.

최종 해결: 별도 파일로 분리하고 main.tsx에서 가장 먼저 import

결국 src/buffer-polyfill.ts 파일을 만들어서 Buffer polyfill을 분리하고, main.tsx에서 가장 먼저 import하도록 했다:

// src/buffer-polyfill.ts
// Buffer polyfill for napi-rs WASM compatibility
// This must be imported before any WASM modules
if (typeof globalThis.Buffer === 'undefined') {
  globalThis.Buffer = class Buffer extends Uint8Array {
    static from(data: any) {
      if (data instanceof Uint8Array) return data;
      if (data instanceof ArrayBuffer) return new Uint8Array(data);
      if (Array.isArray(data)) return new Uint8Array(data);
      return new Uint8Array(data);
    }
    static isBuffer(obj: any) {
      return obj instanceof Uint8Array;
    }
  } as any;
}

// Ensure Buffer is set on window as well for compatibility
if (typeof window !== 'undefined' && typeof window.Buffer === 'undefined') {
  (window as any).Buffer = globalThis.Buffer;
}
// src/main.tsx
import './buffer-polyfill';  // 가장 먼저 import
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';

이렇게 하면:

  • TypeScript 타입 체크를 받을 수 있음
  • 코드가 깔끔하게 분리됨
  • import 순서가 명확함
  • 유지보수가 쉬움

하지만 이런 간단한 문제를 해결하는데도 여러 번 시도해야 했다.
import 순서 문제는 생각보다 까다로웠다.

3. WebAssembly 로딩과 배포 방식 개선

NAPI-RS가 생성한 WebAssembly 모듈을 로드하는 방법도 일반적인 방법과 달랐다.
초기에는 직접 @napi-rs/wasm-runtime을 사용해서 로드하려고 했지만, 여러 문제가 있었다.

최종 해결: NAPI-RS의 자동 패키징 활용

NAPI-RS는 WASM 빌드 시 자동으로 별도 패키지(@ohah/hwpjs-wasm32-wasi)를 생성한다.
이 패키지는 npm/wasm32-wasi/ 디렉토리에 생성되고, 자동으로 NPM에 배포된다.

그리고 메인 패키지(@ohah/hwpjs)에서는 package.jsonexports 필드를 사용해서 브라우저 환경에서 자동으로 WASM 패키지를 사용하도록 설정했다:

{
  "exports": {
    ".": {
      "browser": {
        "types": "./dist/index.d.ts",
        "default": "./dist/browser.js"
      },
      "node": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    }
  }
}

dist/browser.js는 단순히 WASM 패키지를 re-export한다:

// dist/browser.js
export * from '@ohah/hwpjs-wasm32-wasi'

이렇게 하면:

  • 브라우저 환경에서는 자동으로 WASM 패키지가 사용됨
  • Node.js 환경에서는 네이티브 바이너리가 사용됨
  • 사용자는 환경을 신경 쓸 필요가 없음
  • 번들러가 자동으로 올바른 패키지를 선택함

SSG 빌드 문제 해결

하지만 이 방식도 문제가 있었다.
Rspress 같은 SSG(Static Site Generation) 도구에서 빌드할 때, Node.js 환경으로 인식해서 네이티브 바이너리를 로드하려고 했다.
그런데 문서 사이트 빌드 환경에는 네이티브 바이너리가 없어서 빌드가 실패했다.

그래서 browser export condition을 추가해서, 브라우저/SSG 환경에서는 항상 WASM 패키지를 사용하도록 했다:

{
  "exports": {
    ".": {
      "browser": {
        "types": "./dist/index.d.ts",
        "default": "./dist/browser.js"
      },
      "node": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "default": "./dist/browser.js"
    }
  }
}

이렇게 하면 SSG 빌드 환경에서도 browser condition이 적용되어서 WASM 패키지를 사용하게 됐다.

Buffer Polyfill은 여전히 필요

하지만 Buffer polyfill은 여전히 필요했다.
WASM 패키지가 로드되기 전에 Buffer가 설정되어야 하기 때문이다.
그래서 예제 앱에서는 여전히 buffer-polyfill.ts를 가장 먼저 import하고 있다.

하지만 이제는 NAPI-RS의 자동 패키징을 활용해서, 사용자가 직접 설정할 필요가 없어졌다.
메인 패키지를 import하면 자동으로 올바른 환경의 패키지가 선택된다.

커스텀한 부분과 앞으로의 숙제

WebAssembly 환경은 아직 완전히 안정적이지 않았다.
NAPI-RS의 WebAssembly 지원이 실험적 단계라서, 여러 부분을 커스텀해야 했다.

커스텀한 부분:

  1. Buffer Polyfill 직접 구현:

    • 브라우저에는 Node.js의 Buffer가 없어서, Uint8Array를 기반으로 한 커스텀 Buffer 클래스를 만들었다
    • globalThis.Bufferwindow.Buffer 모두 설정해서 호환성을 확보했다
    • Buffer.from()Buffer.isBuffer() 같은 기본 메서드만 구현했다
    • NAPI-RS의 WebAssembly 모듈이 요구하는 최소한의 API만 제공하도록 최적화했다
  2. Vite 워크스페이스 alias 설정:

    • 모노레포 환경에서 @ohah/hwpjs를 워크스페이스 내의 패키지로 연결했다
    • path.resolve를 사용해서 상대 경로로 명시적으로 설정했다
    • .wasm 파일을 assetsInclude에 추가해서 정적 에셋으로 처리했다
  3. Buffer Polyfill 로딩 순서 보장:

    • main.tsx에서 buffer-polyfill.ts를 가장 먼저 import했다
    • WebAssembly 모듈이 로드되기 전에 Buffer가 설정되도록 순서를 보장했다
    • 이게 안 되면 WebAssembly 모듈이 Buffer를 찾지 못해서 크래시가 발생했다

앞으로의 숙제:

  1. WebAssembly 빌드 안정화:

    • NAPI-RS의 WebAssembly 지원이 실험적이라서, 실제로 제대로 작동하는지 확인이 필요하다
    • wasm32-wasip1-threads 타겟이 모든 브라우저에서 지원되는지 확인 필요
    • 빌드된 .wasm 파일이 실제로 로드되고 실행되는지 검증 필요
  2. Buffer Polyfill을 라이브러리 안에 포함하기:

    • 현재는 사용자가 직접 Buffer polyfill을 설정해야 하는데, 이건 사용자 경험상 좋지 않다
    • 라이브러리를 import하면 자동으로 Buffer polyfill이 설정되도록 하고 싶지만, WASM 실행 순서 문제로 실패했다
    • WASM 모듈이 로드되기 전에 Buffer가 설정되어야 하는데, 라이브러리 파일이 import되는 시점에는 이미 늦다
    • 이 문제를 해결할 방법을 찾아야 한다 (예: 별도의 초기화 함수 제공, 또는 다른 방식의 polyfill 주입)
  3. Buffer Polyfill 완성도 향상:

    • 현재는 최소한의 API만 구현했는데, NAPI-RS가 더 많은 Buffer 메서드를 요구할 수 있다
    • Buffer.alloc(), Buffer.concat() 같은 추가 메서드 구현 필요할 수 있음
    • 실제 사용하면서 필요한 메서드를 점진적으로 추가해야 함
  4. 성능 최적화:

    • WebAssembly 모듈 크기 최적화 (현재 크기 확인 필요)
    • 로딩 시간 개선 (코드 스플리팅, 지연 로딩 등)
    • 메모리 사용량 최적화
  5. 에러 처리 강화:

    • WebAssembly 로딩 실패 시 적절한 에러 메시지 표시
    • 브라우저 호환성 체크 (WebAssembly 지원 여부 확인)
    • 폴백 전략 (WebAssembly가 안 되면 다른 방법 제시)
  6. 테스트 환경 구축:

    • Web 환경에서의 통합 테스트 코드 작성
    • 다양한 브라우저에서의 호환성 테스트
    • CI/CD 파이프라인에 Web 빌드 테스트 추가

하지만 일단 기본적인 설정은 해뒀고, 나머지는 사용자가 실제로 사용해보면서 개선해나가면 된다고 생각했다.
WebAssembly는 Node.js와 React Native보다 우선순위가 낮았기 때문에, 일단 다른 환경이 안정화된 후에 집중하기로 했다.

실제로 지금 시점에서는 WebAssembly 빌드가 제대로 작동하는지도 확실하지 않다.
NAPI-RS의 WebAssembly 지원이 실험적이라서, 문서도 부족하고 예제도 많지 않다.
그래서 일단 설정만 해두고, 나중에 필요할 때 제대로 구현하기로 했다.

1줄 요약

NAPI-RS의 WebAssembly 빌드를 사용해서 웹 환경을 설정했고, Buffer polyfill 등 커스텀 설정을 추가했다.