WebAssembly가 왜 웹에서 ‘2급 언어’처럼 느껴지는지, 그 결과 개발자 경험이 어떻게 악화되는지, 그리고 WebAssembly 컴포넌트 모델이 로딩·Web API 사용·상호운용성 문제를 어떻게 개선할 수 있는지 살펴본다.
URL: https://hacks.mozilla.org/2026/02/making-webassembly-a-first-class-language-on-the-web/
Title: 웹에서 WebAssembly를 1급 언어로 만들기 – Mozilla Hacks - 웹 개발자 블로그
이 글은 2025년 뮌헨에서 열린 WebAssembly CG 미팅에서 제가 발표했던 내용을 확장한 버전입니다.
WebAssembly는 2017년 첫 릴리스 이후 큰 발전을 이뤘습니다. WebAssembly의 첫 버전은 C와 C++ 같은 저수준 언어에 이미 매우 잘 맞았고, 많은 새로운 종류의 애플리케이션이 효율적으로 웹을 타깃할 수 있게 해 주었습니다.
그 이후 WebAssembly CG는 언어의 핵심 역량을 대폭 확장해 공유 메모리, SIMD, 예외 처리, 테일 콜, 64비트 메모리, GC 지원을 추가했고, 이와 함께 벌크 메모리 명령, 다중 반환, 참조 값 같은 여러 작은 개선도 더했습니다.
이러한 추가 기능 덕분에 더 많은 언어가 WebAssembly를 효율적으로 타깃할 수 있게 되었습니다. 물론 스택 스위칭과 개선된 스레딩 같은 중요한 작업이 더 남아 있지만, WebAssembly는 여러 측면에서 네이티브와의 격차를 줄여 왔습니다.
그런데도, 웹에서 더 널리 채택되는 것을 막는 무언가가 아직 빠져 있는 듯한 느낌이 듭니다.
이에는 여러 이유가 있지만, 핵심 문제는 **WebAssembly가 웹에서 2급 언어(second-class language)**라는 점입니다. 새로운 언어 기능이 많이 추가되었음에도, WebAssembly는 웹 플랫폼과 마땅히 그래야 할 만큼 긴밀하게 통합되어 있지 않습니다.
그 결과 개발자 경험이 좋지 않고, 개발자들은 정말 필요할 때만 WebAssembly를 사용하게 됩니다. 많은 경우 JavaScript가 더 단순하고 “충분히 괜찮기” 때문입니다. 따라서 WebAssembly 사용자는 투자할 여력이 있는 대기업에 편중되는 경향이 있고, 그로 인해 WebAssembly의 혜택이 더 넓은 웹 커뮤니티의 일부 소수에만 제한됩니다.
이 문제를 해결하는 것은 어렵고, CG는 지금까지 WebAssembly 언어 자체를 확장하는 데 집중해 왔습니다. 이제 언어가 상당히 성숙했으니, 이 문제를 더 면밀히 살펴볼 때입니다. WebAssembly 컴포넌트(WebAssembly Components)가 어떻게 상황을 개선할 수 있는지 이야기하기에 앞서, 문제를 깊게 파고들어 보겠습니다.
아주 높은 수준에서 보면, 웹 플랫폼의 스크립팅 영역은 다음과 같이 계층화할 수 있습니다:

