[PonsWarp] WebRTC는 파일을 보내주지 않는다, 길만 열어준다
WebRTC를 처음 붙잡을 때는 약간의 낙관브라우저끼리 직접 연결되고, data channel이 열리고, 그 위로 바이트를 보내면 파일 전송의 큰 문제가 해결될 것 같았다. 서버에 파일을 올리지 않아도 되고, 두 사람이 바 는 점이 매력적이었다. 그런데 PonsWarp를 정리하면서 더 분명해진 건 WebRTC가 파일을 보내주는 기술이 아니라 길을 열어주는 기술이라는 점이었다. 그래서 용어 사전 대신, 그 날 무엇을 보고 무엇을 바꿨는지부터 적는다. 아래에서는 PonsWarp을 중심으로, 깨진 가정과 다시 그은 경계를 순서대로 남긴다.

WebRTC가 필요해진 지점
이 분리는 구현자에게도 도움이 된다. signaling 서버의 /ready가 통과해도 receiver writer가 준비되지 않았으면 전송 준비가 끝난 것이 아니다. TURN credential이 있어도 브라우저 저장 권한이 막히면 파일은 완성되지 않는다. 반대로 저장 경계가 안전해도 peer가 다시 만나지 못하면 재개할 수 없다. 각각의 성공 조건을 작게 두면, 실패했을 때 제품이 거짓말을 덜 하게 된다. As-Is는 “WebRTC 연결이 열리면 파일 전송도 열린다”였다.
Pain은 “연결과 저장은 같은 성공 조건이 아니다”였다. To-Be는 “WebRTC를 운반 계층으로 두고, 저장과 재개 기준은 별도 계층에서 확인한다”였다. 브라우저별 차이도 여기서 다시 올라온다. PonsWarp README는 Chrome/Edge가 File System Access API와 StreamSaver 동작이 강하고, Firefox는 fallback save behavior를 쓰며, Safari는 filesystem과 background transfer가 더 제한적이라고 적는다. 이 말은 WebRTC가 열렸더라도 저장 계층의 조건은 달라질 수 있다는 뜻이다. 같은 data channel 위에서도 어떤 브라우저는 더 직접적인 저장 경로를 쓰고, 어떤 브라우저는 더 보수적인 설명이 필요하다.
길은 같아 보여도 도착 방식은 다를 수 있다. PonsWarp가 현재 코드에서 공개하고 있는 backpressure 기준도 본문에 드러내야 한다.

처음에는 WebRTC가 파일을 보낼 줄 알았다
결국 WebRTC에 대한 내 판단은 낙관에서 분리로 바뀌었다. WebRTC를 낮게 평가하게 된 것이 아니라, 더 정확한 자리에 두게 됐다. signaling은 만남, WebRTC는 길, flow control은 속도 조절, DirectFileWriter는 도착 확인, receiver offset은 재개의 기준이다. 이 구분을 해야 제품은 사용자가 묻는 질문에 대답할 수 있다. “연결됐나요?”와 “파일이 안전한가요?”는 서로 다른 질문이고, PonsWarp는 둘을 같은 초록불로 묶지 않는 쪽을 택해야 했다.
ponswarp-signaling-rs README를 보면 signaling server는 Rust, Tokio, Axum 기반이고 WebSocket signaling과 TURN credential 생성을 제공한다. API에는 /ws, health, ready, Cloud Drop 관련 endpoint가 나뉘어 있다. 메시지 프로토콜 예시도 JoinRoom과 Offer를 JSON으로 주고받는 형태다. 이 서버는 두 브라우저가 서로를 찾고 연결 협상을 진행하도록 돕는다. 초기에는 peer connection이 붙고 data channel이 open 상태가 되면 꽤 많은 문제가 해결된 듯했다. 개발 화면에서 연결 이벤트가 찍히고 작은 메시지가 오가면 이제 파일만 얹으면 된다고 생각하기 쉽다.
signaling도 방을 만들고 offer와 answer를 교환하고 candidate를 넘기면 역할을 다한 것처럼 보인다. PonsWarp README는 Direct 모드가 WebRTC data channel로 브라우저끼리 전송한다고 말한다. 동시에 전체 파일을 메모리에 올리지 않고, StreamSaver나 File System Access API로 직접 저장하고, 끊긴 전송은 수신자 현재 offset에서 재개할 수 있다고 설명한다. 이 문장을 나눠 읽으면 WebRTC는 전체 구조의 한 구간일 뿐이다. 파일 전송을 완성하는 건 그 앞뒤의 읽기, 쓰기, 재개, 사용자 설명이다.
중요한 건 여기까지다. signaling은 길 안내자다. 연결 후보를 주고받고, NAT 환경에서 TURN을 쓸 수 있게 하고, 운영에서는 readiness로 라우팅 전에 서버 상태를 확인하게 한다. 하지만 signaling server가 receiver disk에 파일이 안전하게 저장됐다고 말할 수는 없다. 그 판단은 receiver worker와 DirectFileWriter, 저장 API 쪽의 책임이다.

