Rust 🦀 and WebAssembly 🕸

이 간결한 책은 RustWebAssembly를 함께 사용하는 방법을 다룹니다.

번역된 버전에 이슈나 풀 리퀘스트를 생성하고자 하시나요? 번역 버전의 레포지토리를 확인해 주세요.

누구를 위한 책인가요?

이 책은 Rust와 WebAssembly를 함께 사용하여 웹에서 동작하는 빠르고 안정된 코드를 작성하는 방법에 대해 관심이 있는 분들을 위해 작성되었습니다. 꼭 전문가가 돼야할 필요는 없지만, Rust를 조금이라도 알아야 하고 JavaScript와 HTML, CSS에 익숙하면 더 좋습니다.

Rust를 아직 모르시나요? The Rust Programming Language 책으로 시작해 보세요.

JavaScript나 HTML, CSS를 모르시나요? MDN에서 더 알아보세요.

이 책을 읽는 방법

왜 Rust로 WebAssembly 개발을 해야 하나요? 섹션을 읽어보면 좋고, 배경지식과 먼저 친숙해져 보는 것도 좋습니다.

튜토리얼은 처음부터 끝까지 읽도록 작성됐습니다. 튜토리얼에 있는 코드를 작성, 컴파일하고 직접 실행해 보세요. Rust와 WebAssembly를 같이 사용해 본 적이 없다면, 튜토리얼을 한번 활용해 보세요!

참조 섹션 은 아무 순서로 정독해도 괜찮습니다.

💡 팁: 페이지 최상단에 있는 🔍 아이콘을 누르거나 s 키를 눌러서 책 전체를 검색해 볼 수도 있습니다.

번역본

커뮤니티 번역본도 있으니 한번 확인해 보세요.

이 책에 기여하기

이 책은 오픈소스입니다! 오타를 찾으셨나요? 누락된 부분이 있나요? 풀 리퀘스트를 생성해 보세요!

왜 Rust로 WebAssembly 개발을 해야 하나요?

저레벨 컨트롤과 고레벨 개발자 경험

JavaScript 웹 앱들은 안정된 성능을 확보하고 유지하는 데 어려움을 겪습니다. JavaScript 동적 타입 시스템과 가비지 콜렉션 (Garbage Collection) 을 잠시 중단하는 것만으로는 크게 도움이 되지 않습니다. 보기에는 작은 내용의 코드를 수정하더라도 JIT에 치명적인 코드를 작성한다면 드라마틱한 성능 저하를 일으킬 수도 있습니다.

Rust는 JavaScript를 병들게 만드는 비결정적인 가비지 콜렉션 중단으로부터 자유로울 뿐 아니라, 프로그래머들이 간접지정(indirection)과 단일화(monomorphization), 메모리 레이아웃을 컨트롤할 수 있도록 함으로 저레벨 컨트롤과 안정된 성능을 제공합니다.

작은 .wasm 사이즈

.wasm 파일이 네트워크로 전송돼야 하는 점을 고려하면 코드의 파일 사이즈는 매우 중요합니다. Rust는 런타임이 작고 가비지 콜렉터와 같은 불필요한 요소가 없어 .wasm 사이즈를 효과적으로 줄일 수 있도록 해주고, 실제로 사용하지 않는 함수들을 (빌드하는 코드 파일에서) 제외시켜줍니다.

이렇게 코드 파일 사이즈의 관점에서 실제로 사용하는 기능만 출력되는 코드에 포함할 수 있게 됩니다.

모든 내용을 다시 작성하지 말아 주세요

기존에 존재하는 코드 베이스를 지울 필요는 없습니다. 바로 효과를 볼 수 있도록 성능 개선이 중요한 JavaScript 함수들을 먼저 Rust로 옮기는 것으로 시작해 볼 수도 있습니다. 원한다면 거기서 멈춰도 괜찮습니다.

다른 툴과 잘 작동합니다

Rust와 WebAssembly는 기존에 존재하는 JavaScript 툴들과 함께 가장 잘 작동합니다. WebAssembly는 ECMAScript 모듈을 지원하고, 개발 환경에서 npm과 Webpack처럼 기존에 사랑받던 툴들을 계속 사용할 수도 있습니다.

기대할 수 있는 강점들

Rust는 다음과 같이 개발자들이 일반적으로 기대하게 되는 현대적인 편의 기능을 갖추고 있습니다.

  • cargo로 강력한 패키지 관리하기

  • (추가 비용 없이) 이해하기 쉬운 코드를 쓸 수 있게 해주는 추상화

  • 환영하는 커뮤니티! 😊

배경지식

이 섹션은 Rust와 WebAssembly 개발을 시작할 때 필요한 배경지식을 설명해줍니다.

WebAssembly가 뭔가요?

WebAssembly(wasm)는 포괄적인 사양을 가지고 있는 간단한 기계 모델이자 실행 가능한 포맷입니다. 휴대 가능하고, 가벼우며 거의 네이티브 프로그램과 같은 속도로 실행될 수 있도록 설계됐습니다.

프로그래밍 언어로써, WebAssembly는 같은 구조를 나타내는데, 두 가지 다른 포맷으로 구성돼 있습니다.

  1. ("WebAssembly Text" 에서 이름이 유래된) .wat 텍스트 포맷은 S-expressions 구조를 사용하고 Scheme이나 Clojure와 같은 Lisp 계열 언어와 유사점들을 공유합니다.

  2. .wasm 바이너리 포맷은 더 저레벨이면서 WebAssembly 가상 머신에서 바로 사용되도록 의도됐습니다. 개념적으로 ELF 와 Mach-0와 비슷합니다.

참고 자료로, wat 언어로 작성된 팩토리얼 함수를 확인해 보세요.

(module
  (func $fac (param f64) (result f64)
    local.get 0
    f64.const 1
    f64.lt
    if (result f64)
      f64.const 1
    else
      local.get 0
      local.get 0
      f64.const 1
      f64.sub
      call $fac
      f64.mul
    end)
  (export "fac" (func $fac)))

위 예제가 .wasm 파일로는 어떻게 보일지 궁금하다면, wat2wasm 데모 웹사이트를 이용해 보세요.

선형 메모리 (Linear Memory)

WebAssembly 매우 간단한 메모리 모델을 가지고 있고, 한 wasm 모듈은 하나의 "선형 메모리" 에 접근할 수 있습니다.

이 메모리는 페이지 사이즈 (64K)의 곱만큼 커질 수 있으며 이 사이즈는 줄어들 수 없습니다.

웹에서만 WebAssembly를 사용할 수 있나요?

현재로는 JavaScript 웹 커뮤니티에서 주로 주목을 받고 있지만, wasm은 특정 실행 환경을 필요로 하지 않습니다. 그러므로, wasm이 미래에 다양한 맥락에서 사용할 수 있는 "휴대 가능한 실행할 수 있는" 포맷이라고 여겨질수도 있습니다. 하지만 오늘날 현재 시점에서는 wasm은 (웹과 Node.js을 포함한) 다양한 형태로 존재하는 JavaScript (JS)와 함께 주로 언급됩니다.

튜토리얼: Conway's Game of Life

이 튜토리얼은 Conway's Game of Life를 Rust와 WebAssembly로 구현하는 내용을 다룹니다.

누구를 위한 튜토리얼인가요?

이 튜토리얼은 이미 기초적인 Rust와 JavaScript를 배웠고, Rust와 WebAssembly, JavaScript를 같이 사용하고 싶어 하는 사람들을 위해 작성됐습니다.

원활한 진행을 위해 기초적인 Rust, JavaScript, HTML 코드를 문제없이 작성할 수 있어야 합니다. 하지만 전문가가 돼야 할 필요는 전혀 없습니다.

무엇을 배우게 되나요?

  • WebAssembly를 컴파일할 수 있도록 Rust 툴체인을 설정하는 법.

  • Rust, WebAssembly, JavaScript, HTML, CSS으로 다언어 프로그램을 개발할 수 있는 워크플로우.

  • Rust와 WebAssembly, 그리고 JavaScript의 강점을 모두 살리도록 API를 설계하는 방법.

  • Rust 코드에서 컴파일된 WebAssembly 모듈을 디버깅하는 방법.

  • Rust와 WebAssembly 프로그램을 더 빠르게 만들기 위해 타임 프로파일링하는 방법.

  • .wasm 바이너리를 더 작고 빠르게 만들어 네트워크를 통한 다운로드가 더 원활할 수 있도록 Rust와 WebAssembly 프로그램을 사이즈 프로파일링하는 방법.

셋업

이 섹션에서는 Rust 프로그램들을 WebAssembly로 컴파일하고 JavaScript 환경과 통합시키는 방법에 대해 다루어 보겠습니다.

Rust 툴체인

진행을 위해 rustup, rustc, cargo를 포함한 스탠다드 Rust 툴체인이 필요합니다.

Rust 툴체인을 설치하려면 이 지침을 따라주세요.

Rust와 WebAssembly 개발 경험이 stable 버전의 Rust에 포함될 만큼 안정화되고 있기 때문에 어떤 실험적 기능 flag도 요구되지 않습니다. 하지만 Rust 1.30이나 그 이후 버전이 요구됩니다.

wasm-pack

wasm-pack은 Rust로 생성한 WebAssembly를 개발, 테스팅, 배포하도록 도와주는 만능 툴입니다.

여기서 wasm-pack 다운로드 해보세요!

cargo-generate

cargo-generate를 통해 기존에 존재하는 git 레포지토리를 템플릿으로 사용하면서 새 Rust 프로젝트를 시작하고 빠르게 실행할 수 있습니다.

이 명령어로 cargo-generate를 설치해 보세요:

cargo install cargo-generate

npm

npm은 JavaScript와 함께 사용되는 패키지 매니저입니다. 이 책을 진행하면서 JavaScript 번들러와 개발 서버를 설치하고 구동하는 데 사용될 예정입니다. 이 튜토리얼 끝에서는 컴파일된 .wasmnpm 레지스트리에 배포해 봅니다.

npm 을 설치하려면 이 지침을 따라주세요.

이미 npm이 설치돼 있다면, 이 명령어를 통해 최신 버전으로 업데이트돼 있는지 확인해 주세요:

npm install npm@latest -g

Hello, World!

이 섹션에서는 Rust와 WebAssembly 프로그램을 처음 실행하는 과정을 다룹니다. "Hello, World!" 메세지를 보여주는 웹페이지를 만들어봅시다.

시작하기 전에 셋업 가이드를 읽고 잘 따라왔는지 한 번 더 확인해 주세요.

프로젝트 템플릿 클론하기

이 프로젝트 템플릿은 합리적인 기본 설정으로 구성돼 있습니다. 이 템플릿을 활용해서 웹으로 코드를 빠르게 개발, 통합 및 패키징할 수 있습니다.

이 명령어로 프로젝트를 클론해보세요:

cargo generate --git https://github.com/rustwasm/wasm-pack-template

새 프로젝트 이름을 어떻게 지을지 입력하게 됩니다. "wasm-game-of-life" 이라는 이름을 사용합시다.

wasm-game-of-life

어떻게 구성돼 있나요?

새로 생성한 wasm-game-of-life 프로젝트를 열어보도록 합시다.

cd wasm-game-of-life

그다음 안에 어떤 파일이 담겨있는지 확인해 봅시다:

wasm-game-of-life/
├── Cargo.toml
├── LICENSE_APACHE
├── LICENSE_MIT
├── README.md
└── src
    ├── lib.rs
    └── utils.rs

몇 가지 파일을 더 자세히 살펴볼까요?

wasm-game-of-life/Cargo.toml

Cargo.toml 파일은 Rust의 패키지 매니저이자 빌드 툴인 cargo와 함께 사용되는데, 의존성과 메타데이터를 지정하는 역할을 합니다. 이 파일은 wasm-bindgen 의존성과 함께 wasm 라이브러리 생성에 사용될 수 있도록 올바르게 설정된 crate-type이 함께 사전에 미리 포함돼 있습니다. 몇 가지 필수가 아닌 의존성은 나중에 살펴보겠습니다.

wasm-game-of-life/src/lib.rs

src/lib.rs 파일은 WebAssembly로 컴파일하는 Rust 크레이트의 핵심 코드입니다. wasm-bindgen을 JavaScript를 조작하기 위해 사용하고, window.alert JavaScript 함수를 불러온 다음에 greet Rust 함수를 JavaScript로 보냅니다. 이렇게 "Hello World" alert 메세지를 표시시킬 수 있게 됩니다.

#![allow(unused)]
fn main() {
mod utils;

use wasm_bindgen::prelude::*;

// `wee_alloc` 기능이 활성화돼 있으면, `wee-alloc`를 전역 할당자로 사용합니다.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

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

#[wasm_bindgen]
pub fn greet() {
    alert("Hello, wasm-game-of-life!");
}
}

wasm-game-of-life/src/utils.rs

src/utils.rs 모듈은 자주 사용되는 유틸리티를 제공하는데, 이 유틸리티가 WebAssembly로 컴파일된 Rust 코드 작업을 더 쉽게 할 수 있도록 도와줍니다. wasm 코드 디버깅하기와 같은 튜토리얼 후반 섹션에서 이러한 유틸리티들을 더 자세히 살펴보겠습니다. 현재 시점에서는 이 파일을 무시해도 괜찮습니다.

프로젝트 빌드하기

wasm-pack을 사용하여 다음 빌드 과정을 자동화하게 됩니다:

  • Rust 1.30나 그 이후 버전을 사용하고 있는지, wasm32-unknown-unknown 타겟이 rustup을 통해 설치돼 있는지 확인합니다.
  • cargo를 사용하여 Rust 소스 코드를 WebAssembly .wasm 바이너리로 컴파일합니다.
  • wasm-bindgen을 사용하여 Rust로 생성한 WebAssembly를 사용할 수 있도록 JavaScript API를 생성합니다.

위 작업을 시작하려면, 프로젝트 경로에서 다음 명령어를 실행해 주세요:

wasm-pack build

빌드가 완료되면 결과물을 pkg 경로에서 확인해 보세요. 다음 내용물을 확인할 수 있습니다:

pkg/
├── package.json
├── README.md
├── wasm_game_of_life_bg.wasm
├── wasm_game_of_life.d.ts
└── wasm_game_of_life.js

README.md 파일이 메인 프로젝트에서 복사됐지만 나머지 파일들은 완전히 새로 생성된 부분을 확인할 수 있습니다.

wasm-game-of-life/pkg/wasm_game_of_life_bg.wasm

이러한 .wasm 파일은 Rust 컴파일러가 Rust 소스 코드로 생성한 바이너리 파일입니다. 이 파일은 wasm 형식으로 컴파일된 모든 Rust 함수들과 데이터로 구성돼 있습니다. 변환된 "greet" 함수가 예가 될 수 있습니다.

wasm-game-of-life/pkg/wasm_game_of_life.js

이러한 .js 파일은 wasm-bindgen에 의해 생성됩니다. DOM과 JavaScript 함수를 Rust에서 호출할 수 있게 해주고, JavaScript 환경에서도 WebAssembly 함수를 호출할 수 있도록 도와주는 유용한 API를 노출시켜줍니다. 예를 들어서, greet 이라는 JavaScript 함수를 통해 WebAssembly의 해당하는 함수를 호출할 수 있습니다. 현재 시점에서는 이러한 바인딩(bindings glue)들이 큰 역할을 하지는 않지만, 더 복잡한 값들을 wasm과 JavaScript 사이에서 주고받을 때 정말 도움이 많이 됩니다.

import * as wasm from './wasm_game_of_life_bg';

// ...

export function greet() {
    return wasm.greet();
}

wasm-game-of-life/pkg/wasm_game_of_life.d.ts

.d.ts 파일은 생성한 JavaScript 파일과 함께 사용할 수 있는 TypeScript 타입 정의를 포함합니다. TypeScript를 사용한다면 정적 타입 정의를 통해 IDE가 제공하는 자동 완성과 같은 기능을 사용해서 더 쉽게 WebAssembly 함수를 호출할 수 있게 됩니다. TypeScript를 사용하지 않는다면, 이 파일은 무시해도 괜찮습니다.

export function greet(): void;

wasm-game-of-life/pkg/package.json

package.json 파일은 생성된 JavaScript와 WebAssembly 패키지에 대한 메타데이터를 포함합니다. 이런 메타데이터는 npm과 JavaScript 번들러가 여러 패키지들의 종속성, 패키지 이름, 버전 등을 결정할 때 사용됩니다. JavaScript 툴링과 함께 사용하고 npm에 WebAssembly 패키지를 배포할 때 유용합니다.

{
  "name": "wasm-game-of-life",
  "collaborators": [
    "Your Name <your.email@example.com>"
  ],
  "description": null,
  "version": "0.1.0",
  "license": null,
  "repository": null,
  "files": [
    "wasm_game_of_life_bg.wasm",
    "wasm_game_of_life.d.ts"
  ],
  "main": "wasm_game_of_life.js",
  "types": "wasm_game_of_life.d.ts"
}

웹 페이지에 포함시키기

wasm-game-of-life 패키지를 웹페이지에 포함시키고 웹 환경에서 작동시키기 위해, create-wasm-app JavaScript 프로젝트 템플릿 을 사용해 봅시다.

다음 명령어를 wasm-game-of-life 경로 내부에서 실행해 주세요:

npm init wasm-app www

새롭게 생성된 내부 경로인 wasm-game-of-life/www 는 다음 파일들을 포함합니다:

wasm-game-of-life/www/
├── bootstrap.js
├── index.html
├── index.js
├── LICENSE-APACHE
├── LICENSE-MIT
├── package.json
├── README.md
└── webpack.config.js

한 번 더 생성된 파일들을 자세히 살펴봅시다.

wasm-game-of-life/www/package.json

package.json 파일은 webpackwebpack-dev-server 종속성들로 미리 셋업이 되어 있고, npm 에 배포돼 있는 wasm-pack-template의 초기 버전인 hello-wasm-pack 패키지 또한 종속성으로 포함하고 있습니다.

wasm-game-of-life/www/webpack.config.js

이 파일은 webpack과 로컬 개발 서버를 설정합니다. 미리 셋업 작업이 돼 있는데, webpack과 로컬 개발 서버를 사용할 때에도 전혀 따로 수정할 필요가 없습니다.

wasm-game-of-life/www/index.html

웹사이트에 사용되는 최상단 HTML 파일입니다. index.js 를 감싸주는 bootstrap.js를 부르는 것 외에는 특별한 동작을 하지 않습니다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Hello wasm-pack!</title>
  </head>
  <body>
    <script src="./bootstrap.js"></script>
  </body>
</html>

wasm-game-of-life/www/index.js

index.js는 작업을 하게 될 웹사이트 JavaScript의 진입점 (entry point) 입니다. JavaScript 코드와 wasm-pack-template의 컴파일 결과물인 WebAssembly가 포함돼 있는데, hello-wasm-pack npm 패키지를 로드하고 패키지 내부의 greet 함수를 호출해 줍니다.

import * as wasm from "hello-wasm-pack";

wasm.greet();

종속성 설치하기

우선, wasm-game-of-life/www 내부 경로에서 npm install 명령어를 실행하여 로컬 개발 서버와 종속성들이 설치돼 있는지 확인해 주세요:

npm install

이 커맨드는 한 번만 실행돼야 합니다. 실행하면 webpack JavaScript 번들러와 개발 서버를 설치할 수 있습니다.

