📦

JavaScript에서 Rust, 그리고 Rust에서 JavaScript: wasm-bindgen 스토리

@April 11, 2018

원글: https://hacks.mozilla.org/2018/04/javascript-to-rust-and-back-again-a-wasm-bindgen-tale/

역자 주:

  • 원글의 의도를 해치지 않는 범위에서 의역을 포함한다.
  • 의도를 명확하게 전달하기 위한 몇몇 단어는 번역하지 않았다.

우리는 최근 WebAssembly 컴파일이 얼마나 빠른지얼마나 빠른 JS 라이브러리를 만들 수 있는지, 거기에 얼마나 더 작은 바이너리를 만들어 주는지 확인했다. 더욱이 우리는 Rust와 JavaScript 커뮤니티의 더 나은 협업을 위한 계획을 세웠고, 이것에는 다른 웹 프로그래밍 언어들을 위한 것도 포함되어있다. 이전 글에서도 슬쩍 말했지만, 나는 wasm-bidgen에 대해서 좀 더 자세하게 알아보려 한다.

현재 WebAssembly 스펙은 단지 두 개의 정수(integer)와 두 개의 실수(floating-point), 즉 4가지의 타입만을 정의하고 있다. 그러나, JS와 Rust 개발자는 보통 그보다 훨씬 다양한 타입을 다룬다! 예를 들자면 JS 개발자는 HTML node들을 추가하거나 수정하기 위해 document를 사용하고, Rust 개발자는 오류를 처리하기 위해 Result 같은 타입을 다룬다. 그리고 대부분 개발자는 문자열을 사용한다.

WebAssembly가 현재 제공하는 4가지 타입에 손발이 묶이는 것은 너무한 일이다. 여기에 wasm-bindgen이 딱이다. wasm-bidgen는 JS와 Rust 타입들의 가교 구실을 하려는 것이다. 이것은 JS에서 Rust API를 문자열로 호출하거나, Rust가 JS에서 발생한 예외처리를 할 수 있도록 해준다. wasm-bindgen은 WebAssembly와 JavaScript사이의 주파수를 맞춰준다. 번거로움 없이 효율적으로 JavaScript가 WebAssembly 함수를 호출하고, WebAssembly 역시 JavaScript 함수를 사용할 수 있도록 한다.

wasm-bindgen 프로젝트의 README 에 더 많은 설명이 있다. 이제 시작으로 wasm-bindgen 예제를 하나 살펴보고, 이것이 어떤 기능이 있는지 알아보자.

Hello, World!

고전적이지만, 새로운 도구를 배우는 가장 좋은 방법의 하나는 “Hello, World!”를 출력해보는 것이다. 그러니 “Hello, World!” alert를 띄우는 이 예제를 살펴보기로 하자.

이 예제의 목표는 간단명료하다. 이름 하나를 받아 Hello, ${name}! 다이얼로그를 생성하는, Rust 함수를 만들 것이다. JavaScript에서는 아마 아래처럼 함수를 정의할 수 있을 것이다.

export function greet(name) {
  alert(`Hello, ${name}!`);
}

다만, 우리는 이것을 Rust로 작성하고 싶다. 이제 우리가 할 아래와 같은 몇 가지 일이 보인다.

  • JavaScript는 WebAssembly 모듈, 즉 greet export를 호출한다.
  • 그 Rust 함수는 환영(greet)해 줄 이름(name)을 문자열로 받는다.
  • 내부적으로 Rust는 새로운 문자열을 생성하여 그 이름을 저장한다.
  • 그리고 마지막으로 Rust는 이름을 저장한 문자열과 함께 JavaScript alert 함수를 호출한다.

이제 이 일을 시작하기 위해, 새로운 Rust 프로젝트를 생성하자.

$ cargo new wasm-greet --lib

이 명령은 우리가 작업할 wasm-greet 폴더를 만든다. 다음으로 Cargo.toml(Rust의 package.json 같은)을 아래처럼 수정하자.

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

일단 [lib] 이 무엇을 하는지는 넘어가자. 그다음은 wasm-bindgen crate dependency를 선언한다. 이 crate는 Rust에서 wasm-bindgen을 사용하는데 필요한 모든 것들을 포함한다.

