개발 회고

[PonsWarp] - JS로 버티다 Rust로 내려간 순간

AC 2026. 6. 24. 00:23

PonsWarp가 Rust/WASM 코어로 내려간 이유를 “JS가 느려서”라고만 말하면 핵심을 놓칩니다.

속도 문제는 분명 있었지만, 더 큰 압력은 전송 제품의 책임이 브라우저 JS 이벤트 흐름만으로 닫히지 않았다는 점이었습니다. UI가 빠르게 움직이고 DataChannel이 비어도, packet이 맞는지, 순서가 맞는지, 저장된 크기가 맞는지, 복사 경계가 어디인지 설명하지 못하면 제품은 완료를 말할 자격이 없었습니다.

PonsWarp browser JS and Rust WASM core boundary
React와 worker JS는 흐름을 다루고, Rust/WASM core는 검증 가능한 바이트 경계를 맡습니다.

🧭 JS 계층이 잘하던 일과 못하던 일

💡 Rust가 JS보다 무조건 낫다는 이야기가 아닙니다. React와 worker JS는 여전히 사용자 흐름과 브라우저 API를 다룹니다. 다만 packet verification, memory pressure, copy boundary, integrity, deterministic core responsibility는 브라우저 JS 위에 끝까지 남기기 어려운 책임이었습니다.

브라우저 JS 계층은 PonsWarp에서 꼭 필요했습니다. WebRTC DataChannel을 열고, sender와 receiver worker를 분리하고, 사용자가 보는 진행률과 오류 상태를 업데이트하고, File System Access API나 StreamSaver 같은 브라우저 저장 API와 연결해야 했기 때문입니다.

하지만 같은 JS 계층에 전송 core의 모든 책임을 얹으면 경계가 흐려졌습니다. post-22에서 정리했듯 현재 sender worker는 pons-core-wasmPacketEncoder, CryptoSession, Zip64Stream, ZeroCopyPacketPool을 사용합니다. receiver worker도 PacketDecoder, CryptoSession을 사용하고 CRC32 fallback을 둡니다.

이 구조는 UI 편의 기능을 Rust로 옮긴 것이 아닙니다. packet을 만들고, 검증하고, 암호화하고, 큰 파일의 ZIP64 스트림을 다루고, 재정렬과 메모리 풀을 관리하는 책임을 더 결정적인 계층으로 내린 것입니다.

🛡️ packet verification은 화면 상태가 아니라 바이트 계약이었다

PonsWarp의 packet 구조는 이미 이전 글들에서 반복해서 등장했습니다. constants.tsHEADER_SIZE = 22를 FileIndex, ChunkIndex, Offset, DataLen, Checksum의 합으로 설명합니다. pons-core-wasm/src/packet.rsPacketEncoder는 payload의 CRC32를 header에 기록하고, PacketDecoder::verify는 길이와 checksum을 다시 계산합니다.

진행률은 99%일 수 있고, DataChannel 큐는 비어 있을 수 있고, worker는 메시지를 받은 상태일 수 있습니다. 그래도 packet header의 길이와 payload checksum이 맞지 않으면 저장하면 안 됩니다. 이 판단은 사용자가 보는 상태가 아니라, 바이트가 자기 설명과 일치하는지에 대한 계약입니다.

packet verification은 “편의상 JS에서 해도 되는 검증”이라기보다 core 책임에 가까워졌습니다. 같은 입력이면 같은 결과를 내고, 실패하면 조용히 저장하지 않는 경계가 필요했습니다.

🧱 메모리 압력은 복사 횟수의 문제로 바뀌었다

성능 최적화 초반에는 청크 크기, prefetch, pipeline, ChunkPool 같은 단어가 먼저 보였습니다. post-14와 post-15에서 다룬 949d921의 파이프라인 병렬화와 청크 풀링은 반복 할당과 GC 압박을 줄이려는 자연스러운 시도였습니다. 하지만 그 시기의 청크 풀링도 완전한 zero-copy는 아니었습니다.

PonsWarp copy boundaries and memory pressure
zero-copy는 모든 복사를 없앴다는 뜻이 아니라, 복사와 할당이 일어나는 경계를 통제했다는 뜻에 가깝습니다.

현재 sender worker가 ZeroCopyPacketPool을 쓰는 흐름은 같은 문제를 더 아래로 내린 결과입니다. WASM 초기화 시 pool을 만들고, WASM memory 참조를 잡은 뒤 acquire_slot(), commit_slot(), get_packet_view(), release_slot() 흐름으로 패킷을 만듭니다.

현재 코드에도 WebRTC가 ArrayBuffer를 전송 후 detach하므로 복사가 필요하다는 현실적인 한계가 남아 있습니다. 핵심은 복사를 없앴다는 선언이 아니라, 어떤 복사를 어디에서 감수할지 명시했다는 점입니다.