단순히 간편하게 책을 진행하기 위해 이 번들러와 개발 서버를 사용할 예정이지만 webpack이 Rust와 WebAssembly 작업에 필수가 아니라는 점을 기억해 주세요. Parcel과 Rollup도 WebAssembly와 ECMAScript 모듈을 부르는 데 사용할 수 있습니다. 원한다면 Rust와 WebAssembly를 번들러 없이 사용할 수도 있습니다.

www 패키지 내부에서 로컬 wasm-game-of-life 패키지 사용하기

npm에서 다운로드한 hello-wasm-pack 대신에 로컬 환경에 있는 wasm-game-of-life를 사용해 봅시다. 이렇게 함으로써 Game of Life 프로그램을 더 점진적으로 개발할 수 있게 됩니다.

wasm-game-of-life/www/package.json 파일을 열고 devDependencies 다음에 dependencies 필드를 생성해 주세요. 그다음에, "wasm-game-of-life": "file:../pkg" 엔트리를 포함시켜주세요.

{
  // ...
  "dependencies": {                     // 이 3줄 길이의 블럭을 추가해주세요!
    "wasm-game-of-life": "file:../pkg"
  },
  "devDependencies": {
    //...
  }
}

다음으로, hello-wasm-pack 대신에 wasm-game-of-life를 부를 수 있도록 wasm-game-of-life/www/index.js 파일을 수정해 주세요:

import * as wasm from "wasm-game-of-life";

wasm.greet();

새 종속성을 만들었다면, 다음 명령어로 설치해야 합니다:

npm install

이제 웹사이트를 로컬 환경에서 구동할 수 있게 됐습니다!

로컬 환경에서 구동하기

이제 개발 서버를 구동할 새 터미널을 열어주세요. 새로 연 터미널에서 서버를 구동하면 백그라운드에서 계속 서버를 구동하면서 다른 명령어를 계속 입력할 수 있게 됩니다. 새 터미널에서 wasm-game-of-life/www 경로로 들어간 다음 이 명령어를 실행해 주세요:

npm run start

웹 브라우저를 열고 http://localhost:8080/ 를 열면 표시되는 "Hello World" alert 메세지를 확인할 수 있습니다:

"Hello, wasm-game-of-life!" 웹 페이지 alert 메세지 스크린샷

파일을 저장할 때마다 http://localhost:8080/ 페이지에 반영되도록 하고 싶다면, wasm-pack build 명령어를 wasm-game-of-life 경로에서 다시 실행해 주세요.

연습해 보기

  • wasm-game-of-life/src/lib.rs 경로에 있는 greet 함수를 수정해서 표시되는 메세지를 커스터마이징할 수 있도록 name: &str 매개변수를 추가해 보고, wasm-game-of-life/www/index.js 파일에서 greet 함수를 이름과 함께 호출해 보세요. wasm-pack build 명령어로 .wasm 바이너리를 다시 빌드하고, http://localhost:8080/ 페이지를 새로고침하면 브라우저에서 수정된 알림 메세지를 확인할 수 있습니다.

    정답

    wasm-game-of-life/src/lib.rs 파일에서 새롭게 수정된 greet 함수:

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

    wasm-game-of-life/www/index.js 파일에서 수정된 greet 함수 호출하기:

    wasm.greet("Your Name");
    

Conway's Game of Life의 규칙

Note: 이미 Conway's Game of Life 와 이 게임의 규칙을 잘 알고 있다면 다음 섹션으로 넘어가도 괜찮습니다.

Wikipedia에 Conway's Game of Life의 규칙이 아주 잘 설명돼 있습니다.

Game of Life의 세상(universe)은 사각형 세포로 이루어진 무한한 사이즈의 2차원 정사각형_테셀레이션인데, 각각의 세포는 살아있거나 죽어있거나, 혹은 "주거"나 "무주거" 중 한 상태일 수 있습니다. 각 세포은 수평, 수직, 혹은 대각선으로 이웃하는 여덟개의 이웃 세포과 상호작용합니다. 매 단계마다, 다음 전이가 발생합니다.

  1. 인구 부족으로 2개 미만의 이웃을 가진 세포는 죽게 됩니다.
  2. 2개 혹은 3개의 이웃을 가진 세포는 다음 세대에서 계속 살아있습니다.
  3. 과잉 인구로 3개 초과의 이웃을 가진 모든 세포는 죽게 됩니다.
  4. 세포 증식으로 정확히 3개의 이웃을 가진 세포는 살아나게 됩니다.

이 초기 패턴은 게임 시스템의 시작점(seed)을 만들게 됩니다. 위 규칙들을 시작점(seed)의 모든 세포에 적용하면서 첫 번째 세대가 생성되게 됩니다. 출생과 사망은 동시에 일어나고, 이가 발생하는 각각의 순간을 틱(tick) 이라고 부릅니다. (다시 말해, 각 세대는 직전 세대의 순수 함수입니다.) 이 규칙은 계속 적용되어 반복적으로 추가 세대를 만듭니다.

다음 이미지를 초기 세상이라고 생각해 봅시다:

Initial Universe

다음 세대를 계산할 때 하나씩 각 세포를 고려해 볼 수 있는데, 한번 살펴보도록 합시다. 이 초기 세상에서 왼쪽 최상단 세포는 죽어있습니다. 규칙 (4)는 죽어있는 세포에만 적용되는 유일한 전이 세포이지만, 최상단 왼쪽 세포는 정확히 3개의 살아있는 이웃을 가지고 있지 않기 때문에 전이 규칙이 적용되지 않고 다음 세대에서도 죽어 있는 상태로 유지됩니다. 동일한 이유로 첫 번째 행의 다른 세포들도 그대로 죽어있게 됩니다.

두 번째 행, 세번째 열의 살아있는 세포를 보면 매우 흥미로운 내용을 확인할 수 있는데, 세포가 살아있다면 첫번째 세 규칙이 적용될 수도 있습니다. 이 세포는 단 한 개의 살아있는 이웃을 가지고 있기 때문에 규칙 (1)이 적용되게 되어 다음 세대에서 죽게 됩니다. 최하단의 살아있는 세포도 동일하게 죽습니다.

가운데에 위치한 살아있는 세포는 위아래로 두 개의 살아있는 이웃을 가지고 있습니다. 그러므로 규칙 (2)가 적용이 되어 다음 세대에서도 살아있게 됩니다.

마지막으로 가운데에 살아있는 세포들의 왼쪽과 오른쪽 이웃들을 보면 정말 흥미로운 부분을 확인할 수 있는데, 이 3개의 살아있는 세포들은 양쪽 방향으로 두 세포들과 이웃해 있기 때문에 규칙 (4)가 적용이 됩니다. 그러므로 이 세포들은 다음 세대에서 살아있게 됩니다.

위에서 다룬 내용을 토대로, 다음 틱의 세상은 이렇게 보이게 됩니다:

다음 세상

이러한 간단하고 결정적인 규칙을 적용하는 것으로 다음과 같은 이상하지만 흥미로운 동작을 확인할 수 있게 됩니다:

Gosper's glider gunPulsarSpace ship
Gosper's glider gunPulsarLighweight space ship

연습해 보기

  • 방금 보여드린 다음 틱 예시 이후, 그 다음 틱을 스스로 계산해 보세요. 어떻게 감이 오시는 것 같나요?

    정답

    초기 상태의 우주로 다시 돌아가게 됩니다.

    초기 상태의 우주

    이 패턴은 주기적입니다. 그러므로 두 틱마다 처음 상태로 돌아가게 됩니다.

  • "안정된" 초기 세상을 찾아보실 수 있으신가요? 세대가 바뀌어도 내용이 바뀌지 않는 세상을 찾아보세요.

    정답

    안정된 세상은 무한하게 많습니다. 지루하게도 텅 비어있는 세상도 안정된 세상이고, 살아있는 세포들이 2 x 2 사이즈의 사각형 모양을 형성할 때도 안정된 세상을 확인할 수 있습니다.

Conway's Game of Life 구현하기

설계

시작하기 전에, 어떤 방식으로 Game of Life를 설계할지 살펴봅시다.

무한한 세상

Game of Life는 무한한 세상에서 시작됩니다. 하지만 보통은 우리가 무한한 메모리와 컴퓨터 파워를 가지고 있지 않기 때문에 다음 세 가지 방법 중 한 방법을 통해 이 귀찮은 제한을 우회하게 됩니다:

  1. 세상의 어떤 부분이 많은 컴퓨터 자원을 필요로 하는지 추적하고 이러한 부분을 필요할 때 확장합니다. 최악의 경우에는, 이 확장이 제한 없이 진행되고 코드가 계속해서 느려지면서 결국에는 메모리를 다 차지하게 됩니다.

  2. 모서리에 위치한 세포들이 가운데에 위치한 세포들과 비교해서 더 적은 이웃을 가지게 되는 사이즈가 정해져 있는 세상을 만듭니다. 이 방법에는 gliders와 같은 무한한 패턴이 모서리에서 끝나버리게 된다는 단점이 있습니다.

  3. 사이즈가 정해졌지만 계속해서 연결되는 우주를 만듭니다. 세상의 끝을 반대쪽 세상의 끝으로 연결시켜 세포들이 계속해서 이웃을 가질 수 있게 합니다. 이렇게 gliders 패턴이 계속 움직일 수 있게 됩니다.

그러면 세 번째 방법으로 구현을 해보겠습니다.

Rust와 JavaScript 코드끼리 연결하기

⚡ 다음 내용은 이 튜토리얼에서 다루는 내용 중에서도 아주 중요한 내용입니다. 이 내용을 이해하면서 얻어갈 수 있는 부분이 아주 많습니다!

JavaScript는 Object, Array 그리고 DOM 노드 (node) 들이 할당되는 가비지 콜렉터가 관리하는 힙을 사용하지만, 작성하게 될 Rust 코드의 선형 메모리는 별개의 공간을 사용하게 됩니다. WebAssembly는 현재로써는 가비지 콜렉터가 관리하는 힙에 직접 접근할 수 없습니다. (2018년 4월 기준으로, "인터페이스 타입" 제안 과 함께 변경될 전망이긴 합니다.) 반면에 JavaScript는 ArrayBuffer나 스칼라 값 (scalar values / u8, i32, f64, 등...) 만으로라도 이 선형 메모리를 읽고 쓸 수 있습니다. 이런 내용을 기반으로 모든 WebAssembly와 JavaScript 사이의 커뮤니케이션이 구성되게 됩니다.

wasm_bindgen는 이 경계를 사이로 어떻게 구조체 (compound structure) 들을 주고받아야 하는지 정해주는 역할을 합니다. 이러한 작업은 Rust 구조체를 박싱(boxing)하고, 쉽게 사용하기 위해 JavaScript 클래스에 포인터를 랩핑(wrapping)하고, Rust 코드에서 JavaScript 객체 테이블을 인덱싱(indexing)하는 과정을 포함합니다. wasm_bindgen은 매우 간편하지만, 데이터 표현 설계를 모두 대신 해주진 않습니다. 원하는 방식으로 인터페이스 설계를 구현할 수 있도록 도와주는 도구 정도로 생각하면 좋습니다.

WebAssembly와 JavaScript 사이의 인터페이스를 설계할 때, 다음 내용들을 최적화 작업 시 고려해야 합니다:

  1. JavaScript와 WebAssembly 선형 메모리 사이를 오가는 복사(copy) 최소화하기. 불필요한 복사는 불필요한 오버헤드를 발생시킵니다.

  2. 직렬화(serializing)와 역직렬화(deserializing) 최소화하기. 복사와 마찬가지로, 직렬화와 역직렬화도 오버헤드를 발생시킬 수 있고, 이러한 작업이 복사도 자주 발생시키게 됩니다. 한 곳에서 모든 직렬화 작업을 하는 대신 일반적으로 WebAssembly 선형 메모리의 알려진 위치로 opaque handle들을 넘기는 방식으로 많은 오버헤드를 줄일수 있게 됩니다. 그리고 wasm_bindgen을 통해 JavaScript의 Object나 박싱된 Rust struct를 가리키는 opaque handle들을 더 쉽게 정의하고 사용할 수 있습니다.

대부분의 경우에는, JavaScript와 WebAssembly를 오갈 때 사이즈가 크고 오래 살아있어야 하는 자료 구조를 WebAssembly 선형 메모리에 두고, 이러한 값들을 JavaScript에서 opaque handle로써 노출시키는 것이 좋은 인터페이스 설계입니다. JavaScript가 이러한 opaque handle를 통해 WebAssembly 함수를 호출하고, 데이터를 변형시키고, 무거운 컴퓨팅 작업을 하고, 값을 검색하고, 최종적으로 작은 사이즈의 복사할 수 있는 값을 반환하게 됩니다. 작은 값만 반환하게 되면 JavaScript 가비지 콜렉터가 관리하는 힙과 WebAssembly 선형 메모리 사이의 모든 값들을 앞뒤로 복사하고 직렬화할 필요가 없어지게 됩니다.

Rust와 JavaScript를 구현하는 프로그램에서 조작하기

위험한 사례를 살펴보는 것으로 시작해봅시다. 매 틱마다 세상을 WebAssembly에서 불러오거나 가져오기 위해 복사하지 않아야 하고, 세포 하나씩 객체를 모두 할당하거나 경계를 오가면서 읽고 쓰는 것도 좋지 않습니다.

그렇다면 어떻게 구현하는 게 좋을까요? 죽은 세포를 0로 나타내고 살아있는 세포를 1로 나타내는 식으로 세포들을 각각 1 byte 값으로 나타내볼 수도 있는데, WebAssembly 선형 메모리에 1차원 배열로 나타내봅시다.

4 x 4 사이즈의 우주를 메모리 이미지로 표현해 보겠습니다:

4 x 4 사이즈 세상의 스크린샷

이 공식을 사용해서 주어진 열과 행에 해당하는 배열 인덱스를 찾을 수 있습니다:

index(row, column, universe) = row * width(universe) + column

세포들을 JavaScript에 노출시킬때 여러 가지 방법을 사용해볼수 있는데, 우선은 Rust String 타입의 값으로 세포들을 문자로 표시할 수 있도록 Universe 타입에 std::fmt::Display 트레이트를 구현해 주도록 합시다. 이 트레이트를 통해 Rust String 타입의 값을 WebAssembly 선형 메모리에서 JavaScript 가비지 콜렉터가 관리하는 힙으로 복사할 수 있게 됩니다. 그 다음, 복사된 값을 HTML textConent에 표시해 보도록 하겠습니다. 이 챕터 후반에서는 이 구현에 덧붙여서 세포들을 힙에 복사하지 않도록 해보고 세포들을 <canvas>에 표시해볼 예정입니다.

하나 더 대신 해볼 법한 설계가 있는데, 세상 전체를 노출시키지 않고 매 틱마다 상태가 바뀌게 되는 세포들을 목록으로 만들어서 Rust 코드에서 JavaScript로 반환해 볼 수도 있습니다. 이 방법으로, JavaScript 코드에서 세상 전체를 순회할 필요 없이 일부만 순회할 수 있게 됩니다. 단점으로는, 이 델타 기반 (delta-based)의 설계는 구현하기가 조금 더 어렵습니다.

Rust 코드 구현하기

직전 챕터에서 초기 프로젝트 템플릿을 클론했는데, 이 템플릿을 한번 수정해 보도록 합시다.

alert를 임포트하는 줄과 greet 함수를 wasm-game-of-life/src/lib.rs 파일에서 지워보고, 세포의 타입 정의를 대신 추가해 주는 것으로 시작해 보겠습니다:

#![allow(unused)]
fn main() {
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}
}

각 세포가 1 byte 사이즈로 표현돼야 하므로 #[repr(u8)]을 잊지 않고 붙여주도록 하고, 세포 주변에 살아있는 이웃들을 쉽게 셀 수 있도록, 0Dead로, 1Alive로 정해주는 것도 중요합니다.

그다음, 세상을 정의해봅시다. 세상을 나타내는 구조체는 너비와 높이, 세포들을 나타내는 width * height 크기의 벡터(vector)를 필드로 가지게 됩니다.

#![allow(unused)]
fn main() {
#[wasm_bindgen]
pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}
}

이전에 설명한 내용대로 주어진 행과 열을 세포 벡터의 인덱스로 변환하여 사용할 수 있습니다:

#![allow(unused)]
fn main() {
impl Universe {
    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }

    // ...
}
}

세포의 다음 상태를 계산하려면 몇 개의 이웃이 살아있는지 확인해야 합니다. 이 내용을 토대로 live_neighbor_count 메소드를 작성해 봅시다!

#![allow(unused)]
fn main() {
impl Universe {
    // ...

    fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
        let mut count = 0;
        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {
                if delta_row == 0 && delta_col == 0 {
                    continue;
                }

                let neighbor_row = (row + delta_row) % self.height;
                let neighbor_col = (column + delta_col) % self.width;
                let idx = self.get_index(neighbor_row, neighbor_col);
                count += self.cells[idx] as u8;
            }
        }
        count
    }
}
}

live_neighbor_count 메소드는 델타값과 나머지 값을 각각 확인해서 세상의 가장자리에서 발생할 수 있는 예외를 if 문으로 처리해 줍니다. -1의 델타를 적용할 때, self.height - 1을 추가해서 1을 빼는 대신 나머지 값을 계속 처리하고, rowcolumn은 각각 0이 될 수 있습니다. 이 rowcolumn 값에서 1을 빼려고 시도할 때, unsigned integer underflow 가 발생하게 됩니다.

이제 현재 세대를 기반으로 다음 세대를 처리하는 데 필요한 준비가 완료됐습니다! match 문을 사용해서 게임의 규칙을 보기 명확하게 나타내봅시다. 추가로 틱이 일어날 때 JavaScript가 컨트롤하도록 할 예정이기 때문에, #[wasm_bindgen] 블럭을 추가해서 이 메소드를 JavaScript 코드에 노출시켜보도록 하겠습니다.

#![allow(unused)]
fn main() {
/// Public 메소드, JavaScript로 익스포트 할 수 있도록 함.
#[wasm_bindgen]
impl Universe {
    pub fn tick(&mut self) {
        let mut next = self.cells.clone();

        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];
                let live_neighbors = self.live_neighbor_count(row, col);

                let next_cell = match (cell, live_neighbors) {
                    // 규칙 1: 인구 부족으로 2개 미만의 이웃을 가진 세포는 죽게 됩니다.
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // 규칙 2: 2개 혹은 3개의 이웃을 가진 세포는 다음 세대에서 계속 살아있습니다.
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // Rule 3: 과잉 인구로 3개 초과의 이웃을 가진 모든 세포는 죽게 됩니다.
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // 규칙 4: 세포 증식으로 정확히 3개의 이웃을 가진 세포는 살아나게 됩니다.
                    (Cell::Dead, 3) => Cell::Alive,
                    // 규칙이 적용되지 않는 세포들의 상태는 그대로 유지되게 됩니다.
                    (otherwise, _) => otherwise,
                };

                next[idx] = next_cell;
            }
        }

        self.cells = next;
    }

    // ...
}
}