이제 코드를 작성할 시간이다! 자동생성된 src/lib.rs를 아래의 내용으로 바꾸자.

#![feature(proc_macro, wasm_custom_section, wasm_import_module)]

extern crate wasm_bindgen;

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern {
  fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
  alert(&format!("Hello, {}!", name));
}

만약 당신이 Rust에 익숙지 않다면 살짝 장황해 보일 수 있다. 하지만 걱정하지 마시라! wasm-bindgen 프로젝트는 지속해서 개선되고 있고, 이 모든것들이 항상 필요한 것은 아니다. 여기서 제일 주목할 부분은 #[wasm_bindgen] attribute 인데, 이 Rust annotation은 “필요하면 wrapper로 처리해주세요.” 라는 뜻이다. alert 함수를 가져올 때도, greet 함수를 내보낼 때도, 이 attribute로 annotation을 달았다. 이것이 어떻게 동작하는지는 곧 알아볼 것이다.

그 전에 먼저, 이 wasm 코드를 컴파일해서 브라우저로 열어보자!

$ rustup target add wasm32-unknown-unknown --toolchain nightly # 한 번만 하면 된다.
$ cargo +nightly build --target wasm32-unknown-unknown

이러면 target/wasm32-unknown-unknown/debug/wasm_greet.wasm이 생성된다. wasm2wat 같은 도구로 이 파일을 열어보면 조금 무섭게 생겼다. 사실 아직은 JS에서 사용될 수 없다. 이것을 사용하려면 한 단계가 더 남았다.

$ cargo install wasm-bindgen-cli # 한 번만 하면 된다
$ wasm-bindgen target/wasm32-unknown-unknown/debug/wasm_greet.wasm --out-dir .

이 과정에서 꽤 많은 마법이 일어난다. wasm-bindgen CLI 도구는 wasm 입력 파일을 전처리하여, 사용하기 적절하게 만든다. “적절하게” 만든다는 것이 무슨 뜻인지는 나중에 알아보기로 하자. 지금은 wasm_greet.js(wasm-bindgen 도구로 만든) 파일을 import 하면, Rust에서 정의한 greet 함수를 가져올 수 있다고만 알아두자.

마지막으로, 우리가 할 일은 bundler로 이것을 패키징하고, HTML 파일을 하나 만들어 이 코드를 실행하는 것이다. 내가 이 글을 쓰고 있는 지금은 Webpack’s 4.0 release만이 깔끔하게 WebAssembly를 지원한다. 크롬에서 주의사항이 있기는 하다. 곧 다른 bundler들도 지원하리라 본다. 자세한 내용은 접어두고, Github repo에 있는 예제 설정을 따라 하자.

const rust = import("./wasm_greet");
rust.then(m => m.greet("World!"));

이… 러면 끝! 웹 페이지를 열면 Rust가 만든 “Hello, World!” 다이얼로그가 보인다.

wasm-bindgen 원리

휴~ “Hello, World!” 출력하기 조금 힘들었다. 이것의 원리가 어떻게 되는지, 그리고 도구들이 어떤 일을 했는지 더 자세히 알아보자.

wasm-bindgen의 가장 중요한 사항은, wasm module이 ES module의 한 종류라는 개념을 기반으로 한 연동이라는 것이다. 예를 들어 위 예제의 ES module TypeScript signature는 아래와 같다.

export function greet(s: string);

WebAssembly는 이것이 기본적으로 불가능하다(오직 숫자만 지원하므로). 이 부족함을 채우기 위해 wasm-bindgen이 필요하다. 위의 마지막 단계에서 wasm-bindgen을 실행했을때, wasm_greet_bg.wasm파일과 함께 wasm_greet.js파일이 생성되었다. 이 중 JavaScript 파일은 Rust를 호출할 때 사용될 인터페이스 역할을 한다. 그리고 *_bg.wasm파일이 우리 코드를 컴파일한 것과 실제 구현을 가지고 있다.

