WebAssembly의 제한사항

ko생성일: 2025. 10. 30.갱신일: 2025. 11. 16.

브라우저 환경의 WebAssembly를 중심으로 스택/메모리 모델, GC, 멀티스레딩, Wasm–JS 상호 운용, Web API 접근, Memory64, 디버깅 등 제약과 그 배경 및 우회책을 정리합니다.

배경:

  • WebAssembly는 실행 모델이자 코드 포맷이다.
  • 성능을 중시해 설계되었다. JS보다 더 높은 성능을 달성할 수 있다 1.
  • 안전성을 중시해 설계되었다. 실행은 샌드박스로 격리된다.
  • 브라우저에서 실행될 수 있다.
  • 네이티브 어셈블리(X86, ARM 등)와 가깝지만, 크로스플랫폼 방식으로 추상화되어 C/C++/Rust 등 많은 애플리케이션을 Wasm으로 컴파일할 수 있다(단, 제약이 있다).
  • 이름에 "Web"이 들어가지만 Web 전용은 아니다. 브라우저 밖에서도 사용할 수 있다.
  • 이름에 "Assembly"가 들어가지만, GC처럼 네이티브 어셈블리보다 높은 추상화 계층의 기능을 갖는다. JVM 바이트코드와 유사하다.
  • 브라우저에서는 같은 엔진이 JS와 Wasm을 함께 실행한다. Chromium V8은 JS와 Wasm을 모두 실행한다. Wasm GC는 JS와 같은 GC를 사용한다.

이 글은 브라우저 내 Wasm에 초점을 맞춘다.

Wasm 런타임 데이터

Wasm 프로그램이 다루는 데이터:

  • 런타임이 관리하는 스택. 지역 변수, 함수 인자, 복귀 주소 등. 런타임이 관리하며 선형 메모리에 있지 않다.

  • 선형 메모리(linear memory).

    • 선형 메모리는 바이트 배열이다. 주소(배열 인덱스로 볼 수 있음)로 읽고 쓸 수 있다.
    • Wasm은 여러 개의 선형 메모리를 지원한다.
    • 선형 메모리의 크기는 늘릴 수 있다. 하지만 현재로서는 줄일 수 없다.
    • 선형 메모리는 여러 Wasm 인스턴스 간에 공유될 수 있다(아래 멀티스레딩 섹션 참조).
  • 테이블(Table). 각 테이블은 (성장 가능한) 배열이며 다음을 담을 수 있다:

    • 함수 참조.
    • 외부 값(extern value) 참조. 외부 값은 환경에 따라 JS 값 또는 다른 것일 수 있다.
    • 예외 참조.
    • GC 값 참조.
  • 힙(Heap). GC 값을 담는다. 아래에서 설명.

  • 전역(Globals). 전역은 숫자(i32, i64, f32, f64), i128, 또는 참조(함수 참조, GC 값 참조, 외부 값 참조 등)를 담을 수 있다. 전역은 선형 메모리에 있지 않다.

선형 메모리가 담지 않는 것들:

  • 선형 메모리는 스택을 담지 않는다. 스택은 런타임이 관리하며 주소로 읽거나 쓸 수 없다.
  • 선형 메모리는 함수 참조를 담지 않는다. C의 함수 포인터와 달리, Wasm의 함수 참조는 정수로 변환하거나 정수에서 변환할 수 없다. 이 설계는 안전성을 높인다. 함수 참조는 스택이나 테이블, 전역에 있을 수 있으며, 특수 명령으로 호출된다 2. 함수 포인터는 테이블의 함수 참조에 대응되는 정수 인덱스로 바뀐다.

스택은 선형 메모리에 있지 않음