지금까지는 세상의 상태를 세포들의 벡터로 나타냈습니다. 조금 더 사람이 읽기 쉽도록 텍스트 렌더러 (text renderer) 를 구현해 보도록 합시다. 세상을 한줄 한줄씩 텍스트로 표현을 해보도록 하는데, 살아있는 세포들을 유니코드 문자 ("black medium square") 로 나타내고 죽은 세포들을 ("white medium square") 로 표현해 보겠습니다.

또한, Rust 스탠다드 라이브러리의 Display 트레이트를 구현해서 사람이 읽기 쉬운 방식으로 포맷할 수 있도록 메소드를 추가해 보겠습니다. 이 트레이트를 구현하면 자동적으로 Universe의 인스턴스(instance)들이 to_string 메소드를 사용할 수 있게 됩니다.

#![allow(unused)]
fn main() {
use std::fmt;

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }

        Ok(())
    }
}
}

마지막으로 render 메소드와 함께 생성자를 정의해서 세포들이 살아나고 죽어가는 신기한 세상을 생성할 수 있도록 해보겠습니다.

#![allow(unused)]
fn main() {
/// Public 메소드, JavaScript로 익스포트 할 수 있도록 함.
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn new() -> Universe {
        let width = 64;
        let height = 64;

        let cells = (0..width * height)
            .map(|i| {
                if i % 2 == 0 || i % 7 == 0 {
                    Cell::Alive
                } else {
                    Cell::Dead
                }
            })
            .collect();

        Universe {
            width,
            height,
            cells,
        }
    }

    pub fn render(&self) -> String {
        self.to_string()
    }
}
}

드디어 Game of Life의 Rust 코드 구현이 끝났습니다!

이제 wasm-game-of-life 경로에서 wasm-pack build를 실행해서 WebAssembly 파일을 다시 컴파일해 주세요.

JavaScript로 페이지 렌더링하기

wasm-game-of-life/www/index.html<pre> 요소를 추가해서 세상을 렌더링해 봅시다. <pre> 요소를 <script> 태그 바로 위에 추가해 주세요:

<body>
  <pre id="game-of-life-canvas"></pre>
  <script src="./bootstrap.js"></script>
</body>

추가로, <pre> 가 웹사이트 중간에 표시될 수 있도록 CSS flex box를 사용해 봅시다. wasm-game-of-life/www/index.html 파일을 열고 <head> 내에 <style> 태그를 추가해 주세요:

<style>
  body {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
</style>

이제 wasm-game-of-life/www/index.js 파일 최상단 위치한 기존 greet 함수를 지우고 Universe를 임포트하는 줄을 추가해 주세요.

import { Universe } from "wasm-game-of-life";

<pre> 요소를 pre 상수에 담은 다음 새 우주를 시작해 봅시다.

const pre = document.getElementById("game-of-life-canvas");
const universe = Universe.new();

이 JavaScript 함수는 requestAnimationFrame 루프로 실행해서 매 반복마다 업데이트된 세상을 <pre>에 반영하고 Universe::tick을 호출합니다.

const renderLoop = () => {
  pre.textContent = universe.render();
  universe.tick();

  requestAnimationFrame(renderLoop);
};

렌더링 처리를 시작하려면 다음 코드를 renderLoop 함수 밖에 추가해서 렌더링 루프를 시작해 주세요:

requestAnimationFrame(renderLoop);

다시 한번 (npm runwasm-game-of-life/www 경로에서 실행한) 개발 서버가 아직 구동 중인지 확인해 주세요. http://localhost:8080/ 페이지를 열면 다음 내용을 확인할 수 있게 됩니다:

텍스트를 렌더하는 Game of Life 구현 스크린샷

메모리에서 바로 캔버스로 렌더링하기

Rust 코드에서 String을 생성 (및 할당) 하고 wasm-bindgen로 이 생성한 값을 유효한 JavaScript 문자열로 변환하면 세포들을 불필요하게 복사하게 됩니다. 우리가 JavaScript 코드에서 세상의 너비와 높이를 이미 알고 있고, 세포를 만드는 처리가 이루어지는 WebAssembly 선형 메모리를 읽을 수 있기 때문에, render 메소드를 수정하여 cells 배열의 시작을 가리키는 포인터를 대신 반환해 보도록 합시다.

그리고 유니코드 문자를 렌더링하지 않고 Canvas API 를 대신 사용해 봅시다. 이 API를 이 부분 이후부터 계속 사용하겠습니다.

wasm-game-of-life/www/index.html 파일에서 이전에 추가한 <pre>를 지우고 렌더링에 사용할 <canvas>를 추가해주세요. (<body> 태그 내에서 JavaScript 코드를 시작시키는 <script> 태그 위에 추가돼야 합니다.)

<body>
  <canvas id="game-of-life-canvas"></canvas>
  <script src='./bootstrap.js'></script>
</body>

Rust로 구현한 코드에서 필요한 정보를 얻어올 수 있도록 세상의 넓이, 너비, 세포 배열을 가리키는 포인터를 반환하는 getter 함수들을 조금 더 작성해 보겠습니다. 이 함수들도 JavaScript 코드로 노출시켜야 하니 잘 확인해 주고, wasm-game-of-life/src/lib.rs 파일에 다음 코드를 추가해 주세요:

#![allow(unused)]
fn main() {
/// Public 메소드, JavaScript로 익스포트 할 수 있도록 함.
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn cells(&self) -> *const Cell {
        self.cells.as_ptr()
    }
}
}

그 다음, wasm-game-of-life/www/index.js 파일의 wasm-game-of-life 모듈에서 Cell을 임포트하는 줄을 추가해 준 다음 캔버스를 렌더링할 때 사용할 상수 몇 가지를 정의해주세요:

import { Universe, Cell } from "wasm-game-of-life";

const CELL_SIZE = 5; // px
const GRID_COLOR = "#CCCCCC";
const DEAD_COLOR = "#FFFFFF";
const ALIVE_COLOR = "#000000";

이제 <pre> 태그의 textContent 대신에 <canvas>를 업데이트 할 수 있도록 JavaScript 나머지 코드를 다시 작성해주세요:

// Universe를 생성하고 너비와 높이를 반환받습니다.
const universe = Universe.new();
const width = universe.width();
const height = universe.height();

// 캔버스에 세포들을 표시할 공간을 만들어주고, 각 세포들이 1px 두께의 테두리를 가질 수 있도록 해줍니다.
const canvas = document.getElementById("game-of-life-canvas");
canvas.height = (CELL_SIZE + 1) * height + 1;
canvas.width = (CELL_SIZE + 1) * width + 1;

const ctx = canvas.getContext('2d');

const renderLoop = () => {
  universe.tick();

  drawGrid();
  drawCells();

  requestAnimationFrame(renderLoop);
};

세포들 사이에 격자를 그리려면 일정한 간격으로 나란히 놓인 수평선과 수직선을 그려줍니다. 이러한 선들은 교차하여 격자를 형성하게 됩니다.

const drawGrid = () => {
  ctx.beginPath();
  ctx.strokeStyle = GRID_COLOR;

  // 수직줄
  for (let i = 0; i <= width; i++) {
    ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0);
    ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1);
  }

  // 수평줄
  for (let j = 0; j <= height; j++) {
    ctx.moveTo(0,                           j * (CELL_SIZE + 1) + 1);
    ctx.lineTo((CELL_SIZE + 1) * width + 1, j * (CELL_SIZE + 1) + 1);
  }

  ctx.stroke();
};

raw wasm 모듈인 wasm_game_of_life_bg에 정의된 memory를 임포트해서 WebAssembly의 선형 메모리에 직접 접근할 수 있습니다. 세포를 그리려면 우선 세상에 있는 세포들을 가리키는 포인터를 반환받고 세포 버퍼(buffer)를 오버레이(overlay)하는 Uint8Array 객체를 생성해야 합니다. 그 다음 각 세포를 순회하여 생존 여부에 따라 흰색 또는 검은색 사각형을 그립니다. 포인터를 오버레이 하게 되면서, 모든 틱마다 세포들을 복사하지 않도록 최적화 작업을 해줄 수 있습니다.

// WebAssembly 메모리를 파일 최상단에 임포트 해줍니다.
import { memory } from "wasm-game-of-life/wasm_game_of_life_bg";

// ...

const getIndex = (row, column) => {
  return row * width + column;
};

const drawCells = () => {
  const cellsPtr = universe.cells();
  const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

  ctx.beginPath();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      ctx.fillStyle = cells[idx] === Cell.Dead
        ? DEAD_COLOR
        : ALIVE_COLOR;

      ctx.fillRect(
        col * (CELL_SIZE + 1) + 1,
        row * (CELL_SIZE + 1) + 1,
        CELL_SIZE,
        CELL_SIZE
      );
    }
  }

  ctx.stroke();
};

이전에 보여드린 코드를 사용해서 렌더링 처리를 시작해 보겠습니다:

drawGrid();
drawCells();
requestAnimationFrame(renderLoop);

drawGrid()drawCells() 두 함수를 requestAnimationFrame() 함수 호출 이전에 호출해야 한다는 점을 꼭 기억해 주세요. 초기 상태의 세상이 그려진 이후에 수정 사항을 적용해야 합니다. requestAnimationFrame(renderLoop) 함수만 호출하게 된다면 universe.tick()이 호출된 이후 시점의 두 번째 틱이 대신 그려지게 됩니다.

다 됐어요!

최상단 wasm-game-of-life 경로에서 다음 명령어를 실행하여 WebAssembly와 바인딩 파일 (bindings glue) 들을 다시 빌드해줍시다:

wasm-pack build

다시 한번 개발 서버가 아직 구동 중인지 확인해주세요. 구동 중이지 않다면 wasm-game-of-life/www 경로에서 다시 시작해주세요:

npm run start

http://localhost:8080/ 페이지를 웹 브라우저에서 새로고침하면 구현된 흥미진진한 Game of Life가 시작되게 됩니다.

Game of Life 구현 스크린샷

추가로 관심이 있다면, hashlife 라는 엄청 멋진 Game of Life 알고리즘 구현도 있으니 한번 확인해 보세요. 이 알고리즘은 공격적인 메모이제이션 (aggressive memoizing) 기법을 사용하는데, 덕분에 코드가 더 오래 구동되는 만큼 미래 세대들을 기하급수적으로 더 빠르게 계산할 수 있게 해줍니다. hashlife를 이 튜토리얼에서 구현해 보면 정말 재밌겠지만, 이 책은 Rust와 WebAssembly 사용에 중점을 두고 있으므로 다루지 않도록 하겠습니다. 하지만 hashlife에 대해 따로 배워보길 적극적으로 권장합니다.

연습해 보기

  • space ship 패턴 하나를 표시하는 세상을 만들어보세요.

  • 초기 세상을 하드코딩 하는 대신, 각 세포가 50% 확률로 살아있거나 죽어있는 상태로 랜덤하게 생성될 수 있도록 해보세요.

    힌트: the js-sys crate 크레이트를 사용하여 Math.random JavaScript 함수를 임포트해보세요.

    정답

    먼저, wasm-game-of-life/Cargo.toml 파일을 열고 js-sys를 종속성으로 추가해 주세요:

    # ...
    [dependencies]
    js-sys = "0.3"
    # ...
    

    그 다음 js_sys::Math::random 함수를 사용해서 50% 확률로 값을 결정해 주세요:

    #![allow(unused)]
    fn main() {
    extern crate js_sys;
    
    // ...
    
    if js_sys::Math::random() < 0.5 {
        // 세포가 살아있게 함
    } else {
        // 세포가 죽어있게 함
    }
    }
  • 각 세포를 byte 값으로 표현하면서 순회를 쉽게 할 수 있지만, 메모리 자원을 낭비한다는 단점이 있습니다. 1 byte는 8 bit인데, 실제로 세포 생존 여부를 표시할 때는 1 bit만 사용하고 있습니다. 이 데이터 표현들을 리팩토링하여 각 세포가 1 bit의 사이즈만 사용할 수 있도록 코드를 작성해 보세요.

    정답

    Rust 언어에서 Vec<Cell> 타입 대신 (추가 기능을 제공하는 라이브러리인) fixedbitset 크레이트의 FixedBitSet 타입을 사용하여 세포들을 나타내볼 수도 있습니다.

    #![allow(unused)]
    fn main() {
    // Cargo.toml에 종속성을 추가했는지 확인해 주세요!
    extern crate fixedbitset;
    use fixedbitset::FixedBitSet;
    
    // ...
    
    #[wasm_bindgen]
    pub struct Universe {
        width: u32,
        height: u32,
        cells: FixedBitSet,
    }
    }

    Universe의 생성자를 다음과 같이 수정해 보겠습니다:

    #![allow(unused)]
    fn main() {
    pub fn new() -> Universe {
        let width = 64;
        let height = 64;
    
        let size = (width * height) as usize;
        let mut cells = FixedBitSet::with_capacity(size);
    
        for i in 0..size {
            cells.set(i, i % 2 == 0 || i % 7 == 0);
        }
    
        Universe {
            width,
            height,
            cells,
        }
    }
    }

    다음 틱에서 세포를 업데이트할 수 있도록 FixedBitSet 타입의 set 메소드를 사용해 봅시다:

    #![allow(unused)]
    fn main() {
    next.set(idx, match (cell, live_neighbors) {
        (true, x) if x < 2 => false,
        (true, 2) | (true, 3) => true,
        (true, x) if x > 3 => false,
        (false, 3) => true,
        (otherwise, _) => otherwise
    });
    }

    JavaScript에서 시작하는 bit를 가리키는 포인터를 반환해야 하므로, FixedBitSet 타입을 슬라이스(slice)로 변환한 다음 변환된 슬라이스를 포인터로 다시 변환해 주세요:

    #![allow(unused)]
    fn main() {
    #[wasm_bindgen]
    impl Universe {
        // ...
    
        pub fn cells(&self) -> *const u32 {
            self.cells.as_slice().as_ptr()
        }
    }
    }

    JavaScript의 wasm 메모리에서 Uint8Array를 생성해오는 방법은 이전과 동일합니다. 하지만 이번에는 각 세포를 나타내기 위해 byte 대신 bit을 사용하므로 배열의 길이가 더 이상 width * height가 아니고 width * height / 8이 되어야 하니 다시 잘 확인해 주세요:

    const cells = new Uint8Array(memory.buffer, cellsPtr, width * height / 8);
    

    다음 함수를 사용하여 인덱스와 Uint8Array가 주어질 때 n번째 bit의 값이 0인지 1인지 확인할 수 있습니다:

    const bitIsSet = (n, arr) => {
      const byte = Math.floor(n / 8);
      const mask = 1 << (n % 8);
      return (arr[byte] & mask) === mask;
    };
    

    이제 준비가 됐으니 drawCells 함수를 다음과 같이 업데이트해줍시다:

    const drawCells = () => {
      const cellsPtr = universe.cells();
    
      // 수정된 부분입니다!
      const cells = new Uint8Array(memory.buffer, cellsPtr, width * height / 8);
    
      ctx.beginPath();
    
      for (let row = 0; row < height; row++) {
        for (let col = 0; col < width; col++) {
          const idx = getIndex(row, col);
    
          // 수정된 부분입니다!
          ctx.fillStyle = bitIsSet(idx, cells)
            ? ALIVE_COLOR
            : DEAD_COLOR;
    
          ctx.fillRect(
            col * (CELL_SIZE + 1) + 1,
            row * (CELL_SIZE + 1) + 1,
            CELL_SIZE,
            CELL_SIZE
          );
        }
      }
    
      ctx.stroke();
    };
    

Game of Life 테스팅하기

브라우저 JavaScript 환경에서 실행할 수 있도록 Game of Life를 구현했으니 이제 Rust 코드에서 WebAssembly 함수를 테스팅하는 방법에 대해 알아봅시다.

tick 함수로 예상값과 일치하는 올바른 값을 불러올 수 있는지 테스팅 해보겠습니다.

우선 테스팅을 작성하기 전에 wasm_game_of_life/src/lib.rs 파일에 작성한 impl Universe 블럭 내부에 setter 함수와 getter 함수들을 조금 더 작성해 보겠습니다. set_widthset_height 함수를 작성해서 다른 사이즈의 Universe들을 만들어볼 수 있도록 해봅시다.

#![allow(unused)]
fn main() {
#[wasm_bindgen]
impl Universe { 
    // ...

    /// 세상의 너비를 설정합니다.
    ///
    /// 모든 세포를 죽은 상태로 리셋합니다.
    pub fn set_width(&mut self, width: u32) {
        self.width = width;
        self.cells = (0..width * self.height).map(|_i| Cell::Dead).collect();
    }

    /// 세상의 넓이를 설정합니다.
    ///
    /// 모든 세포를 죽은 상태로 리셋합니다.
    pub fn set_height(&mut self, height: u32) {
        self.height = height;
        self.cells = (0..self.width * height).map(|_i| Cell::Dead).collect();
    }

}
}

wasm_game_of_life/src/lib.rs 파일에 #[wasm_bindgen] 속성 없이 impl Universe 블럭을 하나 더 만들어보겠습니다. 추가로 테스팅에 사용하는 데 필요한 함수가 몇 개 있는데, 이 함수들은 JavaScript로 노출시키지 않아야 합니다. Rust로 생성한 WebAssembly 함수는 대여한 참조를 반환하지 못하는데, 이 함수들 위에 #[wasm_bindgen] 속성을 추가해 보고 어떤 에러를 확인할 수 있게 되는지 살펴봅시다.

get_cells 함수를 구현해서 Universecells 필드 값을 가져와 보겠습니다. set_cells 함수도 작성해서 주어진 행과 열에 위치한 Universe의 세포를 Alive 상태로 업데이트할 수 있도록 해보겠습니다.

#![allow(unused)]
fn main() {
impl Universe {
    /// 세상에 존재하는 모든 죽어있는 세포와 살아있는 세포를 반환합니다.
    pub fn get_cells(&self) -> &[Cell] {
        &self.cells
    }

    /// 배열로 주어진 행과 열들을 확인하고 세포들을 살아있는 상태로 업데이트합니다.
    pub fn set_cells(&mut self, cells: &[(u32, u32)]) {
        for (row, col) in cells.iter().cloned() {
            let idx = self.get_index(row, col);
            self.cells[idx] = Cell::Alive;
        }
    }

}
}

이제 wasm_game_of_life/tests/web.rs 파일에 테스팅 코드를 작성해 보도록 하겠습니다.

진행하기 전에, 이미 완성된 테스팅 코드가 있으니 한번 살펴봅시다. wasm-game-of-life 경로에서 wasm-pack test --chrome --headless 명령어를 실행하여 Rust로 생성한 WebAssembly 테스팅 코드가 잘 작동하는지 확인할 수 있습니다. --firefox, --safari, --node 옵션을 사용하여 특정 브라우저 환경에서 코드를 테스트할 수도 있습니다.

우선 wasm_game_of_life/tests/web.rs 파일에서, wasm_game_of_life 크레이트와 Universe를 익스포트 해줍시다.

#![allow(unused)]
fn main() {
extern crate wasm_game_of_life;
use wasm_game_of_life::Universe;
}

wasm_game_of_life/tests/web.rs 파일에서 spaceship 예시 패턴을 만드는 함수들을 작성해 봅시다.

tick 함수를 호출할 때 사용할 패턴과, 한 틱 이후의 결괏값을 비교할 때 사용할 예상값이 필요한데, 우선은 input_spaceship 함수에서 어떤 세포들을 Alive 상태로 생성할지 정해줍시다. expected_spaceship 함수를 테스트해 보는데, input_spaceship 호출 이후 시점 틱의 spaceship 패턴을 직접 계산해서 값을 채워보도록 하겠습니다.