./wasm_greet 모듈을 import 하면 Rust 코드에서 내보낸 것에 접근할 수 있다. 현재는 JavaScript 인터페이스 없이 직접 접근하지는 못한다. 여기까지 Rust와 JavaScript의 연동이 어떻게 돌아가는지 알아보았다. 이제 스크립트를 실행하면 어떠한 일이 일어나는지 실행을 따라가 보자. 예제를 실행하면:

const rust = import("./wasm_greet");
rust.then(m => m.greet("World!"));

여기서 우리는 비동기적으로 필요한 인터페이스를 import하고, resolve 되기를 기다린다(wasm을 다운로드하고 컴파일 하기를). 이후, 그 모듈의 greet 함수를 호출한다.

Note: 이러한 비동기 로딩은 현재 웹팩에 필요한 사항이다. 하지만 항상 이렇지는 않으며, 다른 bundler의 경우는 다를 수 있다.

wasm_greet.js파일을 살펴보면 wasm-bindgen 도구가 생성한 아래와 같은 코드가 있다.

import * as wasm from './wasm_greet_bg';

// ...

export function greet(arg0) {
  const [ptr0, len0] = passStringToWasm(arg0);
  try {
    const ret = wasm.greet(ptr0, len0);
    return ret;
  } finally {
    wasm.__wbindgen_free(ptr0, len0);
  }
}

export function __wbg_f_alert_alert_n(ptr0, len0) {
  // ...
}
Note: 이 코드는 자동 생성되었으며, 최적화되지도 않았다. 딱히 이쁘지도 작지도 않을 수 있다. 우리가 만약 LTO(Link Time Optimization)로 컴파일하고, Rust로 빌드한 후, JS bundler(minification)를 사용했다면, 이 코드는 훨씬 작았을 것이다.

여기서 wasm-bindgen이 생성한 greet 함수를 보자. 내부적으로 wasm의 greet 함수를 호출하고 있지만, 이 호출은 문자열이 아닌 pointer와 length를 사용한다. 만약 passStringToWasm 에 대해 더 알고 싶다면 Lin Clark의 이전 글을 참고하자. 이 모든 것들은 wasm-bindgen 도구가 아니었다면 우리가 직접 작성해야 했을 거추장스러운 것들이다. __wbg_f_alert_alert_n 함수는 잠시 후 살펴본다.

조금 더 깊이 들어가서, WebAssembly의 greet 함수를 알아보자. 이 코드를 볼 때 Rust 컴파일러의 시점을 따라가 보자. 위에서 JS wrapper가 생성되었듯, 외부로 노출할 greet 심볼 역시 #[wasm_bindgen] attribute가 우리 대신 shim을 생성했다. 즉:

pub fn greet(name: &str) {
  alert(&format!("Hello, {}!", name));
}

#[export_name = "greet"]
pub extern fn __wasm_bindgen_generated_greet(arg0_ptr: *mut u8, arg0_len: usize) {
  let arg0 = unsafe { ::std::slice::from_raw_parts(arg0_ptr as *const u8, arg0_len) }
  let arg0 = unsafe { ::std::str::from_utf8_unchecked(arg0) };
  greet(arg0);
}

여기서 우리가 작성한 greet 원래 코드를 발견할 수 있는데, 이상한 이름의 __wasm_bindgen_generated_greet 함수에 #[wasm_bindgen]attribute가 붙어있다. 이것이 외부로 노출될 함수(#[export_name]과 extern 키워드로 명시되어)이며, JS가 던진 pointer와 length를 받는다. 내부적으로 이것은 pointer와 length를 &str (Rust의 문자열)로 변환한 후 우리가 정의한 greet 함수에 전달한다.

정리하면, #[wasm_bindgen] attribute는 두 개의 wrapper를 생성한다. JS 타입들을 받아서 wasm으로 변환을 하는 JavaScript 측면 하나와 wasm 타입들을 Rust 타입들로 바꾸는 Rust 측면 하나.

자 이제 마지막으로 남아있는 alert 함수 wrapper들을 보자. Rust의 greet 함수는 새로운 문자열을 만들고 이것을 alert에 보내기 위해 표준 format! 매크로를 사용한다. 우리가 alert 함수를 선언할 때 #[wasm_bindgen]을 붙였음을 잊지 말고, rustc가 이 함수를 어떻게 보는지 따라가 보자.

