
[Blueprint W1] PonsWarp는 왜 서버에 파일을 안 올리는가 — WebRTC DataChannel + Rust/WASM E2E 구조 해부
PonsWarp는 10GB 파일을 서버에 올리지 않는다. WebRTC DataChannel, Rust/WASM 7단계 파이프라인, directFileWriter, PARTITION_ACK/RESUME으로 끊김을 복구하는 구조를 설계도로 해부한다.

# [Blueprint W1] PonsWarp는 왜 서버에 파일을 안 올리는가 — WebRTC DataChannel + Rust/WASM E2E 구조 해부
> Series: `blueprint-ponswarp` (1/5) · Category: `P2P/분산기술` + `개발 회고` · Slug: `blueprint-ponswarp-01-webrtc-datachannel-wasm`
> Source: `blueprints/specs/P01_ponswarp_family.md` §01 + `blueprints/01-ponswarp.png` + `ponswarp/ponswarp-signaling-rs` + `ponswarp/pons-core-wasm`
—
## 0. TL;DR
PonsWarp(`warp.ponslink.com`)는 10GB 파일을 서버에 업로드하지 않는다. 브라우저끼리 직접 연결(WebRTC DataChannel)하고, 서버(Rust Axum)는 시그널링(offer/answer/ICE) + TURN 자격증명만 담당한다. 파일 청킹·재정렬·압축·암호화·무결성 검증은 `pons-core-wasm`(Rust→WASM) 코어가, 디스크 저장은 `directFileWriter`(StreamSaver/File System Access API)가 맡는다. 서버는 바이트를 보지 않는다. 끊기면 `PARTITION_ACK/RESUME_REQUEST`로 이어서 보낸다. 서버에 올리는 유일한 경로는 `Cloud Drop`(R2 presigned) 뿐이다.

