Skip to content
Published on

브라우저에서 시스템을 시뮬레이션하기 — WASM이 가능하게 한 것과 여전히 막는 것

공유하기
Authors

들어가며 — 한 탭 안에 TCP 스택 두 개, x86 CPU 하나, 데비안 한 대

2026년 7월 28일 해커뉴스에 Simulating TCP loss and congestion in browser using Go/WASM이 올라왔습니다. 링크를 열면 CUBIC과 BBRv3의 혼잡 윈도가 나란히 그려지는데, 그 그래프를 만드는 것이 데이터 파일이 아니라 브라우저 안에서 실제로 도는 두 개의 gVisor TCP 스택입니다. 저장소의 설명이 그렇게 돼 있고, 브라우저가 받아 가는 .wasm 파일을 직접 확인해 보니 8,664,912바이트, gzip으로 약 2.35MB였습니다.

이건 고립된 사례가 아닙니다. v86은 x86 머신 코드를 실행 중에 WebAssembly 모듈로 번역해 윈도우 98과 ReactOS와 9front를 부팅시키고, 별 23,000개를 넘겼습니다. WebVM은 CheerpX라는 엔진 위에서 수정하지 않은 데비안을 띄웁니다 — x86-to-WASM JIT, 블록 기반 가상 파일 시스템, 리눅스 시스템 콜 에뮬레이터를 갖춘 물건입니다. 2026년 7월 15일에는 Firefox를 통째로 WebAssembly로 빌드한 데모가 273포인트를 받았습니다.

한동안 이런 것들은 "되긴 되는데 왜 하는지 모르겠는" 시연에 가까웠습니다. 지금은 다릅니다. 이 글은 이 패턴이 왜 지금 실용적이 됐는지, 어떤 제약이 여전히 설계를 지배하는지, 그리고 훌륭한 교보재와 장난감을 가르는 선이 어디인지를 정리합니다.

왜 지금 실용적인가 — Wasm 3.0이 채운 조각

WebAssembly 3.0이 2025년 9월 17일에 W3C 커뮤니티 그룹에서 릴리스됐고, 아홉 개의 기능이 한꺼번에 정식화됐습니다. 시스템 시뮬레이션 관점에서 의미 있는 것만 추리면 이렇습니다.

예외 처리가 네이티브가 됐습니다. 이전에는 C++이나 Rust의 언와인딩을 자바스크립트로 왕복시키거나 Asyncify 같은 변환으로 흉내 내야 했고, 둘 다 비쌌습니다. OS를 에뮬레이션하는 코드에서 트랩과 인터럽트를 다루는 경로가 여기 걸립니다.

Memory64로 64비트 주소 지정이 가능해졌습니다. wasm32의 4GiB 주소 공간 천장이 사라진다는 뜻인데, 큰 게스트 메모리를 잡아야 하는 에뮬레이터에게는 직접적인 해방입니다. 다만 공짜가 아닙니다 — 64비트 인덱싱은 경계 검사 비용이 늘어나 wasm32보다 느립니다. 4GiB 안에 들어가는 워크로드라면 wasm32가 여전히 낫습니다.

WasmGC는 관리 언어(Java, Kotlin, Dart 등)가 자체 GC를 번들하지 않고 호스트 VM의 GC를 쓰게 해 줍니다. 바이너리 크기가 크게 줄어드는 경로이고, 사파리를 포함한 주요 브라우저가 지원합니다. 다만 Go와 Rust는 이 경로를 쓰지 않습니다 — Go는 자체 GC를 선형 메모리 위에서 돌리고, Rust는 GC가 없습니다.

여기에 128비트 SIMD, 테일 콜, 다중 메모리, 타입 지정 함수 참조, 분기 힌트가 더해집니다. 테일 콜은 인터프리터 루프를, 다중 메모리는 게스트 메모리와 호스트 자료 구조를 분리하는 데 쓸 수 있습니다.

정리하면, 2020년경의 WASM은 "순수 계산을 빠르게 돌리는 상자"였고 지금의 WASM은 "예외와 GC와 큰 메모리를 다룰 수 있는 실행 환경"입니다. 그 차이가 브라우저 안에서 시스템 소프트웨어를 돌린다는 발상을 현실적으로 만들었습니다. 브라우저 밖의 WASM 이야기는 브라우저 밖의 WebAssembly 편에, 실제로 브라우저에서 도는 개발 도구들은 브라우저에서 진짜 엔진이 돈다 편에 정리해 두었습니다.

