제로 이그레스 비용: 클라우드플레어를 사용해 P2P 파일 공유를 구축한 방법
Source: Dev.to
저는 파일이 브라우저 간에 직접 전송되는 P2P 파일‑공유 도구를 만들었습니다. 서버는 WebRTC 시그널링만 처리하고 실제 파일은 절대 서버를 거치지 않습니다. 10 GB 파일을 전송해도? 여전히 zero egress costs.
Stack: Hono + Cloudflare Workers + Durable Objects + STUN.
문제: 아웃바운드 비용이 빠르게 누적됨
모든 파일 공유 서비스는 대역폭에 대해 요금을 부과합니다. S3, R2, 그 어떤 것이든 — 서버를 떠나는 모든 바이트에 대해 비용을 지불합니다.
간단한 사용 사례, 즉 몇 명의 친구와 대용량 비디오 파일을 공유하는 경우에 대해 비용을 계산해 보았습니다. Cloudflare R2의 “관대한” 무료 티어를 사용해도, 월 몇 개의 4 GB 파일만으로도 비용이 발생합니다. 이를 실제 사용자 규모로 확대하면 청구서가 크게 늘어납니다.
저는 다른 것을 원했습니다: 파일 크기에 관계없이 전송 비용이 전혀 없는.
뒤돌아보면 답은 명확했습니다 — 파일이 서버에 전혀 닿지 않도록 하는 것입니다.
솔루션: WebRTC + Cloudflare
WebRTC는 브라우저가 서로 직접 통신하도록 합니다. 중간에 서버가 없습니다. 문제는? 브라우저가 서로를 찾을 수 있도록 연결 정보를 교환하는 시그널링을 위한 서버는 여전히 필요합니다.
아키텍처
┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐
│ Sender │◄───────►│ Durable Object │◄───────►│ Receiver │
│ │ WS │ (signaling only) │ WS │ │
└─────────────┘ └─────────────────────┘ └─────────────┘
│ │
│ │
└──────────────────── WebRTC P2P ───────────────────────┘
(files go here)
시그널링 메시지는 아주 작습니다 — 몇 KB 정도. 파일은 브라우저 간에 직접 전송됩니다. 서버는 파일을 전혀 보지 못합니다.
스택
| Layer | Tech | Why |
|---|---|---|
| Framework | Hono | TypeScript 우선, Cloudflare와 완벽한 통합 |
| Hosting | Cloudflare Workers | 엣지 배포, 저렴함 |
| State | Durable Objects | WebSocket 연결 + 방 상태 |
| NAT traversal | Cloudflare STUN | 무료, 동일 공급업체 |
모든 것이 Cloudflare 내에 머무릅니다. wrangler deploy 한 번으로 바로 라이브됩니다.
Source: …
왜 Durable Objects인가?
Workers는 상태가 없습니다. 대부분의 경우에는 괜찮지만, 시그널링은 상태가 필요합니다 — 어느 사용자가 어떤 방에 있는지 추적하고, 그들 사이에 메시지를 중계해야 합니다.
Durable Objects는 이를 완벽하게 해결합니다. 각 방마다 자체 인스턴스를 가집니다:
app.get('/ws/:roomId', (c) => {
const roomId = c.req.param('roomId')
const id = c.env.ROOM.idFromName(roomId)
const stub = c.env.ROOM.get(id)
return stub.fetch(c.req.raw)
})
Durable Object는 해당 방의 모든 WebSocket 연결을 처리합니다. 누군가가 오퍼를 보내면, 올바른 피어에게 중계합니다. 간단합니다.
export class Room extends DurableObject {
async fetch(request: Request): Promise {
const clientId = new URL(request.url).searchParams.get('cid')
?? crypto.randomUUID()
this.closeDuplicateClient(clientId) // 재연결 처리
const pair = new WebSocketPair()
this.ctx.acceptWebSocket(pair[1])
return new Response(null, { status: 101, webSocket: pair[0] })
}
webSocketMessage(ws: WebSocket, message: string) {
// 시그널링 메시지를 올바른 피어에게 중계
}
private closeDuplicateClient(clientId: string) {
for (const socket of this.ctx.getWebSockets()) {
const attachment = socket.deserializeAttachment()
if (attachment?.cid === clientId) {
socket.close(1000, 'replaced')
}
}
}
}
어려운 부분: 재연결
초기 연결을 작동시키는 데 하루가 걸렸고, 재연결을 안정적으로 만들려면 일주일이 걸렸다.
문제 1 – 고스트 연결
사용자가 페이지를 새로 고침하면 브라우저가 WebSocket을 닫지만 Durable Object는 즉시 알지 못합니다 — webSocketClose가 발생하기까지 지연이 있습니다. 새로운 연결이 들어오면 중복 소켓이 발생합니다.
해결책: localStorage에 영구적인 클라이언트 ID를 저장합니다.
function getClientId() {
const stored = localStorage.getItem('client-id')
if (stored) return stored
const id = crypto.randomUUID()
localStorage.setItem('client-id', id)
return id
}
새로운 연결이 동일한 클라이언트 ID와 함께 도착하면 이전 연결을 강제로 닫습니다 (위 closeDuplicateClient 참조).
문제 2 – 오래된 시그널링 메시지
이전 세션의 오래된 offer/answer 메시지가 재연결 후 도착하여 새로운 세션과 섞이고 모든 것이 깨질 수 있습니다.
해결책: 모든 시그널링 메시지에 세션 ID를 첨부합니다.
// Sender
const sendOffer = async (peer: OffererPeer) => {
const sid = ++peer.signalSid // Increment on every new offer
peer.activeSid = sid
const offer = await peer.pc.createOffer({ iceRestart: true })
await peer.pc.setLocalDescription(offer)
send({ type: 'offer', to: peer.peerId, sid, sdp: offer })
}
// Receiver
if (msg.sid !== peer.activeSid) return // Ignore stale messages
클라이언트 ID는 중복 연결을 처리하고, 세션 ID는 오래된 메시지를 처리합니다. 이 두 가지가 함께 재연결을 안정적으로 만듭니다.
No‑TURN 트레이드오프
저는 의도적으로 TURN 서버를 제외했습니다. TURN은 P2P가 실패할 때(예: 엄격한 기업 방화벽, 대칭 NAT) 트래픽을 서버를 통해 중계합니다. TURN을 사용하면 전체 목적이 무너지게 됩니다 — 파일이 제 서버를 거쳐 전송되어 외부 트래픽 비용이 발생합니다.
TURN이 없으면 일부 기업 네트워크에서는 동작하지 않을 수 있습니다. 이것이 바로 트레이드오프입니다. 제 사용 사례—일반 가정/사무실 네트워크에서 친구 및 동료와 파일을 공유하는 경우—STUN만으로도 충분히 잘 작동합니다.
보다 엄격한 환경을 지원해야 한다면 옵션으로 TURN을 추가하고 비용을 청구하겠습니다. 하지만 무료 티어는 P2P‑only 상태를 유지합니다.
보너스: E2E 암호화
URL 조각을 이용한 선택적 종단 간 암호화:
https://example.com/room/ABC123#k=Base64EncodedKey
Enter fullscreen modeExit fullscreen mode
# 조각은 서버에 도달하지 않습니다. Cloudflare Workers는 키를 보지 못하며, 링크를 공유하는 브라우저만 복호화할 수 있습니다.
What I Learned
- Durable Objects are underrated. Everyone talks about Workers, but Durable Objects are what make stateful edge applications possible—WebSocket management, room state, connection queueing—all in one primitive.
- WebRTC reconnection is painful. The happy path works quickly. The reconnection edge cases take 10× longer. Budget for it.
- TURN is a business decision, not a technical one. You can always add it later. Starting without it keeps costs at zero and forces you to validate whether P2P alone is good enough.
- The Cloudflare stack is underrated for real‑time apps. Workers + Durable Objects + STUN = no external dependencies, one deploy command, and it just works.
The best file transfer is the one that never touches your server.