웹이 문서뿐만 아니라 앱을 위한 플랫폼이 된 이후로 가장 고급 앱 중 일부는 웹브라우저를 한계까지 밀어붙였습니다. 성능을 개선하기 위해 하위 수준 언어와 인터페이스하여 '더 가까이' 가는 접근 방식은 많은 상위 수준 언어에서 볼 수 있습니다. 예를 들어 Java에는 Java Native Interface가 있습니다. JavaScript의 경우 이 하위 수준 언어는 WebAssembly입니다. 이 도움말에서는 어셈블리어의 정의와 웹에서 유용한 이유를 알아보고 asm.js라는 임시 솔루션을 통해 WebAssembly가 만들어진 방법을 알아봅니다.
어셈블리 언어
어셈블리 언어로 프로그래밍해 본 적이 있나요? 컴퓨터 프로그래밍에서 어셈블리어는 간단히 어셈블리라고도 하며 일반적으로 ASM 또는 asm으로 약칭되는 모든 하위 수준 프로그래밍 언어로, 언어의 명령어와 아키텍처의 머신 코드 명령어 간에 매우 강력한 대응 관계가 있습니다.
예를 들어 Intel® 64 및 IA-32 아키텍처 (PDF)를 살펴보면 MUL
명령어 (multiplication용)는 첫 번째 피연산자 (대상 피연산자)와 두 번째 피연산자 (소스 피연산자)의 부호 없는 곱셈을 실행하고 결과를 대상 피연산자에 저장합니다. 매우 단순화하면 대상 피연산자는 레지스터 AX
에 있는 암시적 피연산자이고 소스 피연산자는 CX
과 같은 범용 레지스터에 있습니다. 결과는 레지스터 AX
에 다시 저장됩니다. 다음 x86 코드 예시를 참고하세요.
mov ax, 5 ; Set the value of register AX to 5.
mov cx, 10 ; Set the value of register CX to 10.
mul cx ; Multiply the value of register AX (5)
; and the value of register CX (10), and
; store the result in register AX.
비교를 위해 5와 10을 곱하는 목표가 주어진다면 JavaScript에서 다음과 유사한 코드를 작성할 것입니다.
const factor1 = 5;
const factor2 = 10;
const result = factor1 * factor2;
어셈블리 경로를 사용하는 이점은 이러한 저수준 및 머신 최적화 코드가 고수준 및 인간 최적화 코드보다 훨씬 효율적이라는 것입니다. 위의 경우에는 문제가 되지 않지만 더 복잡한 작업의 경우 차이가 클 수 있습니다.
이름에서 알 수 있듯이 x86 코드는 x86 아키텍처에 종속됩니다. 특정 아키텍처에 종속되지 않지만 어셈블리의 성능 이점을 상속하는 어셈블리 코드를 작성하는 방법이 있다면 어떨까요?
asm.js
아키텍처 종속성이 없는 어셈블리 코드를 작성하는 첫 번째 단계는 컴파일러의 효율적인 하위 수준 타겟 언어로 사용할 수 있는 JavaScript의 엄격한 하위 집합인 asm.js였습니다. 이 하위 언어는 C 또는 C++과 같은 메모리에 안전하지 않은 언어의 샌드박스 처리된 가상 머신을 효과적으로 설명했습니다. 정적 및 동적 검증을 조합하여 JavaScript 엔진이 유효한 asm.js 코드에 대해 사전 (AOT) 최적화 컴파일 전략을 사용할 수 있었습니다. 수동 메모리 관리가 있는 정적 유형 언어 (예: C)로 작성된 코드는 초기 Emscripten (LLVM 기반)과 같은 소스-소스 컴파일러에 의해 변환되었습니다.
언어 기능을 AOT에 적합한 기능으로 제한하여 성능이 개선되었습니다. Firefox 22는 OdinMonkey라는 이름으로 출시되었으며 asm.js를 지원하는 최초의 브라우저였습니다. Chrome 버전 61에서 asm.js 지원이 추가되었습니다. asm.js는 브라우저에서 계속 작동하지만 WebAssembly로 대체되었습니다. 이 시점에서 asm.js를 사용하는 이유는 WebAssembly를 지원하지 않는 브라우저의 대안으로 사용하기 위해서입니다.
WebAssembly
WebAssembly는 네이티브에 가까운 성능으로 실행되고 C/C++, Rust 등 다양한 언어에 컴파일 타겟을 제공하여 웹에서 실행할 수 있는 컴팩트한 바이너리 형식이 있는 하위 수준 어셈블리 유사 언어입니다. Java, Dart와 같은 메모리 관리 언어 지원이 진행 중이며 곧 제공될 예정입니다. Kotlin/Wasm의 경우 이미 지원이 제공되고 있습니다. WebAssembly는 JavaScript와 함께 실행되도록 설계되어 둘 다 함께 작동할 수 있습니다.
브라우저 외에도 WebAssembly 프로그램은 WebAssembly용 모듈식 시스템 인터페이스인 WASI(WebAssembly System Interface) 덕분에 다른 런타임에서도 실행됩니다. WASI는 안전하고 샌드박스 환경에서 실행할 수 있다는 목표를 가지고 운영체제 간에 이식할 수 있도록 만들어졌습니다.
WebAssembly 코드 (바이너리 코드, 즉 바이트코드)는 휴대용 가상 스택 머신 (VM)에서 실행되도록 설계되었습니다. 바이트 코드는 JavaScript보다 더 빠르게 파싱하고 실행할 수 있으며 간결한 코드 표현을 갖도록 설계되었습니다.
명령의 개념적 실행은 명령을 통해 진행되는 기존 프로그램 카운터를 통해 진행됩니다. 실제로 대부분의 Wasm 엔진은 Wasm 바이트 코드를 머신 코드로 컴파일한 후 이를 실행합니다. 지침은 다음 두 가지 카테고리로 분류됩니다.
- 제어 명령어는 제어 구조를 형성하고 스택에서 인수 값을 팝하며 프로그램 카운터를 변경하고 결과 값을 스택에 푸시할 수 있습니다.
- 스택에서 인수 값을 팝하고 값에 연산자를 적용한 다음 결과 값을 스택에 푸시하고 프로그램 카운터를 암시적으로 진행하는 간단한 명령어
이전 예로 돌아가면 다음 WebAssembly 코드는 이 도움말의 시작 부분에 나오는 x86 코드와 동일합니다.
i32.const 5 ; Push the integer value 5 onto the stack.
i32.const 10 ; Push the integer value 10 onto the stack.
i32.mul ; Pop the two most recent items on the stack,
; multiply them, and push the result onto the stack.
asm.js는 소프트웨어로 모두 구현되므로 최적화되지 않은 경우에도 모든 JavaScript 엔진에서 코드를 실행할 수 있지만 WebAssembly에는 모든 브라우저 공급업체가 동의한 새로운 기능이 필요했습니다. 2015년에 발표되고 2017년 3월에 처음 출시된 WebAssembly는 2019년 12월 5일에 W3C 권장사항이 되었습니다. W3C는 모든 주요 브라우저 공급업체와 기타 이해관계자의 기여를 바탕으로 표준을 유지합니다. 2017년부터 브라우저 지원이 보편화되었습니다.
WebAssembly에는 텍스트와 바이너리의 두 가지 표현이 있습니다. 위의 내용은 텍스트 표현입니다.
텍스트 표현
텍스트 표현은 S-expression을 기반으로 하며 일반적으로 파일 확장자 .wat
(WebAssembly text 형식)을 사용합니다. 원한다면 손으로 작성할 수도 있습니다. 위의 곱셈 예시에서 요소를 더 이상 하드코딩하지 않아 더 유용하게 만들면 다음 코드를 이해할 수 있습니다.
(module
(func $mul (param $factor1 i32) (param $factor2 i32) (result i32)
local.get $factor1
local.get $factor2
i32.mul)
(export "mul" (func $mul))
)
이진 표현
파일 확장자 .wasm
를 사용하는 바이너리 형식은 사람이 만들기는커녕 사람이 소비하기 위한 것이 아닙니다. wat2wasm과 같은 도구를 사용하여 위의 코드를 다음 바이너리 표현으로 변환할 수 있습니다. (주석은 일반적으로 바이너리 표현의 일부가 아니지만, 더 나은 이해를 위해 wat2wasm 도구에 의해 추가됩니다.)
0000000: 0061 736d ; WASM_BINARY_MAGIC
0000004: 0100 0000 ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01 ; section code
0000009: 00 ; section size (guess)
000000a: 01 ; num types
; func type 0
000000b: 60 ; func
000000c: 02 ; num params
000000d: 7f ; i32
000000e: 7f ; i32
000000f: 01 ; num results
0000010: 7f ; i32
0000009: 07 ; FIXUP section size
; section "Function" (3)
0000011: 03 ; section code
0000012: 00 ; section size (guess)
0000013: 01 ; num functions
0000014: 00 ; function 0 signature index
0000012: 02 ; FIXUP section size
; section "Export" (7)
0000015: 07 ; section code
0000016: 00 ; section size (guess)
0000017: 01 ; num exports
0000018: 03 ; string length
0000019: 6d75 6c mul ; export name
000001c: 00 ; export kind
000001d: 00 ; export func index
0000016: 07 ; FIXUP section size
; section "Code" (10)
000001e: 0a ; section code
000001f: 00 ; section size (guess)
0000020: 01 ; num functions
; function body 0
0000021: 00 ; func body size (guess)
0000022: 00 ; local decl count
0000023: 20 ; local.get
0000024: 00 ; local index
0000025: 20 ; local.get
0000026: 01 ; local index
0000027: 6c ; i32.mul
0000028: 0b ; end
0000021: 07 ; FIXUP func body size
000001f: 09 ; FIXUP section size
; section "name"
0000029: 00 ; section code
000002a: 00 ; section size (guess)
000002b: 04 ; string length
000002c: 6e61 6d65 name ; custom section name
0000030: 01 ; name subsection type
0000031: 00 ; subsection size (guess)
0000032: 01 ; num names
0000033: 00 ; elem index
0000034: 03 ; string length
0000035: 6d75 6c mul ; elem name 0
0000031: 06 ; FIXUP subsection size
0000038: 02 ; local name type
0000039: 00 ; subsection size (guess)
000003a: 01 ; num functions
000003b: 00 ; function index
000003c: 02 ; num locals
000003d: 00 ; local index
000003e: 07 ; string length
000003f: 6661 6374 6f72 31 factor1 ; local name 0
0000046: 01 ; local index
0000047: 07 ; string length
0000048: 6661 6374 6f72 32 factor2 ; local name 1
0000039: 15 ; FIXUP subsection size
000002a: 24 ; FIXUP section size
WebAssembly로 컴파일
보시다시피 .wat
와 .wasm
모두 인간 친화적이지 않습니다. 이때 Emscripten과 같은 컴파일러가 사용됩니다.
C, C++와 같은 고급 언어에서 컴파일할 수 있습니다. Rust와 같은 다른 언어를 위한 컴파일러도 많이 있습니다. 다음 C 코드를 살펴보세요.
#include <stdio.h>
int main() {
printf("Hello World\n");
return 0;
}
일반적으로 컴파일러 gcc
를 사용하여 이 C 프로그램을 컴파일합니다.
$ gcc hello.c -o hello
Emscripten이 설치된 상태에서 emcc
명령어와 거의 동일한 인수를 사용하여 WebAssembly로 컴파일합니다.
$ emcc hello.c -o hello.html
이렇게 하면 hello.wasm
파일과 HTML 래퍼 파일 hello.html
이 생성됩니다. 웹 서버에서 hello.html
파일을 제공하면 "Hello World"
이 DevTools 콘솔에 출력됩니다.
HTML 래퍼 없이 WebAssembly로 컴파일하는 방법도 있습니다.
$ emcc hello.c -o hello.js
이전과 마찬가지로 hello.wasm
파일이 생성되지만 이번에는 HTML 래퍼 대신 hello.js
파일이 생성됩니다. 테스트하려면 결과 JavaScript 파일 hello.js
를 Node.js와 함께 실행합니다.
$ node hello.js
Hello World
자세히 알아보기
WebAssembly에 대한 이 간략한 소개는 빙산의 일각에 불과합니다.
MDN의 WebAssembly 문서에서 WebAssembly에 대해 자세히 알아보고 Emscripten 문서를 참고하세요. 사실 WebAssembly를 사용하는 것은 올빼미 그리는 방법 밈과 비슷하게 느껴질 수 있습니다. 특히 HTML, CSS, JavaScript에 익숙한 웹 개발자는 C와 같은 컴파일 대상 언어에 반드시 능숙하지는 않기 때문입니다. 다행히도 StackOverflow의 webassembly
태그와 같은 채널에서는 정중하게 요청하면 전문가들이 기꺼이 도와주는 경우가 많습니다.