React Hooks를 사용하여 React와 WebAssembly 사용하기

채동기·2023년 6월 7일
1

Rust

목록 보기
3/5

이 글에서는 React와 WebAssembly에 대한 간략한 개요와 이를 사용하는 이유, 코드에 Wasm 개발 기능을 추가하는 방법, 그리고 hooks를 사용하여 React 애플리케이션에서 Wasm을 사용하는 방법에 대해 다룰 것입니다. 마지막으로, React 앱에서 Wasm의 기능을 활용하기 위해 설정을 완료할 수 있다는 것을 살펴보겠습니다.

WebAssembly이란?

간단히 말하면, WebAssembly은 브라우저에서 실행될 수 있는 언어들 중 최근에 추가된 언어입니다. 이는 어셈블리와 비슷한 언어로, 속도와 보안 그리고 이식성을 제공하는 패키지입니다.

이는 컴파일된 언어이기 때문에 속도가 빠르며, 브라우저는 우리의 코드를 최적화하고 실행하기 위해 많은 작업을 수행하지 않아도 됩니다. 이는 안전한 이유는 Wasm이 사전에 정의된 권한 외에는 접근할 수 없는 안전한 환경에서 실행되기 때문입니다. 또한 이식성이 높은 이유는 Wasm 모듈이 다양한 언어에서 사용될 수 있으며, 다양한 언어로 작성할 수 있기 때문입니다.

왜 React에서 WebAssembly을 사용하길 원할까요?

어쩌면 다른 언어로 작성된 앱과 일관된 로직을 유지해야 할 수도 있습니다. 어쩌면 JS 모듈에서 사용하고 싶은 Rust Crate가 있을 수도 있습니다. WebAssembly의 이식성은 우리를 도와줄 수 있습니다. 사용자들이 보고서를 사용자 정의하거나 상호 작용에 로직을 추가하기 위해 시스템 내에서 신뢰할 수 없는 코드를 실행하도록 허용하고 싶을 수도 있습니다. 이 경우 Wasm의 샌드박싱 기능이 잘 맞을 것입니다. 아마도 단순히 빠른 속도가 필요할 수도 있습니다. 예를 들어 이미지에서 바코드 식별, 협업 디자인 도구, 비디오 게임 등이 있을 수 있습니다.

혹시 그냥 호기심이 생겼을 수도 있습니다.

use-as-bind 란?

use-as-bind는 AssemblyScriptAsBind 로더로 생성된 WebAssembly을 로드하고 인스턴스화할 수 있는 React 훅입니다.

저는 use-as-bind 훅을 워크숍의 일부로 개발했는데, 참가자들이 assembly-script로 빌드된 Wasm을 여러 환경에 통합하는 과정을 진행했습니다. 워크숍 이후에 이 훅이 다른 사람들에게도 유용할 수 있을 것 같아서 NPM에 공개하게 되었습니다.

이 글의 나머지 부분을 건너뛰고 바로 use-as-bind 훅을 사용할 수도 있지만, 이 훅이 어떻게 동작하는지 알아보는 것도 도움이 될 수 있습니다! 이어지는 내용은 use-as-bind 생성에 고려된 사항과 독자가 직접 이 훅을 구현하는 방법을 안내하는 튜토리얼입니다.

작성하기 전 고려 사항

React에서 WebAssembly를 사용하기 전에 세 가지 결정을 내려야 합니다.
1) 어떻게 작성할 것인가
2) 어떻게 로드할 것인가
3) 어떻게 사용할 것인가.

WASM 작성하기

먼저 WebAssembly를 작성하는 방법에 대해 결정해야 합니다. 여기에는 여러 가지 옵션이 있지만, (작성 시점 기준으로) 일회성 Wasm 모듈을 작성하는 가장 인기 있는 옵션은 C, Rust, AssemblyScript입니다. 각각의 옵션에는 장단점이 있습니다.

  • C는 오랫동안 사용되어 온 언어로, 탁월한 성능과 거대한 커뮤니티, 훌륭한 도구를 갖추고 있습니다. 그러나 JS 또는 TS에서 전환하는 데 높은 학습 곡선이 있습니다.

  • Rust는 훌륭한 컴파일러와 메모리 안전성, Wasm을 앱에 통합하기 쉽게 만드는 도구 체인을 갖추고 있습니다. C와 마찬가지로 JS 또는 TS에서 전환하는 데 높은 학습 곡선이 있습니다.

  • AssemblyScript는 TypeScript의 하위 집합으로, JS 개발자로서 우리는 이 시점에서 어느 정도 TS 경험을 가지고 있을 가능성이 있습니다. 또한 Wasm으로 컴파일하기 위해 목적으로 만들어진 언어로서도 훌륭합니다 (물론 다른 용도로도 사용할 수 있습니다).