WebAssembly는 JavaScript와 직접 상호작용할 수 있고, JavaScript는 웹 플랫폼과 직접 상호작용할 수 있습니다. WebAssembly도 웹 플랫폼에 접근할 수는 있지만, JavaScript가 가진 특별한 능력을 통해서만 가능합니다. JavaScript는 웹에서 1급 언어이고, WebAssembly는 그렇지 않습니다.
이는 의도적이거나 악의적인 설계 결정이 아니었습니다. JavaScript는 웹의 원래 스크립팅 언어였고, 플랫폼과 함께 공동 진화(co-evolved)해 왔습니다. 그럼에도 이러한 설계는 WebAssembly 사용자에게 큰 영향을 줍니다.
JavaScript의 ‘특별한 능력’이란 무엇일까요? 오늘 논의에서는 크게 두 가지입니다:
WebAssembly 코드는 불필요하게 번거롭게 로드해야 합니다. JavaScript 코드는 단순히 script 태그에 넣기만 하면 됩니다:
<script src="script.js"></script>
현재 WebAssembly는 script 태그에서 지원되지 않기 때문에, 개발자들은 WebAssembly JS API를 사용해 코드를 수동으로 로드하고 인스턴스화해야 합니다.
let bytecode = fetch(import.meta.resolve('./module.wasm'));
let imports = { ... };
let { exports } =
await WebAssembly.instantiateStreaming(bytecode, imports);
사용해야 하는 API 호출의 정확한 순서는 난해하고, 이 과정을 수행하는 방법도 여러 가지이며 각각의 트레이드오프가 대부분의 개발자에게 명확하지 않습니다. 일반적으로 이 과정은 외워야 하거나, 도구가 대신 생성해 주어야 합니다.
다행히 esm-integration 제안이 있습니다. 이는 이미 오늘날 번들러에 구현되어 있고, Firefox에서도 적극적으로 구현 중입니다. 이 제안은 개발자가 익숙한 JS 모듈 시스템을 사용해 JS 코드에서 WebAssembly 모듈을 import할 수 있게 해 줍니다.
import { run } from "/module.wasm";
run();
또한 type="module"을 사용해 script 태그에서 WebAssembly 모듈을 직접 로드할 수도 있습니다:
<script type="module" src="/module.wasm"></script>
이는 WebAssembly 모듈을 로딩하고 인스턴스화하는 가장 흔한 패턴을 간소화해 줍니다. 하지만 초기의 어려움을 어느 정도 완화해 주긴 해도, 곧바로 진짜 문제에 부딪히게 됩니다.
JavaScript에서 Web API를 사용하는 것은 다음처럼 간단합니다:
console.log("hello, world");
WebAssembly의 경우 상황은 훨씬 더 복잡합니다. WebAssembly는 Web API에 직접 접근할 수 없고, JavaScript를 통해서만 접근해야 합니다.
같은 한 줄짜리 console.log 프로그램을 위해 다음과 같은 JavaScript 파일이 필요합니다:
js// Wasm 코드의 원시 메모리에 접근해야 하므로, // 여기에서 메모리를 만들고 import로 제공한다. let memory = new WebAssembly.Memory(...); function consoleLog(messageStartIndex, messageLength) { // 문자열은 Wasm 메모리에 저장되어 있지만, // DOM API가 요구하는 JS 문자열로 디코드해야 한다. let messageMemoryView = new UInt8Array( memory.buffer, messageStartIndex, messageLength); let messageString = new TextDecoder().decode(messageMemoryView); // Wasm은 `console` 전역에 접근하거나 // 프로퍼티 조회를 할 수 없으므로, 여기에서 처리한다. return console.log(messageString); } // 래핑된 Web API를 import를 통해 Wasm 코드에 전달한다. let imports = { "env": { "memory": memory, "consoleLog": consoleLog, }, }; let { instance } = await WebAssembly.instantiateStreaming(bytecode, imports); instance.exports.run();
그리고 다음과 같은 WebAssembly 파일이 필요합니다:
(module
;; JS 코드로부터 메모리를 import
(import "env" "memory" (memory 0))
;; JS consoleLog 래퍼 함수를 import
(import "env" "consoleLog"
(func $consoleLog (param i32 i32))
)
;; run 함수를 export
(func (export "run")
(local i32 $messageStartIndex)
(local i32 $messageLength)
;; Wasm 메모리에 문자열을 만들고 로컬에 저장
...
;; consoleLog 메서드 호출
local.get $messageStartIndex
local.get $messageLength
call $consoleLog
)
)
이런 코드를 “바인딩(bindings)” 또는 “글루 코드(glue code)”라고 부르며, 소스 언어(C++, Rust 등)와 Web API 사이를 잇는 다리 역할을 합니다.
이 글루 코드는 WebAssembly 데이터와 JavaScript 데이터 간의 재인코딩을 담당합니다. 예를 들어 JavaScript에서 WebAssembly로 문자열을 반환할 때, 글루 코드는 WebAssembly 모듈의 malloc 함수를 호출해 메모리를 할당하고 그 주소에 문자열을 재인코딩해야 할 수 있으며, 이후 모듈은 적절한 시점에 free를 호출해야 합니다.
이 과정은 매우 번거롭고 정형적이며 작성하기도 어렵기 때문에, 보통 embind나 wasm-bindgen 같은 도구로 자동 생성하는 것이 일반적입니다. 이는 작성 과정을 간소화하지만, 네이티브 플랫폼에서는 보통 요구되지 않는 빌드 복잡도를 추가합니다. 게다가 이 빌드 복잡도는 언어별로 다릅니다. Rust 코드는 C++ 코드와 다른 바인딩이 필요하고, 그 반대도 마찬가지입니다.
물론 글루 코드는 런타임 비용도 발생시킵니다. JavaScript 객체는 할당되고 가비지 컬렉션되어야 하며, 문자열은 재인코딩되어야 하고, 구조체는 역직렬화되어야 합니다. 이 비용 중 일부는 어떤 바인딩 시스템에서도 불가피하지만, 상당 부분은 그렇지 않습니다. 이는 JavaScript와 WebAssembly 경계에서 지불하는 만연한 비용이며, 호출 자체가 빠르더라도 마찬가지입니다.
많은 사람들이 “Wasm은 언제 DOM 지원을 하게 되나요?”라고 물을 때 의미하는 바가 이것입니다. WebAssembly로 어떤 Web API든 접근하는 것은 이미 가능하지만, JavaScript 글루 코드가 필요합니다.
기술적인 관점에서 보면, 현 상태로도 동작합니다. WebAssembly는 웹에서 실행되며, 많은 사람들이 이미 이를 사용해 소프트웨어를 성공적으로 출시해 왔습니다.
하지만 평균적인 웹 개발자 관점에서 현 상태는 만족스럽지 못합니다. WebAssembly는 웹에서 사용하기에 너무 복잡하고, 언제나 2급 경험을 하고 있다는 느낌에서 벗어날 수 없습니다. 우리의 경험상 WebAssembly는 평균적인 개발자가 쓰지 않는 ‘파워 유저 기능’이며, 프로젝트에 더 나은 기술적 선택이 될 수 있는 경우에도 그렇습니다.
JavaScript를 시작하는 사람의 평균적인 개발자 경험은 대략 이런 모습입니다:

프로젝트의 범위가 커질수록 점진적으로 더 복잡한 기능을 사용하게 되는 완만한 곡선이 있습니다.
반면 WebAssembly를 시작하는 사람의 평균적인 개발자 경험은 대략 이런 모습입니다:

서로 다른 많은 조각을 함께 동작하도록 다루는 “벽”을 처음부터 바로 올라야 합니다. 최종 결과는 종종 대규모 프로젝트에서만 가치가 있습니다.
왜 이런 일이 생길까요? 여러 이유가 있으며, 모두 WebAssembly가 웹에서 2급 언어라는 사실에서 직접적으로 비롯됩니다.
웹을 타깃으로 하는 어떤 언어든 Wasm 파일만 생성해서는 안 되고, Wasm 코드를 로드하고, Web API 접근을 구현하며, 그 밖의 여러 꼬리 문제를 처리하는 동반 JS 파일도 생성해야 합니다. 이 작업은 웹을 지원하려는 모든 언어마다 다시 해야 하고, 웹이 아닌 플랫폼에서는 재사용할 수도 없습니다.
Clang/LLVM 같은 업스트림 컴파일러는 JS나 웹 플랫폼을 알기를 원치 않습니다. 단지 노력이 부족해서가 아닙니다. JS 및 웹 글루 코드를 생성하고 유지하는 일은 전문성이 필요한 기술이고, 이미 여력이 부족한 유지관리자들이 이를 정당화하기 어렵습니다. 그들이 원하는 것은 단 하나의 바이너리를 생성하는 것이며, 이상적으로는 웹 이외의 플랫폼에서도 사용 가능한 표준화된 포맷이길 바랍니다.
그 결과, 웹에서의 WebAssembly 지원은 종종 사용자가 직접 찾아서 배우고 설치해야 하는 서드파티 비공식 툴체인 배포판이 맡게 됩니다. 진정한 1급 경험이라면 사용자가 이미 알고 설치해 둔 도구에서 시작할 수 있어야 합니다.
불행히도 이것이 많은 개발자가 WebAssembly를 시작할 때 겪는 첫 번째 장애물입니다. rustc를 설치해 두고 --target=wasm 플래그를 주기만 하면 브라우저에서 로드할 수 있는 결과물이 나올 것이라고 가정합니다. 그렇게 하면 WebAssembly 파일 자체는 얻을 수 있지만, 필요한 플랫폼 통합이 전혀 포함되어 있지 않습니다. JS API로 파일을 어떻게든 로드하는 데 성공하더라도, 미스터리하고 디버깅하기 어려운 이유로 실패할 것입니다. 실제로 필요한 것은 플랫폼 통합을 대신 구현해 주는 비공식 툴체인 배포판입니다.
웹 플랫폼은 대부분의 기술 플랫폼보다 훨씬 뛰어난 문서를 가지고 있습니다. 하지만 대부분은 JavaScript를 기준으로 작성되어 있습니다. JavaScript를 모르면 많은 Web API의 사용법을 이해하기가 훨씬 어려워집니다.
새로운 Web API를 쓰고 싶은 개발자는 먼저 JavaScript 관점에서 이를 이해한 다음, 자신이 쓰는 소스 언어에서 사용 가능한 타입과 API로 번역해야 합니다. 툴체인 개발자들이 자신의 언어를 위해 기존 웹 문서를 수동으로 번역해 제공하려 할 수는 있지만, 이는 지루하고 오류가 나기 쉬운 과정이며 확장되지 않습니다.
위의 console.log 한 번 호출을 위한 JS 글루 코드를 보면, 오버헤드가 많다는 것을 알 수 있습니다. 엔진들은 이를 최적화하는 데 많은 시간을 쏟았고, 더 많은 작업도 진행 중입니다. 하지만 이 문제는 여전히 존재합니다. 모든 워크로드에 영향을 주는 것은 아니지만, 모든 WebAssembly 사용자가 주의해야 하는 부분입니다.
이를 벤치마킹하는 것은 까다롭지만, 우리는 2020년에 실제 DOM 애플리케이션에서 JS 글루 코드가 초래하는 오버헤드를 정밀하게 측정하기 위해 실험을 수행했습니다. 실험적 Dodrio Rust 프레임워크로 고전적인 TodoMVC 벤치마크를 만들고 DOM API 호출 방식에 따른 차이를 측정했습니다.
Dodrio는 필요한 DOM 변경을 실제 적용과 분리하여 계산했기 때문에 이 실험에 완벽했습니다. 덕분에 벤치마크의 나머지 부분은 그대로 유지하면서 “DOM 변경 목록 적용” 함수만 바꿔 끼워 JS 글루 코드의 영향을 정확히 측정할 수 있었습니다.
우리는 두 가지 구현을 테스트했습니다:

DOM 변경을 적용하는 데 걸리는 시간이 JS 글루 코드를 제거했을 때 45% 감소했습니다. DOM 연산은 원래도 비쌀 수 있기 때문에, WebAssembly 사용자는 그 위에 2배에 가까운 성능 세금을 더 지불할 여유가 없습니다. 그리고 이 실험은 오버헤드를 제거하는 것이 가능함을 보여 줍니다.
“추상화는 항상 새기 마련이다(leaky)”라는 말이 있습니다.
웹에서 WebAssembly의 최첨단 사용 방식은, 각 언어가 JavaScript를 사용해 웹 플랫폼에 대한 자신만의 추상화를 구축하는 것입니다. 하지만 이런 추상화는 새기 마련입니다. 웹에서 WebAssembly를 진지하게 사용하다 보면 언젠가는 무언가를 동작하게 만들기 위해 직접 JavaScript를 읽거나 작성해야 하는 순간이 옵니다.
이는 개발자에게 부담이 되는 개념적 레이어를 추가합니다. 소스 언어와 웹 플랫폼만 알면 충분해야 할 것 같습니다. 하지만 WebAssembly에서는 유능한 개발자가 되기 위해 JavaScript까지 알아야 한다고 요구합니다.
이는 복잡한 기술적·사회적 문제이며, 단 하나의 해결책이 존재하지 않습니다. 또한 WebAssembly에서 무엇을 가장 먼저 고칠지에 대한 우선순위도 서로 다릅니다.
스스로에게 질문해 봅시다. 이상적인 세상에서는 무엇이 도움이 될까요?
다음과 같은 무언가가 있다면 어떨까요?
그런 것이 존재한다면, 언어는 그 아티팩트를 생성하고 브라우저는 이를 실행할 수 있으며, JavaScript가 전혀 필요 없게 됩니다. 이 포맷은 언어가 지원하기 더 쉬울 것이고, 서드파티 배포판 없이도 표준 업스트림 컴파일러, 런타임, 툴체인, 인기 패키지에 존재할 가능성이 높아집니다. 즉, 모든 언어가 JavaScript를 이용해 웹 플랫폼 통합을 재구현하는 세상에서, 브라우저에 직접 내장된 공통 통합을 공유하는 세상으로 갈 수 있습니다.
물론 해결책을 설계하고 검증하는 데는 엄청난 작업이 필요합니다! 다행히도 이런 목표를 가진 제안이 이미 있고, 수년간 개발되어 왔습니다. 바로 WebAssembly 컴포넌트 모델(WebAssembly Component Model)입니다.
여기서의 목적상, WebAssembly 컴포넌트는 저수준 WebAssembly 코드 번들을 통해 구현되는 고수준 API를 정의합니다. 이는 2021년부터 개발되어 온 WebAssembly CG의 표준화 트랙 제안입니다.
오늘날 이미 WebAssembly 컴포넌트는…
더 자세한 내용이 궁금하다면 Component Book을 보거나 “What is a Component?” 영상을 시청해 보세요.
우리는 WebAssembly 컴포넌트가 웹 플랫폼에서 WebAssembly에 1급 경험을 제공하고, 앞서 설명한 ‘빠진 고리’를 메울 잠재력이 있다고 봅니다.
이전의 console.log 예제를 JavaScript 없이, WebAssembly 컴포넌트만으로 다시 만들어 봅시다.
NOTE: WebAssembly 컴포넌트와 웹 플랫폼 간 상호작용은 아직 완전히 설계되지 않았고, 도구는 활발히 개발 중입니다.
이는 튜토리얼이나 약속이 아니라, 앞으로 이렇게 될 수 있다는 열망(aspiration)으로 받아들여 주세요.
첫 단계는 애플리케이션에 필요한 API가 무엇인지 명시하는 것입니다. 이는 WIT라는 IDL을 사용해 수행합니다. 이 예제에서는 Console API가 필요합니다. 인터페이스 이름을 지정해 import할 수 있습니다.
component {
import std:web/console;
}
std:web/console 인터페이스는 현재는 존재하지 않지만, 가정하자면 브라우저가 Web API를 설명하는 데 사용하는 공식 WebIDL에서 유래할 수 있습니다. 이 인터페이스는 대략 이런 모습일 수 있습니다:
package std:web;
interface console {
log: func(msg: string);
...
}
이제 위의 인터페이스를 갖췄으니, WebAssembly 컴포넌트로 컴파일되는 Rust 프로그램을 작성할 때 이를 사용할 수 있습니다:
rustuse std::web::console; fn main() { console::log(“hello, world”); }
컴포넌트를 만들고 나면 script 태그로 브라우저에 로드할 수 있습니다.
<script type="module" src="component.wasm"></script>
이게 전부입니다! 브라우저는 컴포넌트를 자동으로 로드하고, 네이티브 Web API를 (JS 글루 코드 없이) 직접 바인딩한 다음, 컴포넌트를 실행할 것입니다.
이 방식은 전체 애플리케이션이 WebAssembly로 작성되어 있을 때는 훌륭합니다. 하지만 대부분의 WebAssembly 사용은 JavaScript도 함께 포함된 “하이브리드 애플리케이션”의 일부입니다. 이 사용 사례도 단순화하고 싶습니다. 웹 플랫폼이 서로 상호작용할 수 없는 “사일로”로 나뉘어서는 안 됩니다. 다행히 WebAssembly 컴포넌트는 언어 간 상호운용성을 지원함으로써 이 문제도 해결합니다.
JavaScript 코드에서 사용할 이미지 디코더를 export하는 컴포넌트를 만들어 봅시다. 먼저 이미지 디코더를 설명하는 인터페이스를 작성해야 합니다:
interface image-lib {
record pixel {
r: u8;
g: u8;
b: u8;
a: u8;
}
resource image {
from-stream:
static async func(bytes: stream<u8>) -> result<image>;
get: func(x: u32, y: u32) -> pixel;
}
}
component {
export image-lib;
}
이제 이를 바탕으로 컴포넌트를 지원하는 어떤 언어로든 컴포넌트를 작성할 수 있습니다. 어떤 언어가 적합한지는 무엇을 만들고 있는지, 어떤 라이브러리가 필요한지에 따라 달라집니다. 이 예제에서는 이미지 디코더 구현은 독자의 연습 문제로 남겨두겠습니다.
그 다음 이 컴포넌트는 JavaScript에서 모듈로 로드할 수 있습니다. 우리가 정의한 이미지 디코더 인터페이스는 JavaScript에서 접근 가능하며, JavaScript 라이브러리를 import해 작업에 사용하는 것처럼 사용할 수 있습니다.
jsimport { Image } from "image-lib.wasm"; let byteStream = (await fetch("/image.file")).body; let image = await Image.fromStream(byteStream); let pixel = image.get(0, 0); console.log(pixel); // { r: 255, g: 255, b: 0, a: 255 }
현재 상태로 보면, 우리는 WebAssembly 컴포넌트가 웹을 위한 올바른 방향의 한 걸음이 될 수 있다고 생각합니다. Mozilla는 WebAssembly CG와 함께 WebAssembly 컴포넌트 모델을 설계하는 작업을 진행하고 있습니다. Google도 현재 이를 평가하고 있습니다.
직접 시험해 보고 싶다면, 첫 컴포넌트 만들기를 학습하고 Jco를 이용해 브라우저에서 시도하거나, 커맨드라인에서 Wasmtime로 실행해 보세요. 도구는 활발히 개발 중이며, 기여와 피드백을 환영합니다. 개발 중인 명세 자체에 관심이 있다면 component-model 제안 저장소를 확인해 보세요.
WebAssembly는 2017년 첫 릴리스 이후 매우 먼 길을 왔습니다. 이것이 “파워 유저” 기능에 머무르지 않고 평균적인 개발자도 혜택을 볼 수 있는 무언가로 바뀔 수 있다면, 앞으로가 더욱 기대된다고 생각합니다.