제약 1 — 소켓이 없다

가장 근본적인 제약입니다. 브라우저 샌드박스는 원시 소켓을 주지 않습니다. 임의의 TCP 연결도, UDP도, 하물며 raw IP도 없습니다. 쓸 수 있는 것은 fetch, WebSocket, WebRTC 데이터 채널, WebTransport뿐이고 모두 상위 프로토콜입니다.

이 하나가 브라우저 시스템 시뮬레이터를 딱 두 갈래로 나눕니다.

첫째 갈래는 네트워크를 통째로 시뮬레이션하는 것입니다. ccsim이 이쪽입니다 — 송신자와 수신자와 그 사이 링크가 전부 프로세스 안에 있으므로 애초에 나갈 필요가 없습니다. 링크 모델이 토큰 버킷으로 대역폭을 제한하고, 지연과 지터를 넣고, 시드된 난수로 손실을 만들고, taildrop·RED·CoDel·FQ-CoDel 중 하나로 큐를 관리하고, ECN CE 마킹까지 합니다. 실제 네트워크가 필요 없는 정도가 아니라 없는 편이 낫습니다 — 재현 가능해지기 때문입니다.

둘째 갈래는 아래층을 터널링하는 것입니다. v86에는 NE2000 PCI 네트워크 카드가 에뮬레이션돼 있지만, 게스트가 내보낸 이더넷 프레임은 결국 WebSocket 릴레이를 타고 실제 네트워크로 나가야 합니다. WebVM은 더 노골적입니다 — 네트워킹을 Tailscale로 붙이고, 공용 인터넷에 나가려면 exit node를 쓰라고 안내합니다. 그리고 README에 이런 주석이 붙어 있습니다.

저수준 네트워킹 작업 일부(특히 ping이 쓰는 ICMP)는 현재 이 환경에서 사용할 수 없습니다. 연결 확인에는 curl이나 wget을 쓰세요.

브라우저 안에 완전한 리눅스가 있는데 ping이 안 됩니다. 이 한 줄이 제약의 정확한 모양을 보여 줍니다 — ICMP는 소켓 계층 아래에 있고, 터널 반대편이 그걸 대신 만들어 주지 않으면 존재하지 않습니다.

설계 관점의 교훈은 이렇습니다. 네트워크 동작을 가르치려는 시뮬레이터라면 첫째 갈래를 골라야 합니다. 터널링은 릴레이 서버라는 의존성을 만들고, 그 릴레이가 시뮬레이션하려던 바로 그 특성(지연, 손실, 큐)을 오염시킵니다. 반대로 실제 소프트웨어를 실행해 보여 주는 것이 목적이라면 터널링 외에 길이 없습니다.

제약 2 — 스레드는 HTTP 헤더 두 줄에 달려 있다

WebAssembly에서 진짜 병렬 실행을 하려면 여러 워커가 같은 선형 메모리를 공유해야 하고, 그러려면 SharedArrayBuffer가 필요합니다. 그런데 Spectre 계열 취약점 이후 SharedArrayBuffer교차 출처 격리된 문서에서만 쓸 수 있습니다. 조건은 응답 헤더 두 줄입니다.

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

credentialless로 완화할 수 있지만 본질은 같습니다. 그리고 이 두 줄의 파급이 생각보다 큽니다.

  • 페이지가 불러오는 모든 교차 출처 하위 리소스가 CORP 또는 CORS 헤더를 갖고 있어야 합니다. 이미지 CDN, 폰트, 애널리틱스 스크립트, 임베드된 유튜브 — 하나라도 헤더가 없으면 로드가 막힙니다.
  • COOP는 window.opener 관계를 끊습니다. OAuth 팝업 흐름이나 부모 창과의 통신에 의존하는 코드가 깨집니다.
  • 무엇보다 서버 설정을 통제할 수 있어야 합니다. GitHub Pages 같은 정적 호스팅에 응답 헤더를 추가할 수 없다면 이 경로 자체가 닫힙니다. 서비스 워커로 우회하는 해킹이 알려져 있지만, 첫 방문에는 동작하지 않고 디버깅이 고약합니다.