우리는 이미 React를 사용하고 JavaScript 또는 TypeScript로 작성하고 있으므로, AssemblyScript가 자연스럽고 코드베이스에서 작업하는 사람들에게 상당히 접근하기 쉬울 것입니다. 이것으로 결정합시다.

Wasm 로드하기

두 번째는 WebAssembly를 인스턴스화하는 방법입니다. WebAssembly는 바이너리 데이터로 다운로드되며 사용하기 전에 인스턴스화되어야 합니다. AssemblyScript를 사용하는 경우 세 가지 인기 있는 옵션이 있습니다:

  1. 내장된 WebAssembly 모듈은 브라우저에서 사용할 수 있으며 저수준 기능을 제공합니다.

  2. AssemblyScript Loader는 AssemblyScript로 컴파일된 Wasm과 함께 사용하기 위해 고안되었습니다. 메모리 조작을 위한 메서드를 제공합니다.

  3. AsBind는 AssemblyScript Loader 위에 구축된 로더로, Wasm과 안전하게 문자열과 숫자 배열을 사용하는 데 필요한 추가 단계를 추상화합니다.

AsBind를 사용하면 다른 로더들이 수행하는 모든 작업을 수행할 수 있으며, 문자열과 숫자 배열을 쉽게 사용할 수 있습니다. 그러므로 AsBind를 사용해 봅시다.

WASM 사용하기

세 번째로, 우리가 만든 WebAssembly 기능을 React 앱에 어떻게 적용할지 결정해야 합니다. React에는 데이터를 가져오기 위한 많은 흥미로운 패턴이 있습니다.

  1. 컴포넌트 라이프사이클 동안 호출되는 서비스를 만들 수 있습니다.
  2. Redux를 사용하는 애플리케이션에서 부수 효과로 데이터를 가져오기 위해 thunks, sagas, 또는 observables를 사용할 수 있습니다.
  3. Wasm을 가져오고 인스턴스화하기 위한 커스텀 훅을 만들 수 있습니다.

커스텀 훅을 사용해 봅시다! 커스텀 훅은 함수형 컴포넌트와 함께 사용할 수 있으며, React 이외의 추가 라이브러리가 필요하지 않아 이점이 있습니다.

앱 설정하기

AssemblyScript와 AsBind를 사용하여 React 애플리케이션을 설정하는 과정을 6단계로 안내하겠습니다.

  1. React 애플리케이션 설정하기
$ npx create-react-app wasm-hook-example
$ cd wasm-hook-example
  1. AssemblyScript와 AsBind 설치하기
$ npm i -D assemblyscript
$ npm i as-bind
  1. AssemblyScript 설정하기
$ mkdir assembly
$ touch assembly/tsconfig.json
$ touch assembly/index.ts
  1. assembly/tsconfig.json 파일에 다음 내용 추가하기
{
  "extends": "../node_modules/assemblyscript/std/assembly.json",
  "include": ["index.ts"],
  "optimizeLevel": 3
}
  1. assembly/index.ts 파일에 다음 내용 추가하기
export function addStrings(a: string, b: string): string {
  return a + b;
}

이 코드는 두 개의 문자열을 연결합니다. 그리 화려하지는 않지만, AsBind가 예상대로 작동하는지 확인하기에 유용합니다.

  1. package.json 파일에 다음 내용 추가하기
"scripts": {
  "build:react": "react-scripts build",
  "build:wasm": "asc assembly/index.ts -b assembly/as-bind.ts -t public/my-wasm.wasm",
  "build": "npm run build:react && npm run build:wasm"
}

