직접 만든 언어 Mere의 WebAssembly 지원 — 2026년 9월 시점의 현재 위치
한 언어의 Wasm 백엔드에 대한 상황 보고. 능력을 WASI 인터페이스로 내리고, 실제 네트워크 프로그램을 두 호스트에서 컴포넌트로 돌리고, WASI의 것이 아니었던 병행성의 벽에 부딪히고, 아무도 재지 않던 playground가 조용히 세 배가 되어 있었다. 전체로 1,064,718바이트에서 616,025바이트로.
Mere는 내가 나를 위해 쓰는 작은 언어다. 인터프리터와 네 개의 컴파일 백엔드(C, LLVM IR, WebAssembly 텍스트, RISC-V 기계어)를 가지고 있고, Wasm 백엔드는 약 11,400줄이다. 이 글은 그 백엔드에 대한 상황 보고다. 2026년 9월 현재 무엇을 할 수 있고 무엇을 할 수 없는지, 그리고 아무도 지켜보지 않던 숫자가 세 배가 되어 있었다는 것을 알게 된 하루에 대해.
측정 기록이다. 아래의 모든 수치는 이 기계 또는 CI에서 나왔고, 불리한 숫자도 유리한 숫자와 같은 표에 실려 있다.
설계상의 세부 하나를 먼저 적어 둔다. 이것이 나머지 전부의 모양을 결정하기
때문이다. 이 백엔드가 내놓는 것은 WebAssembly 텍스트이지 바이너리가 아니다.
wabt의 wat2wasm이 조립한다. 즉 출력은 어느 단계에서든 읽을 수 있고, diff를 뜰
수 있고, grep할 수 있다. 아래 측정 중 몇 가지가 가능했던 것은 그 덕분이다.
산출물 자체에 질문을 던질 수 있었다.
능력이 WASI 인터페이스가 되었다
첫 번째 아크는 Component Model이었다. Mere에는 작은 언어가 흔히 그러듯 호스트
능력이 자라 있었다. print가 한 줄을 쓰고, args가 명령줄을 돌려주고,
read_file이 파일을 읽는다. 각각이 env라는 모듈에서 오는 ambient import였는데,
이는 정확히 컴포넌트화할 수 없는 모양이다. 컴포넌트는 필요한 것을 world에
선언하지만 env.*는 아무것도 선언하지 않는다.
그래서 각 능력을 WASI 인터페이스 위로 내렸다.
| 능력 | 내린 곳 |
|---|---|
print |
fd_write |
args |
args_get |
env_var |
environ_get |
time |
clock_time_get |
| stdin | fd_read |
read_file / write_file |
path_open + fd_read / fd_write |
| TCP / UDP / DNS | wasi:sockets(p2, resource 타입) |
파일시스템 쪽은 예상보다 싸게 끝났다. preview 1 어댑터의 path_open 계열이 그걸
날라 주기 때문에, 파일을 위해 preview 2의 resource와 stream 모델을 건드릴 필요가
없었다. 소켓은 반대였다. preview 1으로는 소켓을 만들 수조차 없어서 wasi:sockets로
곧장 가야 한다. resource 핸들, pollable.block, 그리고 canonical ABI가 record와
variant를 i32 열두 개로 평탄화하는 일이 한꺼번에 따라온다.
그 결과 테스트 케이스가 아니라 실제 프로그램이 컴포넌트로 돈다. Mere로 쓴 HTTP
클라이언트가 실제 소켓으로 페이지를 가져온다. Mere로 쓴 DNS 리졸버가 8.8.8.8에 UDP
질의를 보내고 A 레코드를 읽는다. 둘 다 하나의 산출물이 두 호스트에서 돈다.
네이티브 wasmtime과 Node 위의 jco. 이 두 호스트 성질은 의도한 것이고 보기보다
값어치가 있다. 런타임 하나에서만 도는 컴포넌트는 명세가 아니라 구현에 대해서만
시험된 것이다.
버전은 wasi:*@0.2.3으로 한 곳에 고정되어 있다. 예전에는 32개의 import 각각에
적혀 있었고 빌드 스크립트에 여덟 곳이 더 있었다. 하나의 규칙이 마흔 가지로 적혀
있는 상태였고, 그것을 옮기는 릴리스는 마흔 곳 모두에서 옳아야 했다. 빌드 스크립트는
이제 되풀이하지 않는다. 컴파일러가 방금 내놓은 모듈에서 버전을 되읽는다. 그래서
embed하는 world가 import가 쓰지 않는 버전을 내세울 수 없다.
WASI의 것이 아니었던 벽
닿지 못한 dogfood가 하나 있다. Redis 프로토콜 KV 서버다. 연결마다 스레드를 spawn하고 채널로 store 스레드와 이야기한다. 소통으로 공유하는 형태이고, 핸들러는 맵을 전혀 건드리지 않는다.
Component Model은 단일 스레드다. canonical ABI는 공유 메모리 스레드를 상정하지
않고, wasi-threads는 그것과 호환되지 않는 core module 기능이다. 그래서 나는 구멍을
기록하고, 선택지를 적어 두고, 재방문 조건을 놓았다. component-model async가 WASI
0.3에서 성숙하면.
WASI 0.3.0은 2026년 6월 11일에 나왔다. 확인하러 돌아가서, 내가 적어 둔 것의 두 절반이 반대 방향으로 낡아 있는 것을 발견했다.
도구 쪽 절반은 충족되어 있었다. wasmtime 46은 -S p3=y를 가지고 있다. 그러나
WASI 0.3은 threading을 명시적으로 범위 밖으로 둔다. 0.3이 넣은 것은 async func / stream<T> / future<T> — 호스트가 이벤트 루프 하나를 도는 병행성이지
병렬성이 아니다. 내 재방문 조건은 0.3이 아무리 성숙해도 충족될 수 없었다.
서버의 소스를 다시 읽고 나머지가 정해졌다. 연결마다 spawn 하나, 채널, store를 건드리지 않는 핸들러. 이것은 I/O 바운드 CSP이고 병렬성을 필요로 하지 않는다. 모양 으로는 협조적 스케줄링에 완벽하게 맞는다.
하지만 WASI 0.3이 정하는 것은 컴포넌트 경계의 async ABI이지, 게스트가 계산 도중에 중단하는 수단이 아니다. direct style 언어에서 나온 core module은 여전히 자기 상태 기계나 스택 전환이 필요하다. 그것은 당시 이미 선택지에 적혀 있던 것(green threads 런타임, 가장 어렵다고 주석을 달아 둔 것)이고, 0.3의 출시로 그 일은 한 줄도 줄지 않았다.
벽은 처음부터 어댑터가 아니었다. 나는 보류를 외부 이벤트의 이름에 묶었고, 그 이벤트는 다른 질문에 대한 답을 들고 도착했다. 정직한 표현은 이렇다. 이것은 내 쪽의 연속(continuation) 기구를 필요로 한다. 착수는 판단이지 기다림이 아니다.
아무도 바이트를 재지 않았다
문서 사이트에는 playground가 있다. 열다섯 개의 데모가 .wasm으로 컴파일되어
브라우저로 배포된다. 아무도 재지 않았다.
보러 가서, 체크아웃되어 있던 빌드 디렉터리를 찾아 거기서 숫자를 읽었다. 나쁘지 않아 보였다. 그러다 그 디렉터리가 두 달 된 것임을 알아챘다. gitignore되어 있었고 한 번도 다시 지어지지 않았다. 소스에서 다시 지으니 그림이 통째로 달라졌다.
| 7월 빌드(오래된 것) | 실제 | |
|---|---|---|
| 파일 수 | 10 | 15 |
| 합계 | 632 KB | 1,064,718 B |
hello.wasm |
5.2 KB | 15.0 KB |
hello.wasm은 두 달 만에 세 배가 되었고 아무도 아무 말도 하지 않았다. 빌드
산출물의 크기는 다른 어떤 검사도 보지 않는 양이기 때문이다. 그리고 오래된 트리를
읽은 것이 내가 처음에 “충분히 작다”고 결론 내린 이유이기도 하다. 잘못된 산출물의
측정은 측정과 똑같은 얼굴을 하고 있다.
맥락을 덧붙이면, Web Almanac의 2025년 크롤에서 실세계 .wasm의 중앙값은 14 KB다.
한 줄을 인쇄하는 프로그램이 웹 전체의 중앙값을 넘고 있었다.
그래서 게이트를 만들었다. 진짜 사이트를 임시 디렉터리에 짓고(체크아웃된 것은 결코 보지 않는다), 각 모듈을 floor와 ceiling을 모두 가진 밴드와 비교한다. ceiling은 누구나 예상하는 회귀 쪽이다. floor가 더 중요한 절반인데, 내용이 사라져 stub이 된 데모는 ceiling만 있는 검사가 영원히 “통과”라고 부르는 종류의 실패이기 때문이다.
최적화기가 가져갈 수 없었던 것
wasm-opt -Oz는 어디에서도 돌지 않고 있었다. 넣었더니 playground에서 34.1%가
떨어졌다. 작은 데모에서 약 -48%, 셀프호스팅된 컴파일러 빌드에서 -27% ~ -33%. 결과는
모두 validate를 통과했고, 헤드리스로 돌릴 수 있는 데모는 인터프리터와 정확히 같은
답을 돌려준다.
더 흥미로운 것은 가져가지 못한 쪽의 숫자이고, 한 열이 그것을 알려주었다. elem의 수가 한 파일도 움직이지 않았다. 65는 65 그대로, 627은 627 그대로. 함수 수는 3분의 1이 떨어졌는데도.
Mere의 클로저 호출은 전부 함수 테이블을 지난다. 그래서 elem 세그먼트의 각 항목은
최적화기가 지워서는 안 되는 root다. call_indirect가 그것을 겨냥할 수 있다.
hello.wasm은 -Oz를 지나고도 152개 함수 중 101개를 유지했고, 그중 65개가 테이블
항목이었다.
그것이 얼마의 값어치인지 알기 위해, 테이블을 손으로 모듈에서 빼냈다. 상한을 재기 위해서만 만든 망가진 산출물이다. 그리고 두 가지로 갈랐다.
hello.wasm, -Oz 후 |
바이트 | 함수 |
|---|---|---|
| 출하 그대로, 65 roots | 7,915 | 101 |
| top-level fn 어댑터 34개 제외 | 7,267 | 58 |
| prelude 람다 31개 제외 | 4,596 | 54 |
| 둘 다 제외 | 2,299 | 7 |
34개의 어댑터를 빼면 43개 함수와 648바이트가 사라진다. 대신 31개의 람다를 빼면 4개 함수와 3,319바이트가 사라진다. 싸 보였던 절반은 실제로 쌌고, 둘은 서로를 살려 두기 때문에 초가법적이며, 한 줄을 인쇄하는 프로그램이 정말로 필요로 하는 것은 7개 함수였다.
이것이 일의 틀을 바꿨다. “테이블을 좁힌다”가 아니었다. 항목들은 정적으로는 도달
가능하고, 그 모듈에는 call_indirect가 51곳 있다. “프로그램이 도달할 수 없는
prelude 함수를 애초에 내놓지 않는다“였다. 함수 본문을 내놓는 것이 곧 그 람다를
테이블에 넣는 행위이기 때문이다.
프로그램이 부르지 않는 prelude
그래서 백엔드는 이제 AST 위에서 도달 가능성을 계산하고, main body에서 seed해서
도달한 것만 내놓는다. hello.wasm은 1,946바이트 8개 함수가 되었고, 테이블
항목은 하나가 되었다.
두 가지 제약이 이것의 모양을 정했다. 깎는 것은 prelude뿐이다. 사용자가 쓴 함수는
walk가 사용처를 찾지 못하더라도 언제나 root로 남는다. 폭발 반경을 자기가 쓰지 않은
코드 안에 가두기 위해서다. 그리고 walk에는 catch-all 팔이 없다. 나중에 구문
노드가 늘어나면 그 아래의 이름을 조용히 떨어뜨리는 대신 빌드가 멈춘다. 넉넉하게
잡으면 아무도 부르지 않는 함수 하나가 남을 뿐이지만, 모자라게 잡으면 “내놓지 않은
함수에 대한 호출”이 나오고 wat2wasm이 그것을 이름으로 거절한다.
그리고 진짜 구멍 하나가 바로 그렇게 스스로 나타났다. 단상화된 특수화는
<base>__<타입 태그>라는 이름을 갖는데, 그 이름은 소스 어디에도 나오지 않는다.
호출부는 list_map이라고 말하고, 방출부는
list_map__list_top_decl__closure_top_decl_top_decl__list_top_decl이라고 쓴다.
기저에 도달했으면 그 모든 인스턴스에 도달해야 한다. 셀프호스팅 bootstrap 테스트가
한 번의 실행으로 그것을 잡았다.
두 번째 구멍은 내 변경보다 오래된 것이고 내 변경이 드러냈을 뿐이다.
call_indirect는 아무것도 등록되어 있지 않아도 지나갈 테이블이 필요한데, 테이블
선언은 하나의 구문 이름을 가진 플래그가 섰을 때만 이루어지고 있었다. 고차 벡터
헬퍼, 그것을 필요로 한다고 관측된 유일한 경우다. 그것이 성립했던 것은 다른 무언가가
모든 프로그램에서 테이블을 비지 않게 유지하고 있었기 때문일 뿐이다. 바로 이
어댑터들이다. 가지치기가 그 우연을 걷어냈고, 차분 코퍼스의 bytes→vector 브리지가
table variable out of range: 0 (max 0)로 조립되지 않게 되었다.
그에 대한 첫 수정은 더 조용한 방식으로 틀렸다. “내놓은 텍스트에 call_indirect가
있는가”로 판정했는데, 테이블 절은 main 함수의 본문과 몇몇 런타임 절이 문자열로
존재하기 전에 만들어진다. 스캔은 부분집합을 읽고 전체에 대해 답하고 있었던
셈이다. 지금은 테이블을 무조건 선언한다. 한 번도 지나지 않는 모듈에서 약 20바이트,
그리고 틀릴 수가 없다.
착지점:
| before | after | |
|---|---|---|
| playground 합계 | 1,064,718 B | 616,025 B |
| 바닥(최소 모듈) | 15,329 B | 1,662 B |
hello.wasm |
15,403 B / 152 함수 | 1,946 B / 8 함수 |
가지치기 비율은 프로그램에서 예상되는 대로 움직인다. hello는 top-level 함수 34개
중 1개, 워드 카운터는 35개 중 2개, 2048 구현은 59개 중 26개, 셀프호스팅 컴파일러는
319개 중 287개를 유지한다.
피검체를 보고 있지 않던 스무 개의 단언
가지치기는 스무 개의 단위 테스트를 깨뜨렸다. 그리고 그 전부가 이미 틀려 있었다.
내놓은 Wasm에 대한 부분 문자열 단언, 즉 “이 프로그램을 컴파일하면 i32.store offset=4가 나온다”는 주장으로, 값 표현이 64비트로 넓어지기 전의 메모리 배치를
말하고 있었다. 오랫동안 거짓이었다. 통과하고 있었던 것은 그 문자열이 피검체
프로그램이 쓰지 않는 런타임 헬퍼 함수 안에 나타나고 있었기 때문이다. 가지치기가
헬퍼를 걷어냈고 우연한 일치도 함께 사라졌다.
그래서 하나씩 고치기를 그만두고 같은 모양의 단언 97개를 전부 훑었다. 열세 개가 더
거짓이었다. 교체는 모두 프로그램 자신의 main 함수에서 읽어냈고, 자명한 프로그램
0에 없다는 것을 확인했다. 그것은 원래의 단언들이 한 번도 하지 않았던 확인이고,
배치 변경에서 살아남은 이유이기도 하다.
훑기는 하나를 더 찾아냈다. 97개 중 41개는 프로그램 0에도 들어맞는다. 거짓이
아니라 공허할 뿐이다. 런타임이 그것들이 지목하는 opcode를 전부 담고 있어서 거의
무엇에나 통과한다. 그것들은 손대지 않고 적어 두었다. 다른 일이고, 훑었으니 고쳐진
척하는 것은 그 자체가 또 하나의 거짓 초록이 된다.
계기 쪽에도 짝이 되는 이야기가 있다. 크기 게이트를 독으로 시험하려고 hello.wasm을
다른 유효한 모듈로 바꿔치기했을 때, 크기 검사는 초록이라고 말했다. 바꿔치기한
쪽이 밴드 안에 착지했기 때문이다. 잡아낸 것은 출하 모듈을 인터프리터와 맞춰
실행하는 동작 검사 쪽이었다. 크기만 아는 게이트는 그것이 다른 물건이라고 말해 주지
못한다.
계기가 나에게 한 일
그날의 실패 중 셋은 내 것이고, 서로 운을 맞춘다.
게이트는 착지한 순간부터 세 커밋 연속으로 CI가 빨갰다. 사이트 빌드는 dune exec로 시작하는데, CI에서 빌드 도구는 패키지 매니저 환경 안에서만 경로가 잡힌다.
배포 워크플로는 처음부터 그 스크립트를 올바르게 부르고 있었다. 나는 CI 단계를 옆
게이트의 모양을 복사해서 썼는데, 옆은 컴파일러 바이너리를 직접 부르므로 그것이
필요 없다. 본보기가 틀렸다. 게이트 자신의 보고가 그것을 악화시켰다. “사이트 빌드가
실패했다”는 한 줄이 단 한 줄짜리 “명령을 찾을 수 없음”을 덮고 있어서, 거기서 나온
첫 추측도 빗나갔다.
최적화기의 버전이 측정의 일부라는 것을 알게 되었다. 이 저장소가 다른 모든 것을 고정하는 방식으로 툴체인을 고정했지만, binaryen만은 배포판에 맡겼다. Ubuntu 24.04가 나눠 주는 것은 version 108이고, 이것은 이 모듈들을 전혀 읽지 못한다. 플래그 없이는 tail call을 거절하고, 플래그와 함께는 mutable하게 export된 global을 거절한다. 밴드는 바이트 수이므로, 그것을 낳은 최적화기는 고정 안에 속한다. version 132는 Linux x86-64에서도 macOS arm64에서도 기록된 바이트를 정확히 내놓는다.
그리고 나는 방금 쓴 게이트는 다시 돌렸지만, 같은 양을 줄곧 지켜보던 쪽은 돌리지 않았다. 별도의 budget 검사가 example 서버의 Wasm 크기를 같은 종류의 floor와 함께 오래 재 왔다. 가지치기는 셋 모두를 그 아래로 밀어 넣었다. 내가 보지 않은 것을 CI가 잡았다. 어떤 숫자를 위해 계기를 하나 만들었다는 이유로, 이미 그 숫자를 향해 있던 계기들을 찾기를 그만두고 있었던 것이다.
새 게이트를 CI와 같은 이미지에서 한 번 돌린 것이 출하 전에 네 번째를 잡았다.
컨테이너의 Node 18은 opcode 0x12를 거절하고, 동작 검사는 그것을 “최적화된 모듈이
인터프리터와 다른 답을 돌려준다”고 보고했다. 엉뚱한 도구를 지목하는 실패는 실패가
없는 것보다 나쁘다. 지금은 전제를 먼저 묻고, CI는 버전이 아니라 능력 자체를 표명하게
한다.
남아 있는 것
Mere는 WasmGC를 쓰지 않는다. 선형 메모리 위에 자기 bump 할당기와 자기 클로저 표현을 얹어 배포한다. 2026년 생태계에서 관리 언어의 크기에 관한 표제는 정확히 반대 방향이다. 메모리 관리를 호스트에 넘기고 수집기를 함께 싣기를 그만두는 것. 보고된 절감은 진짜다. 그것이 자기가 쓴 CPU도 동시에 겨냥하는 언어에게 옳은 거래인지는 아직 열린 질문이고, 나는 재 보지 않았다.
마흔한 개의 공허한 단언. 거짓이 아니라 약하다. 하나하나에 판별하는 입력이 필요하다.
병행성. 지금은 바르게 정리되어 있다. 컴파일러 쪽의 연속 또는 협조적 스케줄링 기구이고, 기다림이 아니라 판단이다.
wasm-opt가 컴포넌트를 처리하지 못한다. 이것은 상류의 미해결 사항이다. preview
2 이후로는 컴포넌트가 기본 산출물이므로, 가장 잘 듣는 최적화 패스가 실제로 출하하는
것을 그냥 지나친다.
Wasm 3.0 기능들 중 이 백엔드가 쓰는 것은 tail call뿐이다. GC도, 예외 처리도, 64비트 메모리도, 원자적 연산도 쓰지 않는다. 그것이 현재 위치의 공정한 요약일 것이다. 컴포넌트화된 능력 층을 가진 선형 메모리 컴파일러, 예전의 4분의 1 비용이 된 브라우저 playground, 그리고 모양을 알게 된 벽 하나.