제로 이그레스 비용: 클라우드플레어를 사용해 P2P 파일 공유를 구축한 방법

발행: (2026년 1월 9일 오후 02:05 GMT+9)
9 분 소요
원문: Dev.to

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 정도. 파일은 브라우저 간에 직접 전송됩니다. 서버는 파일을 전혀 보지 못합니다.

스택

LayerTechWhy
FrameworkHonoTypeScript 우선, Cloudflare와 완벽한 통합
HostingCloudflare Workers엣지 배포, 저렴함
StateDurable ObjectsWebSocket 연결 + 방 상태
NAT traversalCloudflare 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 mode
  • Exit 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.

Back to Blog

관련 글

더 보기 »

안녕, 뉴비 여기요.

안녕! 나는 다시 S.T.E.M. 분야로 돌아가고 있어. 에너지 시스템, 과학, 기술, 공학, 그리고 수학을 배우는 것을 즐겨. 내가 진행하고 있는 프로젝트 중 하나는...