#![allow(unused)]
fn main() {
#[cfg(test)]
pub fn input_spaceship() -> Universe {
    let mut universe = Universe::new();
    universe.set_width(6);
    universe.set_height(6);
    universe.set_cells(&[(1,2), (2,3), (3,1), (3,2), (3,3)]);
    universe
}

#[cfg(test)]
pub fn expected_spaceship() -> Universe {
    let mut universe = Universe::new();
    universe.set_width(6);
    universe.set_height(6);
    universe.set_cells(&[(2,1), (2,3), (3,2), (3,3), (4,2)]);
    universe
}
}

마지막으로 test_tick 함수를 구현해 주도록 하겠습니다. 먼저 input_spaceship()expected_spaceship()를 호출해서 인스턴스들을 만들어줍시다. 그다음에 input_universe 인스턴스의 tick 함수를 호출해 주도록 합시다. 추가로, assert_eq! 매크로를 사용해서 get_cells()를 호출한 다음 input_universeexpected_universe 가 동일한 Cell 타입의 배열을 값으로 가지고 있는지 확인해 보겠습니다. 마무리로는 이 코드 블럭 위에 #[wasm_bindgen_test] 속성을 추가해서 wasm-pack test 명령어로 Rust로 생성한 WebAssembly 코드를 테스트할 수 있도록 해줍시다.

#![allow(unused)]
fn main() {
#[wasm_bindgen_test]
pub fn test_tick() {
    // 작은 사이즈의 Universe과 spaceship 패턴을 생성해 봅시다.
    let mut input_universe = input_spaceship();

    // `input_universe` 인스턴스의 다음 틱 결괏값과 비교하게 될 spaceship 패턴입니다.
    let expected_universe = expected_spaceship();

    // `tick` 함수를 실행하고 두 `Universe`의 세포들이 동일한지 확인합니다.
    input_universe.tick();
    assert_eq!(&input_universe.get_cells(), &expected_universe.get_cells());
}
}

이제 wasm-game-of-life 경로에서 wasm-pack test --firefox --headless 명령어를 실행하여 테스팅 코드를 실행해 주면 됩니다.

디버깅

코드를 더 작성하기 전에, 디버깅 툴들을 조금 살펴보고 장전해 볼까요? 관심이 있다면 Rust로 WebAssembly 바이너리를 생성하는 데 사용해 볼 수 있는 접근 방법들과 툴들에 대해 다루는 참조 페이지도 확인해 보세요!

패닉 로그 활성화하기

코드가 패닉 할 때 개발자 콘솔에 도움이 되는 에러 메세지를 표시하면 디버깅이 더 쉬워집니다.

필수로 사용할 필요는 없지만 wasm-pack-templatewasm-game-of-life/src/utils.rs 파일에 기본으로 활성화돼 있는 종속성인 the console_error_panic_hook 크레이트를 포함합니다. 이 훅(hook)을 생성자에 포함시켜주면 이 기능을 사용할 수 있게 됩니다. wasm-game-of-life/src/lib.rs 파일 내의 Universe::new 생성자에서 불러보도록 하겠습니다.

#![allow(unused)]
fn main() {
pub fn new() -> Universe {
    utils::set_panic_hook();

    // ...
}
}

구현한 Game of Life에 로그 기능 추가하기

web-sys 크레이트를 통해 console.log 함수를 사용해 보고, 매 세포들의 정보를 로그 할 수 있도록 기능을 추가해 보겠습니다.

우선, wasm-game-of-life/Cargo.toml 파일을 열고 web-sys를 종속성으로 추가한 다음 "console" 기능을 활성화해 주세요:

[dependencies]

# ...

[dependencies.web-sys]
version = "0.3"
features = [
  "console",
]

더 나은 개발자 경험을 위해 console.log 함수를 println! 스타일의 매크로로 감싸보겠습니다:

#![allow(unused)]
fn main() {
extern crate web_sys;

// `println!(...)` 스타일의 문법을 `console.log`에 사용할 수 있게 해주는 매크로.
macro_rules! log {
    ( $( $t:tt )* ) => {
        web_sys::console::log_1(&format!( $( $t )* ).into());
    }
}
}

이제 Rust 코드에서 log 매크로를 호출해서 콘솔에 메세지를 로그 해봅시다. 예를 들어서, 세포의 상태, 이웃 수, 다음 틱 상태를 로그 할 수 있도록 wasm-game-of-life/src/lib.rs 파일을 수정해 주세요:

diff --git a/src/lib.rs b/src/lib.rs
index f757641..a30e107 100755
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -123,6 +122,14 @@ impl Universe {
                 let cell = self.cells[idx];
                 let live_neighbors = self.live_neighbor_count(row, col);

+                log!(
+                    "cell[{}, {}] is initially {:?} and has {} live neighbors",
+                    row,
+                    col,
+                    cell,
+                    live_neighbors
+                );
+
                 let next_cell = match (cell, live_neighbors) {
                     // 규칙 1: 인구 부족으로 2개 미만의 이웃을 가진 세포는 죽게 됩니다. 
@@ -140,6 +147,8 @@ impl Universe {
                     (otherwise, _) => otherwise,
                 };

+                log!("    it becomes {:?}", next_cell);
+
                 next[idx] = next_cell;
             }
         }

디버거를 사용하여 매 틱마다 일시정지 시키기

브라우저의 스탭핑 디버거 (stepping debugger) 는 Rust로 작성한 WebAssembly와 상호작용하는 JavaScript 코드를 살펴볼 때 유용합니다.

예를 들어서, universe.tick() 호출 이전에 JavaScript 코드에 debugger;을 추가하면 디버깅 툴을 사용하여 renderLoop의 매 순회마다 코드 실행을 일시정지시킬수 있게 됩니다.

const renderLoop = () => {
  debugger;
  universe.tick();

  drawGrid();
  drawCells();

  requestAnimationFrame(renderLoop);
};

이제 로그 메세지를 쉽게 살펴볼 수 있도록 간편한 체크포인트(checkpoint) 기능을 사용할 수 있게 됐습니다. 이 기능을 통해 현재 렌더된 프레임과 이전 프레임을 비교할 수 있습니다.

Game of Life 디버깅 화면 스크린샷

연습해 보기

  • 죽게 되거나 살아나게 되는 식으로 상태가 전환되는 각각 세포의 행과 열을 기록할 수 있도록 tick 함수를 로그 하는 코드를 추가해 보세요.

  • Universe::new 메소드에 panic!() 매크로를 추가해서 웹 브라우저의 JavaScript 디버깅 툴에서 패닉한 코드를 퇴각검색(backtrace) 할 수 있도록 수정해 보세요. 추가로 debug 심볼을 비활성화한 다음 console_error_panic_hook 선택적 종속성을 다시 빌드했을 때 스택 추적도 다시 확인해 보세요. 그렇게 유용하진 않은 것 같은데, 안 그런가요?

상호작용 추가하기

구현한 Game of Life에 상호작용을 조금 더 추가해 보면서 JavaScript와 WebAssembly 인터페이스를 조금 더 탐험해 보도록 하겠습니다. 이 섹션에서는 유저들이 세포를 클릭해서 생존 여부를 전환시킬수 있도록 해보고, 일시정지 기능도 구현해서 세포 패턴을 유저들이 손쉽게 그려볼 수 있도록 해볼 예정입니다.

일시정지 버튼

게임을 일시정지 시킬 수 있도록 버튼을 하나 만들어 봅시다. wasm-game-of-life/www/index.html 파일 내의 <canvas> 바로 위에 이 버튼을 추가해주세요:

<button id="play-pause"></button>

wasm-game-of-life/www/index.js JavaScript 파일에 다음 변경사항들을 적용해 보겠습니다:

  • 취소시키고 싶은 식별자를 cancelAnimationFrame 함수로 전달시킬 수 있도록 requestAnimationFrame 함수가 제일 마지막으로 반환한 식별자를 추적합니다.

  • 재생/일시정지 버튼을 눌렀을 때, 대기 중인 애니메이션 프레임을 가리키는 식별자를 가지고 있는지 확인합니다. 이 식별자를 가지고 있는 시점에서는 게임이 실행 중일 것이므로 renderLoop가 다시 호출되지 않도록 애니메이션을 일시정지 시킵니다. 식별자를 가지고 있지 않다면 게임이 멈춰있다는 의미이므로 requestAnimationFrame 함수를 사용하여 게임을 다시 시작해주도록 합니다.

이 작업은 JavaScript 환경에서 진행해야 하기 때문에 따로 Rust 소스 코드를 수정할 필요가 없습니다.

animationId 변수를 추가하여 requestAnimationFrame 함수가 반환하는 식별자를 추적해 봅시다. 대기 중인 애니메이션이 없을 때는 이 값을 null로 설정해 주겠습니다.

let animationId = null;

// `requestAnimationFrame`의 반환값이 `animationId`에
// 할당된다는 점 외에는 기존과 동일한 함수입니다.
const renderLoop = () => {
  drawGrid();
  drawCells();

  universe.tick();

  animationId = requestAnimationFrame(renderLoop);
};

언제든 animationId의 값을 확인해서 게임이 멈춰있는지 확인할 수 있습니다:

const isPaused = () => {
  return animationId === null;
};

이제 재생/일시정지 버튼이 클릭 됐을 때 게임의 재생 여부를 확인할 수 있게 됐습니다. 마찬가지로 renderLoop 함수를 통해 애니메이션을 다시 시작하거나 일시정지 시킬수도 있습니다. 추가로, 버튼을 클릭했을 때 표시되는 텍스트의 아이콘을 업데이트해 주도록 하겠습니다.

const playPauseButton = document.getElementById("play-pause");

const play = () => {
  playPauseButton.textContent = "⏸";
  renderLoop();
};

const pause = () => {
  playPauseButton.textContent = "▶";
  cancelAnimationFrame(animationId);
  animationId = null;
};

playPauseButton.addEventListener("click", event => {
  if (isPaused()) {
    play();
  } else {
    pause();
  }
});

마지막으로, 추가한 버튼이 올바른 초기 텍스트 아이콘을 표시하도록 이전에 requestAnimationFrame(renderLoop)을 직접 불러서 빠르게 시작했던 부분을 play 함수를 대신 호출하도록 변경해 보겠습니다.

// 기존에는 `requestAnimationFrame(renderLoop)`를 호출하는 줄이었습니다.
play();

http://localhost:8080/ 페이지를 새로고침하면 일시정지/재생 버튼이 추가된 부분을 확인할 수 있습니다!

onclick 이벤트로 세포 상태 전환하기

이제 게임을 일시정지 할 수 있게 됐으니 세포들을 클릭해서 상태를 바꿀 수 있도록 코드를 수정해 보겠습니다.

세포를 클릭할 때, 클릭한 세포의 생존 여부를 전환할 수 있도록 코드를 작성해 보겠습니다. toggle 메소드를 wasm-game-of-life/src/lib.rs 파일 내의 Cell에 추가해 줍시다.

#![allow(unused)]
fn main() {
impl Cell {
    fn toggle(&mut self) {
        *self = match *self {
            Cell::Dead => Cell::Alive,
            Cell::Alive => Cell::Dead,
        };
    }
}
}

주어진 행과 열에 위치한 세포의 상태를 전환할 수 있도록, 행과 열을 벡터의 인덱스로 변환하고, 변환한 인덱스를 세포 벡터로 변환하겠습니다. 세포 벡터가 준비됐다면 변환된 인덱스에 위치하는 세포를 전환할 수 있도록 메소드를 호출해 봅시다:

#![allow(unused)]
fn main() {
/// Public 메소드, JavaScript로 익스포트 할 수 있도록 함.
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn toggle_cell(&mut self, row: u32, column: u32) {
        let idx = self.get_index(row, column);
        self.cells[idx].toggle();
    }
}
}

impl 블럭 내에 정의된 메소드에 #[wasm_bindgen] 속성을 추가하여 JavaScript 코드에서 호출할 수 있도록 했습니다.

wasm-game-of-life/www/index.js 파일에서 <canvas> 요소의 클릭 이벤트를 수신(listening)해보겠습니다. 콜백 함수 내부에는 페이지의 상대 좌표를 캔버스 상대 좌표로 변환한 다음 toggle_cell 메소드를 호출하여 장면을 다시 그려볼수 있도록 코드를 작성해보겠습니다.

canvas.addEventListener("click", event => {
  const boundingRect = canvas.getBoundingClientRect();

  const scaleX = canvas.width / boundingRect.width;
  const scaleY = canvas.height / boundingRect.height;

  const canvasLeft = (event.clientX - boundingRect.left) * scaleX;
  const canvasTop = (event.clientY - boundingRect.top) * scaleY;

  const row = Math.min(Math.floor(canvasTop / (CELL_SIZE + 1)), height - 1);
  const col = Math.min(Math.floor(canvasLeft / (CELL_SIZE + 1)), width - 1);

  universe.toggle_cell(row, col);

  drawGrid();
  drawCells();
});

wasm-game-of-life 경로에서 wasm-pack build 명령어를 실행하여 다시 빌드한 다음, http://localhost:8080/ 페이지를 새로고침해 보세요. 이제 세포를 직접 클릭해서 상태를 전환할 수 있게 됐습니다. 패턴을 한번 그려보세요!

연습해 보기

  • <input type="range"> 위젯을 추가하여 매 프레임마다 발생하는 틱의 수를 조절할 수 있도록 코드를 작성해 보세요.

  • 세상을 랜덤한 초기 상태로 리셋할 수 있도록 버튼을 하나 더 추가해 보세요. 또 다른 버튼을 추가해서 모든 세포가 죽은 상태로 새로 시작할 수 있도록 구현해 봐도 좋습니다.

  • Ctrl을 누른 상태로 세포를 클릭하면 (Ctrl + 클릭) 클릭한 세포를 중심으로 glider 패턴이 추가되도록 코드를 작성해 보세요. Shift를 누른 상태로 클릭할 때는 (Shift + Click) pulsar 패턴을 그리도록 작성해 봐도 좋습니다.

타임 프로파일링

이 챕터에서는 타임 프로파일링 작업을 해보면서 이전에 구현한 Game of Life의 성능을 개선시켜보겠습니다.

시작하기 전에, Rust와 WebAssembly 코드에 사용해 볼 수 있는 타임 프로파일링 툴들을 살펴보셔도 좋습니다.

window.performance.now 함수를 사용하여 초당 프레임 (FPS, Frames Per Second) 타이머 만들기

이 FPS 타이머는 구현한 게임의 렌더링 속도를 어떻게 개선시킬지 살펴볼 때 매우 유용하게 사용될 예정입니다.

wasm-game-of-life/www/index.js 파일에 fps 객체를 추가하는 것으로 시작해 봅시다:

const fps = new class {
  constructor() {
    this.fps = document.getElementById("fps");
    this.frames = [];
    this.lastFrameTimeStamp = performance.now();
  }

  render() {
    // 마지막 프레임 렌더부터의 델타 시간을 fps 단위로 변환합니다.
    const now = performance.now();
    const delta = now - this.lastFrameTimeStamp;
    this.lastFrameTimeStamp = now;
    const fps = 1 / delta * 1000;

    // 마지막 100개의 타이밍만 저장합니다.
    this.frames.push(fps);
    if (this.frames.length > 100) {
      this.frames.shift();
    }

    // 최대, 최소 타이밍과 마지막 100개 타이밍의 평균을 찾습니다.
    let min = Infinity;
    let max = -Infinity;
    let sum = 0;
    for (let i = 0; i < this.frames.length; i++) {
      sum += this.frames[i];
      min = Math.min(this.frames[i], min);
      max = Math.max(this.frames[i], max);
    }
    let mean = sum / this.frames.length;

    // 통계를 렌더합니다.
    this.fps.textContent = `
Frames per Second:
         latest = ${Math.round(fps)}
avg of last 100 = ${Math.round(mean)}
min of last 100 = ${Math.round(min)}
max of last 100 = ${Math.round(max)}
`.trim();
  }
};

fps 객체의 render 함수를 renderLoop 함수의 매 반복마다 호출해 보겠습니다:

const renderLoop = () => {
    fps.render(); //new

    universe.tick();
    drawGrid();
    drawCells();

    animationId = requestAnimationFrame(renderLoop);
};

마지막으로, fps 요소를 wasm-game-of-life/www/index.html 파일에 잊지 않고 추가해 줍시다. <canvas> 바로 위에 추가해 주세요:

<div id="fps"></div>

CSS 프로퍼티를 추가해서 깔끔하게 포맷해 주겠습니다:

#fps {
  white-space: pre;
  font-family: monospace;
}

짜잔! 이제 http://localhost:8080 페이지를 새로고침 하면 FPS 카운터를 확인할 수 있게 됐습니다!

Universe::tick의 매 틱을 console.time, console.timeEnd 를 사용하여 측정하기

web-sys 크레이트의 console.time, console.timeend 를 활용하여 각 Universe::tick이 호출되는데 걸리는 시간을 확인해 볼 수 있습니다.

먼저, wasm-game-of-life/Cargo.toml 파일을 열고 web-sys 를 종속성으로 추가해 주세요:

[dependencies.web-sys]
version = "0.3"
features = [
  "console",
]

console.time를 호출할 때마다 console.timeEnd도 같이 호출될 예정이기 때문에, RAII 타입으로 묶어서 간편하게 사용 할 수도 있습니다:

#![allow(unused)]
fn main() {
extern crate web_sys;
use web_sys::console;

pub struct Timer<'a> {
    name: &'a str,
}

impl<'a> Timer<'a> {
    pub fn new(name: &'a str) -> Timer<'a> {
        console::time_with_label(name);
        Timer { name }
    }
}

impl<'a> Drop for Timer<'a> {
    fn drop(&mut self) {
        console::time_end_with_label(self.name);
    }
}
}

그다음, 메소드 최상단에 다음 코드를 추가해서 각 Universe::tick 호출이 얼마나 오래 걸리는지 측정해 볼 수 있습니다:

#![allow(unused)]
fn main() {
let _timer = Timer::new("Universe::tick");
}

콘솔에 Universe::tick를 호출하는 데 걸리는 시간을 로그로 표시합니다:

console.time 로그 스크린샷

추가로, 브라우저 프로파일러(profiler)의 timeline 혹은 waterfall 뷰에서 console.timeconsole.timeEnd가 같이 실행된 부분을 확인할 수 있습니다:

console.time 로그 스크린샷

Game of Life 세상의 사이즈 늘려보기

⚠️ 이 섹션은 FireFox의 스크린샷을 예시로 보여줍니다. 거의 모든 모던 브라우저가 비슷한 기능들을 가지고 있지만, 브라우저마다 개발자 도구에 약간의 차이가 있을 수는 있습니다. 뜯어보게 될 프로파일 정보는 기본적으로 같지만, 보게 될 뷰나 도구 이름 등이 살짝 다를 수 있는 점을 미리 확인해 주세요.

구현한 Game of Life 세상을 더 크게 만들어보면 어떨까요? 64 x 64 사이즈의 세상을 128 x 128 사이즈로 늘려봅시다. (wasm-game-of-life/src/lib.rs 파일에서 Universe::new를 수정해 주세요.) 제 컴퓨터에서는 사이즈를 늘렸을 때 부드럽게 작동하던 60fps 화면이 버벅이면서 40fps 처럼 보이는 부분이 확인됩니다.

