[PonsWarp 구현 분석 2] 복사하지 않는 게 제일 빠르다 — WASM 메모리와 제로카피
쉽게 말하면, **PonsWarp의 제로카피는 "WASM 메모리 안에 슬롯을 미리 만들어 두고, JS가 그 슬롯에 직접 쓰게 하는 것"**이다. 복사 자체를 없애는 게 아니라, 복사가 일어나는 방향을 통제한다.

"복사 0회"를 외치는 구현이 실제로는 절반만 제로카피인 지점까지 이 글에서 정직하게 파헤친다.
이게 왜 필요한가 — 복사가 왜 문제인가
브라우저에서 파일 조각을 WebRTC로 보내려면 데이터가 JS↔WASM 경계를 오간다. 문제는 경계를 넘을 때마다 메모리 복사가 일어난다는 점이다.
- JS가 만든
Uint8Array를 WASM 함수에 넘기면 → WASM 선형 메모리로 복사 - WASM이 만든 결과를 JS가 받으면 → JS 힙으로 복사
- WebRTC가 그 ArrayBuffer를 전송하고 detach하면 → 또 복사
대용량 파일은 수십만 개의 조각이 이 경계를 건넌다. 조각 하나당 몇 번씩 복사하면 GC(가비지 컬렉션) 압박과 대역폭 낭비가 쌓인다. 그래서 PonsWarp는 "복사가 아니라, 미리 빌려둔 메모리에 직접 쓰기"를 선택했다.
구조 — WASM 메모리 안의 슬롯 풀
ZeroCopyPacketPool은 WASM 선형 메모리에 64KB + 128바이트 슬롯 64개를 미리 할당해 둔다.
rust// pons-core-wasm/src/zero_copy_pool.rs const SLOT_SIZE: usize = 64 * 1024 + 128; // 64KB 데이터 + 헤더/태그 여유 const POOL_SLOTS: usize = 64; pub struct ZeroCopyPacketPool { buffer: Vec<u8>, // SLOT_SIZE × 64 = 사전 할당된 단일 버퍼 states: Vec<SlotState>, // Free / Acquired / Committed... packet_starts: Vec<usize>, ... }
각 슬롯은 상태 머신을 가진다: Free → Acquired → Committed → (전송) → Free. 슬롯을 놓았다가 다시 쓰므로, 매번 새로 할당하는 대신 같은 메모리를 반복 사용한다.
WASM 선형 메모리 (단일 버퍼)
┌──────────┬──────────┬──────────┬───
│ slot 0 │ slot 1 │ slot 2 │ ...
│ 64KB+128 │ 64KB+128 │ 64KB+128 │
└──────────┴──────────┴──────────┴───
각 슬롯: [헤더 영역 38B][데이터 영역][태그 영역 16B]
흐름 — 쓰기 0회, 읽기 1회
쓰기 방향 (JS → WASM): 복사 0회
acquire_slot()은 JS에 [slot_id, data_ptr, max_data]를 돌려준다. data_ptr은 WASM 선형 메모리의 절대 주소다. JS는 그 주소 위에 Uint8Array 뷰를 얹어 바로 쓴다.
typescript// PonsWarp/src/workers/file-sender.worker.ts const slotInfo = zeroCopyPool.acquire_slot(); // [slot_id, data_ptr, max_size] const dataPtr = slotInfo[1]; const wasmBuffer = new Uint8Array(wasmMemory.buffer, dataPtr, data.length); wasmBuffer.set(data); // ← WASM 메모리에 직접 기록 (복사 0회)
암호화와 커밋 — WASM 안에서 in-place
쓰기가 끝나면 commit_encrypted_slot()이 슬롯 버퍼 안에서 AES-GCM 암호화를 수행한다. 암호화도 같은 메모리 안에서 일어나므로 추가 복사가 없다.
읽기 방향 (WASM → JS): 복사 1회
get_packet_view()는 [packet_ptr, packet_len]을 돌려준다. 그런데 여기서 함정이 있다. WebRTC는 전송 후 ArrayBuffer를 detach하므로, WASM 메모리를 가리키는 뷰를 그대로 넘길 수 없다. 그래서 JS는 새 ArrayBuffer를 만들고 복사한다.
typescript// PonsWarp/src/workers/file-sender.worker.ts const packet = new ArrayBuffer(packetLength); // 새 버퍼 const packetView = new Uint8Array(packet); const sourceView = new Uint8Array(wasmMemory.buffer, packetPtr, packetLength); packetView.set(sourceView); // ← 복사 1회 (필수) zeroCopyPool.release_slot(slotId); // 슬롯 반환, 재사용
즉 **"쓰기 0회 + 읽기 1회"**다. 읽기 1회는 WebRTC의 detach 제약 때문에 피할 수 없다. 진짜 제로카피가 아니라는 뜻이고, PonsWarp는 이 점을 코드 주석에 명시해 두었다.
절반만 제로카피인 지점
더 정직하게 말할 게 하나 더 있다. 슬롯의 데이터 상한은 64KB + 128 - 38 - 16 = 65,610바이트다. 그런데 PonsWarp의 기본 청크 크기는 최대 192KiB다.
슬롯 데이터 상한: 65,610B (64KB + 여유)
기본 청크 크기: 192KiB (host 경로)
→ 192KiB 청크는 슬롯에 안 들어간다
그래서 코드에는 명시적인 분기가 있다. 데이터가 슬롯 상한을 넘으면 제로카피를 포기하고 레거시 경로(createPacketLegacy)로 폴백한다. 즉 host 경로(192KiB)에서는 제로카피의 이점이 발휘되지 않을 수 있고, 64KiB 릴레이 청크만 실제로 제로카피 경로를 탄다.
typescript// PonsWarp/src/workers/file-sender.worker.ts if (data.length > maxSize) { zeroCopyPool.release_slot(slotId); return createPacketLegacy(data); // ← 슬롯 초과 시 폴백 }
이 지점은 추정이 아니라 코드 분기로 확인되는 사실이다. 다만 런타임에서 host 경로가 실제로 항상 폴백하는지는 관찰되지 않았고, 청크 크기 프로파일이 어떻게 적용되는지에 따라 달라질 수 있다.
WASM 빌드와 메모리 전략
WASM 코어는 wasm-bindgen --target web으로 빌드되고, 단일 비공유 WebAssembly.Memory를 사용한다. SharedArrayBuffer/스레드는 없다 — 병렬화는 Web Worker로, 메모리 공유로 하지 않는 선택이다.
bash# pons-core-wasm/build.sh wasm-pack build --target web --release wasm-opt -O4 # 바이너리 최적화
PonsWarp 적용 지점
| 항목 | 값 | 증거 |
|---|---|---|
| 슬롯 크기 | 64KiB + 128B (데이터 상한 65,610B) | zero_copy_pool.rs:13, 91-96 |
| 슬롯 수 | 64 (커스텀 1~256) | zero_copy_pool.rs:14, 62-67 |
| 상태 머신 | Free→Acquired→Committed→Free | zero_copy_pool.rs:22-33, 76-104 |
| 쓰기 경로 | acquire_slot() 절대 포인터 → JS 직접 기록 (복사 0회) | file-sender.worker.ts:815-835 |
| 암호화 | commit_encrypted_slot() in-place AES-GCM | zero_copy_pool.rs:143-201 |
| 읽기 경로 | get_packet_view() → ArrayBuffer 복사 1회 (WebRTC detach 때문) | file-sender.worker.ts:855-867 |
| 슬롯 초과 폴백 | data.length > maxSize → createPacketLegacy | file-sender.worker.ts:827-831 |
| WASM 메모리 | 단일 비공유 WebAssembly.Memory, 스레드 없음 | file-sender.worker.ts:178-181, build.sh:12-18 |
| 빌드 | wasm-pack --target web + wasm-opt -O4 | build.sh:12-18 |
지금 돌아보는 판단
세 가지를 솔직히 정리한다.
첫째, "제로카피"라는 이름이 오해를 부른다. 실제로는 "복사 위치를 통제"한 것이고, 읽기 방향 1회 복사는 WebRTC detach 제약 때문에 어쩔 수 없다. 이름보다 복사 횟수(0+1)와 복사 지점을 말하는 게 정확하다.
둘째, 슬롯 상한과 청크 크기의 불일치가 구조적 약점이다. 65,610B 슬롯에 192KiB 청크가 안 들어가서 host 경로는 레거시로 폴백할 가능성이 크다. 제로카피 설계의 의도(릴레이 64KiB)와 host 경로(192KiB)가 어긋나 있다. 재구축한다면 슬롯 크기를 최대 청크 크기에 맞추거나, 청크 크기를 슬롯에 맞추는 일관성을 먼저 잡을 것이다.
셋째, 메모리 사전 할당의 대가가 있다. 64KB × 64 = 약 4MB를 전송 시작부터 점유한다. 속도와 메모리의 트레이드오프이고, 이 선택은 애플리케이션 성격(대용량 전송)에 맞다.
한 문장 정리
PonsWarp의 제로카피는 WASM 선형 메모리에 슬롯 풀을 사전 할당해 쓰기 0회·읽기 1회로 복사를 통제하되, 192KiB host 청크가 65,610B 슬롯을 넘어 레거시 경로로 폴백할 수 있는 절반만의 제로카피다.
관련 개념: [개념 3 — AES-256-GCM] (in-place 암호화) · [개념 4 — GCM nonce]
다음 글: [개념 5 — HMAC과 상수시간 비교: 키 확인과 타이밍 공격]