📦 ZIP64, 재정렬, 암호화는 결정론적 core 책임이었다

시리즈 계획과 evidence index는 pons-core-wasm의 책임 목록을 꽤 분명하게 남깁니다. compression, crypto, packet, reordering_buffer, zero_copy_pool, zip64, file_signature, merkle_tree가 같은 core/backend 층에 묶여 있습니다.

커밋 흐름도 15aef19의 초기 pons-core-wasm 설정, 1b6fb15의 ZIP64 스트리밍 압축, 7b0301d의 sender WASM ZIP64 마이그레이션, 58e6b89의 수신측 패킷 재정렬 버퍼 WASM 마이그레이션, e589cf2의 압축·Merkle Tree·파일 서명, 7fe0ca1의 암호화 지원 Zero-Copy Pool로 이어집니다.

PonsWarp deterministic core responsibilities
패킷, 재정렬, ZIP64, 암호화, proof 계열 책임은 같은 입력에 대해 예측 가능한 결과를 내야 합니다.

이 목록은 브라우저 앱의 부가 기능이 아닙니다. 4GB 이상 파일을 ZIP64로 다루는 일, out-of-order chunk를 offset 기준으로 재정렬하는 일, payload가 header와 맞는지 확인하는 일, 암호화 경계를 유지하는 일, Merkle tree와 file signature로 후속 신뢰 경계를 만드는 일은 모두 같은 입력에 대해 예측 가능한 결과를 내야 합니다.

⚖️ Rust/WASM은 마법이 아니라 책임 분리였다

그래서 “Rust로 내려간 순간”은 언어 취향의 전환이 아니라 책임 경계의 전환에 가깝습니다. React UI는 사용자가 이해할 상태를 보여 주고, worker JS는 브라우저 API와 네트워크 이벤트를 연결합니다. Rust/WASM core는 packet, checksum, ZIP64, crypto, reordering, zero-copy pool처럼 반복적이고 검증 가능한 바이트 책임을 맡습니다.

이 분리는 과장하면 안 됩니다. 현재 코드에도 JS fallback이 남아 있고, DirectFileWriter는 브라우저 저장 API와 직접 맞물립니다. WebRTC 전송 경계에서는 복사도 남습니다. Rust/WASM은 모든 문제를 지우지 않습니다. 대신 제품이 “완료”라고 말하기 전에 통과해야 하는 검증과 메모리 경계를 더 작고 명확한 단위로 고정합니다.

🧭 지금 돌아보는 판단

PonsWarp는 처음부터 Rust 프로젝트가 아니었습니다. 브라우저에서 직접 파일을 보내겠다는 제품이었고, 그 말은 JS와 WebRTC, worker, 브라우저 저장 API를 끝까지 끌고 가야 한다는 뜻이었습니다. 그래서 한동안 JS로 버틴 것은 틀린 선택이 아니었습니다.

다만 전송 core가 커질수록 질문이 바뀌었습니다. “어떻게 빠르게 보낼까”가 아니라 “어느 계층이 바이트의 진실을 책임질까”가 됐습니다. packet verification, memory pressure, copy boundary, integrity, deterministic core responsibility가 한꺼번에 등장한 순간, PonsWarp는 일부 책임을 Rust/WASM 아래로 내려야 했습니다.

사용자는 그 경계를 몰라도 됩니다. 대신 받은 파일이 열리고, 크기가 맞고, 순서가 맞고, 앱이 끝났다고 말한 순간 정말 끝났다는 사실을 믿을 수 있으면 됩니다.

📚 읽은 코드
content/ponswarp-retrospective/evidence-index.md:79-86 — pons-core-wasm 초기 설정, ZIP64, 재정렬 버퍼, Merkle Tree, file signature, Zero-Copy Pool 커밋 흐름
content/ponswarp-retrospective/series-plan.md:137-148 — core/backend 층의 compression, crypto, packet, reordering_buffer, zero_copy_pool, zip64, file_signature, merkle_tree 책임 목록
content/ponswarp-retrospective/drafts/post-11.txt:39-45 — packet header, CRC32, PacketEncoder, PacketDecoder::verify, Merkle Tree와 file signature 연결
content/ponswarp-retrospective/drafts/post-15.txt:35-38 — ZeroCopyPacketPool과 WASM 선형 메모리 기반 pool 설명
content/ponswarp-retrospective/drafts/post-20.txt:37-39 — wasmReorderingBuffer의 WASM 우선 초기화와 JS fallback, 보수적 해석
content/ponswarp-retrospective/drafts/post-22.txt:49-51 — sender/receiver worker의 WASM packet, encryption, ZIP64, zero-copy 경계 요약
LIST