이렇게 하면 빌드 스크립트를 build:react에서 build:react로 변경하고 build:wasm 스크립트를 추가합니다. 이렇게 하면 AssemblyScript 컴파일러가 as-bind.ts와 index.ts 파일을 입력으로 사용하여 최적화된 Wasm 파일인 public/my-wasm.wasm을 빌드합니다. 마지막으로, Wasm 파일과 React 앱을 모두 빌드하는 새로운 build 스크립트를 추가했습니다.

이제 다음 명령어를 실행하여 앱을 실행하고 살펴봅시다.

$ npm run build

이제 build 폴더 안에 멋진 앱이 준비되었고, 루트 폴더에는 Wasm 파일들을 찾을 수 있습니다.

커스텀 훅 설정하기

먼저 새로운 파일을 생성하여 훅을 작성해봅시다.

$ touch src/useWasm.js

그리고 다음과 같이 설정합니다.

import { useEffect, useState } from 'react';
import { AsBind } from 'as-bind';

export const useWasm = () => {
  const [state, setState] = useState(null);
  useEffect(() => { 
    const fetchWasm = async () => {
      const wasm = await fetch('my-wasm.wasm');
      const instance = await AsBind.instantiate(wasm, {});
      setState(instance);
    };
    fetchWasm();
  }, []);
  return state;
}

이 훅은 useState를 사용하여 상태를 가져오고 반환하며, 동시에 useEffect 훅을 사용하여 부수 효과를 수행합니다. 이 훅은 public 디렉토리에서 Wasm 파일을 가져와 AsBind를 사용하여 인스턴스화한 후 상태를 업데이트합니다. 이는 다시 뷰를 업데이트하게 됩니다.

이제 남은 일은 앱에 훅을 연결하고 사용하는 것입니다.


import { useWasm } from './useWasm';

function App() {
  const instance = useWasm();
  return (
    <div className="App">
      {instance &&
        instance.exports.addString('hello', "wasm")
      }
    </div>
  );
}

이 훅은 함수형 컴포넌트인 AppComponent에서 실행되며, 인스턴스가 로드되면 WebAssembly 모듈의 addStrings 메서드를 호출합니다.

이렇게 놀라운 노력의 작은 양으로도 React 애플리케이션에서 WebAssembly을 실행할 수 있습니다! 이것으로도 충분하지만, 여러 개의 Wasm 파일을 로드해야 하거나 파일이 로드가 완료되기 전에 사용자가 다른 페이지로 이동할 수 있는 경우에는 어떨까요? 훅을 더 견고하고 재사용 가능하게 개선해보겠습니다.

하나의 훅으로 여러 파일 사용하기

우리가 먼저 할 일은 훅이 다른 기능을 가진 여러 Wasm 파일을 로드할 수 있도록 하는 것입니다. 이렇게 하면 훅을 애플리케이션 내에서 재사용할 수 있으며 다른 애플리케이션에도 추가할 수 있습니다. 이를 위해 훅이 인수를 받을 수 있도록 기능을 추가해야 합니다. Wasm 파일마다 이름과 요구사항이 다를 수 있으므로, 이를 가능하게 하기 위해 인스턴스화 환경으로부터 받을 함수를 정의할 수 있습니다. AsBind를 사용하여 두 번째 인자로 이러한 함수들을 instantiate 메서드에 전달할 수 있습니다.

// src/useWasm.js
- export const useWasm = () => {
+ export const useWasm = (fileName, imports) => {
  const [state, setState] = useState(null);
  useEffect(() => { 
    const fetchWasm = async () => {
-     const wasm = await fetch('my-wasm.wasm');
-     const instance = await AsBind.instantiate(wasm, {});
+     const wasm = await fetch(fileName);
+     const instance = await AsBind.instantiate(wasm, imports);
      setState(instance);
    };
    fetchWasm();
  }, []);
  return state;
}

좋아요, 이제 인수를 받고 훅을 다양한 상황에서 사용할 수 있습니다. 그런데 동적으로 로딩하는 방식이 필요하다면 어떨까요? 현재 useEffect의 두 번째 인자로 빈 배열을 사용하고 있으므로 해당 효과는 거의 다시 실행되지 않을 것입니다. 아마도 인수 중 하나가 변경될 때마다 효과가 다시 실행되어야 할 것입니다. 그러니 useEffect의 의존성에 이러한 인수를 추가해보겠습니다.

// src/useWasm.js
export const useWasm = (fileName, imports) => {
  const [state, setState] = useState(null);
  useEffect(() => { 
    const fetchWasm = async () => {
      const wasm = await fetch(fileName);
      const instance = await AsBind.instantiate(wasm, imports);
      setState(instance);
    };
    fetchWasm();
- }, []);
+ }, [fileName, imports]);
  return state;
}