*그림: PonsWarp 원본 설계도. 좌/우 브라우저(Sender/Receiver), 중앙 Rust Axum 시그널링, 굵은 DataChannel 화살표, 점선 PARTITION_ACK/RESUME, 별도 Cloud Drop→R2 흐름.*
—
## 1. 문제 정의 — 왜 서버 업로드가 실패하는가
대용량 P2P가 풀어야 할 제약은 3개다.
1. **서버 비용/프라이버시:** 10GB를 서버에 올리면 스토리지·대역폭·법적 책임이 폭증한다. E2E 암호화도 서버가 평문을 보면 의미가 없다.
2. **브라우저 메모리 한계:** 4GB 파일을 `ArrayBuffer`에 들고 있으면 탭이 죽는다. 스트리밍 저장이 필수다.
3. **끊김:** 모바일 백그라운드, NAT 변경, 네트워크 전환에서 연결은 반드시 끊긴다. 재개(resume) 없이 3GB에서 실패하면 처음부터 다시 보내는 UX는 제품이 아니다.
PonsWarp의 답은 **”서버는 연결만 도와주고, 바이트는 브라우저끼리, 복구는 오프셋으로”** 다.
—
## 2. 설계도 한눈에 — 3가지 화살표
설계도를 3색으로 읽으면 구조가 끝난다.
* **얇은 실선 (시그널링):** `Sender/Receiver React SPA → signaling-factory → adapter → WS /ws(roomCode) → Rust Axum` . `offer/answer/ICE/TURN cred` 교환만 한다. 방(룸코드) 상태머신은 서버가 들고 있다.
* **굵은 실선 (데이터):** `Browser A ←WebRTC DataChannel→ Browser B` . 이 채널로만 파일 바이트가 흐른다. 서버 릴레이 없음(coturn TURN 경유만, 이 경우에도 바이트는 TURN을 경유하지만 서버 앱이 보지 않는다).
* **점선 역방향 (컨트롤):** `Receiver → Sender: PARTITION_ACK(offset, runId), RESUME_REQUEST` . 수신자가 “여기까지 썼다”를 확인한다. 역방향이라 끊김 감지에 유용하다.
* **별도 흐름 (Cloud Drop):** `Front → /api → R2 presigned PUT → QR/Drop Code 픽업` . 상대가 오프라인일 때만 서버(R2)에 임시 보관(24h)한다. 기본값은 P2P.
브라우저 내부 스택: `SenderView/ReceiverView → swarmManager/webRTCService → Web Worker(file-sender/receiver.worker) → pons-core-wasm → directFileWriter → 디스크` . 메인 스레드는 UI만, 무거운 일은 Worker+WASM 격리.
Rust 서버 내부: `ponswarp-signaling-rs/handlers/ , protocol/ , mesh*.rs(저장소·보안·남용방어), billing.rs(LemonSqueezy/PayPal/Stripe), auth.rs(Google OAuth), admin.rs, database.rs(Postgres 8 migrations)` . AppState 공유, Docker Compose `3500/5502`.
—
## 3. 아키텍처 해부 — 레이어별 역할
### 3.1 시그널링 팩토리/어댑터 (전략 패턴)
`swarmManager`가 직접 `WebSocket`을 만지지 않는다. `signaling-factory`가 `adapter`를 생성한다. 테스트에서는 `MockAdapter`, 프로덕션에서는 `AxumWsAdapter`로 교체. 재연결·백오프·룸코드 검증이 어댑터 안에 캡슐화된다. 이게 없으면 UI가 소켓 상태에 오염된다.
### 3.2 Web Worker 분리
`file-sender.worker.ts` / `file-receiver.worker.ts`가 `postMessage`로 메인 스레드와 통신. 500MB 파일을 읽으면서 UI가 60fps를 유지하는 이유다. Worker 내부에서 `pons-core-wasm`을 로드한다.
### 3.3 pons-core-wasm — 바이트가 통과하는 7단계 파이프라인
Rust로 작성, `wasm-pack`으로 빌드된 코어. JS가 하면 느리거나 GC가 터지는 일을 맡는다.
“`
[File] → 청킹 → 재정렬버퍼 → LZ4 압축 → AES-GCM E2E 암호화 → Reed-Solomon FEC → Merkle트리 → 스트리밍 SHA-256 → [DataChannel]
“`
* **재정렬 버퍼 + zero-copy 풀:** DataChannel은 순서가 뒤죽박죽 올 수 있다. WASM이 `offset` 기반 링버퍼에 재배열하고, `ArrayBuffer` 복사를 피하는 풀을 쓴다.
* **LZ4:** 텍스트/CSV는 2~3배 압축. 이미 압축된 영상/zip은 패스.
* **AES-GCM E2E:** 키는 시그널링 서버를 안 거치고 `roomCode` 유도 또는 별도 교환. 서버는 복호화 불가.
* **Reed-Solomon FEC:** 패킷 손실 시 재전송 없이 복구. 10개 중 2개 손실까지 복구하는 파라미터.
* **Merkle + 스트리밍 SHA-256:** 1GB 파일도 메모리에 안 올리고 청크별 해시 트리로 무결성 검증. 마지막조각에서 루트 해시 비교.
### 3.4 directFileWriter — 메모리를 디스크로
`StreamSaver.js` + `File System Access API` 폴백. 청크가 도착하면 즉시 디스크에 `WritableStream.write()` 한다. 10GB 파일도 힙 100MB 이하로 전송 가능. 실패하면 partial-write로 남고, resume 시점에 이어쓴다.
### 3.5 Cloud Drop (R2 presigned)
`POST /api/drop → R2 presigned PUT URL 발급 → 브라우저가 R2에 직접 PUT → Drop Code/QR 생성` . 서버는 URL만 발급하고 바이트를 프록시하지 않는다. 만료(24h) 크론이 R2 객체를 삭제.
—
## 4. 로직/알고리즘 딥다이브 — 3개만 깊게
### 4.1 RTT 기반 혼잡제어 + 백프레셔 (transferFlowControl)
TCP처럼 `RTT`를 관측해 `chunkSize`와 `sendRate`를 조절한다.
* **관측:** DataChannel `bufferedAmount` + `RTT` 왕복시간.
* **제어:** RTT ↑ 또는 `bufferedAmount` 임계치 초과 → `chunkSize` 축소, `setTimeout` 간격 증가. RTT 안정 → 크기 증가.
* **백프레셔:** `bufferedAmount > highWaterMark`면 `await`로 송신 일시정지. 수신자 `PARTITION_ACK`가 와야 재개. 브라우저가 OOM으로 죽는 걸 막는 최후의 방벽.
> Rejected: 고정 64KB 청크 + 무제한 `send()` → 고대역폭에서 빠르지만 저대역폭/모바일에서 패킷 손실 폭증, 탭 크래시.
### 4.2 PARTITION_ACK / RESUME_REQUEST 상태머신
“`
Sender: [SEND partition N offset=X] ──→ Receiver
Receiver: [write to disk offset=X len=Y] ──PARTITION_ACK(offset=X+Y, runId=R)──→ Sender
… (연결 끊김) …
Receiver (재접속): [RESUME_REQUEST runId=R lastOffset=X+Y] ──→ Sender
Sender: [offset=X+Y부터 재전송, 중복 버림]
“`
* `runId`로 재전송 세션 구분. 이전 세션의 늦게 도착한 ACK는 버린다.
* 수신자는 `partial-write`를 파일 끝에 남긴다. `offset`은 디스크에 실제로 쓴 바이트 기준. 메모리 기준이 아니라서 전원 꺼짐에도 안전.
* `DOWNLOAD_COMPLETE`는 Merkle 루트 검증 후에만 보낸다. 중간에 끊기면 미완료로 남는다.
### 4.3 TURN credential TTL 발급
`POST /turn/credentials`가 `coturn`용 `username: expiry:hmac`를 발급. TTL 1시간. 시그널링 WS 연결 시 같이 내려준다. 클라가 `RTCPeerConnection(iceServers=[{urls: turn:…, credential}])`로 사용. 서버는 릴레이 바이트를 보지 않고, 자격증명만 짧게 발급해 남용을 막는다.
—
## 5. 왜 이 선택인가 — Rejected 대안
| 선택 | 채택 | 버린 대안 | 버린 이유 |
|—|—|—|—|
| 전송 | WebRTC DataChannel P2P | WebSocket 서버 릴레이 | 서버 대역폭 비용, E2E 암호화 불가, 10GB 업로드 UX |
| 코어 | Rust/WASM | 순수 JS 청킹 | JS GC/복사 오버헤드, 1GB 이상에서 프레임 드랍, 암호화 속도 3~5배 느림 |
| 저장 | 스트리밍 directFileWriter | Blob 누적 후 `a[download]` | 2GB 이상에서 메모리 초과, 모바일에서 탭 킬 |
| 시그널링 | adapter/factory 분리 | 컴포넌트 내 직접 `new WebSocket` | 재연결/테스트/룸코드 검증 로직이 UI에 스파게티 |
| 백업 | R2 presigned Cloud Drop | 서버가 파일 받아서 S3 업로드 | 서버 메모리 2배, 타임아웃, 프라이버시 리스크 |
—
## 6. 실패와 복구 — 운영에서 배운 것
* **실패 1: 시그널링 서버가 바이트를 보면 안 된다.** 초기 프로토타입에서 디버깅 편의상 서버에 로그를 남기려다 E2E 원칙 위반. `mesh*.rs`에 `남용방어·레이트리밋`만 남기고 페이로드는 절대 로깅하지 않는 규칙으로 고정.
* **실패 2: 고정 청크로 저대역폭에서 죽음.** `ponswarp-lan-10mbps` 포크로 10Mbps 제약을 걸고 측정. 고정 256KB 청크는 재전송 폭증. 이후 `networkAdaptiveController` 도입(다음 편 W3에서 다룸).
* **실패 3: 모바일 백그라운드 복귀 시 중복 전송.** 포그라운드 복귀 시 `RESUME_REQUEST`를 재전송 + sender가 `offset` 기준으로 중복 버리는 로직 추가. `lan-10mbps`에서 검증.
* **복구 증거:** `Docker Compose 3500(front)/5502(signaling)` 헬스체크 `/health,/ready`, Postgres 마이그레이션 8종, `ADMIN_EMAILS` 화이트리스트, `LemonSqueezy/PayPal/Stripe` 빌링 웹훅 멱등 처리.
—
## 7. 운영 증거 — 숫자와 명령
* **스택:** React19+Vite+Tailwind+Framer Motion+Three.js / Rust Tokio Axum / Postgres / coturn / R2 / simple-peer
* **제약:** 무료 10GB/24h, 멀티파일/폴더는 ZIP64로 묶음, 유료 플랜은 빌링 웹훅으로 활성화
* **검증:** `cargo test` + `wasm-pack test` + `Playwright` E2E(다음 편 `vnext`의 `verify` 체인)
* **보안:** Google OAuth, TURN TTL 1h, R2 presigned 만료, E2E AES-GCM
—
## 8. 다음 편 예고
**W2: `ponswarp-bulk-plane-vnext` — 헥사고날로 찢은 전송 코어.** `transfer/ports.ts/state.ts/useCases.ts`에 `hybridBulkTransport(WebRTC+LAN TCP)`, `crypto-plane.worker` 분리, `contracts/protocol/v1/messages.json` 28종 고정, `SpeedSender/Receiver speedFrames`를 다룬다. 설계도가 어떻게 코드 계약이 되었는지 보여준다.
—
### 부록: 재현 경로
* 설계도: `blueprints/01-ponswarp.png` (프롬프트: `prompts.part1.json#01-ponswarp`)
* 명세: `blueprints/specs/P01_ponswarp_family.md` §01
* 코드 진입점: `ponswarp/PonsWarp/src/services/webRTCService.ts`, `ponswarp/pons-core-wasm/src/lib.rs`, `ponswarp/ponswarp-signaling-rs/src/handlers/ws.rs`
* 로컬 실행: `compose.yaml up` → `http://localhost:3500` (front) + `ws://localhost:5502/ws`
*이 글은 설계도 풀이 8부작 중 1편이다. 시리즈 전체: `blueprint-ponswarp`(5) + `blueprint-ponslink`(3). 다음 글에서 `vnext`의 헥사고날 리팩터링을 이어간다.*

댓글