그리고 Go를 쓴다면 이 논의가 아예 무의미해집니다. GOOS=js GOARCH=wasm 타깃은 단일 스레드입니다. 고루틴은 하나의 자바스크립트 이벤트 루프 위에 다중화되므로, 고루틴을 아무리 많이 띄워도 코어 하나만 씁니다. ccsim이 워커 한 개에서 도는 것도 그래서입니다. 다만 ccsim의 경우 이건 손해가 아니라 오히려 설계 요구사항이었는데, 결정성을 위해 모든 netstack TCP 처리를 이벤트 루프 고루틴에서 인라인으로 강제하고 있기 때문입니다.

헤더가 실제로 붙었는지 확인하는 것은 한 줄입니다. 그리고 로컬 개발 서버는 기본값이 이 헤더를 안 주므로 직접 붙여야 합니다.

# 배포된 페이지에 격리 헤더가 붙어 있는지
curl -sI https://example.com/app/ | grep -i 'cross-origin-'

# 로컬에서 붙여 보기 (파이썬 표준 라이브러리만으로)
python3 - <<'PY'
import http.server, functools
class H(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
        self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
        super().end_headers()
http.server.ThreadingHTTPServer(('', 8000), H).serve_forever()
PY

브라우저 콘솔에서 crossOriginIsolated가 true인지 보면 최종 확인이 끝납니다. false라면 SharedArrayBuffer 생성자 자체가 없습니다.

단일 스레드에서 UI를 얼어붙지 않게 하는 실무 패턴은 정해져 있습니다. 시뮬레이션은 워커에서 돌리고, 메인 스레드와는 메시지로만 주고받고, 워커 안에서도 일정 단위마다 양보합니다. ccsim의 워커 글루는 시뮬레이션 시간 250ms 단위로 배치를 끊고 MessageChannel로 양보한 뒤 다음 배치를 이어 갑니다. 그래서 실행이 끝날 때까지 기다릴 필요 없이 차트가 점진적으로 채워집니다.

제약 3 — 바이너리 크기와 시작 시간

여기가 언어 선택이 실제로 갈리는 지점입니다. 직접 확인한 값과 널리 보고된 값을 구분해 정리하면 이렇습니다.

툴체인대표적 산출물 크기스레드성격
Go (GOOS=js GOARCH=wasm)ccsim 실측 8.66MB, gzip 2.35MB. 최소 예제도 보통 MB 단위단일 스레드 고정런타임과 GC를 통째로 링크. 표준 라이브러리 전체가 그대로 쓰임
TinyGo커뮤니티 보고 기준 표준 Go 대비 10~20배 작음제한적쓰는 것만 링크. 리플렉션과 일부 표준 라이브러리에 제약
Rust (wasm-bindgen)작은 모듈은 수십 KB 수준SAB 있으면 가능런타임이 사실상 없음. wasm-opt로 추가로 10~30% 감소
C/C++ (Emscripten)코드량에 비례SAB 있으면 가능기존 네이티브 코드베이스 이식 경로. v86의 상당 부분이 이 계열

직접 확인하는 방법도 간단합니다. 자기 프로젝트를 WASM으로 빌드해 두 값을 비교해 보면 어느 쪽 비용이 문제인지 바로 나옵니다.

# Go: 표준 툴체인
GOOS=js GOARCH=wasm go build -o main.wasm ./cmd/sim
ls -l main.wasm
gzip -9 -c main.wasm | wc -c        # 실제 전송량은 이쪽이다
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .

# 어떤 심볼이 크기를 먹고 있는지
go tool nm -size -sort size main.wasm | head -30

# TinyGo로 같은 코드를 빌드해 비교 (제약이 있는지 먼저 확인할 것)
tinygo build -o tiny.wasm -target wasm -opt=z ./cmd/sim

# Binaryen이 있다면 한 번 더 줄인다
wasm-opt -Oz main.wasm -o main.opt.wasm && ls -l main.opt.wasm

Go의 8.66MB를 보고 "Go는 WASM에 부적합"이라고 결론 내리기 전에, 그 8.66MB에 무엇이 들어 있는지 봐야 합니다. ccsim의 경우 gVisor의 전체 TCP/IP 스택 두 벌, RACK/TLP를 포함한 손실 감지, SACK 스코어보드, BBRv3 상태 기계, 링크 모델과 네 종류의 큐 규율이 들어 있습니다. 이걸 Rust로 다시 쓰는 비용과 8.66MB(전송은 2.35MB) 중 무엇이 더 큰 문제인지는 상황에 따라 다릅니다. gVisor가 Go로 쓰여 있다는 사실이 언어 선택을 이미 결정한 것이고, 그것이 대체로 옳은 판단 기준입니다.

Go의 진짜 비용은 크기보다 시작 시간일 때가 많습니다. wasm_exec.js 글루(약 17KB)를 로드하고, 8MB 넘는 모듈을 컴파일·인스턴스화하고, Go 런타임이 초기화되고, GC가 준비돼야 첫 줄이 실행됩니다. Rust는 이 중 마지막 두 단계가 사실상 없습니다.

실행 속도도 무시할 수 없습니다. ccsim 저장소의 수용 기준 표가 정직합니다 — 같은 30초 시뮬레이션이 네이티브 arm64에서 1.4초, Node v26의 WASM에서 7.6초입니다. 약 5.4배입니다. 이 정도 격차가 UI에 어떻게 나타나는지도 코드에 흔적이 남아 있는데, 화면 하단에 이런 경고가 붙어 있습니다.

기본값은 미리 계산된 것입니다 — 슬라이더를 움직이면 이 기기에서 시뮬레이터가 실제로 돌고, 시간이 걸릴 수 있습니다.

만든 쪽 블로그에도 "오래된 기기에서는 1분이 넘게 걸리는 것을 발견하고 기본 시나리오를 미리 계산해 두었다"고 적혀 있습니다. 브라우저 시뮬레이터를 만들 때 거의 반드시 만나는 결정입니다 — 첫 화면은 미리 계산한 결과로 즉시 그리고, 상호작용이 시작될 때만 실제로 돌립니다.

메모리 쪽도 짚어 둘 만합니다. wasm32의 주소 공간은 4GiB지만, 실제로 한 탭에서 안정적으로 잡을 수 있는 선형 메모리는 그보다 훨씬 작고 브라우저·플랫폼별로 다릅니다. 모바일 사파리는 특히 인색합니다. 게스트 RAM을 크게 잡는 에뮬레이터는 여기서 먼저 막히고, 그래서 v86의 데모들이 대부분 128MB 안팎의 옛 시스템인 것도 우연이 아닙니다.

뜻밖의 이득 — 결정성

브라우저 시뮬레이터를 만들면서 얻는 부수 효과 중 가장 값진 것이 이것입니다. WASM 타깃은 스레드가 없고, 시간은 호스트가 주고, 난수도 호스트가 줍니다. 그래서 결정성을 얻기 쉬운 환경입니다. 그리고 결정적인 시뮬레이터는 그 자체로 회귀 테스트가 됩니다.

ccsim이 이걸 끝까지 밀어붙인 사례라 재료가 그대로 참고가 됩니다. 저장소가 밝힌 결정성의 구성 요소는 네 개입니다.

  1. 가상 클록 하나. netstack 타이머, 링크 이벤트, 애플리케이션 쓰기, 샘플링 틱, 시나리오 주입이 전부 하나의 최소 힙에 올라가고 동률일 때는 FIFO로 깨집니다. 시간의 출처가 정확히 하나입니다.
  2. 인라인 디스패치. 모든 netstack TCP 처리를 이벤트 루프 고루틴에서 동기적으로 처리하도록 gVisor를 패치했습니다. 어떤 고루틴도 클록과 경쟁하지 않습니다.
  3. 이름 붙은 PCG 서브스트림. 링크 손실(정방향/역방향), RED 결정, 도착 시각, 각 스택의 난수원, 흐름별 BBR 프로브 지터가 각각 별도의 서브스트림을 갖습니다. 한 곳의 호출 횟수가 바뀌어도 다른 곳의 난수열이 흔들리지 않습니다.
  4. FMA 융합 차단. 시뮬레이션 경로의 모든 부동소수 곱셈-덧셈에 명시적 float64 변환을 넣어 컴파일러가 FMA로 융합하지 못하게 했습니다. arm64와 amd64와 wasm이 같은 비트를 내기 위해서입니다.

이 네 번째가 특히 시사적입니다. 크로스 플랫폼 결정성을 원한다면 부동소수 연산의 융합 여부까지 통제해야 한다는 뜻이고, 대부분의 프로젝트는 여기까지 가지 않습니다. 그 대가로 얻은 것은 명확합니다 — 네이티브 빌드와 WASM 빌드의 샘플 스트림이 바이트 단위로 동일하고, 그것이 테스트로 강제됩니다. 브라우저에서 본 그래프와 CI에서 돌린 결과가 같은 물건이라는 보장입니다.

교보재로서 이것이 왜 중요하냐면, "내 브라우저에서는 다르게 나오는데요"라는 질문이 애초에 성립하지 않기 때문입니다.

좋은 교보재와 장난감을 가르는 선

브라우저 시뮬레이터가 실제로 가르치는 물건이 되려면 몇 가지가 맞아야 합니다. 실패하는 쪽과 나란히 놓으면 선이 보입니다.

진짜 구현을 돌리는가, 아니면 애니메이션인가. ccsim은 gVisor의 실제 TCP 코드를 돌립니다. 그래서 SACK 스코어보드가 100개 범위에서 넘칠 때의 동작, RACK 재정렬 판정, ECN 에코 같은 것이 근사가 아니라 실물입니다. 반대로 슬라이더를 움직이면 미리 그려 둔 곡선을 보간하는 물건은 "혼잡 제어가 이렇게 생겼다"는 인상을 줄 뿐이고, 예상 밖의 조합에서 진실을 말하지 않습니다. 예상 밖의 조합에서 진실을 말하는 것이 시뮬레이터의 유일한 존재 이유입니다.

검증되는가. ccsim은 CUBIC 성장 곡선을 RFC 9438의 삼차 함수에 맞춰 보고 결정계수를 기록하고, RED 마킹 곡선을 카이제곱으로 검정하고, 골든 스트림 회귀 테스트를 돌립니다. 검증되지 않은 시뮬레이터는 잘못된 직관을 자신 있게 심어 줍니다. 아무것도 안 배우는 것보다 나쁩니다.

경계를 밝히는가. 흐름이 하나뿐이고, 병목이 하나이고, 미들박스가 없고, CPU와 인터럽트 처리가 없다는 사실을 말해 주는 시뮬레이터와 말해 주지 않는 시뮬레이터는 다른 물건입니다.

시작이 빠른가. 8MB를 받고 30초를 기다려야 첫 그림이 나오는 교보재는 아무도 안 봅니다. 미리 계산한 기본값으로 즉시 그리는 선택이 교육적 가치를 절반쯤 좌우합니다.

반대로, 이 방식이 장난감으로 끝나는 전형적인 조건도 명확합니다. 실제 하드웨어 타이밍이 본질인 주제(캐시 계층, NUMA, 인터럽트 지연)를 브라우저에서 시뮬레이션하면 배우는 것보다 오해하는 것이 많습니다. 규모가 본질인 주제(수천 흐름의 상호작용, 대규모 클러스터의 꼬리 지연)도 마찬가지입니다. 그리고 실제 네트워크와 상호작용해야만 의미가 있는 주제라면, 앞서 본 대로 릴레이가 시뮬레이션 대상을 오염시킵니다.

한 문장으로 줄이면 이렇습니다 — 브라우저 시뮬레이터는 "닫힌 계에서 규칙이 어떻게 상호작용하는가"를 가르치는 데 강하고, "실제 세계가 얼마나 지저분한가"를 가르치는 데 약합니다.

마치며 — 링크 하나로 배포되는 실행 환경

정리하면 이렇습니다.

  • Wasm 3.0의 예외 처리, Memory64, WasmGC가 "계산 상자"였던 WASM을 시스템 소프트웨어가 돌 수 있는 실행 환경으로 바꿨습니다.
  • 원시 소켓이 없다는 제약이 설계를 두 갈래로 나눕니다. 네트워크를 통째로 시뮬레이션하거나(ccsim), 릴레이로 터널링하거나(v86, WebVM). 가르치는 것이 목적이면 전자입니다.
  • 진짜 병렬 실행은 SharedArrayBuffer에, 그것은 COOP·COEP 헤더에, 그것은 서버 통제권에 달려 있습니다. Go 타깃이라면 이 논의 자체가 없습니다 — 단일 스레드입니다.
  • Go는 크고(실측 8.66MB, gzip 2.35MB) 시작이 느리지만, 이식하려는 코드가 이미 Go라면 그것이 결정적 요인입니다. 실행 속도는 네이티브 대비 5배 안팎을 각오해야 합니다.
  • WASM 타깃의 제약(스레드 없음, 호스트가 주는 시간과 난수)이 결정성을 얻기 쉽게 만듭니다. 결정적 시뮬레이터는 그 자체로 회귀 테스트입니다.

그리고 이 모든 제약을 감수할 만한 이유가 하나 있습니다. 설치도 계정도 클러스터도 없이, 링크 하나로 실행 가능한 시스템을 배포할 수 있다는 것입니다. 교보재로서 그 성질을 이기는 것은 별로 없습니다.

참고 자료