fn alert(s: &str) {
  #[wasm_import_module = "__wbindgen_placeholder__"]
  extern {
    fn __wbg_f_alert_alert_n(s_ptr: *const u8, s_len: usize);
  }
  unsafe {
    let s_ptr = s.as_ptr();
    let s_len = s.len();
    __wbg_f_alert_alert_n(s_ptr, s_len);
  }
}

이것은 우리가 작성한 것과는 조금 다르지만, 이것이 어떻게 연동되는지 볼 수 있다. 이 alert 함수는 사실 Rust &str을 받아 wasm 타입들(numbers)로 바꿔주는 얇은 wrapper이다. 이 함수는 위에서 보았던 이상한 모양의 __wbg_f_alert_alert_n 함수를 호출하는데, #[wasm_import_module] attribute가 궁금해진다.

WebAssembly에서 함수를 가져오기 위한 모든 코드는 함수가 포함된 모듈을 하나씩 가지게 된다. wasm-bindgen역시 ES 모듈들로 만들어졌으며, ES module import로 변환된다! __wbindgen_placeholder__ 모듈은 실제 존재하지는 않지만, 이것은 우리가 생성한 JS 파일을 가져오기 위해, wasm-bindgen 도구가 다시 작성할 지시어 역할을 한다.

그리고 마지막 퍼즐 조각인, 아래의 내용을 담은 JS 생성 파일이 있다.

export function __wbg_f_alert_alert_n(ptr0, len0) {
  let arg0 = getStringFromWasm(ptr0, len0);
  alert(arg0)
}

와우! 알고 보니 내부적으로 꽤 많은 일이 일어나고 있고, JS의 greet에서 브라우저의 alert에까지 살짝 긴 여정이 되었다. 하지만 걱정하지 마시라. wasm-bindgen의 핵심이 바로 이러한 것들을 없애준다는 것이다! 여러분은 단지 몇몇 #[wasm_bindge]을 여기저기에 적기만 하면 된다. 그러면 여러분은 Rust를 JS 모듈처럼 사용할 수 있다.

이 외에 wasm-bindgen이 할 수 있는 일?

wasm-bindgen 프로젝트는 방대한 범위의 프로젝트이고, 여기서 그 세부사항들을 다 알아볼 수는 없다. 우리가 위에서 보았던 Hello, World!부터 Rust 코드만으로 DOM nodes 다루기까지 다양한 예제 디렉터리가 있다. 이것을 둘러보는 것은 wasm-bindgen이 어떤 기능을 가졌는지 알 수 있는 아주 좋은 방법이다.

wasm-bindgen 의 주요 기능은 아래와 같다:

  • JS structs, functions, objects 등을 가져와 wasm에서 호출하기. 여러분은 struct 의 method를 호출하거나 property에 접근할 수 있다. 한번 #[wasm_bindgen] annotation들을 설정하고 나면, 살짝 “native” 느낌으로 Rust 코드를 작성할 수 있다.
  • Rust structure들과 함수들을 JS에 내보내기. Rust struct 을 내보내서 JS class로 변환할 수 있다. 정수들뿐만 아니라 struct 도 주고받을 수 있다. smorgasboard 예제는 이러한 연동을 잘 보여준다.
  • global scope(alert 함수 등) 가져오기, JS exception을 Rust에서 Result로 처리하기, Rust 프로그램에서 JS 값들 저장하는 것처럼 generic을 사용하기 등 다양한 기능들이 있다.

만약 더 많은 기능이 궁금하다면, 이슈 트래커에 채널고정!

앞으로의 wasm-bindgen?

이 글을 마무리하기 전에 잠시 wasm-bindgen의 비전에 대해 쓰려 한다. 왜냐하면 나는 이것이 오늘날 최고로 흥미 있는 프로젝트 중 하나라고 생각하기 때문이다.

Rust 이외 다른 언어의 지원