길만 열림이 선명해진 순간
보낸 쪽은 내가 어디까지 보냈다고 주장할 수 있다. 그러나 받은 쪽만 내가 어디까지 저장했다고 말할 수 있다. 재연결 뒤에 더 믿을 수 있는 기준은 후자다. 이 원칙을 세우면 WebRTC는 길이 되고, DirectFileWriter는 기준점이 된다. 제품은 연결 상태를 저장 완료로 과장하지 않는다. 여기서 일부러 속도 숫자나 성공률을 넣지 않은 이유도 있다. 근거 문서가 말하는 것은 벤치마크가 아니라 구조다.
Direct는 브라우저끼리 스트리밍하고, Cloud Drop은 R2 임시 링크를 만들고, signaling은 WebSocket과 TURN 준비를 맡고, core는 WASM 안에서 큰 바이트 흐름과 ZIP64 같은 경계를 다룬다. 이 사실만으로도 판단은 충분히 바뀐다. 파일 전송 글에서 중요한 것은 멋진 수치가 아니라 사용자가 실패했을 때 납득할 수 있는 경계다. data channel이 있다고 해서 아무 크기의 chunk를 아무 속도로 밀어 넣어도 되는 것은 아니다.
PonsWarp README는 adaptive flow control, chunk sizing, backpressure, buffer thresholds를 기능으로 적고 있다. 이 말은 직접 전송에서 속도보다 흐름 관리가 중요하다는 뜻이기도 하다. 보내는 쪽이 빨리 읽어도 받는 쪽 저장이 늦으면 버퍼는 압박을 받는다. PonsWarp에는 Direct만 있는 게 아니다. Cloud Drop은 Cloudflare R2를 써서 임시 링크를 제공한다. signaling server README는 R2 S3 호환 API, presigned PUT/GET, CORS, cleanup, 무료 10GB/24시간, 유료 권한의 더 큰 용량과 보관 기간을 설명한다. 이 경로는 WebRTC 길과 성격이 다르다. WebRTC가 “길”이라면, PonsWarp가 따로 붙인 것은 그 길 위의 교통 규칙이다.
구현을 기준으로 보면 책임은 이렇게 나뉜다. 사용자가 원하는 것도 사실 이 구분에 가깝다. 사용자는 ICE candidate나 TURN이라는 말을 알고 싶어 하지 않는다. 대신 지금 상대와 다시 만나는 중인지, 파일 쓰기는 멈췄는지, 저장된 위치가 남아 있는지 알고 싶어 한다. WebRTC를 길로만 두면 제품은 그 질문에 더 가까운 답을 할 수 있다. PonsWarp를 설명할 때 서버/클라이언트/사용자 중 누구 문장으로 말하는지 먼저 고르면 논쟁이 줄었다.
WebRTC 경계 나누기
또 하나의 실패 장면은 방을 다시 만났지만 같은 전송을 이어 간다고 말하기 애매한 경우였다. signaling server는 JoinRoom, Offer 같은 메시지로 peer를 다시 만나게 할 수 있다. TURN credential도 네트워크 통과에 도움을 준다. 하지만 재회한 두 브라우저가 이전 파일의 같은 위치를 보고 있는지는 별도 문제다. 받는 쪽에 남은 파일 조각과 보내는 쪽이 기억하는 위치가 어긋나면, 연결은 성공했는데 데이터는 위험해진다.
그래서 재연결 뒤에는 항상 저장 상태 확인이 뒤따라야 했다. 대용량 파일에서는 특히 이 차이가 커진다. sender disk에서 읽는 속도, WebRTC buffer가 비워지는 속도, receiver worker가 처리하는 속도, DirectFileWriter가 디스크에 쓰는 속도가 모두 같을 수 없다. 어느 한쪽이 밀리면 제품은 기다리거나 줄여야 한다. 무조건 빠르게 보내는 방식은 결국 브라우저 메모리와 저장 API를 압박한다. | 기준 | 값 | 의도 | | --- | ---: | --- | | CHUNK_SIZE_INITIAL / MAX | 192KB | 256KB 안팎의 SCTP 메시지 한계 아래에서 안정성을 우선한다.
| | HIGH_WATER_MARK | 4MB | DataChannel sender queue가 과도하게 쌓이기 전에 보류한다. | | LOW_WATER_MARK | 1MB | 큐가 충분히 비었을 때 재개한다. | | TRANSFER_PARTITION_SIZE | 16MB | 일정량마다 receiver 쪽 처리가 따라오는지 확인한다. | | writer buffer high / low | 32MB / 16MB | 디스크 쓰기 전 대기 데이터가 커지면 receiver가 PAUSE를 요청할 수 있게 한다. | | MAX_DIRECT_PEERS | 3 | 다중 receiver가 sender와 writer를 동시에 압박하는 상황을 제한한다. | | 질문 | 답 | | --- | --- | | WebRTC만 붙이면 대용량 파일 전송이 완성되나? | 아니다. WebRTC는 연결과 운반 계층이고, 대용량 전송의 신뢰는 chunk sizing, backpressure, receiver writer, resume 기준에서 나온다.
| | PonsWarp가 receiver offset을 중시하는 이유는? | sender가 보냈다고 주장하는 위치보다 receiver가 실제로 쓴 위치가 재개 기준으로 더 안전하기 때문이다. | | 검색어로는 무엇이 맞나? | “WebRTC 파일 전송”, “DataChannel backpressure”, “브라우저 직접 파일 전송”, “대용량 파일 전송 resume”처럼 길과 저장을 함께 검색하는 독자를 겨냥해야 한다. |
책임 흐름이 바뀐 지점
내가 WebRTC를 다시 보게 된 가장 구체적인 장면은 연결 이벤트가 성공했는데도 사용자가 받을 파일은 아직 안전하지 않은 순간이었다. 개발자 도구에는 data channel open이 찍히고, signaling 로그도 정상처럼 보인다. 그런데 받는 쪽 브라우저는 저장 권한을 기다리고 있거나, writer가 느리거나, 이전에 받던 조각의 경계를 확인하는 중일 수 있다. 이때 연결 성공을 전송 성공처럼 UI에 말하면 사용자는 나중에 더 크게 실망한다.
길이 열렸다는 말은 출발 허가이지 도착 증명서가 아니었다. 하지만 파일 전송에서 연결 성공은 출발선일 뿐이었다. 파일은 크고 오래 걸린다. 중간에 네트워크가 흔들리고, 브라우저가 백그라운드로 밀리고, 저장 API가 브라우저마다 다르게 동작한다. WebRTC가 길을 열어도 그 길 위에서 어떤 속도로 보내고 언제 멈추고 어디서 다시 시작할지는 제품이 정해야 한다.
그래서 제품 안의 문장도 더 조심스러워져야 했다. “완료”라는 말은 받은 쪽이 실제로 열 수 있는 결과를 가질 때 써야 하고, “재개”라는 말은 확인된 저장 위치가 있을 때 써야 한다. “연결됨”은 길이 열렸다는 뜻이지 파일이 안전하다는 뜻이 아니다. 이런 단어 하나하나가 사소해 보이지만, 큰 파일을 기다리는 사용자에게는 제품이 정직한지 판단하는 기준이 된다.
내가 얻은 결론은 이렇다. signaling은 만남을 돕고, WebRTC는 길을 열고, flow control은 길 위의 속도를 조절하고, receiver writer는 도착을 확정한다. 사용자는 하나의 전송으로 보지만, 제품 안에서는 이 책임들이 분리되어야 한다. 그 분리가 있어야 PonsWarp는 연결 성공을 전송 성공으로 과장하지 않고, 확인된 저장 상태를 기준으로 정직하게 이어갈 수 있다. 이 차이는 제품 설계에서 중요하다.
WebRTC Direct는 두 브라우저가 동시에 온라인일 때 길을 열어 바이트를 흘린다. Cloud Drop은 한쪽이 업로드하고 다른 쪽이 나중에 다운로드할 수 있도록 서버 저장소를 일정 시간 빌린다. Direct가 어려울 때 Cloud Drop을 선택할 수는 있지만, 둘을 같은 전송 기술처럼 말하면 안 된다. '[PonsWebRTC는 파일을 보내주지 않는다, 길만 열어준다'을 관통하는 질문은 이 섹션에서도 같았고, 답만 더 구체적이 됐다.
흐름 제어에서도 판단이 바뀌었다. 예전에는 WebRTC data channel이 열리면 대역폭을 최대한 쓰는 것이 좋은 전송이라고 생각했다. 지금은 buffer가 차오르는 속도와 receiver writer가 비워 내는 속도를 같이 보는 편이 맞다고 본다. README에 적힌 chunk sizing, backpressure, buffer thresholds는 성능 튜닝 용어처럼 보이지만, 실제로는 저장소와 브라우저를 보호하는 안전장치다. 받는 쪽이 느리면 보내는 쪽도 늦춰야 한다. 이것이 길 위의 예의였다.
이 코드는 실제 제품 코드를 그대로 옮긴 것이 아니라, 본문 독자가 이해할 수 있도록 책임을 줄인 의사코드다. 중요한 건 “WebRTC로 보낸다”가 아니라 “receiver가 쓸 수 있을 때만 계속 보낸다”는 정책이다. 특히 PonsWarp에서는 재현 조건을 같이 적어야 의미가 있었다. ts async function pumpChunk(channel: RTCDataChannel, chunk: ArrayBuffer) { if (channel.bufferedAmount > HIGH_WATER_MARK) { await waitUntil(() => channel.bufferedAmount < LOW_WATER_MARK); } channel.send(chunk); } function onReceiverBuffer(bytesWaitingForDisk: number) { if (bytesWaitingForDisk > WRITER_HIGH_MARK) return 'PAUSE'; if (bytesWaitingForDisk < WRITER_LOW_MARK) return 'RESUME'; return 'KEEP_CURRENT_RATE'; } 이렇게 나누고 나면 디버깅의 질문도 달라진다. “WebRTC가 안 된다”가 아니라 “signaling에서 못 만났는가, data channel은 열렸지만 buffer가 막혔는가, receiver writer가 저장을 멈췄는가, 사용자가 브라우저 저장 권한 경계에서 멈췄는가”로 쪼갤 수 있다.
질문이 쪼개지면 사용자에게도 더 정직하게 말할 수 있다. 모든 실패를 연결 실패라고 부르지 않는 것만으로도 제품은 훨씬 덜 모호해진다. 그래서 WebRTC를 설명할 때도 Cloud Drop과 비교가 필요했다. WebRTC는 서버 저장소를 줄이는 길이지만, 동시 온라인과 브라우저 실행 상태를 요구한다. Cloud Drop은 시간 맞추기 문제를 해결하지만, 용량과 보관 정책이 붙는다.
사용자는 이 차이를 알아야 자기 상황에 맞게 고를 수 있다.
| 계층 | 실제 역할 | 성공으로 착각하면 안 되는 것 | | --- | --- | --- | | signaling /ws | JoinRoom, Offer, Answer, ICE candidate를 중계한다 | peer가 만났다고 파일이 저장된 것은 아니다 | | TURN credential | NAT 환경에서 우회 경로를 준비한다 | TURN을 얻었다고 receiver writer가 준비된 것은 아니다 | | WebRTC data channel | encrypted SCTP 경로 위로 chunk를 운반한다 | open 상태가 전송 완료를 의미하지 않는다 | | sender worker | 파일을 조각으로 읽고 packet을 만든다 | 읽은 바이트가 곧 저장된 바이트는 아니다 | | receiver worker | 받은 packet을 writer로 넘긴다 | packet 수신이 곧 파일 close는 아니다 | | DirectFileWriter | StreamSaver, File System Access API, Blob/OPFS fallback으로 디스크 쓰기를 확정한다 | writer 완료 이 관점은 사용자 문구에도 영향을 줬다. “연결되었습니다”라는 문장은 필요하지만 충분하지 않다. 큰 파일을 받는 사람에게 더 중요한 말은 “저장 위치를 확인했습니다”, “이어 받을 수 있는 지점을 찾았습니다”, “받는 쪽 저장이 느려 잠시 조절합니다” 같은 문장이다. WebRTC 상태를 그대로 보여주는 것보다 제품이 어떤 기준으로 안전을 판단하는지 말하는 편이 낫다. 기술 상태와 사용자 신뢰 사이에는 번역이 필요하다. 이 원칙은 multi-file과 folder support에서도 이어진다. README는 raw source-byte transfer와 receiver-side ZIP64 packaging을 설명한다. 폴더를 받는 경우에도 결과물을 안전하게 닫는 책임은 수신자 쪽에 가까워진다. WebRTC가 파일 목록을 의미 있게 만들어 주는 게 아니라, 받은 쪽이 저장 가능한 구조로 써 내려가야 한다. 그래서 WebRTC를 쓰는 파일 전송은 길을 열고 끝나는 작업이 아니다. 열린 길 위에서 교통량을 조절해야 한다. backpressure를 제품 구조 안에 넣어야 하고, receiver offset을 재개의 기준으로 삼아야 하며, 사용자가 보는 상태도 “연결됨” 하나로 끝나면 안 된다. 같은 장애가 다시 와도 팀 문장이 흔들리지 않아야 한다. 실패 시 사용자에게 보이는 문장, 내부 상태 이름, 재시도 한도, 포기 조건을 한 세트로 적어두면 토론이 짧아졌다. 특히 WebRTC가 관여하는 경로에서는 '일단 동작'보다 '중단 후에도 설명 가능'이 우선이었다. 같은 용어를 쓰더라도 팀마다 기대하는 보장 수준이 달랐고, 그 차이를 숨기면 구현이 흔들렸다. 그래서 배포 노트에는 기능 목록 대신 깨진 가정과 새로 고정한 규칙을 남겼다.
마치며
이 글에서 확인한 경계는 다음 변경의 기준으로 남겼다.