마지막으로 App.js 파일을 업데이트하여 예상되는 인수를 전달해봅시다.

// src/App.js
function App() {
- const instance = useWasm();
+ const instance = useWasm('my-wasm.wasm');
  return (
    <div className="App">
      {instance &&
        instance.exports.addString('hello', 'wasm')
      }
    </div>
  );
}

이제 훅을 사용하여 다양한 인수로 다른 Wasm 파일을 로드할 수 있습니다. 훅을 동적으로 활용할 수 있게 되었지만, 아직 useEffect의 두 번째 인자로 빈 배열을 사용하여 효과가 다시 실행되지 않는 문제가 있습니다. 이 문제를 해결하기 위해 의존성으로 인수를 추가했습니다. 마지막으로 App.js 파일을 업데이트하여 예상되는 인수를 전달하였습니다.

업데이트 API

이제 우리는 원하는 경우 동적으로 다른 Wasm 파일을 로드하고 imports 객체를 통해 기능을 제공할 수 있습니다. 그러나 API가 조금 미완성된 느낌이 있습니다. 결국 WebAssembly가 인스턴스화되기 전에는 간단히 null을 반환하고 있습니다. 웹어셈블리가 인스턴스화될 때 초기 상태를 명시적으로 추가하고 해당 상태가 업데이트되도록 해보겠습니다.

// src/useWasm.js
export const useWasm = (fileName, imports) => {
- const [state, setState] = useState(null);
+ const [state, setState] = useState({
+   loaded: false,
+   instance: null
+ });
  useEffect(() => { 
    const fetchWasm = async () => {
      const wasm = await fetch(fileName);
      const instance = await AsBind.instantiate(wasm, imports);
-     setState(instance);
+     setState({instance, loaded:true});
    }
    fetchWasm();
  }, [fileName, imports]);
  return state;
}

그리고 새로운 API를 사용하기 위해 App.js 파일을 업데이트해봅시다.