프로파일을 기록하고 waterfall 뷰를 확인하면, 각 애니메이션이 처리되는데 20 밀리초보다 더 많이 걸리는 것을 확인할 수 있습니다. 60fps로 표시됐을 때는 프레임 전체를 렌더하는데 16 밀리초가 걸린 것을 떠올려보면 확실히 차이가 있는 것 같습니다. 참고로, JavaScript와 WebAssembly외에도 페이지를 그리는 등 브라우저가 수행하는 다른 작업의 영향도 있으니 참고해 주세요.

페이지 렌더링 처리의 waterfall 뷰 스크린샷

한 애니메이션 프레임 동안 어떤 일이 일어나는지 잘 확인해 보면, CanvasRenderingContext2D.fillStyle의 setter가 많은 성능을 요구하는 부분을 확인할 수 있습니다.

⚠️ FireFox 브라우저에서 위 내용에서 언급된 CanvasRenderingContext2D.fillStyle 대신에 "DOM"이 표시된다면 성능 개발자 도구 (performance developer tools) 에서 "Gecko 플랫폼 데이터 표시하기 (Show Gecko Platform Data)" 옵션을 활성화해줘야 할수도 있습니다:

"Gecko 플랫폼 데이터 표시하기 (Show Gecko Platform Data)" 옵션 활성화하기

페이지 렌더링 처리의 flamegraph 뷰 스크린샷

많은 프레임의 호출 트리 집계 (call tree's aggregation) 를 살펴보면 이게 전혀 이상한 동작이 아님을 확인할 수 있습니다:

페이지 렌더링 처리의 flamegraph 뷰 스크린샷

어이쿠! 거의 40% 분량을 이 setter에 사용해버렸네요!

tick 메소드가 성능 병목을 일으키는데 특별한 이유가 있을 것 같았지만, 사실 그렇지 않은 부분을 확인했습니다. 이렇게 작업을 하다 보면 예상치 못한 부분에서 시간을 많이 쓰게 될 수도 있으니, 항상 정말 중요한 프로파일링 도구를 먼저 살펴보도록 합시다.

wasm-game-of-life/www/index.js 파일 내의 drawCells 함수에서 fillStyle 프로퍼티가 한번 정해지면 세상 내의 모든 세포와 모든 애니메이션에 이 프로퍼티가 사용되게 됩니다.

for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);

    ctx.fillStyle = cells[idx] === DEAD
      ? DEAD_COLOR
      : ALIVE_COLOR;

    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

fillStyle가 많은 성능을 요구하는 부분을 확인했는데, 그렇다면 어떤 식으로 코드를 작성해서 개선시킬수 있을까요? 세포의 생존 여부에 따라 fillStyle를 사용하도록 바꿔봅시다. fillStyle = ALIVE_COLOR를 추가해 줘서 살아있는 세포만 그리도록 하고 fillStyle = DEAD_COLOR도 추가해 줘서 죽은 세포도 동일한 방식으로 처리를 해준다면 세포들을 모두 한 번에 그리는 대신 fillStyle을 두 번만 설정하게 됩니다.

// 살아있는 세포들을 처리합니다.
ctx.fillStyle = ALIVE_COLOR;
for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);
    if (cells[idx] !== Cell.Alive) {
      continue;
    }

    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

// 죽어있는 세포들을 처리합니다.
ctx.fillStyle = DEAD_COLOR;
for (let row = 0; row < height; row++) {
  for (let col = 0; col < width; col++) {
    const idx = getIndex(row, col);
    if (cells[idx] !== Cell.Dead) {
      continue;
    }

    ctx.fillRect(
      col * (CELL_SIZE + 1) + 1,
      row * (CELL_SIZE + 1) + 1,
      CELL_SIZE,
      CELL_SIZE
    );
  }
}

코드 파일을 저장하고 http://localhost:8080/ 페이지를 새로고침 해주면, 웹사이트가 60fps로 다시 부드럽게 렌더 됩니다.

프로파일을 다시 확인해 보면, 각 애니메이션 프레임마다 오직 10 밀리초만 걸리는 부분도 확인할 수 있습니다.

drawCells 함수를 업데이트한 이후 페이지 렌더링 처리의 waterfall 뷰 스크린샷

한 프레임을 다시 분석해 보면, fillStyle 이 더 이상 성능을 많이 사용하지 않고, fillRect가 각 세포 사각형을 그리는데 대부분의 시간이 소비되는 것을 확인할 수 있습니다.

drawCells 함수를 업데이트한 이후 페이지 렌더링 처리의 flamegraph 뷰 스크린샷

Game of Life가 더 빠르게 진행되도록 만들어보기

어떤 사람들은 빨리빨리 진행하는 것을 선호해서 매 프레임마다 1틱이 아니라 9틱씩 진행되도록 수정하고 싶을 수도 있습니다. 놀랍게도 이 작업은 wasm-game-of-life/www/index.js 파일의 renderLoop 함수를 수정해서 생각 외로 간단하게 해볼 수 있습니다:

for (let i = 0; i < 9; i++) {
  universe.tick();
}

제 컴퓨터에서 구현한 게임이 35fps 속도로 다시 느려진 것 같습니다. 좋지 않은 현상이니 다시 60fps 로 만들어보겠습니다!

잘 확인해보면 Universe::tick 에서 시간이 많이 소요되는 것으로 보입니다. Timer를 추가해서 console.timeconsole.timeEnd 호출을 감싸고 다시 한번 살펴보겠습니다. 제 예상대로라면 매 틱마다 세포의 새 벡터를 할당하고 기존 벡터를 해제(freeing)하는 작업은 많은 성능을 사용할 뿐 아니라 시간도 많이 소비하게 될것 같습니다.

#![allow(unused)]
fn main() {
pub fn tick(&mut self) {
    let _timer = Timer::new("Universe::tick");

    let mut next = {
        let _timer = Timer::new("다음 세포들을 할당합니다.");
        self.cells.clone()
    };

    {
        let _timer = Timer::new("다음 세대를 처리합니다.");
        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];
                let live_neighbors = self.live_neighbor_count(row, col);

                let next_cell = match (cell, live_neighbors) {
                    // 규칙 1: 인구 부족으로 2개 미만의 이웃을 가진 세포는 죽게 됩니다.
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // 규칙 2: 2개 혹은 3개의 이웃을 가진 세포는 다음 세대에서 계속 살아있습니다.
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // 규칙 3: 과잉 인구로 3개 초과의 이웃을 가진 모든 세포는 죽게 됩니다.
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // 규칙 4: 세포 증식으로 정확히 3개의 이웃을 가진 세포는 살아나게 됩니다.
                    (Cell::Dead, 3) => Cell::Alive,
                    // 규칙이 적용되지 않는 세포들의 상태는 그대로 유지되게 됩니다.
                    (otherwise, _) => otherwise,
                };

                next[idx] = next_cell;
            }
        }
    }

    let _timer = Timer::new("기존 세포들을 해제합니다.");
    self.cells = next;
}
}

브라우저 개발자 도구에서 타이밍(timing)을 잘 확인해 보면 이 예상이 사실은 명백하게 틀린 부분을 확인할 수 있습니다. 실제로는 대부분의 시간이 다음 세대 세포들을 계산하는데 사용되게 됩니다. 그리고 의외로 매 틱마다 벡터 값을 할당하고 해제하는 작업이 그렇게 성능을 많이 사용하지 않습니다. 다시 한번 정말 중요한 프로파일링을 해보겠습니다!

Universe::tick 타이머 결과 값의 스크린샷

사용하게 될 테스트 기능 게이트 (test feature gate)nightly 버전에 같이 포함돼 있기 때문에, nightly 컴파일러가 필요합니다. 그러면 벤치마킹에 이 기능을 한번 사용해 보겠습니다. cargo benchcmp이라는 툴도 필요하니 설치해 주도록 합시다. 참고로 cargo benchcmp는 cargo bench로 생성한 마이크로 벤치마킹을 비교하는 데 사용하는 작은 사이즈의 유틸리티입니다.

WebAssembly와 동일한 작업을 수행하는 네이티브 코드인 #[bench]를 작성해 봅시다. 이렇게 네이티브 코드를 작성하게 되면 더 많은 기능을 사용할 수 있게 됩니다. 이제 새롭게 작성된 wasm-game-of-life/benches/bench.rs을 확인해 보겠습니다:

#![allow(unused)]
#![feature(test)]

fn main() {
extern crate test;
extern crate wasm_game_of_life;

#[bench]
fn universe_ticks(b: &mut test::Bencher) {
    let mut universe = wasm_game_of_life::Universe::new();

    b.iter(|| {
        universe.tick();
    });
}
}

#[wasm_bindgen] 속성을 모두 주석 처리 해주도록 하고, Cargo.toml 파일의 "cdylib"도 주석 처리 해주겠습니다. 이렇게 주석 처리를 하지 않으면 빌드가 실패하고 링크 오류(link errors)가 발생하게 됩니다.

준비가 다 됐다면, cargo bench | tee before.txt 명령어를 실행해서 코드를 컴파일하고 벤치마크를 실행해 보겠습니다. | tee before.txt를 포함하면서 cargo bench 명령어의 출력값을 가져와서 before.txt 파일에 저장할 예정이니 이 부분도 다시 확인해 주세요.

$ cargo bench | tee before.txt
    Finished release [optimized + debuginfo] target(s) in 0.0 secs
     Running target/release/deps/wasm_game_of_life-91574dfbe2b5a124

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/release/deps/bench-8474091a05cfa2d9

running 1 test
test universe_ticks ... bench:     664,421 ns/iter (+/- 51,926)

test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured; 0 filtered out

여기서 바이너리 파일의 경로도 같이 표시되는 부분을 확인할 수 있습니다. 이번에는 운영체제의 프로파일러를 사용하여 벤치마킹을 다시 한번 해보겠습니다. 저는 리눅스(Linux)를 사용하고 있으니 perf를 예제로 사용하겠습니다:

$ perf record -g target/release/deps/bench-8474091a05cfa2d9 --bench
running 1 test
test universe_ticks ... bench:     635,061 ns/iter (+/- 38,764)

test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured; 0 filtered out

[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.178 MB perf.data (2349 samples) ]

perf report 명령어로 프로파일을 로드하면, 예상한 대로 대부분의 시간이 Universe::tick를 실행하는데 소비되는 것을 확인할 수 있습니다:

perf report 스크린샷

perf를 실행한 다음 a 키를 누르면 어떤 어셈블리 명령어(instruction)이 함수를 처리할 때 사용되고 있는지 확인할 수 있습니다:

perf 화면에서 어셈블리 명령어를 표시하는 화면의 스크린샷

위 내용을 확인하면 26.67%의 시간이 이웃 세포들을 만들어내는 데 사용되고, 23.41%를 이웃들의 열 인덱스, 그리고 나머지 15.42%를 행 인덱스를 더하는 데 사용되는 것을 알 수 있습니다. 제일 많은 성능을 사용하는 세 명령어들 중, 두 번째와 세 번째로 비용이 많이 드는 명령어가 div 명령어인 부분도 확인할 수 있습니다. 이 div 구현들이 Universe::live_neighbor_count 함수에서 나머지 연산자로 인덱싱 하도록 구현했던 부분입니다. (modulo indexing logic)

wasm-game-of-life/src/lib.rs 파일에서 live_neighbor_count를 정의했던 내용을 다시 떠올려봅시다:

#![allow(unused)]
fn main() {
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;
    for delta_row in [self.height - 1, 0, 1].iter().cloned() {
        for delta_col in [self.width - 1, 0, 1].iter().cloned() {
            if delta_row == 0 && delta_col == 0 {
                continue;
            }

            let neighbor_row = (row + delta_row) % self.height;
            let neighbor_col = (column + delta_col) % self.width;
            let idx = self.get_index(neighbor_row, neighbor_col);
            count += self.cells[idx] as u8;
        }
    }
    count
}
}

코드를 다시 확인해 보면 if 블럭들로 첫째와 마지막 행과 열의 엣지 케이스(edge case) 를 처리하는데 코드를 불필요하게 길게 작성하는 대신 나머지 연산자 (modulo operator) 를 사용하고 있는 부분을 확인할 수 있습니다. rowcolumn 둘 다 세상의 가장자리에 있지 않고 나머지 연산자를 쓸 필요가 없는 일반적인 경우를 처리하는데도 div 명령어를 사용해서 많은 성능을 사용하는 부분도 확인됩니다. 반복문을 사용하는 대신에 if 문으로 이러한 엣지 케이스들을 처리하고, 여러 분기들을 CPU의 분기 예측기(branch predictor)가 예측하기 쉽도록 만들어보겠습니다.

live_neighbor_count를 다음과 같이 다시 작성해 봅시다:

#![allow(unused)]
fn main() {
fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;

    let north = if row == 0 {
        self.height - 1
    } else {
        row - 1
    };

    let south = if row == self.height - 1 {
        0
    } else {
        row + 1
    };

    let west = if column == 0 {
        self.width - 1
    } else {
        column - 1
    };

    let east = if column == self.width - 1 {
        0
    } else {
        column + 1
    };

    let nw = self.get_index(north, west);
    count += self.cells[nw] as u8;

    let n = self.get_index(north, column);
    count += self.cells[n] as u8;

    let ne = self.get_index(north, east);
    count += self.cells[ne] as u8;

    let w = self.get_index(row, west);
    count += self.cells[w] as u8;

    let e = self.get_index(row, east);
    count += self.cells[e] as u8;

    let sw = self.get_index(south, west);
    count += self.cells[sw] as u8;

    let s = self.get_index(south, column);
    count += self.cells[s] as u8;

    let se = self.get_index(south, east);
    count += self.cells[se] as u8;

    count
}
}

벤치마킹을 다시 해보겠습니다! 이번에는 출력되는 메세지들을 after.txt로 출력해 봅시다.

