브라우저 환경의 WebAssembly를 중심으로 스택/메모리 모델, GC, 멀티스레딩, Wasm–JS 상호 운용, Web API 접근, Memory64, 디버깅 등 제약과 그 배경 및 우회책을 정리합니다.
배경:
이 글은 브라우저 내 Wasm에 초점을 맞춘다.
Wasm 프로그램이 다루는 데이터:
런타임이 관리하는 스택. 지역 변수, 함수 인자, 복귀 주소 등. 런타임이 관리하며 선형 메모리에 있지 않다.
선형 메모리(linear memory).
테이블(Table). 각 테이블은 (성장 가능한) 배열이며 다음을 담을 수 있다:
힙(Heap). GC 값을 담는다. 아래에서 설명.
전역(Globals). 전역은 숫자(i32, i64, f32, f64), i128, 또는 참조(함수 참조, GC 값 참조, 외부 값 참조 등)를 담을 수 있다. 전역은 선형 메모리에 있지 않다.
선형 메모리가 담지 않는 것들:
일반적으로 프로그램은 스택과 함께 실행된다. 네이티브 프로그램에서 스택은 다음을 담는다:
stackalloc, Go의 defer 메타데이터)Wasm에서는 메인 스택을 Wasm 런타임이 관리한다. 메인 스택은 선형 메모리에 있지 않으며, 주소로 읽거나 쓸 수 없다.
그로 인한 이점:
하지만 단점도 있다:
void f() { int localVariable = 0; int* ptr = &localVariable; ...}
localVariable은 주소가 취해졌으므로(컴파일러가 포인터를 최적화로 제거하지 않는 한) Wasm 실행 스택이 아니라 선형 메모리에 있어야 한다.
일반적인 해결책은 선형 메모리에 있는 "섀도 스택"을 두는 것이다. 이 스택은 Wasm 코드가 관리한다. (섀도 스택을 aux stack이라고 부르기도 한다.)
두 가지 다른 스택을 요약하면:
스택 전환을 가능케 하려는 stack switching 제안이 있다. 이를 통해 코드 변환과 많은 분기 추가 없이 경량 스레드(버추얼 스레드, 고루틴 등) 구현이 쉬워진다.
섀도 스택을 사용할 때는 아래에서 설명할 재진입성 문제가 얽힌다.
Wasm 선형 메모리는 큰 바이트 배열로 볼 수 있다. 선형 메모리의 주소는 그 배열의 인덱스다.
memory.grow 명령으로 선형 메모리를 늘릴 수 있다. 하지만 선형 메모리를 줄이는 방법은 없다.
(Wasm GC를 사용하지 않는) Wasm 애플리케이션은 Wasm 코드로 자체 할당기를 구현한다. 그 할당기에서 해제된 메모리 영역은 이후 할당에 다시 사용할 수 있다. 하지만 해제된 메모리 자원을 운영체제에 돌려줄 수는 없다.
모바일(iOS, Android 등)은 백그라운드 프로세스의 메모리 사용량이 크면 프로세스를 종료하는 일이 흔하므로, 메모리를 OS에 반환하지 못하는 문제는 중요하다. 참고: Wasm needs a better memory management story.
이 제약 때문에 Wasm 애플리케이션은 피크 메모리 사용량만큼의 물리 메모리를 소비한다.
피크 메모리 사용량을 줄이기 위한 가능 우회책:
ArrayBuffer에 저장한다. ArrayBuffer가 GC되면 그 물리 메모리를 OS에 반환할 수 있다.이 문제를 다루는 memory control 제안이 있다.
비 GC 언어(C/C++/Rust/Zig 등)를 Wasm으로 컴파일할 때는 선형 메모리를 사용하고 Wasm 코드로 할당기를 구현한다.
GC 언어(Java/C#/Python/Golang 등)는 Wasm에서 GC를 동작시켜야 한다. 접근법은 두 가지다:
첫 번째, 수동으로 GC를 구현하는 접근은 난관에 부딪힌다:
그렇다면 Wasm 내장 GC를 쓰면 어떨까? 데이터 구조를 Wasm GC의 데이터 구조로 매핑해야 한다. Wasm GC의 데이터 구조는 자바와 유사한 클래스(오브젝트 헤더 포함), 자바와 유사한 프리픽스 서브타이핑, 자바와 유사한 배열을 제공한다.
Wasm 내장 GC 사용의 이점:
Wasm GC가 지원하지 않는 중요한 메모리 관리 기능:
또한 일부 메모리 레이아웃 최적화를 지원하지 않는다:
참고: C# Wasm GC 이슈, Golang Wasm GC 이슈
각 웹 탭에는 JS 코드가 실행되는 이벤트 루프가 있다. 이벤트 큐도 있다 4.
(각 탭의 메인 스레드에서) 단순화한 이벤트 루프의 의사코드:
for (;;) { while (!eventQueue.isEmpty()) { eventQueue.dequeue().execute() // 이곳에서 JS 코드가 실행됨 } doRendering()}
(doRendering()은 브라우저가 이미지를 렌더링해 표시하는 것을 의미하며, React 컴포넌트의 "rendering"이 아니다.)
새 이벤트는 여러 방식으로 이벤트 큐에 추가될 수 있다:
이벤트 루프와 관련된 중요 사항:
doRendering() 호출) 화면에 표시된다. 그리는 도중 비동기 코드가 미해결 프로미스를 await 하면, 반쯤 그려진 캔버스가 표시된다.useEffect의 이펙트 콜백은 다음 이벤트 루프 반복에서 실행된다(React는 MessageChannel로 태스크를 스케줄한다). 반면 useLayoutEffect의 이펙트는 현재 이벤트 루프 반복에서 실행된다.병렬 실행이 가능한 웹 워커가 있다. 각 웹 워커도 이벤트 루프에서 실행되며(각 웹 워커는 단일 스레드), 렌더링은 없다. 의사코드:
for (;;) { while (!eventQueue.isEmpty()) { eventQueue.dequeue().execute() // 이곳에서 JS 코드가 실행됨 } waitUntilEventQueueIsNotEmpty()}
웹 스레드(메인 스레드와 웹 워커)는 변경 가능한 데이터를 공유하지 않는다(SharedArrayBuffer 예외):
ArrayBuffer를 보내면, 그 ArrayBuffer는 이진 데이터를 분리(detach)한다. 오직 한 스레드만 그 이진 데이터에 접근할 수 있다.WebAssembly.Module처럼 불변인 것들은 다른 웹 워커로 복사나 분리 없이 보낼 수 있다.이 설계는 JS 및 DOM의 데이터 경쟁을 피한다.
WebAssembly 멀티스레딩은 웹 워커와 SharedArrayBuffer에 의존한다.
SharedArrayBuffer의 보안 이슈Spectre 취약점은 브라우저에서 실행되는 JS 코드가 브라우저 메모리를 읽을 수 있게 하는 취약점이다. 이를 악용하려면 메모리 접근 지연 시간을 정밀하게 측정해 특정 메모리 영역이 캐시에 있는지 여부를 판단해야 한다.
현대 브라우저는 performance.now()의 정밀도를 낮춰 익스플로잇에 사용할 수 없게 했다. 하지만 (상대적) 지연 시간을 정밀하게 재는 또 다른 방법이 있다: 멀티스레드 카운터 타이머. 한 스레드(웹 워커)가 SharedArrayBuffer의 카운터를 계속 증가시키고, 또 다른 스레드가 그 카운터를 읽어 "시간"으로 취급한다. 두 시점의 "시간"을 빼면 정밀한 상대 지연 시간을 얻을 수 있다.
이 보안 이슈의 해법은 교차 출처 격리(cross-origin isolation)다. 교차 출처 격리는 서로 다른 웹사이트를 서로 다른 브라우저 프로세스에 할당한다. 한 웹사이트가 Spectre 취약점을 악용하더라도, 자신의 웹사이트 프로세스 메모리만 읽을 수 있고 다른 웹사이트의 프로세스 메모리는 읽을 수 없다.
교차 출처 격리는 HTML 로딩 응답에 다음 헤더들을 포함시켜 활성화할 수 있다:
Cross-Origin-Opener-Policy가 same-origin. 참고Cross-Origin-Embedder-Policy가 require-corp 또는 credentialless. 참고
require-corp인 경우, 다른 웹사이트(오리진)에서 로드되는 모든 리소스는 응답 헤더에 Cross-Origin-Resource-Policy: cross-origin(CORS 모드에서는 다르게) 을 포함해야 한다.credentialless인 경우, 다른 웹사이트로 보내는 요청은 쿠키 같은 자격 증명을 포함하지 않는다.스레드 제안은 스레드를 일시 중단하기 위한 memory.atomic.wait32, memory.atomic.wait64 명령을 추가하며, 이를 잠금(및 조건 변수 등) 구현에 사용할 수 있다. 참고
그러나 메인 스레드는 이러한 명령으로 일시 중단할 수 없다. 이는 웹 페이지 응답성에 대한 우려 때문이었다.
이 제한 때문에 네이티브 멀티스레드 코드를 Wasm으로 포팅하기가 더 어렵다. 예를 들어, 웹 워커에서는 일반 잠금을 쓸 수 있지만, 메인 스레드에서는 스핀락을 써야 한다. 오랫동안 스핀락을 돌면 성능이 나빠진다.
메인 스레드는 JS Promise 통합을 사용해 "차단처럼" 보이게 할 수 있다. 이 차단 동안에는 다른 코드(JS와 Wasm)가 실행될 수 있다. 이를 재진입성(reentrancy)이라고 한다.
Wasm 애플리케이션은 종종 섀도 스택을 사용한다. 섀도 스택은 선형 메모리에 있고 Wasm 런타임이 아니라 Wasm 앱이 관리한다. JS Promise 통합으로 Wasm 코드가 일시 중단/재개될 때 섀도 스택을 올바르게 전환해야 한다. 그렇지 않으면 서로 다른 실행의 섀도 스택 부분이 섞여 엉망이 된다. 재진입성 하에서 깨질 수 있는 다른 것들도 주의해야 한다.
또한 앞서 언급했듯, 캔버스 그리기 코드가( JS Promise 통합으로) 일시 중단되면 반쯤 그려진 캔버스가 페이지에 표시될 수 있다. 이는 오프스크린 캔버스를 사용해 웹 워커에서 그리도록 우회할 수 있다.
웹의 멀티스레딩은 웹 워커에 의존한다. 현재 브라우저에서 Wasm 스레드를 직접 시작하는 방법은 없다.
멀티스레드 Wasm 애플리케이션을 시작하려면 공유 WebAssembly.Memory(내부에 SharedArrayBuffer 포함)를 다른 웹 워커에 전달해야 한다. 그 웹 워커는 같은 WebAssembly.Memory(그리고 WebAssembly.Module)를 사용해 새 Wasm 인스턴스를 별도로 생성해야 한다.
Wasm 전역은 스레드 로컬(실제로는 글로벌이 아님)이다. 한 스레드에서 변경 가능한 전역을 수정해도 다른 스레드에는 영향을 주지 않는다. 변경 가능한 전역 변수는 선형 메모리에 두어야 한다.
또 다른 중요한 제약: Wasm 테이블은 공유할 수 없다.
이는 실행 중 새 Wasm 코드를 로드할 때(동적 링크) 문제를 만든다. 기존 코드가 새 함수를 호출하려면 테이블의 함수 참조를 통한 간접 호출이 필요하다. 그러나 서로 다른 웹 워커의 Wasm 인스턴스끼리는 테이블을 공유할 수 없다.
현재 우회책은 웹 워커들에게 알림을 보내 그들이 능동적으로 새 코드를 로드하고 테이블에 새 함수 참조를 넣도록 하는 것이다. 간단한 방법은 웹 워커에 메시지를 보내는 것이다. 하지만 웹 워커의 Wasm 코드가 여전히 실행 중일 때는 동작하지 않는다. 이런 경우에는(성능 비용이 드는) 다른 메커니즘이 필요하다.
로드 시점의 동적 링크는 문제 없이 동작하지만, 실행 중
dlopen/dlsym을 통한 동적 링크는 추가 고려가 필요할 수 있습니다. 그 이유는 간접 함수 포인터 테이블을 스레드 간 동기화하는 작업을 emscripten 라이브러리 코드가 해야 하기 때문입니다. 새 라이브러리가 로드되거나dlsym으로 새 심볼이 요청될 때마다 테이블 슬롯이 추가될 수 있으며, 이러한 변경은 프로세스의 모든 스레드에 반영되어야 합니다.테이블 변경은 뮤텍스로 보호되며, 어떤 스레드든
dlopen이나dlsym에서 반환하기 전에 프로세스의 다른 모든 스레드가 동기화될 때까지 기다립니다. 이 동기화를 가능한 한 매끄럽게 만들기 위해, 우리는 emscripten_futex_wait 및 emscripten_yield 의 저수준 원시 기능을 후킹합니다.
이를 해결하려는 shared-everything threads 제안이 있다.
모든 웹 워커가 브라우저의 이벤트 루프를 활용하고 각 실행에서 오래 블록하지 않는다면, 웹 워커 메시지를 처리하는 방식으로 협력적으로 새 Wasm 코드를 로드할 수 있으며 큰 지연 없이 이뤄질 수 있다.
숫자(i32, i64, f32, f64)는 JS와 Wasm 사이에 직접 전달할 수 있다(i64는 JS의 BigInt, 나머지 3개는 number에 매핑된다).
JS 문자열을 Wasm으로 전달하려면 다음이 필요하다:
선형 메모리의 문자열을 JS로 전달하는 것도 마찬가지로 쉽지 않다.
Wasm과 JS 사이의 문자열 전달은 성능 병목이 될 수 있다. 애플리케이션이 Wasm-JS 간 데이터 전달을 자주 한다면, JS를 Wasm으로 대체해도 오히려 성능이 떨어질 수 있다.
현대 Wasm/JS 런타임(V8 포함)은 Wasm과 JS 간의 상호 호출을 JIT 및 인라이닝할 수 있다. 하지만 복사 비용은 없앨 수 없다.
Wasm Component Model은 이를 해결하려는 시도다. 인터페이스에서 문자열, 레코드(구조체), 리스트, enum 같은 더 높은 수준의 타입을 전달할 수 있게 해준다. 하지만 서로 다른 컴포넌트는 메모리를 공유할 수 없고, 전달되는 데이터는 복사되어야 한다.
문자열 전달 비용을 줄이려는 Wasm-JS 문자열 빌트인도 있다.
Wasm 코드는 Web API를 직접 호출할 수 없다. Web API는 JS 글루 코드(접착 코드)를 통해 호출해야 한다.
웹의 모든 JS API에는 Web IDL 명세가 있지만, 여기에는 GC되는 객체, 이터레이터 및 비동기 이터레이터가 관여한다(예: fetch()는 Response를 반환하며, 그 body는 ReadableStream). 이러한 GC 관련 및 비동기 관련 요소들은 선형 메모리를 사용하는 Wasm 코드에 쉽게 맞추기 어렵다. Web IDL 인터페이스를 Wasm 인터페이스로 변환하는 명세를 설계하는 일이 쉽지 않다.
과거 Web IDL Bindings 제안이 있었으나 Component Model 제안으로 대체되었다.
현재 Wasm은 Wasm을 부트스트랩하는 JS 코드 없이는 브라우저에서 실행될 수 없다.
원래의 Wasm은 32비트 주소만 지원하고 선형 메모리를 최대 4GiB까지 지원한다.
Wasm에서 선형 메모리는 유한한 크기를 갖는다. 크기를 벗어난 주소 접근은 실행을 중단시키는 트랩을 발생시켜야 한다. 일반적으로 그 범위 검사를 구현하려면 각 선형 메모리 접근에 분기를 삽입해야 한다(예: if (address >= memorySize) {trap();}).
하지만 Wasm 런타임은 최적화를 사용한다: 4GB 선형 메모리를 가상 메모리 공간에 매핑하는 것이다. 범위 밖 페이지는 OS로부터 실제 할당되지 않으므로, 해당 영역을 접근하면 OS 에러가 발생한다. Wasm 런타임은 이 에러를 시그널 핸들링으로 처리한다. 이 경우 범위 검사 분기가 필요 없다.
이 최적화는 64비트 주소를 지원할 때는 통하지 않는다. Wasm 선형 메모리를 담을 만큼의 가상 주소 공간이 충분하지 않기 때문이다. 그래서 모든 선형 메모리 접근마다 범위 검사 분기를 계속 삽입해야 하며, 이는 성능 비용을 초래한다.
참고: Is Memory64 actually worth using?
일반적으로, 같은 소스 코드에서 네이티브로 컴파일한 애플리케이션보다 WebAssembly가 느리게 동작한다. 여러 이유가 있다:
mmap 같은 일부 OS 기능에 접근할 수 없다.먼저 .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.
배경:
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로 코드를 실행한다:
simpleByteArray[index]를 읽는다. 이는 범위 밖이며, 그 결과는 브라우저 프로세스 메모리의 비밀이다.probeTable을 투기적으로 읽는다.probeTable의 특정 메모리 영역이 캐시에 로드된다. 그 영역은 더 빨리 접근된다.공격자는 probeTable에서 메모리 읽기 지연 시간을 측정한다. 더 빠르게 접근되는 위치가 곧 비밀 값에 대응한다.
메모리 접근 지연 시간을 정확히 측정하기에 performance.now()는 충분히 정밀하지 않다. 멀티스레드 카운터 타이머가 필요하다: 한 스레드(웹 워커)가 루프에서 공유 카운터를 계속 증가시키고, 공격 스레드가 그 카운터를 읽어 "시간"을 얻는다. 표준 단위(예: 나노초)로 시간을 측정할 수는 없어도, 캐시 접근의 빠른 지연과 RAM 접근의 느린 지연을 구분할 만큼은 충분히 정밀하다. 스레드 간 카운터 공유에는 SharedArrayBuffer가 필요하다.
동일한 일은 SharedArrayBuffer를 사용하는 동등한 Wasm 코드로도 할 수 있다.
관련: 캐시 사이드 채널과 관련된 또 다른 취약점 GoFetch. 이는 Apple 프로세서의 캐시 프리페칭 기능을 악용한다.
참고: WebAssembly Troubles part 2: Why Do We Need the Relooper Algorithm, Again? 이는 Wasm으로의 컴파일 및 JIT 최적화 성능을 낮출 수 있다. 이 이슈는 애플리케이션 개발자에게 직접 적용되지는 않는다.
WebAssembly가 항상 JS보다 빠른 것은 아니며, 투입한 최적화 노력과 브라우저 제약에 따라 달라진다. 그러나 WebAssembly는 JS보다 성능 잠재력이 높다. JS는 유연성이 매우 크다. 유연성은 성능 비용을 수반한다. JS 런타임은 종종 런타임 통계를 사용해 사용되지 않는 유연성을 찾아 그에 맞춰 최적화한다. 하지만 통계는 확실하지 않아, JS 런타임은 여전히 유연성에 대비해야 한다. 이러한 런타임 통계 수집과 "유연성 대비"는 코드 포맷을 바꾸지 않고는 최적화하기 어려운 방식으로 성능 비용을 유발한다. ↩
call_ref는 스택의 함수 참조를 호출한다. call_indirect는 테이블의 인덱스로 함수 참조를 호출한다. return_call_ref, return_call_indirect는 꼬리 호출용이다. ↩
세이프포인트 메커니즘은 스레드가 특정 지점에서 협력적으로 멈추게 한다. 실행 중인 스레드의 스택을 스캔하는 것은 메모리 순서 문제와 경쟁 상태 때문에 신뢰할 수 없고, 일부 포인터는 스택이 아니라 레지스터에 있을 수 있다. OS 기능으로 스레드를 중단시키면 일부 지역 변수가 레지스터에 있을 수 있고, 레지스터의 데이터가 포인터인지 다른 데이터인지 구분하기 어렵다(정수를 포인터로 취급하면 메모리 안전성 문제나 누수를 야기할 수 있다). 스레드가 특정 지점에서 협력적으로 멈춘다면 참조를 신뢰성 있게 스캔할 수 있다. 세이프포인트를 구현하는 한 가지 방법은 전역 세이프포인트 플래그를 두는 것이다. 코드는 그 플래그를 자주 읽고, true이면 멈춘다. OS 페이지 폴트 시그널 핸들러를 활용하는 최적화도 있다. ↩
이는 단순화다. 실제로는 각 탭의 메인 스레드마다 두 개의 이벤트 큐가 있다. 하나는 저우선순위 이벤트용 콜백 큐, 다른 하나는 고우선순위 이벤트용 마이크로태스크 큐다. 고우선순위 큐가 먼저 실행된다. ↩