// src/App.js
function App() {
- const instance = useWasm('my-wasm.wasm');
+ const { loaded, instance } = useWasm('my-wasm.wasm');
  return (
    <div className="App">
-     {instance &&
-       instance.exports.addString('hello', 'wasm')
+     {loaded &&
+       instance.exports.addString('hello', 'wasm')
      }
    </div>
  );
}

에러 핸들링

마지막으로 오류 처리를 추가해야합니다. 훅 내에서 일부 부분에서 매우 심각한 문제가 발생할 수 있습니다. 특히, fetch 요청이 아무것도 찾지 못할 수 있거나 찾은 것이 올바르게 구성된 .wasm 파일이 아닐 수 있습니다. 이 두 가지 중 어느 하나라도 발생하면 오류가 발생합니다. 하나의 옵션은 오류가 발생하도록 놔두고 부모 응용 프로그램을 충돌시키는 것입니다. 다른 옵션은 오류를 잡고 부모 응용 프로그램에 오류를 표시하여 오류에 기반한 동작을 수행할 수 있도록 하는 것입니다. 이를 위해 상태에 대한 새로운 키와 Wasm의 가져오기와 인스턴스화 주위에 추가 로직이 필요합니다.

// src/useWasm.js
export const useWasm = (fileName, imports) => {
  const [state, setState] = useState({
    loaded: false,
    instance: null,
+   error: null,
  });
  useEffect(() => { 
    const fetchWasm = async () => {
+     try {
        const wasm = await fetch(fileName);
        const instance = await AsBind.instantiate(wasm, imports);
-       setState({instance, loaded: true});
+       setState({instance, loaded: true, error: null});
+     } catch (e) {
+       setState({...state, error: e });
+     }
    }
    fetchWasm();
  }, [fileName, imports]);
  return state;
}

여기에서는 상태에 새로운 error 키가 추가되었으며, fetchWasm 함수는 .wasm 파일을 로드하고 인스턴스화하는 동안 오류가 발생하면 error 키를 업데이트합니다. 이렇게하면 잘못된 형식의 .wasm 파일을로드하는 경우 작동하지만, 존재하지 않는 파일을 가져 오려고하면 fetch가 해당 경우에 대해 오류를 throw하지 않으므로 작동하지 않습니다. 다음과 같이 추가 업데이트를 수행해 봅시다.

// src/useWasm.js
export const useWasm = (fileName, imports) => {
  const [state, setState] = useState({
    loaded: false,
    instance: null,
    error: null,
});
  useEffect(() => { 
    const fetchWasm = async () => {
      try {
        const wasm = await fetch(fileName);
+       if (!wasm.ok) {
+         throw new Error(`Failed to fetch resource ${fileName}.`);
+       }
        const instance = await AsBind.instantiate(wasm, imports);
        setState({instance, loaded:true, error: null});
      } catch (e) {
        setState({...state, error: e });
      }
    }
    fetchWasm();
  }, [fileName, imports]);
  return state;
}

이제 fetch가 파일을 찾지 못하면 오류를 throw하고 catch 블록을 트리거합니다. 지금까지 아주 좋아 보이는데, 또 하나의 문제가 있습니다. Wasm 파일은 상당히 크고 로드하는 데 시간이 걸릴 수 있습니다. 사용자가 우리의 훅을 사용하는 컴포넌트가 있는 페이지를 방문하고, Wasm이 로드되기 전에 떠날 수도 있습니다. 이 경우 귀찮은 오류 메시지와 메모리 누수가 발생할 수 있습니다. 이러한 상황을 피하기 위해 AbortController를 사용할 수 있습니다.

// src/useWasm.js
export const useWasm = (fileName, imports) => {
  const [state, setState] = useState({
    loaded: false,
    instance: null,
    error: null,
  });
  useEffect(() => {
+   const abortController = new AbortController();
    const fetchWasm = async () => {
      try {
-       const wasm = await fetch(fileName);
+       const wasm = await fetch(
+         fileName,
+         { signal: abortController.signal }
+       );
        if (!wasm.ok) {
          throw new Error(`Failed to fetch resource ${fileName}.`);
        }
        const instance = await AsBind.instantiate(wasm, imports);
-       setState({instance, loaded:true, error: null});
+       if (!abortController.signal.aborted) {
+         setState({instance, loaded:true, error: null});
+       }
      } catch (e) {
-       setState({...state, error: e });
+       if (!abortController.signal.aborted) {
+         setState({...state, error: e });
+       }
      }
    }
    fetchWasm();
+   return function cleanup() {
+     abortController.abort();
+   };
  }, [fileName, imports]);
  return state;
}

이제 컴포넌트가 파괴되고 훅이 정리될 때 AbortController가 fetch 요청을 중단하고 상태 설정을 멈출 것입니다. 이를 통해 재앙을 피할 수 있습니다. 마지막으로 새로운 오류 상태를 활용하기 위해 컴포넌트를 업데이트해야 합니다.

// src/App.js
function App() {
- const { loaded, instance } = useWasm('my-wasm.wasm');
+ const { loaded, instance, error } = useWasm('my-wasm.wasm');
  return (
    <div className="App">
      {loaded &&
        instance.exports.addString('hello', 'wasm')
      }
+     {error && error.message}
    </div>
  );
}

모든 작업이 완료되었습니다!
요약하자면, AssemblyScript와 AsBind를 설정하여 WebAssembly를 빌드하고 인스턴스화했습니다. 또한 React 훅을 작성하여 Wasm 파일을 React 애플리케이션에 로드하고, 가능한 예외 상황을 처리하기 위해 오류 처리를 추가했습니다. 이제 문자열을 추가하는 것보다 더 많은 작업을 수행하는 AssemblyScript를 작성해 보세요! 🎉 🎉 🎉

참조

https://levelup.gitconnected.com/webassembly-react-simplified-cc092521a984

profile
what doesn't kill you makes you stronger

0개의 댓글