$ cargo bench | tee after.txt
   Compiling wasm_game_of_life v0.1.0 (file:///home/fitzgen/wasm_game_of_life)
    Finished release [optimized + debuginfo] target(s) in 0.82 secs
     Running target/release/deps/wasm_game_of_life-91574dfbe2b5a124

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/release/deps/bench-8474091a05cfa2d9

running 1 test
test universe_ticks ... bench:      87,258 ns/iter (+/- 14,632)

test result: ok. 0 passed; 0 failed; 0 ignored; 1 measured; 0 filtered out

훨씬 나은 것 같습니다! benchcmp 툴과 방금 생성한 두 텍스트 파일을 확인해 보면 얼마나 나이졌는지 볼 수 있습니다:

$ cargo benchcmp before.txt after.txt
 name            before.txt ns/iter  after.txt ns/iter  diff ns/iter   diff %  speedup
 universe_ticks  664,421             87,258                 -577,163  -86.87%   x 7.61

우와! 7.61 배나 빨라졌네요!

WebAssembly는 의도적으로 일반적인 하드웨어 아키텍처와 밀접하게 매핑(mapping)되도록 설계돼 있지만, 이러한 네이티브 코드의 속도 향상이 WebAssembly 성능 향상으로도 이어질 수 있는지 확실히 확인해 보는 것도 중요합니다.

wasm-pack build 명령어를 실행해서 .wasm 파일을 다시 빌드하고 http://localhost:8080/ 페이지를 새로고침 해주세요. 제 컴퓨터에서는 60 fps의 속도로 다시 작동하는 것이 확인됩니다. 브라우저의 프로파일러를 사용해서 다시 확인해 보니 각 애니메이션 프레임이 10 밀리초씩 걸리는 것으로 확인됩니다.

성공적으로 잘 마무리한 것 같습니다!

나머지 연산자를 if문 분기들로 바꾼 이후 페이지 렌더링 처리의 waterfall 뷰 스크린샷

연습해 보기

  • 현재로는 할당과 해제를 처리하는 코드를 지우는 게 Universe::tick 속도를 향상시키는 가장 쉬운 방법입니다. Universe가 두 벡터만 관리하도록 하고, 두 벡터를 코드가 실행되는 내내 해제되거나 tick 함수에서 새 버퍼를 할당하지 않도록 세포들을 이중 버퍼링 (double buffering) 기법으로 구현해 보세요.

  • "Game of Life 구현하기" 섹션에서 델타 기반으로 설계한 내용을 토대로, Rust 코드가 상태가 바뀐 세포들의 목록을 반환하는 방식으로 다시 코드를 설계해 보세요. <canvas> 가 더 빨리 렌더되는 게 보이시나요? 매 틱마다 새 델타 목록을 할당하지 않으면서 구현하실수 있을것 같으신가요?

  • 프로파일링 툴로 확인할 수 있듯, 2D <canvas> 렌더링이 특별히 빠르지는 않은 것 같습니다. 2D 캔버스 렌더러 대신 WebGL 렌더러를 대신 사용해서 코드를 다시 구현해 보세요. WebGL로 구현한 버전은 얼마나 더 빠른가요? 답답하게 느렸던 WebGL과 비교하면 얼마나 큰 사이즈의 세상을 가볍게 처리할 수 있게 됐나요?

.wasm 파일 사이즈 줄이기

구현했던 Game of Life 프로그램과 같이 .wasm 형식으로 컴파일된 바이너리를 유저들에게 네트워크로 전송할 때 코드 사이즈를 신경 쓰는 편이 좋습니다. 대부분의 경우에는 .wasm 파일의 사이즈가 작을수록 페이지가 빨리 로드되고 유저 경험이 더 나아집니다.

빌드 설정으로 .wasm 바이너리 사이즈를 얼마나 줄일 수 있나요?

wasm 바이너리 사이즈를 줄일 때 어떤 빌드 설정을 수정해 볼 수 있는지 한번 살펴보세요.

(debug 심볼이 빠져있는) 기본 빌드 설정으로 Game of Life를 빌드하면 29,410 bytes 사이즈의 바이너리가 출력되게 됩니다:

$ wc -c pkg/wasm_game_of_life_bg.wasm
29410 pkg/wasm_game_of_life_bg.wasm

LTO를 활성화한 다음 opt-level = "z"를 설정하고 wasm-opt -Oz 명령어를 실행해서 빌드하면 출력되는 .wasm 바이너리의 사이즈가 17,317 bytes 정도로 줄어드는 부분을 확인할 수 있습니다.

$ wc -c pkg/wasm_game_of_life_bg.wasm
17317 pkg/wasm_game_of_life_bg.wasm

그다음에는 출력된 바이너리를 (거의 모든 HTTP 서버가 하는 대로) gzip를 사용하여 압축해 보는데, 정말 귀엽게도 바이너리 파일의 사이즈를 9,045 bytes까지 줄일 수 있게 됩니다!

$ gzip -9 < pkg/wasm_game_of_life_bg.wasm | wc -c
9045

연습해 보기

  • wasm-snip을 사용해서 구현했던 Game of Life의 .wasm 바이너리에서 패닉 디버깅 함수를 제외시켜보세요. 파일의 사이즈가 얼마나 줄어드나요?

  • wee_alloc 를 전역 할당자 (global allocator)를 사용해 보고 파일의 사이즈를 이전과 비교해 보세요. 프로젝트를 시작할 때 클론 했던 rustwasm/wasm-pack-template 템플릿은 "wee_alloc" 이라는 cargo 기능을 포함하고 있는데, 이 기능은 wasm-game-of-life/Cargo.toml 파일에 있는 [features] 섹션의 default 필드에 추가해서 활성화할 수 있습니다.

    [features]
    default = ["wee_alloc"]
    

    wee_alloc을 활성화하면 .wasm 바이너리 사이즈가 얼마나 줄어드나요?

  • 튜토리얼을 진행하는 내내 한 Universe만 페이지에 포함시켰는데, 생성자를 사용하는 대신 단 한 개의 static mut 전역 인스턴스 (global instance) 만 수정하는 코드를 익스포트 해볼 수도 있습니다. 이전 챕터에서 다룬 이중 버퍼링 기법 (double buffering technique) 을 사용하고 싶다면, 이러한 버퍼도 static mut 키워드를 사용하여 전역적으로 만들어볼 수 있습니다. 이런 방식으로, 구현한 게임에서 모든 동적 할당 (dynamic allocation) 을 없앨 수 있고, #![no_std] 속성을 추가하여 할당기(allocator)가 없는 크레이트를 만들어볼 수도 있습니다. 동적 할당에 필요한 종속성을 다 없애면 .wasm 파일의 사이즈가 얼마나 줄어드나요?

npm에 배포하기

사이즈가 작고 빠르게 작동하는 wasm-game-of-life 패키지가 준비됐습니다. 이제 다른 JavaScript 개발자들이 Game of Life 코드를 바로 종속성으로 받아 사용해 볼 수 있도록 npm에 배포해 보겠습니다.

준비 사항

우선, 기존에 생성된 npm 계정이 있는지 확인해주세요.

그 다음, 다음 명령어를 실행하여 로컬 머신에서 계정에 로그인되어 있는지 확인해 주세요:

wasm-pack login

배포

wasm-game-of-life 경로에서 wasm-pack 명령어를 실행하여 wasm-game-of-life/pkg 경로에 작성한 코드가 잘 빌드돼 있는지 확인해 주세요:

wasm-pack build

wasm-game-of-life/pkg 폴더의 내용물들을 한번 살펴보겠습니다. 이 폴더에 있는 파일들을 이제 npm에 배포해 볼 예정입니다!

준비가 됐다면, wasm-pack publish 를 실행해서 패키지를 npm에 업로드해보세요:

wasm-pack publish

정말 놀랍게도 이게 답니다! 이렇게 npm에 패키지를 업로드할 수 있는데...

... 이 튜토리얼을 끝낸 다른 사람들도 npm에 배포를 했을 가능성이 높습니다. 그렇기 때문에 wasm-game-of-life 라는 패키지 이름이 높은 확률로 이미 사용 중일 수도 있습니다. 마지막으로 실행한 명령어가 이러한 이유로 성공적으로 실행되지 못한 부분이 확인되나요?

wasm-game-of-life/Cargo.toml 파일을 열고 작성한 패키지가 고유한 이름을 가질 수 있도록 name 필드에 원하는 유저 이름을 추가해 주세요.

[package]
name = "wasm-game-of-life-my-username"

이제 다시 빌드하고 배포해 보겠습니다:

wasm-pack build
wasm-pack publish

이번에는 잘 배포됐을 겁니다!

참조

이 섹션은 Rust와 WebAssembly 개발에 도움이 되는 참조 자료를 포함하고 있습니다. 순서가 정해져 있지 않아 처음부터 끝까지 읽을 필요가 없습니다. 각 하위 섹션은 개별적으로 각자 다른 내용을 다룹니다.

알면 좋은 크레이트들

Rust와 WebAssembly로 개발할 때 알면 좋은 크레이트들을 모아놓은 목록입니다.

creates.io 웹사이트에서 WebAssembly 카테고리로 등록된 크레이트들을 필터링해서 볼수도 있습니다.

JavaScript와 DOM 조작하기

wasm-bindgen | crates.io | 레포지토리

wasm-bindgen은 Rust와 JavaScript 사이의 고레벨 상호작용을 도와주는 크레이트입니다. JavaScript와 Rust를 넘나들면서 임포트를 할 수 있도록 도와줍니다.

wasm-bindgen-futures | crates.io | 레포지토리

wasm-bindgen-futures는 JavaScript의 Promise와 Rust의 Future을 연결해 주는 다리 역할을 하는 크레이트입니다. Rust와 JavaScript 사이에서 양방향으로 변환이 가능하고 Rust에서 비동기(asynchronous) 작업을 수행할 때 유용합니다. DOM 이벤트 및 I/O 작업과 상호작용할 수 있도록 해줍니다.

js-sys | crates.io | 레포지토리

wasm-bindgen으로 작성된 js-sys 크레이트는 Object, Function, eval 등의 JavaScript 전역 타입과 메소드를 모두 임포트합니다. 이러한 API들은 웹 뿐만 아니라 Node.js를 포함한 다른 모든 ECMAScript 환경에 이식해서 사용할 수 있습니다.

web-sys | crates.io | 레포지토리

DOM 조작, setTimeout, Web GL, Web Audio 등과 같은 다른 모든 웹 API들을 wasm-bindgen으로 작성한 크레이트입니다.

오류 보고 및 로깅

console_error_panic_hook | crates.io | 레포지토리

console.error로 메세지를 패닉 메세지를 넘겨주는 패닉 훅 (panic hook) 과 함께, wasm32-unknown-unknown 타겟으로 패닉들을 디버깅할 수 있도록 도와주는 크레이트입니다.

console_log | crates.io | 레포지토리

log 크레이트의 백엔드를 제공해 주는 크레이트입니다. 로그 된 메세지들을 개발자 도구(devtools) 콘솔로 넘겨줍니다.

동적 할당

wee_alloc | crates.io | 레포지토리

이 크레이트의 이름은 Wasm-Enabled, Elfin Allocator 에서 유래됐는데, (1000 bytes 이하 사이즈의 압축되지 않은 .wasm 바이너리로 구성된) 코드 사이즈가 할당 성능보다 더 중요할 때 유용하게 사용할 수 있도록 할당자를 구현한 코드입니다.

.wasm 바이너리를 파싱(parsing)하고 생성하기

parity-wasm | crates.io | 레포지토리

직렬화(serializing)과 역직렬화(deserializing), 그리고 .wasm 바이너리를 빌드하는데 사용하는 저레벨 WebAssembly 포맷입니다. "names" 및 "reloc.WHATEVER"과 같이 잘 알려진 섹션들이 잘 지원돼 있습니다.

wasmparser | crates.io | 레포지토리

WebAssembly 바이너리 파일을 읽는 데 사용하는 간단한 이벤트 기반 (event-driven) 라이브러리입니다. 각각 파싱한 내용의 바이트 오프셋 (byte offset) 을 제공하는데, 예를 들어 reloc을 읽는 작업 등에 필요합니다.

WebAssembly 컴파일하고 인터프리팅(Interpreting)하기

wasmi | crates.io | 레포지토리

Parity 라는 회사에서 만든 임베딩 할 수 있는 WebAssembly 인터프리터입니다.

cranelift-wasm | crates.io | 레포지토리

WebAssembly 코드를 네이티브 호스트의 기계어(machine code)로 컴파일해 주는 크레이트인 Cranelift (né Cretonne) 코드 생성기 프로젝트의 일부입니다.

알면 좋은 툴들

Rust와 WebAssembly로 개발할 때 알면 좋은 툴들을 모아놓은 목록입니다.

개발, 빌드 및 워크플로우 관리 (Orchestration)

wasm-pack | 레포지토리

wasm-pack은 Web과 Node.js 환경에서 JavaScript와 상호 운용하면서 Rust로 WebAssembly 작업을 할 때 사용할 수 있는 만능 툴입니다. wasm-pack을 사용해서 더 쉽게 WebAssembly를 빌드하고 npm 레지스트리에 배포하고, 기존에 이미 사용하는 워크플로우가 사용하는 패키지들과 같이 사용할 수 있도록 도와줍니다.

.wasm 바이너리 최적화와 조작하기

wasm-opt | 레포지토리

wasm-opt는 WebAssembly을 입력으로 받고 변형하고 최적화한 다음, 원한다면 코드를 측정/분석까지 한 다음 WebAssembly 파일을 출력합니다. rustc가 작동하는 방식처럼, LLVM으로 생성한 .wasm 바이너리를 입력으로 넣고 wasm-opt를 실행하면 더 작고 더 빠르게 실행할 수 있는 .wasm 바이너리 파일을 생성할 수 있습니다.

wasm2js | 레포지토리

wasm2js 툴은 WebAssembly 파일을 "거의 asm.js"처럼 컴파일 해줍니다. Internet Explorer 11처럼 WebAssembly 기능이 구현되지 않은 브라우저를 지원할 때 좋습니다. 이 툴은 binaryen 프로젝트의 일부입니다.

wasm-gc | 레포지토리

WebAssembly를 가비지 콜렉팅하고 필요하지 않은 익스포트, 임포트, 함수 등을 지울 때 사용 수 있는 심플한 툴입니다. WebAssembly 파일과 함께 --gc-sections 링커 플래그 (linker flag) 를 사용하면 더 효과적입니다.

보통은 다음과 같은 이유로 이 툴을 개발자가 "직접" 프로젝트에 포함시키지 않습니다:

  1. rustc 컴파일러가 이제 새 버전의 lid를 지원하는데, 이 버전이 --gc-sections 라는 플래그를 지원합니다. 이 플래그는 LTO 빌드를 하는데 자동으로 활성화되게 됩니다.
  2. wasm-bindgen CLI (커맨드 라인 인터페이스 / Command Line Interface) 툴이 자동으로 wasm-gc 를 실행시켜 줍니다.

wasm-snip | 레포지토리

wasm-snip는 WebAssembly 함수의 내용을 unreachable 명령어(instruction)으로 바꿔줍니다.

어떤 함수가 실제로 호출되지 않는걸 아는데 컴파일러가 이걸 모를 때가 있나요? 일단 컴파일하고 wasm-gc를 실행해 보세요! (런타임(runtime)에서 호출되지 않는) 다른 간접적으로 호출되는 다른 함수들도 모두 제거됩니다.

디버깅 코드가 없는 실제로 배포하는 빌드 (production build) 에서 강제로 Rust 의 패닉 인프라(infrastructure)를 제외할 때 유용합니다.

.wasm 바이너리 살펴보기

twiggy | 레포지토리

twiggy.wasm 바이너리에 사용하는 코드 프로파일러입니다. 바이너리의 호출 그래프 (call graph) 를 분석하고 다음과 같은 내용을 알려줍니다:

  • 애초에 빌드할 때 어떤 함수가 왜 바이너리에 포함됐나요? 예: 어떤 익스포트한 함수들이 간접적으로 호출되고 있나요?

  • 어떤 함수를 지우면 얼마나 공간을 아낄 수 있나요? 예: 이 함수와 다른 사용되던 함수들까지 지우면 얼마나 공간이 절약되나요?

twiggy를 사용해서 바이너리를 더 가볍게 만들어보세요!

wasm-objdump | 레포지토리

wasm 바이너리의 저레벨 상세 정보와 섹션들을 출력합니다. WAT 텍스트 포맷으로 디어셈블링(disassembling)할 수도 있습니다. WebAssembly에 사용하는 objdump으로 생각해도 좋습니다. 이 툴은 WABT 프로젝트의 일부입니다.

wasm-nm | 레포지토리

.wasm 바이너리 내의 임포트되고, 익스포트되고, 그리고 private인 함수 심볼들 (function symbols) 을 나열합니다. WebAssembly에 사용하는 nm으로 생각해도 좋습니다.

프로젝트 템플릿

"The Rust and WebAssembly Working Group"은 개발자들이 빠르게 새 프로젝트를 시작하고 실행할 수 있도록 여러 가지 프로젝트 템플릿 목록을 만들고 관리합니다.

wasm-pack-template

wasm-pack으로 셋업 된 이 템플릿은 Rust와 WebAssembly 프로젝트를 손쉽게 시작할 수 있도록 준비돼 있습니다.

cargo generate 명령어를 실행하여 프로젝트 템플릿을 클론해보세요:

cargo install cargo-generate
cargo generate --git https://github.com/rustwasm/wasm-pack-template.git

create-wasm-app

이 템플릿wasm-pack을 통해 배포된 npm 패키지를 간편하게 사용할 때 유용한 기능들을 포함합니다.

npm init 명령어로 사용해 보세요:

mkdir my-project
cd my-project/
npm init wasm-app

이 템플릿은 주로 wasm-pack-template이라는 종속성괴 함께 사용되는데, create-wasm-app으로 프로젝트를 생성하면 npm link 기능으로 생성한 프로젝트와 연결하는 데 사용됩니다.

rust-webpack-template

이 템플릿은 Rust 코드를 WebAssembly로 컴파일하고 출력된 파일들을 Webpack의 rust-loader를 사용해서 바로 파이프라인으로 후킹 하여 연결할 수 있도록 거의 모든 보일러플레이트들을 대신 설정해 줍니다.

npm init 명령어로 사용해 보세요:

mkdir my-project
cd my-project/
npm init rust-webpack

Rust로 생성한 WebAssembly 디버깅하기

이 섹션은 Rust로 생성한 WebAssembly를 디버깅하는데 유용한 정보를 포함합니다.

debug 심볼을 포함해서 빌드하기

⚡ 디버깅할 때 항상 debug 심볼을 포함하고 빌드하는지 확인해 주세요!

debug 심볼이 활성화 되어있지 않다면 "name" 커스텀 섹션이 컴파일된 .wasm 파일에 반영되지 않을 수도 있습니다. 이런 경우에는 스택 추적 (stack trace) 을 할 때 함수 이름이 Rust 함수 이름 대신 wasm-function[42] 처럼 읽기 어렵게 표시됩니다. 정상적으로 이 심볼이 활성화 된 경우에는 wasm_game_of_life::Universe::live_neighbor_count 와 같이 표시됩니다.

(wasm-pack build --debug 혹은 cargo build 명령어로) "debug" 빌드를 할 경우 이 심볼이 기본값으로 활성화됩니다.

"release" 빌드를 할 때는 debug 심볼이 기본값으로 활성화되지 않으니 참고해주세요. 그래도 활성화를 하고 싶다면 Cargo.toml 파일을 열고 [profile.release] 섹션에 debug = true를 포함시켜주세요.

[profile.release]
debug = true

console API로 로깅 하기

로깅(logging) 작업은 코드를 설계하면서 만든 가설들을 확인하고 프로그램의 버그를 잡아내는 데 매우 효과적입니다. 보통은 웹 환경에서 console.log 함수를 호출하여 브라우저의 개발자 콘솔에 메세지를 로그할 수 있습니다.

하지만 web-sys 크레이트를 사용하여 console 로깅 함수를 해볼수도 있습니다.

#![allow(unused)]
fn main() {
extern crate web_sys;

web_sys::console::log_1(&"Hello, world!".into());
}

또 다른 옵션으로는 console.error 함수가 있습니다. console.errorconsole.log와 같은 타입 시그니처(signature)를 포함하고 있지만 개발자 툴에서 로그 메세지와 함께 스택 추적을 찾고 표시하는 데 사용할 수 있습니다.

참조

패닉 로그 하기

console_error_panic_hook 크레이트는 console.error 함수를 사용하여 예상치 못한 패닉들을 로그 합니다. 이 크레이트를 통해 외계어같이 보이고 디버깅하기 어려운 RuntimeError: unreachable executed 에러 메세지 대신 Rust 환경에서 로그 했던 것과 같이 깔끔하게 포맷된 패닉 메세지를 표시할 수 있습니다.

정말 간단하게도 코드가 시작되는 함수에서 console_error_panic_hook::set_once()를 호출해서 훅(hook)을 설정하기만 하면 됩니다.

#![allow(unused)]
fn main() {
#[wasm_bindgen]
pub fn init_panic_hook() {
    console_error_panic_hook::set_once();
}
}

디버거 사용하기

정말 아쉽게도 WebAssembly 디버깅 경험은 아직 개선돼야할 부분이 많습니다. 대부분의 Unix 시스템에서는 DWARF를 사용하여 소스 레벨에서 실행하는 프로그램을 살펴보게 되는데, 이 DWARF를 사용하여 디버거가 필요로 하는 정보를 인코딩(encoding)할 수 있습니다. 반면에 Windows에서는 대신 사용할 수 있는 포맷이 있음에도 현재로서는 WebAssembly를 직접적으로 지원하지는 않습니다. 그러므로 따로 작성했던 Rust 소스 텍스트 대신 컴파일러가 출력한 WebAssembly 명령어를 그대로 확인해야 합니다.

W3C WebAssembly group의 디버깅 하위 조항도 있으므로 미래에는 이런 문제가 개선될 것으로 예상됩니다!

그래도 WebAssembly와 함께 작성된 JavaScript 코드를 살펴보고 wasm의 상태를 직접 살펴보는데 디버거가 매우 유용하므로 참고해 주세요.

참조

처음부터 WebAssembly 디버깅을 최소화할 수 있도록 신경 써서 코드를 작성해 주세요

고쳐야 할 버그가 JavaScript나 Web API 환경을 필요로 한다면 wasm-bindgen-test으로 테스팅 코드를 작성해 보세요.

그렇지 않다면 #[test] 속성을 포함하여 일반적인 Rust 코드처럼 실행 환경을 재현해 보세요. 이런 방식으로 운영체제의 성숙한 네이티브 툴링을 최대한 활용하여 디버깅할 수 있게 됩니다. quickcheck과 같은 테스팅 크레이트와 테스팅 코드 축소기도 보면서 기계적으로 테스트 코드의 사이즈를 줄여볼수도 있습니다. 최종적으로는, JavaScript 환경이 요구되지 않도록 더 작은 Rust 테스트 코드로 분리시키는 식으로 버그를 더 쉽게 찾고 고칠 수 있게 됩니다.

컴파일러와 링커(linker) 오류 없이 네이티브 #[test] 속성을 사용하려면 Cargo.toml 파일의 [lib.crate-type] 배열에 rlib가 포함돼 있는지 확인해 주세요.

[lib]
crate-type ["cdylib", "rlib"]

타임 프로파일링

이 섹션은 Rust와 WebAssembly을 사용하여 성능을 덜 쓰면서도 지연 시간도 줄일 수 있도록 페이지를 프로파일링하는 방법에 대해 다룹니다.

⚡ 프로파일링을 할 때에는 항상 최적화된 빌드를 사용해주세요! wasm-pack build 명령어를 사용하고 있다면 이러한 최적화 옵션이 기본적으로 활성화돼있으니 참고해 주세요.

사용 가능한 툴들

window.performance.now() 타이머

performance.now() 함수는 웹 페이지가 로드된 시점부터 측정된 밀리초 단위의 단조 시간 (monotonic timestamp)를 반환합니다.

performance.now를 호출할 때에는 거의 오버헤드가 발생하지 않습니다. 그러므로 시스템 성능에 큰 영향을 주거나 측정 값에 편향을 주지 않으면서 간단하고 상세한 측정을 원활하게 할 수 있게 됩니다.

이 함수를 사용하여 프로그램의 여러 작업들이 실행되는 시간을 측정해 볼 수 있고, web-sys 크레이트를 통해 window.performance.now()에 접근할 수도 있습니다:

#![allow(unused)]
fn main() {
extern crate web_sys;

fn now() -> f64 {
    web_sys::window()
        .expect("should have a Window")
        .performance()
        .expect("should have a Performance")
        .now()
}
}

개발자 도구 내의 프로파일러

모든 웹 브라우저의 개발자 도구는 프로파일러를 포함하고 있습니다. 이러한 프로파일러들은 "call tree"나 "flame graph"와 같은 일반적인 시각 자료와 함께 어떤 함수들이 가장 많은 시간을 소비하는지 표시해 줍니다.

"name" 커스텀 섹션이 wasm 바이너리에 포함되도록 debug 심볼을 포함해서 빌드하면 프로파일러가 wasm-function[123]와 같이 복잡하고 불투명한 이름 대신 Rust 함수 이름을 대신 표시하게 됩니다.

이러한 프로파일러들은 인라인 함수를 표시하지 않는다는 점을 기억해 주세요. Rust와 LLVM는 인라인 작업을 매우 무겁게 수행하기 때문에 이러한 작업을 해주더라도 결괏값이 조금은 읽기 어려울수 있습니다.

Rust 심볼을 표시하는 프로파일러 스크린샷

추가 자료

console.timeconsole.timeEnd 함수

console.timeconsole.timeEnd 함수를 사용해서 브라우저 개발자 도구 콘솔에 명명된 작업의 타이밍을 기록할 수 있습니다. 작업이 시작될 때 console.time("어떤 거 하는 작업")를 호출하고, 작업이 완료될 때는 console.timeEnd("어떤 거 하는 작업")를 호출합니다. 참고로 작업의 이름을 지어주는 문자열 인자는 필수가 아닙니다.

web-sys 크레이트를 통해 이러한 함수들을 직접적으로 사용할 수 있습니다:

브라우저의 콘솔에 console.time 로그를 표시하는 스크린샷을 확인해보세요:

console.time 로그 스크린샷

추가로, 다음 이미지에 보이는 것과 같이 브라우저 프로파일러의 "timeline"과 "waterfall" 뷰에 console.timeconsole.timeEnd의 로그가 표시됩니다.

console.time 로그 스크린샷

#[bench] 속성으로 네이티브 환경을 사용할 때

웹 브라우저에서 #[test] 속성을 대신 이용하여 운영체제의 네이티브 코드로 디버깅을 했던 것과 같이, #[bench] 속성과 함께 함수를 작성해서 운영 체제의 네이티브 코드 프로파일링 툴을 사용해 볼 수도 있습니다.

작업 중인 크레이트의 하위 경로인 benches에 벤치마킹 코드를 작성해 보겠습니다. 먼저 crate-type"rlib"이 포함하고 있는지 확인해 주세요. 그렇지 않다면 벤치마크 바이너리가 메인 라이브러리 (main lib) 에 링크되지 못하게 됩니다.

하지만 실제로는 거의 사용하지 않는 코드에 시간을 소비하고 있을 수도 있으니, 네이티브 코드로 벤치마킹을 하기 전에 브라우저 프로파일링 툴을 먼저 확인해 보는 것도 좋습니다!

추가 자료

.wasm 파일 사이즈 줄이기

이 섹션에서는 어떻게 Rust 코드를 수정하고 .wasm 빌드를 최적화해야 더 작은 사이즈의 바이너리를 출력할 수 있는지 살펴보겠습니다.

왜 출력되는 파일의 사이즈가 중요한가요?

.wasm 파일을 네트워크로 전송할 때 파일의 사이즈가 작을수록 클라이언트에서 더 빠르게 다운로드 할 수 있습니다. .wasm 파일이 빨리 다운로드 될수록 페이지가 더 빨리 로드되고 유저 경험이 더 나아지게 됩니다.

하지만 코드 사이즈가 어쩌면 제일 중요하게 확인해야 할 부분이 아닐 수도 있습니다. 모호하고 측정하기 어렵겠지마 "페이지가 로드되고 사용할 수 있게 될 때까지 걸리는 시간"이 실제로는 더 중요하게 고려해 봐야 할 부분일 수도 있습니다. (코드가 로드돼야 사이트가 작동하기 시작한다는 내용을 생각해 본다면) 코드 사이즈가 이 시간에 큰 영향을 미치긴 하지만 이게 유일하게 확인해야 할 부분은 아닌 것을 알 수 있습니다.

WebAssembly는 보통 gzip 파일 포맷 형식으로 압축되어 전송되는데, 그러므로 유선을 통해 파일을 더 빠르게 보낼 수 있도록 gzip 포맷으로 압축된 파일의 사이즈를 비교해야 합니다. 참고로 WebAssembly 바이너리 포맷은 gzip 포맷에 적합하므로 50% 이상으로 사이즈를 줄일 수 있습니다.

게다가, WebAssembly의 바이너리 포맷은 매우 빠르게 읽고 처리할 수 있도록 최적화가 잘 되어있습니다. 요즘 사용되는 브라우저들은 보통 "baseline compilers" 라는 기능을 가지고 있는데, 이 기능을 통해 네트워크로 wasm 파일을 보내는 동시에 동일하게 빠른 속도로 전송받은 WebAssembly 코드를 네이티브 기계어로 컴파일할 수 있습니다. 그렇기 때문에 instantiateStreaming을 사용한다면 웹페이지가 한 번 로드 된 이후부터는 WebAssembly 모듈을 바로 사용할 수 있게 됩니다. 반면에 JavaScript 코드를 실행할 때는 주로 파싱하는 처리 외에도 코드를 빠르게 실행할 수 있도록 JIT 컴파일 과정 등을 거쳐야 하기 때문에 주로 시간이 더 오래 걸리게 됩니다.

마지막으로, WebAssembly가 실행 속도 측면에서 JavaScript와 비교했을 때 훨씬 최적화가 잘 돼있다는 부분을 기억해주세요. 더 확실하게 하고 싶다면 JavaScript와 WebAssembly 런타임 속도를 각각 측정해 보고 코드 사이즈가 얼마나 중요한지 확인해볼수도 있습니다.

하지만 .wasm 파일의 사이즈가 예상보다 크더라도 바로 낙담하지는 말아주세요! 코드 사이즈는 큰 그림의 일부일 뿐입니다. JavaScript와 WebAssembly를 비교할 때 코드 사이즈만 비교하게 된다면 많은 부분을 놓치게 됩니다.

코드 사이즈를 줄일 수 있도록 빌드 최적화하기

rustc에는 더 작은 .wasm 바이너리를 생성할 때 유용한 옵션을 몇가지 가지고 있습니다. 어떤 상황에서는, 컴파일 시간이 더 오래 걸리는 부분을 희생해서 .wasm 사이즈를 더 작게 줄이기도 합니다. 또 다른 경우에는, 더 빠른 런타임을 위해 .wasm 파일 사이즈를 포기하기도 합니다. 각 옵션의 장단점을 잘 이해하고, 프로파일링과 측정 작업을 해보면서 코드 사이즈와 런타임 속도 중 어떤 것이 우선시되어야 하는지 잘 알고 결정하는 것이 중요합니다.

Cargo.toml 파일의 [profile.release] 섹션에 lto = true를 추가해 주세요:

[profile.release]
lto = true

이렇게 LLVML이 사용하지 않는 코드를 더 공격적으로 제거하고 인라인 작업을 더 적극적으로 처리할 수 있도록 설정할 수 있습니다. LTO를 사용하면 .wasm 파일의 사이즈가 작아질 뿐 아니라, 런타임을 더 빠르게 만들 수도 있습니다! 하지만 컴파일 작업이 더 오래 걸린다는 단점이 있으니 참고해 주세요.

런타임 속도 대신 코드 사이즈를 최적화하도록 LLVM 설정하기

LLVM의 최적화 작업은 기본적으로 사이즈 대신 속도 개선에 중점을 두고 진행됩니다. [profile.release]파일의 [profile.release] 섹션을 수정하여 이 설정을 변경할 수도 있습니다:

[profile.release]
opt-level = 's'

추가로 발생할 수 있는 속도 저하를 감수해서라도 더 공격적으로 최적화를 해볼 수도 있습니다:

[profile.release]
opt-level = 'z'

정말 놀랍게도, 출력되는 파일의 사이즈가 opt-level = "z"대신 opt-level = "s"를 사용했을 때 더 작아지는 경우도 있습니다. 항상 잘 확인해 보는 걸 잊지 말아 주세요!

wasm-opt 툴 사용하기

Binaryen 툴킷은 WebAssembly에 특화된 컴파일러 툴링을 포함합니다. 단순히 LLVM의 WebAssembly 백엔드 작업 외에도 훨씬 더 다양한 작업에 사용할 수 있고, wasm-opt 툴을 사용해서 LLVM이 빌드한 .wasm 바이너리를 최적화 하면 보통은 코드 사이즈를 15-20% 정도 더 줄일 수 있게 됩니다. 런타임 속도도 같이 개선될 수도 있으니 참고해 주세요!

# 사이즈 최적화.
wasm-opt -Os -o output.wasm input.wasm

# 공격적인 사이즈 최적화.
wasm-opt -Oz -o output.wasm input.wasm

# 속도 최적화.
wasm-opt -O -o output.wasm input.wasm

# 공격적인 속도 최적화.
wasm-opt -O3 -o output.wasm input.wasm

디버그 정보에 관해 알아두면 좋은 점

wasm 바이너리 사이즈를 줄이는 데 바이너리 파일에 포함된 디버그 정보와 names 섹션이 정말 큰 역할을 합니다. 하지만 wasm-pack이 기본값으로 디버그 정보를 삭제하고, 추가로 wasm-opt도 명령어에 -g가 포함되지 않는 이상 names 섹션을 기본적으로 지우는 부분을 잘 기억해 주세요.

이 책을 잘 따라왔다면 기본적으로는 디버그 정보나 names 섹션 없이 wasm파일을 빌드하게 됩니다. 하지만, 이러한 디버깅 정보가 wasm 바이너리에 포함돼야 하는 상황에서는 이 내용을 잘 참고해 주세요!

사이즈 프로파일링하기

빌드 최적화 설정을 바꿨는데도 .wasm 코드 사이즈가 충분히 줄어들지 않는다면, 어떤 부분이 나머지 공간을 사용하는지 프로파일링 작업을 해보면서 알아보도록 합시다.

⚡ 타임 프로파일링 가이드를 따라왔던 것처럼, 사이즈 프로파일링 가이드도 한번 읽어보고 시도해 봅시다. 읽어보는 것만으로도 시간을 정말 많이 아낄 수 있습니다!

twiggy 코드 사이즈 프로파일러

twiggy는 WebAssembly를 입력으로 받는 코드 사이즈 프로파일러입니다. 바이너리의 호출 그래프 (call graph) 를 분석하고 다음곽 같은 내용을 알려줍니다:

  • 어떤 함수들이 애초에 왜 바이너리에 포함되는 건가요?

  • 이 함수에 사용되는 공간의 크기가 어떻게 되나요? 예: 이 함수와 내부에서 호출해서 사용하게 되는 함수들까지 제거한다면 얼마나 공간을 아낄 수 있나요?

$ twiggy top -n 20 pkg/wasm_game_of_life_bg.wasm
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼────────────────────────────────────────────────────────────────────────────────────────
          9158 ┊    19.65% ┊ "function names" subsection
          3251 ┊     6.98% ┊ dlmalloc::dlmalloc::Dlmalloc::malloc::h632d10c184fef6e8
          2510 ┊     5.39% ┊ <str as core::fmt::Debug>::fmt::he0d87479d1c208ea
          1737 ┊     3.73% ┊ data[0]
          1574 ┊     3.38% ┊ data[3]
          1524 ┊     3.27% ┊ core::fmt::Formatter::pad::h6825605b326ea2c5
          1413 ┊     3.03% ┊ std::panicking::rust_panic_with_hook::h1d3660f2e339513d
          1200 ┊     2.57% ┊ core::fmt::Formatter::pad_integral::h06996c5859a57ced
          1131 ┊     2.43% ┊ core::str::slice_error_fail::h6da90c14857ae01b
          1051 ┊     2.26% ┊ core::fmt::write::h03ff8c7a2f3a9605
           931 ┊     2.00% ┊ data[4]
           864 ┊     1.85% ┊ dlmalloc::dlmalloc::Dlmalloc::free::h27b781e3b06bdb05
           841 ┊     1.80% ┊ <char as core::fmt::Debug>::fmt::h07742d9f4a8c56f2
           813 ┊     1.74% ┊ __rust_realloc
           708 ┊     1.52% ┊ core::slice::memchr::memchr::h6243a1b2885fdb85
           678 ┊     1.45% ┊ <core::fmt::builders::PadAdapter<'a> as core::fmt::Write>::write_str::h96b72fb7457d3062
           631 ┊     1.35% ┊ universe_tick
           631 ┊     1.35% ┊ dlmalloc::dlmalloc::Dlmalloc::dispose_chunk::hae6c5c8634e575b8
           514 ┊     1.10% ┊ std::panicking::default_hook::{{closure}}::hfae0c204085471d5
           503 ┊     1.08% ┊ <&'a T as core::fmt::Debug>::fmt::hba207e4f7abaece6

LLVM-IR 직접 살펴보기

LLMV-IR은 LLVM이 WebAssembly를 출력하기 전에 거치게 되는 최종 중간 표현 (final intermediate representation) 입니다. 이 최종 중간 표현은 최종적으로 출력하는 WebAssembly 바이너리와 매우 유사하게 생겼습니다. LLVM-IR의 크기가 클수록 출력되는 .wasm 파일의 크기도 커지게 되고, 함수들이 LLVM-IR 크기의 25%까지 차지하게 된다면 .wasm 파일에서도 함수들이 마찬가지로 25%를 차지하게 됩니다. 이러한 수치들이 보통은 일치하지만, LLVM-IR은 .wasm 파일이 (DWARF와 같은 디버깅 정보 처럼) 가지고 있지 않는 다른 중요한 정보들도 가지고 있는 점 또한 잘 참고해 주세요. 이러한 하위 루틴들은 해당 함수의 위치에 인라인됩니다.

cargo 명령어를 실행해서 LLVM-IR 파일을 직접 생성해 보세요:

cargo rustc --release -- --emit llvm-ir

그다음 find 명령어를 사용해서 .ll 파일을 검색해 보겠습니다. 이 LLVM-IR 파일은 cargotarget 경로에 위치하게 됩니다:

find target/release -type f -name '*.ll'

참조

툴과 테크닉을 활용하여 더 깊게 파고들어서 최적화하기

.wasm 바이너리 사이즈를 줄이는 설정은 보통 자동화가 돼 있습니다. 하지만 추가로 불필요한 코드를 제거하고 최적화를 해줘야 하는 경우에는 더 깊게 들어가서 수정해 줘야 할 때가 있습니다. 이 섹션에서는 코드 사이즈를 줄일 때 사용해 볼 수 있는 투박한 방법들에 대해 알아보겠습니다.

문자열 포맷 피하기

format!이나 to_string 과 같은 함수/매크로들을 사용하면 출력되는 바이너리의 사이즈가 불필요하게 커질 수도 있습니다. 가능하면 문자열 포맷은 디버그 모드에서만 사용하고, 배포 버전을 빌드할 때에는 정적 문자열 (static string) 을 사용해 보세요.

코드 패닉 피하기

말처럼 쉽지는 않겠지만, twiggy 와 같은 툴을 사용하거나 LLVM-IR 파일을 살펴보면서 어떤 함수들이 패닉하는지 살펴볼 수 있습니다.

패닉이 항상 panic!() 매크로의 형식으로 나타나지는 않고 보통은 다음과 같은 여러 가지 다양한 이유로 발생할 수 있습니다:

  • 슬라이스 사이즈 범위를 벗어난 인덱스의 요소에 접근하고자 시도할 때 (out of bounds) : my_slice[i]

  • 나머지 연산자를 사용할 때 나누는 수가 0인 경우: 나눠지는 수 / 나누는 수

  • Option이나 Result의 값을 unwrap()를 사용하여 값에 접근할 때: opt.unwrap() 또는 res.unwrap()

처음 두 방법 대신 사용해 볼 수 있는 더 안전한 방법들도 있습니다. my_slice[i] 처럼 인덱스를 통해 직접 접근하지 않고 my_slice.get(i) 를 사용하여 Option 타입의 값에 접근해 볼 수도 있고, check_div 함수를 불러서 값을 나눌수 있는지 확인해 볼 수도 있습니다. 이렇게 3번째 경우만 집중적으로 신경 쓸 수 있게 됐습니다.

추가로, 코드를 패닉 시키지 않으면서 Option이나 Result 타입의 값을 "안전한 방법과 불안전한 방법", 두 가지 방법으로 처리해볼수도 있습니다.

안전한 방법부터 한번 살펴보도록 합시다. None이나 Error 값을 반환받았을 때 코드를 패닉시키는 대신 abort 함수를 사용해 보겠습니다:

#![allow(unused)]
fn main() {
#[inline]
pub fn unwrap_abort<T>(o: Option<T>) -> T {
    use std::process;
    match o {
        Some(t) => t,
        None => process::abort(),
    }
}
}

최종적으로는 패닉 코드가 wasm32-unknown-unknown 타겟의 abort 명령어로 옮겨지기 때문에, 이런 식으로 코드를 작성하면서 불필요한 코드를 지울 수 있게 됩니다.

다른 시도해볼수 있는 방법으로는, unreachable 크레이트가 있습니다. 이 크레이트는 OptionResult 타입의 값들과 함께 사용할 수 있도록 불안전한 unchecked_unwrap 확장 크레이트들을 제공하는데, 컴파일러가 OptionSome으로, ResultOk추측해서 옮길 수 있도록 도와줍니다. 하지만 이런 추측이 맞아떨어지지 않으면 정의하지 않은 동작 (undefined behavior) 이 발생하게 되므로 코드가 잘 작동한다고 확신하지만 컴파일러가 잘 모르고 있는 상황을 잘 이해하고 있을 때만 이 방법을 사용해 주세요. 일반적으로는 이 방법을 배포 버전에서만 적용하고 그 외에는 컴파일러가 확인할 수 있도록 디버그 빌드를 따로 설정하길 권장합니다.

할당을 피하거나 wee_alloc을 대신 사용해 보세요.

Rust는 기본값으로 dlmalloc라는 할당자를 이식해서 사용합니다. 이 할당자는 10 KB 정도의 사이즈를 차지하게 되는데, 동적 할당을 사용하지 않아도 괜찮다면 이 사이즈를 절약해 볼 수도 있습니다.

완전히 동적 할당 없이 작업하기가 사실은 쉽지는 않은 편인데, 코드의 핫 스팟 (hot spot) 에서 할당을 없애는 작업은 대부분은 훨씬 쉬운 편입니다. (보통은 이 작업을 통해 핫 스팟인 코드들을 훨씬 빠르게 만들 수도 있습니다.) 이러한 상황에서 전역 할당자 대신 wee_alloc를 사용하면 (전부는 아니지만) 이 10 KB 만큼 차지되는 공간의 대부분을 절약할 수 있습니다. wee_alloc는 할당자를 필요로 하지만 굳이 아주 빠를 필요가 없고, 실행 속도가 느려지는 대신 코드 사이즈를 줄여도 괜찮을 때 사용하도록 설계됐습니다.

제네릭 타입 매개변수 대신 트레이트 객체를 사용해 보세요

다음 예시와 같이 타입 매개변수를 사용하는 제네릭 함수를 작성한다고 가정해 봅시다:

#![allow(unused)]
fn main() {
fn whatever<T: MyTrait>(t: T) { ... }
}

rustc와 LLVM는 제네릭 함수가 호출될 때 사용된 타입에 해당하는 바이너리 코드를 각각 따로 생성합니다. 이런 접근은 어떤 T 타입을 컴파일러가 처리하는지에 따라 컴파일러 최적화에 유용할 수도 있습니다. 하지만 동시에 코드 사이즈가 빠르게 늘어날 수도 있다는 단점도 가지고 있습니다.

다음과 같이 타입 매개변수 대신 트레이트 객체를 사용해 보겠습니다:

#![allow(unused)]
fn main() {
fn whatever(t: Box<MyTrait>) { ... }
// or
fn whatever(t: &MyTrait) { ... }
// etc...
}

이 코드를 컴파일할때 가상 환경에서 함수들을 동적 디스패치 (dynamic dispatch) 라는 메커니즘으로 처리를 하게 되는데, 한 가지 버전의 함수만 .wasm 파일로 처리되게 됩니다. 이러한 처리를 하면서 컴파일러 최적화 측면에서 손실이 있을 수 있고, 간접적이고 동적으로 디스패치된 함수를 부르는데 추가적인 비용이 들수도 있다는 단점이 있습니다.

wasm-snip 툴을 사용해 보세요

wasm-snip은 WebAssembly 함수의 코드를 unreachable Assembly 명령어로 교체해 줍니다. 하지만 잘 생각해보면 못 하나를 박는다고 아주 거대한 망치를 가져와서 열심히 두드리는 것 같은 느낌이 조금씩 듭니다.

런타임에서 사용하지 않는 함수들과 이 함수들이 간접적으로 호출하는 다른 함수들을 제거하고 싶은데, 컴파일러가 어떤 함수를 사용하지 않는지 모른다면 어떻게 해야 할까요? 우선은 코드를 빌드해보고 wasm-opt--dce 플래그를 포함해서 다시 실행해 보세요! 간접적으로 호출되는 (런타임에서 부르지 않는) 함수들까지 지워버릴 수 있습니다.

패닉 코드가 이런 문제들로 종종 이어질 수 있기 때문에, 패닉 인프라 (panicking infrastructure) 를 지울 때 이 툴이 유용하게 사용될 수도 있습니다.

JavaScript 상호 운용하기

JavaScript 함수 임포트하고 익스포트하기

Rust 사이드

JavaScript 환경에서 wasm을 사용할 때 Rust 사이드에서 함수들을 임포트하고 익스포팅하는 작업은 의외로 매우 간단합니다. 그리고 C 언어와 유사하게 작동하기도 합니다.

WebAssembly 모듈은 임포트 시퀀스(sequence)를 선언하는데, 각각 시퀀스는 모듈 이름임포트 이름으로 구성됩니다. 기본값으로 "env" 파일에 설정된 것과 같이, #[link(wasm_import_module)]를 사용하여 extern { ... } 블럭 모듈 이름을 직접 지정해 줄 수도 있습니다.

익스포트는 한가지 이름만 가질 수 있는데, 다른 extern 함수들과 마찬가지로, WebAssembly 인스턴스의 선형 메모리는 기본값으로는 "memory" 라는 이름으로 익스포트 됩니다.

#![allow(unused)]
fn main() {
// `foo`라는 JavaScript 함수를 `mod` 모듈에서 임포트해옵니다.
#[link(wasm_import_module = "mod")]
extern { fn foo(); }

// `bar`라는 Rust 함수를 익스포트합니다.
#[no_mangle]
pub extern fn bar() { /* ... */ }
}

wasm은 제한된 수의 값 타입들을 가지고 있기 때문에, 이러한 함수들은 원시 숫자 타입에서만 작동해야 한다는 부분도 참고해 주세요.

JavaScript 사이드

JavaScript 코드 내에서는 wasm 바이너리가 ES6 모듈로 변환되는 것을 확인할 수 있습니다. 이 모듈은 선형 메모리와 함께 먼저 인스턴스화 돼야하고, 임포트한 내용과 일치하는 JavaScript 함수 그룹을 필요로 합니다. 인스턴스화에 대해 더 자세히 알아보려면 MDN를 참고해 주세요.

출력하게 되는 ES6 모듈은 Rust에서 익스포트한 함수들을 모두 포함하게 되는데, 이런 함수들은 JavaScript 함수에서 호출할 수 있습니다.

셋업을 하고 실행시키는 전반적으로 간단한 예시를 확인하고 싶다면 여기를 참고해 주세요.

숫자 외에 다른 값도 사용해보기

JavaScript에서 wasm을 사용할 때는 wasm 모듈의 메모리와 JavaScript 메모리 사이가 명확하게 구분됩니다:

  • (이 문서 최상단에 설명된 대로) 각각의 wasm 모듈은 인스턴스화 과정에서 생성하게 되는 선형 메모리를 가지게 됩니다. JavaScript 코드는 자유롭게 이 메모리를 읽고 쓸 수 있습니다.

  • 반면에, wasm에서는 JavaScript 객체에 직접 접근할 수 없습니다.

그런 이유로 보통은 두 가지 방법으로 정교화된 상호작용을 처리하게 됩니다:

  • 바이너리 데이터를 wasm 메모리에서 복사하거나 붙여놓습니다. 예를 들어서, 이 방법으로 Rust 코드가 소유하는 String 값을 사용할 수도 있습니다.

  • 명시적으로 JavaScript 객체의 힙을 설정하고 이후에 "addresses"를 할당합니다. 이렇게 wasm 코드에서 (정수 값을 사용하여) 간접적으로 JavaScript 객체를 참조할 수 있고, 임포트한 JavaScript 함수를 호출해서 사용할 수 있게 됩니다.

다행스럽게도 이 상호 운용 작업은 wasm-bindgen이라는 bindgen 스타일의 프레임워크를 사용해서 아주 쉽게 처리할 수 있습니다. 이 프레임워크를 사용해서 관용적인 스타일의 JavaScript 함수에 자동으로 매핑되도록 관용적인 Rust 함수 시그니처를 생성할 수 있습니다.

사용자 정의 섹션 (Custom Sections)

사용자 정의 섹션을 통해 wasm 모듈에 명명된 임의의 데이터를 임베딩할 수 있습니다. 이 섹션 데이터는 컴파일 시점에 설정되며 wasm 모듈에서 직접 읽을 수 있지만 런타임에서는 이 모듈을 수정할 수 없습니다.

Rust 코드에서 다음과 같이 사용자 정의 섹션을 정적 배열 ([T; size])로 나타내고 #[link_section] 속성으로 노출시킬 수 있습니다:

#![allow(unused)]
fn main() {
#[link_section = "hello"]
pub static SECTION: [u8; 24] = *b"This is a custom section";
}

이 코드는 wasm 파일에 hello 라는 사용자 정의 섹션을 추가합니다. 변수 이름 SECTION 은 임의로 명명됐으며 이 이름을 변경해도 코드의 동작이 바뀌지 않습니다. 이 변수에 텍스트 바이트 (bytes of text) 를 사용했지만 다른 임의의 데이터를 사용할 수도 있습니다.

이런 사용자 정의 섹션은 JS 사이드에서 WebAssembly.Module.customSections 함수를 사용하여 읽을 수도 있습니다. 이 함수를 호출할 때, wasm 모듈과 섹션 이름을 인자로 주고 ArrayBuffer의 배열을 반환받게 됩니다. 여러 섹션들이 같은 이름을 공유하게 할 수도 있는데, 이 경우에는 한 배열에 여러 섹션들을 담게 됩니다.

WebAssembly.compileStreaming(fetch("sections.wasm"))
.then(mod => {
  const sections = WebAssembly.Module.customSections(mod, "hello");

  const decoder = new TextDecoder();
  const text = decoder.decode(sections[0]);

  console.log(text); // -> "사용자 정의 섹션을 콘솔에 출력합니다"
});

어떤 크레이트들을 WebAssembly에서 바로 사용할 수 있나요?

가장 간단하게 어떤 기능들이 WebAssembly 환경에서 작동하지 않는지 목록을 한번 만들어 보겠습니다. 다음 내용에 해당하지 않는 크레이트들은 휴대성이 좋고 WebAssembly 환경에서 잘 작동한다고 생각해도 좋고, 임베디드 시스템과 #![no_std]를 지원하는 크레이트들도 보통은 WebAssembly도 지원한다고 볼수 있습니다.

이러한 크레이트들은 WebAssembly 환경에서 작동하지 않을 수도 있습니다

C 언어와 시스템 라이브러리 종속성이 있는 경우

wasm에는 시스템 라이브러리가 없기 떄문에 시스템 라이브러리에 바인딩돼 있는 코드가 있다면 작동하지 않습니다.

wasm에는 언어 간 통신에 사용할 수 있는 안정된 버전의 ABI와 언어 간 링킹(linking)이 존재하지 않습니다. 그러므로 C 라이브러리를 사용하는 크레이트도 작동하지 않습니다. 특히 clang 컴파일러가 wasm32 타겟을 기본으로 제공하기 때문에 결국은 작동할 것으로 예상되지만, 현재로서는 완벽하지 않습니다.

파일 I/O

WebAssembly는 파일 시스템에 접근할 수 없습니다. — 파일 시스템을 필요로 하고 wasm에서 사용할 수 있도록 코드가 마련되지 않은 크레이트는 작동하지 않습니다.

스레드 생성

스레딩을 웹어셈블리에 지원하는 계획이 있긴 하지만 아직 준비가 완벽히 되지 않았습니다. wasm32-unknown-unknown 타겟에서 스레드를 생성하려고 시도하면 wasm 트랩 (wasm trap) 이 발생하면서 코드가 패닉하게 됩니다.

어떤 다목적 크레이트들이 WebAssembly에서 바로 작동하는 편인가요?

알고리즘과 자료 구조

A* 알고리즘splay trees처럼, 특정한 알고리즘이나 data structure의 구현을 제공하는 크레이트들은 WebAssembly와 잘 작동하는 편입니다.

#![no_std]

Rust 스탠다드 라이브러리를 사용하지 않는 크레이트들도 WebAssembly와 잘 작동하는 편입니다.

파서 (Parser)

입력을 받고 I/O 작업을 수행하지 않는 이상, 파서들은 보통 WebAssembly와 잘 작동하는 편입니다.

텍스트 처리

인간 언어를 텍스트 형태로 나타냈을 때 발생하는 복잡성을 다루는 크레이트들은 WebAssembly와 잘 작동하는 편입니다.

Rust 패턴

Rust 프로그래밍의 특정한 상황에 쓰도록 공유되는 해결책들은 WebAssembly와 잘 작동하는 편입니다.

다목적 크레이트가 WebAssembly를 지원하도록 코드 수정하기

이 섹션은 WebAssembly를 지원하는 데에 관심이 있는 다목적 크레이트 저자들을 위해 작성됐습니다.

크레이트가 이미 WebAssembly를 지원할 수도 있어요!

우선 어떤 작업을 하는 다목적 크레이트가 WebAssembly에 적합하지 않나요? 의 내용을 먼저 읽어보세요. 작성하는 크레이트가 해당 사항이 없다면, WebAssembly를 지원할 확률이 높습니다.

추가로, 언제든 cargo build 명령어를 입력해서 WebAssembly 타겟을 지원하는지 확인해 볼 수 있습니다:

cargo build --target wasm32-unknown-unknown

명령어 실행이 실패한다면 현재로서는 크레이트가 WebAssembly를 지원하지 않는다는 의미입니다. 하지만 이 명령어가 성공하더라도 크레이트가 꼭 WebAssembly를 지원한다는 의미는 아닙니다. 더 확실하게 확인하고 싶다면 wasm 테스팅 코드를 추가하고 지속성 통합 (continuous integration) 환경에서 테스트를 실행해보기 를 읽어보세요.

WebAssembly 지원 추가하기

바로 I/O를 수행하지 말아주세요

웹 환경에서 I/O는 항상 비동기적일 뿐 아니라 파일 시스템도 존재하지 않습니다. 라이브러리의 I/O를 분리하고 유저들이 I/O를 직접 수행하고 슬라이스를 라이브러리로 대신 입력할 수 있도록 수정해 봅시다.

예를 들어서, 이 코드를 리팩토링해 주세요:

#![allow(unused)]
fn main() {
use std::fs;
use std::path::Path;

pub fn parse_thing(path: &Path) -> Result<MyThing, MyError> {
    let contents = fs::read(path)?;
    // ...
}
}

다음은 리팩토링 된 코드입니다:

#![allow(unused)]
fn main() {
pub fn parse_thing(contents: &[u8]) -> Result<MyThing, MyError> {
    // ...
}
}

wasm-bindgen을 종속성으로 추가하기

(예를 들어서, 라이브러리 사용자가 상호작용을 직접 컨트롤하도록 허용하면 안 되는 경우처럼) 외부 환경과 따로 상호작용을 해야 하는 경우, (필요하다면 js-sysweb-sys와 함께) wasm-bindgen을 컴파일, 타겟팅 종속성으로 추가해야 합니다:

[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = "0.3"

동기적 I/O를 피해주세요

웹 환경에서는 비동기적 I/O만 수행할 수 있으므로 라이브러리에서 I/O 작업을 수행할 때는 동기적으로는 처리할 수 없습니다. futures 크레이트wasm-bindgen-futures 크레이트를 사용해서 비동기 I/O를 관리해 보세요. 라이브러리가 Future 타입 F를 제네릭 타입으로 사용한다면, 웹 환경의 fetch나 운영체제에서 제공하는 논블로킹 (non-blocking) I/O로 코드를 구현해 볼 수 있습니다.

#![allow(unused)]
fn main() {
pub fn do_stuff<F>(future: F) -> impl Future<Item = MyOtherThing>
where
    F: Future<Item = MyThing>,
{
    // ...
}
}

트레이트를 정의하고 WebAssembly와 웹, 네이티브 타겟에서 실행할 수 있도록 구현해 볼 수도 있습니다:

#![allow(unused)]
fn main() {
trait ReadMyThing {
    type F: Future<Item = MyThing>;
    fn read(&self) -> Self::F;
}

#[cfg(target_arch = "wasm32")]
struct WebReadMyThing {
    // ...
}

#[cfg(target_arch = "wasm32")]
impl ReadMyThing for WebReadMyThing {
    // ...
}

#[cfg(not(target_arch = "wasm32"))]
struct NativeReadMyThing {
    // ...
}

#[cfg(not(target_arch = "wasm32"))]
impl ReadMyThing for NativeReadMyThing {
    // ...
}
}

스레드 생성을 피해주세요

Wasm은 아직 스레드를 지원하지 않습니다. (하지만 실험적으로 지원 작업이 진행되고 있긴 합니다.) 그러므로, wasm에서 스레드를 생성하려고 시도하면 코드가 패닉하게 됩니다.

#[cfg(..)]를 사용하여 타겟이 WebAssembly인지 아닌지 여부에 따라 스레드와 비스레드 코드를 따로 작성해 볼 수도 있습니다:

#![allow(unused)]
#![cfg(target_arch = "wasm32")]
fn main() {
fn do_work() {
    // 이 스레드에서만 작업을 수행합니다...
}

#![cfg(not(target_arch = "wasm32"))]
fn do_work() {
    use std::thread;

    // 헬퍼 스레드를 사용해서 작업을 확장합니다...
    thread::spawn(|| {
        // ...
    });
}
}

파일 I/O를 제거하고 유저들이 I/O 코드를 따로 가져올 수 있도록 허용하는 접근 방식과 유사하게, 스레드 생성 코드를 라이브러리에서 제외시킨 다음 유저들이 스레드 코드나 라이브러리를 따로 가져올 수 있도록 변경해 볼 수도 있습니다. 이렇게 구현했을 때 라이브러리 사용자들이 직접 스레드 풀 코드를 마련할 수 있게 되는 긍정적인 부수 효과를 보게 됩니다.

WebAssembly의 지속적인 지원 유지하기

지속성 통합 (CI) 환경을 사용해서 wasm32-unknown-unknown 타겟으로 빌드하기

WebAssembly를 타겟으로 할떄, 다음 명령어를 CI 스크립트에 포함시켜서 컴파일이 실패하지 않는 이유를 더 자세하게 확인할 수 있습니다:

rustup target add wasm32-unknown-unknown
cargo check --target wasm32-unknown-unknown

예를 들어서, Travis CI 설정 파일인 .travis.yml 에 다음 내용을 추가해 보겠습니다:


matrix:
  include:
    - language: rust
      rust: stable
      name: "check wasm32 support"
      install: rustup target add wasm32-unknown-unknown
      script: cargo check --target wasm32-unknown-unknown

Node.js와 헤드리스 브라우저 (Headless Browsers) 에서 테스팅하기

wasm-bindgen-testwasm-pack-test 하위 명령어 (subcommand) 를 사용해서 wasm 테스트들 Node.js나 헤드리스 브라우저에서 실행해보세요. 이러한 테스트들을 CI에 통합시켜 볼 수도 있습니다.

여기서 wasm 테스팅에 대해 더 알아보세요.

Rust로 작성한 WebAssembly 코드를 프로덕션 환경에 배포하기

⚡ 사실 Rust, WebAssembly로 웹 앱을 작성했더라도 배포 과정이 다른 웹 앱을 배포하는 과정과 거의 동일합니다!

Rust로 생성한 WebAssembly를 실행하는 웹 앱을 배포해 보겠습니다. 빌드된 웹 앱을 프로덕션 서버의 파일 시스템으로 복사하고, 복사한 파일에 접근할 수 있도록 HTTP 서버를 설정해 주세요.

HTTP 서버가 application/wasm MIME 타입을 지원하는지 확인해주세요

페이지가 빠르게 로드될 수 있도록, 네트워크 전송을 통해 WebAssembly.instantiateStreaming 함수를 사용하여 wasm 컴파일과 인스턴스화 과정을 파이프라인 처리해야 합니다. (번들러가 이 함수를 사용할 수 있는지도 확인해주세요.) 하지만 instantiateStreaming를 실행할 때 HTTP 응답이 application/wasm MIME 타입 유형을 가지고 있지 않다면 오류가 발생하게 되니 이 부분도 잘 확인해 주세요.

추가 자료

  • 프로덕션 개발 환경에서 Webpack를 사용하는 모범 사례. 많은 Rust와 WebAssembly 프로젝트들은 Rust로 생성한 WebAssembly와 JavaScript, CSS, HTML 코드를 번들링 하기 위해 Webpack을 사용합니다. 이 가이드는 프로덕션 환경에 배포할 때 어떻게 Webpack을 가장 잘 활용할 수 있는지 알려주는 여러 유용한 정보들을 포함합니다.
  • Apache 문서. Apache는 프로덕션 환경에서 많이 사용되는 HTTP 서버입니다.
  • NGINX 문서. NGNIX는 프로덕션 환경에서 많이 사용되는 HTTP 서버입니다.

번역본

이 책을 다른 언어로 번역하는 데에 관심이 있다면 메인 레포지토리를 포크하고 풀 리퀘스트를 생성해보세요.