일반적으로 프로그램은 스택과 함께 실행된다. 네이티브 프로그램에서 스택은 다음을 담는다:

  • 지역 변수와 호출 인자. (모든 것이 스택에 있지는 않다. 일부는 레지스터에 있다)
  • 복귀 주소. 함수가 반환될 때 점프할 기계어 코드의 주소. (함수가 인라인되거나 기계어 코드가 최적화되면 항상 코드와 1:1 대응하지는 않는다)
  • 기타 것들. (예: C#의 stackalloc, Go의 defer 메타데이터)

Wasm에서는 메인 스택을 Wasm 런타임이 관리한다. 메인 스택은 선형 메모리에 있지 않으며, 주소로 읽거나 쓸 수 없다.

그로 인한 이점:

  • 제어 흐름 하이재킹과 관련된 보안 이슈를 피한다. 네이티브 애플리케이션의 스택은 메모리에 있으므로, 경계 밖 쓰기가 스택의 복귀 주소를 바꿔 잘못된 코드를 실행하게 할 수 있다. 데이터 실행 방지(DEP), 스택 카나리아, 주소 공간 배치 난수화(ASLR) 같은 보호 기법이 있다. Wasm에서는 이런 것들이 필요 없다. 참고
  • 런타임이 프로그램 동작을 바꾸지 않고 스택 레이아웃을 최적으로 배치할 수 있다.

하지만 단점도 있다:

  • 어떤 지역 변수는 주소를 취해야 한다. 이런 변수는 선형 메모리에 있어야 한다. 예:

void f() { int localVariable = 0; int* ptr = &localVariable; ...}

localVariable은 주소가 취해졌으므로(컴파일러가 포인터를 최적화로 제거하지 않는 한) Wasm 실행 스택이 아니라 선형 메모리에 있어야 한다.

  • GC는 스택의 참조(포인터)를 스캔해야 한다. Wasm 내장 GC가 아니라 애플리케이션이 자체 GC를 사용할 경우(아래 이유 참조), 스택의 참조(포인터)를 선형 메모리의 "섀도 스택"으로 쏟아내야(spill) 한다.
  • 스택 전환이 불가능하다. Go는 고루틴 스케줄링을 위해 스택 전환을 사용한다(단 Wasm에서는 아님). 현재 Go의 Wasm 성능이 좋지 않은 이유는 단일 스레드 Wasm에서 고루틴 스케줄링을 에뮬레이트하려 시도하기 때문이며, 그 결과 코드에 많은 동적 점프를 추가해야 한다.
  • 동적 스택 리사이징이 불가능하다. Go는 새 고루틴을 작은 스택으로 시작했다가 필요 시 스택을 키우는 방식으로 메모리 사용량을 줄인다.

일반적인 해결책은 선형 메모리에 있는 "섀도 스택"을 두는 것이다. 이 스택은 Wasm 코드가 관리한다. (섀도 스택을 aux stack이라고 부르기도 한다.)

두 가지 다른 스택을 요약하면:

  • 메인 실행 스택: 지역 변수, 호출 인자, 복귀 주소, (Wasm 스택 머신에서) 피연산자 등을 담는다. Wasm 런타임이 관리하며 선형 메모리에 있지 않다. Wasm 코드가 마음대로 조작할 수 없다.
  • 섀도 스택: 선형 메모리에 있다. 선형 메모리에 있어야 하는 지역 변수를 담는다. Wasm 코드가 관리하며 Wasm 런타임이 관리하지 않는다.

스택 전환을 가능케 하려는 stack switching 제안이 있다. 이를 통해 코드 변환과 많은 분기 추가 없이 경량 스레드(버추얼 스레드, 고루틴 등) 구현이 쉬워진다.

섀도 스택을 사용할 때는 아래에서 설명할 재진입성 문제가 얽힌다.

메모리 해제

Wasm 선형 메모리는 큰 바이트 배열로 볼 수 있다. 선형 메모리의 주소는 그 배열의 인덱스다.

memory.grow 명령으로 선형 메모리를 늘릴 수 있다. 하지만 선형 메모리를 줄이는 방법은 없다.

(Wasm GC를 사용하지 않는) Wasm 애플리케이션은 Wasm 코드로 자체 할당기를 구현한다. 그 할당기에서 해제된 메모리 영역은 이후 할당에 다시 사용할 수 있다. 하지만 해제된 메모리 자원을 운영체제에 돌려줄 수는 없다.

모바일(iOS, Android 등)은 백그라운드 프로세스의 메모리 사용량이 크면 프로세스를 종료하는 일이 흔하므로, 메모리를 OS에 반환하지 못하는 문제는 중요하다. 참고: Wasm needs a better memory management story.

이 제약 때문에 Wasm 애플리케이션은 피크 메모리 사용량만큼의 물리 메모리를 소비한다.

피크 메모리 사용량을 줄이기 위한 가능 우회책:

  • 큰 데이터를 JS ArrayBuffer에 저장한다. ArrayBuffer가 GC되면 그 물리 메모리를 OS에 반환할 수 있다.
  • 한 번에 서버에서 작은 청크만 가져온다. 모든 데이터를 받아 일괄 처리하는 것을 피하고, 스트리밍 처리한다.
  • 큰 데이터를 Origin-private file system에 두고, 한 번에 작은 청크만 선형 메모리에 로드한다.

이 문제를 다루는 memory control 제안이 있다.

Wasm GC

비 GC 언어(C/C++/Rust/Zig 등)를 Wasm으로 컴파일할 때는 선형 메모리를 사용하고 Wasm 코드로 할당기를 구현한다.

GC 언어(Java/C#/Python/Golang 등)는 Wasm에서 GC를 동작시켜야 한다. 접근법은 두 가지다:

  • 데이터를 계속 선형 메모리에 둔다. GC를 Wasm 코드로 구현한다.
  • Wasm 내장 GC 기능을 사용한다.

첫 번째, 수동으로 GC를 구현하는 접근은 난관에 부딪힌다:

  • GC는 GC 루트(포인터)를 스캔해야 한다. 일부 GC 루트는 스택에 있다. 하지만 Wasm의 메인 스택은 선형 메모리에 있지 않고 주소로 읽을 수 없다. 한 가지 해법은 포인터를 선형 메모리의 섀도 스택으로 쏟아내는 것이다. 섀도 스택을 두면 바이너리가 커지고 런타임 성능 비용이 든다.
  • 멀티스레드 GC는 스택을 올바르게 스캔하기 위해 실행을 멈춰야 하는 경우가 흔하다. 네이티브 애플리케이션에서는 보통 세이프포인트 메커니즘을 사용한다 3. 이것 역시 바이너리를 키우고 성능 비용을 유발한다.
  • 멀티스레드 GC는 스캔의 정확성을 위해 store barrier나 load barrier를 자주 사용한다. 이것도 바이너리를 키우고 성능 비용을 유발한다.
  • JS 객체와 Wasm 내부 객체가 서로를 참조하는 순환 참조는 수집할 수 없다.

그렇다면 Wasm 내장 GC를 쓰면 어떨까? 데이터 구조를 Wasm GC의 데이터 구조로 매핑해야 한다. Wasm GC의 데이터 구조는 자바와 유사한 클래스(오브젝트 헤더 포함), 자바와 유사한 프리픽스 서브타이핑, 자바와 유사한 배열을 제공한다.

Wasm 내장 GC 사용의 이점:

  • 고도로 최적화된 JS GC를 재사용한다. Wasm 애플리케이션 코드에서 GC를 재구현할 필요가 없다.
  • Wasm GC 참조를 JS로 전달할 수 있다. (하지만 현재 JS 코드가 Wasm GC 객체의 필드를 직접 접근할 수는 없다. 주 용도는 참조를 Wasm 코드로 다시 전달하는 것이다.)
  • Wasm GC 객체와 JS 객체 사이의 순환 참조를 수집할 수 있다.

Wasm GC가 지원하지 않는 중요한 메모리 관리 기능:

  • 스레드 간에 GC 값을 공유할 수 없다. 이것이 가장 큰 제약이다.
  • 약한 참조(weak reference)가 없다.
  • 파이널라이저(객체가 GC로 수집될 때 콜백 실행)가 없다.
  • 내부 포인터가 없다. (Go에는 내부 포인터가 있다)

또한 일부 메모리 레이아웃 최적화를 지원하지 않는다:

  • struct 타입의 배열이 없다.
  • 오브젝트 헤더를 피하기 위한 fat pointer를 사용할 수 없다. (Go가 그렇게 한다)
  • 배열 객체의 헤더 앞에 사용자 정의 필드를 추가할 수 없다. (C#은 가능)
  • 콤팩트한 합타입(sum type) 메모리 레이아웃이 없다.

참고: C# Wasm GC 이슈, Golang Wasm GC 이슈

멀티스레딩

브라우저 이벤트 루프

각 웹 탭에는 JS 코드가 실행되는 이벤트 루프가 있다. 이벤트 큐도 있다 4.

(각 탭의 메인 스레드에서) 단순화한 이벤트 루프의 의사코드:

for (;;) { while (!eventQueue.isEmpty()) { eventQueue.dequeue().execute() // 이곳에서 JS 코드가 실행됨 } doRendering()}

(doRendering()은 브라우저가 이미지를 렌더링해 표시하는 것을 의미하며, React 컴포넌트의 "rendering"이 아니다.)

새 이벤트는 여러 방식으로 이벤트 큐에 추가될 수 있다:

  • 브라우저가 JS/Wasm 코드를 호출할 때마다(예: 이벤트 처리), 큐에 이벤트가 추가된다.
  • JS 코드가 해결되지 않은 프로미스를 await 하면, 이벤트 처리가 끝나고 해당 프로미스가 해결될 때 큐에 새 이벤트가 추가된다.

이벤트 루프와 관련된 중요 사항:

  • 웹 페이지 렌더링은 JS/Wasm 코드 실행에 의해 막힌다. JS/Wasm 코드가 오래 실행되면 페이지가 "멈춘 것처럼" 보인다.
  • JS 코드가 캔버스를 그릴 때, 그려진 내용은 현재 이벤트 루프 반복이 끝나야(doRendering() 호출) 화면에 표시된다. 그리는 도중 비동기 코드가 미해결 프로미스를 await 하면, 반쯤 그려진 캔버스가 표시된다.
  • React에서 컴포넌트가 처음 마운트될 때 useEffect의 이펙트 콜백은 다음 이벤트 루프 반복에서 실행된다(React는 MessageChannel로 태스크를 스케줄한다). 반면 useLayoutEffect의 이펙트는 현재 이벤트 루프 반복에서 실행된다.

병렬 실행이 가능한 웹 워커가 있다. 각 웹 워커도 이벤트 루프에서 실행되며(각 웹 워커는 단일 스레드), 렌더링은 없다. 의사코드:

for (;;) { while (!eventQueue.isEmpty()) { eventQueue.dequeue().execute() // 이곳에서 JS 코드가 실행됨 } waitUntilEventQueueIsNotEmpty()}

웹 스레드(메인 스레드와 웹 워커)는 변경 가능한 데이터를 공유하지 않는다(SharedArrayBuffer 예외):

  • 보통, 다른 웹 워커로 보낸 JS 값은 깊은 복사된다.
  • 스레드를 가로질러 ArrayBuffer를 보내면, 그 ArrayBuffer는 이진 데이터를 분리(detach)한다. 오직 한 스레드만 그 이진 데이터에 접근할 수 있다.
  • WebAssembly.Module처럼 불변인 것들은 다른 웹 워커로 복사나 분리 없이 보낼 수 있다.

이 설계는 JS 및 DOM의 데이터 경쟁을 피한다.

WebAssembly 멀티스레딩은 웹 워커와 SharedArrayBuffer에 의존한다.

SharedArrayBuffer의 보안 이슈

Spectre 취약점은 브라우저에서 실행되는 JS 코드가 브라우저 메모리를 읽을 수 있게 하는 취약점이다. 이를 악용하려면 메모리 접근 지연 시간을 정밀하게 측정해 특정 메모리 영역이 캐시에 있는지 여부를 판단해야 한다.

현대 브라우저는 performance.now()의 정밀도를 낮춰 익스플로잇에 사용할 수 없게 했다. 하지만 (상대적) 지연 시간을 정밀하게 재는 또 다른 방법이 있다: 멀티스레드 카운터 타이머. 한 스레드(웹 워커)가 SharedArrayBuffer의 카운터를 계속 증가시키고, 또 다른 스레드가 그 카운터를 읽어 "시간"으로 취급한다. 두 시점의 "시간"을 빼면 정밀한 상대 지연 시간을 얻을 수 있다.

아래 Spectre 취약점 설명

교차 출처 격리(Cross-origin isolation)

이 보안 이슈의 해법은 교차 출처 격리(cross-origin isolation)다. 교차 출처 격리는 서로 다른 웹사이트를 서로 다른 브라우저 프로세스에 할당한다. 한 웹사이트가 Spectre 취약점을 악용하더라도, 자신의 웹사이트 프로세스 메모리만 읽을 수 있고 다른 웹사이트의 프로세스 메모리는 읽을 수 없다.

교차 출처 격리는 HTML 로딩 응답에 다음 헤더들을 포함시켜 활성화할 수 있다:

  • Cross-Origin-Opener-Policysame-origin. 참고
  • Cross-Origin-Embedder-Policyrequire-corp 또는 credentialless. 참고
    • require-corp인 경우, 다른 웹사이트(오리진)에서 로드되는 모든 리소스는 응답 헤더에 Cross-Origin-Resource-Policy: cross-origin(CORS 모드에서는 다르게) 을 포함해야 한다.
    • credentialless인 경우, 다른 웹사이트로 보내는 요청은 쿠키 같은 자격 증명을 포함하지 않는다.

메인 스레드에서 대기(block)할 수 없음

스레드 제안은 스레드를 일시 중단하기 위한 memory.atomic.wait32, memory.atomic.wait64 명령을 추가하며, 이를 잠금(및 조건 변수 등) 구현에 사용할 수 있다. 참고

그러나 메인 스레드는 이러한 명령으로 일시 중단할 수 없다. 이는 웹 페이지 응답성에 대한 우려 때문이었다.

관련 1 관련 2 관련 3

이 제한 때문에 네이티브 멀티스레드 코드를 Wasm으로 포팅하기가 더 어렵다. 예를 들어, 웹 워커에서는 일반 잠금을 쓸 수 있지만, 메인 스레드에서는 스핀락을 써야 한다. 오랫동안 스핀락을 돌면 성능이 나빠진다.

메인 스레드는 JS Promise 통합을 사용해 "차단처럼" 보이게 할 수 있다. 이 차단 동안에는 다른 코드(JS와 Wasm)가 실행될 수 있다. 이를 재진입성(reentrancy)이라고 한다.

Wasm 애플리케이션은 종종 섀도 스택을 사용한다. 섀도 스택은 선형 메모리에 있고 Wasm 런타임이 아니라 Wasm 앱이 관리한다. JS Promise 통합으로 Wasm 코드가 일시 중단/재개될 때 섀도 스택을 올바르게 전환해야 한다. 그렇지 않으면 서로 다른 실행의 섀도 스택 부분이 섞여 엉망이 된다. 재진입성 하에서 깨질 수 있는 다른 것들도 주의해야 한다.

또한 앞서 언급했듯, 캔버스 그리기 코드가( JS Promise 통합으로) 일시 중단되면 반쯤 그려진 캔버스가 페이지에 표시될 수 있다. 이는 오프스크린 캔버스를 사용해 웹 워커에서 그리도록 우회할 수 있다.

Wasm 인스턴스 재생성

웹의 멀티스레딩은 웹 워커에 의존한다. 현재 브라우저에서 Wasm 스레드를 직접 시작하는 방법은 없다.

멀티스레드 Wasm 애플리케이션을 시작하려면 공유 WebAssembly.Memory(내부에 SharedArrayBuffer 포함)를 다른 웹 워커에 전달해야 한다. 그 웹 워커는 같은 WebAssembly.Memory(그리고 WebAssembly.Module)를 사용해 새 Wasm 인스턴스를 별도로 생성해야 한다.

Wasm 전역은 스레드 로컬(실제로는 글로벌이 아님)이다. 한 스레드에서 변경 가능한 전역을 수정해도 다른 스레드에는 영향을 주지 않는다. 변경 가능한 전역 변수는 선형 메모리에 두어야 한다.

또 다른 중요한 제약: Wasm 테이블은 공유할 수 없다.

이는 실행 중 새 Wasm 코드를 로드할 때(동적 링크) 문제를 만든다. 기존 코드가 새 함수를 호출하려면 테이블의 함수 참조를 통한 간접 호출이 필요하다. 그러나 서로 다른 웹 워커의 Wasm 인스턴스끼리는 테이블을 공유할 수 없다.

현재 우회책은 웹 워커들에게 알림을 보내 그들이 능동적으로 새 코드를 로드하고 테이블에 새 함수 참조를 넣도록 하는 것이다. 간단한 방법은 웹 워커에 메시지를 보내는 것이다. 하지만 웹 워커의 Wasm 코드가 여전히 실행 중일 때는 동작하지 않는다. 이런 경우에는(성능 비용이 드는) 다른 메커니즘이 필요하다.

로드 시점의 동적 링크는 문제 없이 동작하지만, 실행 중 dlopen/dlsym을 통한 동적 링크는 추가 고려가 필요할 수 있습니다. 그 이유는 간접 함수 포인터 테이블을 스레드 간 동기화하는 작업을 emscripten 라이브러리 코드가 해야 하기 때문입니다. 새 라이브러리가 로드되거나 dlsym으로 새 심볼이 요청될 때마다 테이블 슬롯이 추가될 수 있으며, 이러한 변경은 프로세스의 모든 스레드에 반영되어야 합니다.

테이블 변경은 뮤텍스로 보호되며, 어떤 스레드든 dlopen이나 dlsym에서 반환하기 전에 프로세스의 다른 모든 스레드가 동기화될 때까지 기다립니다. 이 동기화를 가능한 한 매끄럽게 만들기 위해, 우리는 emscripten_futex_waitemscripten_yield 의 저수준 원시 기능을 후킹합니다.

Dynamic Linking — Emscripten

이를 해결하려는 shared-everything threads 제안이 있다.

모든 웹 워커가 브라우저의 이벤트 루프를 활용하고 각 실행에서 오래 블록하지 않는다면, 웹 워커 메시지를 처리하는 방식으로 협력적으로 새 Wasm 코드를 로드할 수 있으며 큰 지연 없이 이뤄질 수 있다.

Wasm-JS 간 데이터 전달

숫자(i32, i64, f32, f64)는 JS와 Wasm 사이에 직접 전달할 수 있다(i64는 JS의 BigInt, 나머지 3개는 number에 매핑된다).

JS 문자열을 Wasm으로 전달하려면 다음이 필요하다:

  • 트랜스코딩(예: Rust로 전달하려면 WTF-16을 UTF-8로 변환),
  • Wasm 선형 메모리에 메모리 할당,
  • 변환된 문자열을 Wasm 선형 메모리에 복사,
  • 주소와 길이를 Wasm 코드로 전달,
  • Wasm 코드가 문자열 해제를 신경 써야 한다.

선형 메모리의 문자열을 JS로 전달하는 것도 마찬가지로 쉽지 않다.

Wasm과 JS 사이의 문자열 전달은 성능 병목이 될 수 있다. 애플리케이션이 Wasm-JS 간 데이터 전달을 자주 한다면, JS를 Wasm으로 대체해도 오히려 성능이 떨어질 수 있다.

현대 Wasm/JS 런타임(V8 포함)은 Wasm과 JS 간의 상호 호출을 JIT 및 인라이닝할 수 있다. 하지만 복사 비용은 없앨 수 없다.

Wasm Component Model은 이를 해결하려는 시도다. 인터페이스에서 문자열, 레코드(구조체), 리스트, enum 같은 더 높은 수준의 타입을 전달할 수 있게 해준다. 하지만 서로 다른 컴포넌트는 메모리를 공유할 수 없고, 전달되는 데이터는 복사되어야 한다.

문자열 전달 비용을 줄이려는 Wasm-JS 문자열 빌트인도 있다.

Web API를 직접 호출할 수 없음

Wasm 코드는 Web API를 직접 호출할 수 없다. Web API는 JS 글루 코드(접착 코드)를 통해 호출해야 한다.

웹의 모든 JS API에는 Web IDL 명세가 있지만, 여기에는 GC되는 객체, 이터레이터 및 비동기 이터레이터가 관여한다(예: fetch()Response를 반환하며, 그 bodyReadableStream). 이러한 GC 관련 및 비동기 관련 요소들은 선형 메모리를 사용하는 Wasm 코드에 쉽게 맞추기 어렵다. Web IDL 인터페이스를 Wasm 인터페이스로 변환하는 명세를 설계하는 일이 쉽지 않다.

과거 Web IDL Bindings 제안이 있었으나 Component Model 제안으로 대체되었다.

현재 Wasm은 Wasm을 부트스트랩하는 JS 코드 없이는 브라우저에서 실행될 수 없다.

Memory64 성능

원래의 Wasm은 32비트 주소만 지원하고 선형 메모리를 최대 4GiB까지 지원한다.

Wasm에서 선형 메모리는 유한한 크기를 갖는다. 크기를 벗어난 주소 접근은 실행을 중단시키는 트랩을 발생시켜야 한다. 일반적으로 그 범위 검사를 구현하려면 각 선형 메모리 접근에 분기를 삽입해야 한다(예: if (address >= memorySize) {trap();}).

하지만 Wasm 런타임은 최적화를 사용한다: 4GB 선형 메모리를 가상 메모리 공간에 매핑하는 것이다. 범위 밖 페이지는 OS로부터 실제 할당되지 않으므로, 해당 영역을 접근하면 OS 에러가 발생한다. Wasm 런타임은 이 에러를 시그널 핸들링으로 처리한다. 이 경우 범위 검사 분기가 필요 없다.

이 최적화는 64비트 주소를 지원할 때는 통하지 않는다. Wasm 선형 메모리를 담을 만큼의 가상 주소 공간이 충분하지 않기 때문이다. 그래서 모든 선형 메모리 접근마다 범위 검사 분기를 계속 삽입해야 하며, 이는 성능 비용을 초래한다.

참고: Is Memory64 actually worth using?

기타 성능 제약

일반적으로, 같은 소스 코드에서 네이티브로 컴파일한 애플리케이션보다 WebAssembly가 느리게 동작한다. 여러 이유가 있다:

  • 앞서 언급한 선형 메모리 경계 검사.
  • JIT(Just-in-time) 비용. 네이티브 C/C++/Rust 애플리케이션은 AOT(사전 컴파일)할 수 있다. V8은 시작 속도를 높이기 위해 먼저 빠르고 단순한 컴파일러로 Wasm을 기계어로 빨리 컴파일한다(생성된 기계어는 느림). 그다음 소수의 핫 Wasm 코드에 대해서는 더 느리지만 고도 최적화된 컴파일러로 최적화된 기계어를 생성한다. 참고. 이 최적화는 프로파일 유도이며(일부 핫 코드에 집중, 통계를 활용), 프로파일링·최적화·코드 전환 모두 성능 비용이 든다.
  • 멀티스레딩에서 release-acquire 메모리 오더링을 사용할 수 없어, 일부 원자 연산의 성능 최적화 기회를 잃는다. 참고
  • 멀티스레딩에는 웹 워커를 띄워야 하며, 이는 느린 작업이다.
  • 특정 SIMD 명령과 같은 하드웨어 기능 접근이 제한적이다. 다만 Wasm은 이미 흔한 SIMD 명령을 다수 지원한다.
  • mmap 같은 일부 OS 기능에 접근할 수 없다.

Chrome에서 실행 중인 Wasm 디버깅

먼저 .wasm 파일의 커스텀 섹션에 DWARF 디버그 정보가 있어야 한다.

C/C++ DevTools Support (DWARF) 플러그인 (소스 코드)가 있다. 이 플러그인은 C/C++에 맞춰져 있다. Rust에 사용하면, 브레이크포인트와 정수 지역 변수 검사까진 동작하지만(기타 기능: 문자열 검사, 전역 검사, 표현식 평가 등은 미지원).

VSCode는 vscode-js-debug 플러그인을 사용해 Chrome에서 실행 중인 Wasm을 디버깅할 수 있다. 문서, 문서. 정수 지역 변수 검사를 지원한다. 하지만 지역 변수 보기에는 문자열 내용이 표시되지 않는다. 문자열 내용은 선형 메모리를 검사해야만 볼 수 있다. 디버그 콘솔의 표현식 평가는 함수 호출을 허용하지 않는다.

(또한 VSCode의 WebAssembly DWARF Debugging 확장이 필요하다. 현재(2025년 9월) Cursor에는 그 확장이 없다.)

Chromium 디버깅 API.

부록

Spectre 취약점 설명

배경:

  • CPU에는 메모리 접근을 가속하기 위한 캐시가 있다. 메모리의 일부가 캐시에 들어간다. 캐시에 들어간 메모리는 캐시를 통해 더 빠르게 접근할 수 있다.
  • 캐시 용량은 제한적이다. 새로운 메모리에 접근하면 기존 캐시 데이터가 축출되고 새로 접근한 데이터가 캐시에 들어간다.
  • 어떤 메모리 내용이 캐시에 있는지는 메모리 접근 지연 시간으로 판별할 수 있다.
  • CPU는 투기 실행과 분기 예측을 수행한다. CPU는 가능한 많은 명령을 병렬로 실행하려 한다. 분기(if 등)를 만나면 분기를 예측하고 해당 분기 코드를 투기적으로 실행한다.
  • 나중에 분기 예측이 틀렸음이 밝혀지면, 투기 실행의 효과(레지스터/메모리 쓰기 등)는 롤백된다. 그러나 메모리 접근이 남긴 캐시 부작용은 롤백되지 않는다.
  • 분기 예측기는 통계에 의존하므로 "학습"될 수 있다. 한 분기가 여러 번 첫 번째 경로로만 가면, 예측기는 항상 첫 번째 경로로 갈 것이라 예측한다.

Specture 취약점(Variant 1) 핵심 익스플로잇 JS 코드(참고):

...if (index < simpleByteArray.length) { index = simpleByteArray[index | 0]; index = (((index * 4096)|0) & (32*1024*1024-1))|0; localJunk ˆ= probeTable[index|0]|0;}...

|0는 값을 32비트 정수로 변환해 JS 런타임이 정수 연산으로 최적화하도록 돕는다(JS는 동적이므로, 그게 없으면 JIT 코드가 다른 일을 할 수도 있다). localJunk는 이러한 읽기 연산이 최적화로 제거되지 않도록 하기 위한 것이다.

  • 공격자는 먼저 범위 내 index로 그 코드를 여러 번 실행해 분기 예측기를 "훈련"한다.

  • 그런 다음 많은 다른 메모리 위치에 접근해 캐시를 무효화한다.

  • 이후 공격자는 특정 범위 밖 index로 코드를 실행한다:

    • CPU는 투기적으로 simpleByteArray[index]를 읽는다. 이는 범위 밖이며, 그 결과는 브라우저 프로세스 메모리의 비밀이다.
    • 이어서 CPU는 그 비밀로부터 계산한 인덱스로 probeTable을 투기적으로 읽는다.
    • probeTable의 특정 메모리 영역이 캐시에 로드된다. 그 영역은 더 빨리 접근된다.
    • CPU는 분기 예측이 잘못됐음을 깨닫고 롤백하지만, 캐시에 남은 부작용은 롤백하지 않는다.
  • 공격자는 probeTable에서 메모리 읽기 지연 시간을 측정한다. 더 빠르게 접근되는 위치가 곧 비밀 값에 대응한다.

  • 메모리 접근 지연 시간을 정확히 측정하기에 performance.now()는 충분히 정밀하지 않다. 멀티스레드 카운터 타이머가 필요하다: 한 스레드(웹 워커)가 루프에서 공유 카운터를 계속 증가시키고, 공격 스레드가 그 카운터를 읽어 "시간"을 얻는다. 표준 단위(예: 나노초)로 시간을 측정할 수는 없어도, 캐시 접근의 빠른 지연과 RAM 접근의 느린 지연을 구분할 만큼은 충분히 정밀하다. 스레드 간 카운터 공유에는 SharedArrayBuffer가 필요하다.

동일한 일은 SharedArrayBuffer를 사용하는 동등한 Wasm 코드로도 할 수 있다.

관련: 캐시 사이드 채널과 관련된 또 다른 취약점 GoFetch. 이는 Apple 프로세서의 캐시 프리페칭 기능을 악용한다.

구조적 제어 흐름 강제

참고: WebAssembly Troubles part 2: Why Do We Need the Relooper Algorithm, Again? 이는 Wasm으로의 컴파일 및 JIT 최적화 성능을 낮출 수 있다. 이 이슈는 애플리케이션 개발자에게 직접 적용되지는 않는다.

각주

  1. WebAssembly가 항상 JS보다 빠른 것은 아니며, 투입한 최적화 노력과 브라우저 제약에 따라 달라진다. 그러나 WebAssembly는 JS보다 성능 잠재력이 높다. JS는 유연성이 매우 크다. 유연성은 성능 비용을 수반한다. JS 런타임은 종종 런타임 통계를 사용해 사용되지 않는 유연성을 찾아 그에 맞춰 최적화한다. 하지만 통계는 확실하지 않아, JS 런타임은 여전히 유연성에 대비해야 한다. 이러한 런타임 통계 수집과 "유연성 대비"는 코드 포맷을 바꾸지 않고는 최적화하기 어려운 방식으로 성능 비용을 유발한다.

  2. call_ref는 스택의 함수 참조를 호출한다. call_indirect는 테이블의 인덱스로 함수 참조를 호출한다. return_call_ref, return_call_indirect는 꼬리 호출용이다.

  3. 세이프포인트 메커니즘은 스레드가 특정 지점에서 협력적으로 멈추게 한다. 실행 중인 스레드의 스택을 스캔하는 것은 메모리 순서 문제와 경쟁 상태 때문에 신뢰할 수 없고, 일부 포인터는 스택이 아니라 레지스터에 있을 수 있다. OS 기능으로 스레드를 중단시키면 일부 지역 변수가 레지스터에 있을 수 있고, 레지스터의 데이터가 포인터인지 다른 데이터인지 구분하기 어렵다(정수를 포인터로 취급하면 메모리 안전성 문제나 누수를 야기할 수 있다). 스레드가 특정 지점에서 협력적으로 멈춘다면 참조를 신뢰성 있게 스캔할 수 있다. 세이프포인트를 구현하는 한 가지 방법은 전역 세이프포인트 플래그를 두는 것이다. 코드는 그 플래그를 자주 읽고, true이면 멈춘다. OS 페이지 폴트 시그널 핸들러를 활용하는 최적화도 있다.

  4. 이는 단순화다. 실제로는 각 탭의 메인 스레드마다 두 개의 이벤트 큐가 있다. 하나는 저우선순위 이벤트용 콜백 큐, 다른 하나는 고우선순위 이벤트용 마이크로태스크 큐다. 고우선순위 큐가 먼저 실행된다.