wasm-bindgen CLI 도구는 첫 시작부터 여러 언어를 지원할 수 있도록 디자인되었다. 현재는 Rust가 유일한 지원 언어이지만, 이 도구는 C, C++ 플러그인도 지원할 수 있도록 디자인되었다. #[wasm_bindgen] attribute는 *.wasm에 특별한 섹션을 만들어 내는데, 이는 wasm-bindgen 도구가 읽어들인 후 삭제한다. 이 섹션에는 어떤 인터페이스를 가진 JS binding을 생성할지 쓰여있다. 이 섹션은 Rust만을 위한 것이 아니기에, C++ 컴파일러 플러그인도 이것을 생성하여 wasm-bindgen 도구가 처리토록 할 수 있다.

나는 이 점이 wasm-bindgen같은 도구가 WebAssembly와 JS와의 연동을 위한 표준으로 만들 수 있을 것이라 믿기에 특별히 흥미를 느끼고 있다. 바라건대, 모든 언어를 WebAssembly로 컴파일시킬 수 있도록 하고, bundler는 자동으로 이를 인식하여 위에서 알아보았던 도구나 설정들이 필요 없었으면 한다.

JS 에코 시스템 자동 바인딩

현재 #[wasm_bindgen] 매크로의 단점 중 하나는 모든 것을 직접 실수 없이 작성해야 한다는 것이다. 이것은 종종 지루하고(실수하기 쉬운) 작업이지만 자동화 가능한 것이다.

모든 웹 API들은 WebIDL에 정의되어있고, WebIDL을 이용해 #[wasm_bindgen] annotation을 생성하는 일은 충분히 실현할 수 있다. 다시 말하면 위에서 우리가 한대로 alert 함수를 정의할 필요 없이, 그냥 아래처럼만 사용하면 된다는 뜻이다.

#[wasm_bindgen]
pub fn greet(s: &str) {
  webapi::alert(&format!("Hello, {}!", s));
}

이 경우, 오류 없는 webapi crate가 WebIDL 스펙에 따라 자동 생성될 수 있다.

한 발자국 더 나가서 Typescript 커뮤니티의 멋진 작업을 활용하면 TypeScript에서 #[wasm_bindgen]을 생성할 수도 있다. 이렇게 하면 npm에 등록된 어떤 TypeScript 패키지도 손쉽게 자동 바인딩할 수 있다!

JS보다도 빠른 DOM 성능

마지막으로 중요한 wasm-bindgen의 염원: 초-빠른 DOM 조작 — 많은 JS 프레임워크들의 성배. 현재 DOM 함수 호출은 JavaScript에서 C++ 엔진으로 변환이 필요하므로 비싼 과정을 거쳐야 한다. 하지만 WebAssembly를 사용하면 그러한 과정이 필요 없다. WebAssembly는 잘… 까지는 모르겠지만, 어쨌거나 타입을 가지고 있다!

wasm-bindgen은 미래의 WebAssembly host binding proposal을 처음부터 염두에 두고 디자인되었다. WebAssembly의 이 기능이 가능해지면 wasm-bindgen의 JS shim들 없이 함수를 호출할 수 있다. 더 나아가, WebAssembly의 호출은 타입을 가지고 있으므로, arguments의 유효성 검사(JS의 호출은 필요로 하는)가 필요 없다. 이로 인해, JS 엔진들이 적극적으로 WebAssembly의 DOM 조작을 최적화할 수 있다. 이때가 되면 wasm-bindgen은 문자열 같은 다양한 타입들을 사용하기 편하게 할 뿐 아니라, DOM 조작에서 최고의 성능을 제공할 수 있다.

마무리

개인적으로 WebAssembly로 일하는 것이 꽤 재미있는데, 이는 커뮤니티 때문만은 아니고 발전이 아주 빠르기 때문이다. wasm-bindgen 도구의 전망은 아주 밝다. 이것은 JS와 Rust 같은 언어의 연동을 훌륭히 지원할 뿐만 아니라, WebAssembly가 지속해서 발전하는데 발맞추어 장기적인 효용을 제공한다.

Alex Crichton

Alex는 Rust 코어 팀의 멤버로 2012년부터 Rust에서 일해왔다. 최근에는 WebAssembly Rust Working group이 Rust+Wasm이 최고의 경험을 줄 수 있도록 돕고 있다. Alex는 또한 Cargo(Rust 패키지 매니저), Rust standard library, Rust의 release와 CI에 필요한 인프라의 유지도 돕고 있다.