Skip to content

Cloudflare Durable Objects

Serverless stateful backend를 구현할 수 있는 서비스.

Workers의 일종이고, Workers Paid Plan이어야 사용 가능함. 5$ 기본 요금이 있는데, 여기에 durable objects기본 사용량도 포함되어있음. 사용사례로 실시간 채팅, 멀티플레이 게임을 든다.

EC2컴퓨팅 돌리듯이 시간당 청구되는 옵션이 있으니, 다 썼으면 잘 끄고 다녀야한다. 특히, 연결된 스토리지를 비우지 않으면(deleteAll()) 꺼지지 않으니 주의 필요.

아래는 Cloudflare Durable Object를 통한 WebSocket 연결 방법을 MediaWiki 문법으로 정리한 내용입니다.

Cloudflare Durable Object WebSocket 연결 가이드

Cloudflare Workers의 Durable Object(DO)를 활용하여 WebSocket 연결을 수립하고 관리하는 방법을 다룬다.

개요

Cloudflare Workers는 기본적으로 stateless하여 WebSocket의 지속적 연결을 직접 유지할 수 없다. Durable Object는 단일 인스턴스에서 상태를 유지하므로, WebSocket 서버 역할을 수행하기에 적합하다.

구성 요소

역할

Worker

클라이언트 요청을 수신하고 DO로 라우팅

Durable Object

WebSocket 연결을 수락하고 메시지를 처리

Hibernation API

비활성 연결의 메모리 사용을 최소화

사전 요구 사항

  • Cloudflare Workers 유료 플랜 (Workers Paid, $5/월 이상)
  • Wrangler CLI 설치
  • wrangler.json (또는 wrangler.toml)에 DO 바인딩 설정

1단계: Wrangler 설정

wrangler.json에 Durable Object 바인딩과 마이그레이션을 선언한다.

{
  "main": "./src/worker/index.ts",
  "compatibility_date": "2025-10-08",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [
      {
        "name": "AGENT_SOCKET",
        "class_name": "AgentSocket"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["AgentSocket"]
    }
  ]
}

필드

설명

name

Worker 코드에서 접근하는 바인딩 이름 (env.AGENT_SOCKET)

class_name

DO 클래스 이름 (export해야 함)

new_sqlite_classes

SQLite 기반 스토리지를 사용하는 DO 클래스 목록

2단계: Durable Object 클래스 구현

기본 구조

import { DurableObject } from 'cloudflare:workers';

export class AgentSocket extends DurableObject<Env> {
  async fetch(request: Request): Promise<Response> {
    // WebSocket 업그레이드 처리
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // 메시지 수신 처리
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
    // 연결 종료 처리
  }

  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    // 에러 처리
  }
}

WebSocket 업그레이드 (fetch 메서드)

DO의 fetch()에서 WebSocket 핸드셰이크를 처리한다.

async fetch(request: Request): Promise<Response> {
  const upgradeHeader = request.headers.get('Upgrade');
  if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') {
    return new Response('Expected WebSocket upgrade', { status: 426 });
  }

  // WebSocketPair 생성 (client ↔ server)
  const pair = new WebSocketPair();
  const [client, server] = Object.values(pair);

  // Hibernation API로 수락 (태그로 식별자 부여)
  this.ctx.acceptWebSocket(server, ['my-tag']);

  return new Response(null, { status: 101, webSocket: client });
}

API

설명

new WebSocketPair()

client/server 소켓 쌍 생성

this.ctx.acceptWebSocket(server, tags)

Hibernation API로 서버 소켓 수락. tags는 문자열 배열

Response(null, { status: 101, webSocket: client })

클라이언트 소켓을 응답으로 반환

Hibernation API vs 기존 API

항목

기존 API

Hibernation API

수락 방식

server.accept()

this.ctx.acceptWebSocket(server)

이벤트 핸들러

server.addEventListener('message', ...)

클래스 메서드 webSocketMessage()

메모리 사용

연결 동안 DO가 항상 메모리에 상주

비활성 시 DO를 메모리에서 해제 (hibernate)

비용

높음 (유휴 연결도 과금)

낮음 (hibernate 상태에서는 과금 최소)

태그 지원

없음

this.ctx.getTags(ws)로 소켓 식별

권장: Hibernation API를 사용하라. 비용 절감과 확장성 면에서 유리하다.

메시지 처리

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
  if (typeof message === 'string') {
    try {
      const data = JSON.parse(message) as { type?: string };

      if (data.type === 'ping') {
        ws.send(JSON.stringify({ type: 'pong' }));
        return;
      }

      // 비즈니스 로직 처리
      ws.send(JSON.stringify({ type: 'ack', received: data }));
    } catch {
      // JSON이 아닌 메시지
    }
  }
}

연결 종료 및 에러

async webSocketClose(ws: WebSocket, code: number, reason: string, _wasClean: boolean): Promise<void> {
  const tags = this.ctx.getTags(ws);
  const id = tags[0];
  // 정리 로직 (DB 상태 업데이트 등)
  ws.close(code, reason);
}

async webSocketError(ws: WebSocket, _error: unknown): Promise<void> {
  const tags = this.ctx.getTags(ws);
  const id = tags[0];
  // 에러 처리 로직
  ws.close(1011, 'Unexpected error');
}

3단계: Worker에서 DO로 라우팅

Worker가 클라이언트의 WebSocket 요청을 DO로 전달한다.

import { Hono } from 'hono';

export { AgentSocket } from './agent-socket';

const app = new Hono<{ Bindings: Env }>();

app.get('/ws/:id', async (c) => {
  const id = c.req.param('id');

  // DO 인스턴스 획득 (같은 id → 같은 인스턴스)
  const doId = c.env.AGENT_SOCKET.idFromName(id);
  const stub = c.env.AGENT_SOCKET.get(doId);

  // 원본 요청을 DO로 전달 (헤더 포함)
  return stub.fetch(
    new Request(c.req.url, {
      headers: c.req.raw.headers,
    })
  );
});

export default app;

핵심 포인트:

  • idFromName(id): 동일한 문자열 → 동일한 DO 인스턴스. 같은 리소스에 대한 모든 WebSocket이 하나의 DO에 모인다.
  • stub.fetch(): Worker → DO로 요청을 전달. 반드시 원본의 Upgrade: websocket 헤더를 포함해야 한다.
  • DO 클래스는 Worker 엔트리 파일에서 re-export해야 한다 (export { AgentSocket }).

4단계: 인증 (선택사항)

WebSocket URL에 인증 토큰을 직접 노출하는 것은 위험하다. 일회용 티켓(ticket) 패턴을 권장한다.

흐름도

클라이언트                    Worker                    Durable Object
    │                           │                           │
    │  POST /connect            │                           │
    │  Authorization: Bearer <token>                         │
    │ ------------------------->│                           │
    │                           │  토큰 검증 (DB 조회)       │
    │                           │                           │
    │                           │  POST /ticket             │
    │                           │  { ticket, agentId }      │
    │                           │ ------------------------->│
    │                           │                           │  티켓 저장
    │                           │                           │  알람 설정 (TTL)
    │                           │<--------------------------│
    │                           │                           │
    │  { url: "wss://.../ws?ticket=xxx" }                    │
    │ <-------------------------│                           │
    │                           │                           │
    │  GET /ws?ticket=xxx       │                           │
    │  Upgrade: websocket       │                           │
    │ ------------------------->│                           │
    │                           │  stub.fetch(request)      │
    │                           │ ------------------------->│
    │                           │                           │  티켓 검증 & 소모
    │                           │                           │  WebSocket 수락
    │ <=====================================================>│
    │               WebSocket 연결 수립                      │

티켓 저장 및 검증 (DO 내부)

const TICKET_TTL_MS = 30_000; // 30초

async fetch(request: Request): Promise<Response> {
  const url = new URL(request.url);

  // 티켓 저장 엔드포인트
  if (url.pathname.endsWith('/ticket')) {
    const { ticket, agentId } = await request.json();
    await this.ctx.storage.put(`ticket:${ticket}`, agentId);
    await this.ctx.storage.setAlarm(Date.now() + TICKET_TTL_MS);
    return new Response('ok');
  }

  // WebSocket 업그레이드 시 티켓 검증
  const ticket = url.searchParams.get('ticket');
  if (!ticket) return new Response('Missing ticket', { status: 400 });

  const agentId = await this.ctx.storage.get<string>(`ticket:${ticket}`);
  if (!agentId) return new Response('Invalid or expired ticket', { status: 401 });

  // 일회용: 즉시 삭제
  await this.ctx.storage.delete(`ticket:${ticket}`);

  // ... WebSocket 업그레이드 진행
}

// 만료된 티켓 정리
async alarm(): Promise<void> {
  const entries = await this.ctx.storage.list({ prefix: 'ticket:' });
  if (entries.size > 0) {
    await this.ctx.storage.delete([...entries.keys()]);
  }
}

5단계: 클라이언트 연결

JavaScript 클라이언트 예제

// 1. 티켓 발급
const res = await fetch('https://example.com/api/agents/my-agent/connect', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer <token>' },
});
const { url } = await res.json();

// 2. WebSocket 연결
const ws = new WebSocket(url);

ws.onopen = () => {
  console.log('연결됨');
  // 하트비트
  setInterval(() => ws.send(JSON.stringify({ type: 'ping' })), 30000);
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('수신:', data);
};

ws.onclose = (event) => {
  console.log(`종료: ${event.code} ${event.reason}`);
};

유용한 DO 스토리지 API

메서드

설명

this.ctx.storage.put(key, value)

키-값 저장

this.ctx.storage.get(key)

값 조회

this.ctx.storage.delete(key)

삭제

this.ctx.storage.list({ prefix })

접두사 기반 목록 조회

this.ctx.storage.setAlarm(time)

지정 시각에 alarm() 호출 예약

this.ctx.getWebSockets(tag?)

현재 연결된 WebSocket 목록 (태그 필터 가능)

this.ctx.getTags(ws)

특정 소켓의 태그 조회

브로드캐스트

하나의 DO 인스턴스에 연결된 모든 클라이언트에게 메시지를 전파하려면:

broadcast(message: string, exclude?: WebSocket): void {
  const sockets = this.ctx.getWebSockets();
  for (const ws of sockets) {
    if (ws !== exclude) {
      ws.send(message);
    }
  }
}

주의사항

  • DO 클래스 re-export 필수: Worker 엔트리 파일에서 export { AgentSocket } from './agent-socket' 해야 Cloudflare가 클래스를 인식한다.
  • Hibernation API 사용 시: server.accept()가 아닌 this.ctx.acceptWebSocket(server)를 호출해야 한다. 두 방식을 혼용하면 안 된다.
  • 마이그레이션 태그: DO 스토리지 스키마 변경 시 migrations 배열에 새 태그를 추가해야 한다. 기존 태그를 수정하지 말 것.
  • 단일 인스턴스 보장: idFromName()에 같은 문자열을 전달하면 항상 같은 DO 인스턴스에 라우팅된다. 이를 활용하여 리소스별 WebSocket 그룹핑이 가능하다.
  • WebSocket 연결 제한: 하나의 DO 인스턴스당 최대 약 32,768개의 동시 WebSocket 연결이 가능하다.

wrangler.toml

Durable Objects를 사용하기 위해 바인딩 설정을 확인하세요.

[[durable_objects.bindings]]
name = "MY_CHAT_ROOM"
class_name = "MyChatRoom"

[[migrations]]
tag = "v1"
new_classes = ["MyChatRoom"]

protobuf와 함께 사용하는 방법

ts-proto

import { UserProfile } from "./generated/user"; // ts-proto로 생성된 파일

export class UserDO extends DurableObject {
  async saveUser(data: UserProfile) {
    // 1. 데이터를 바이너리로 인코딩
    const encoded = UserProfile.encode(data).finish();
    // 2. DO Storage에 직접 저장 (바이너리 저장이 JSON보다 저렴)
    await this.ctx.storage.put("user_data", encoded);
  }

  async getUser(): Promise<UserProfile | undefined> {
    const stored = await this.ctx.storage.get<Uint8Array>("user_data");
    if (!stored) return undefined;
    // 3. 디코딩하여 객체로 복원
    return UserProfile.decode(stored);
  }
}

Connect RPC

API 통신까지 고려한다면 추천... ?

클라이언트와 직접 gRPC 스타일로 통신해야 한다면 Connect를 추천 한다고함.... <- 확인 필요.

protobufjs

기존에 가장 많이 쓰이던 라이브러리입니다. 모든 기능이 들어있는 전체 패키지 대신 protobufjs/light 또는 생성된 정적 코드만 사용하는 방식을 권장합니다.

Hibernation API

가장 중요한 점은 this.ctx.acceptWebSocket(ws)를 호출하여 상태를 위임하는 것입니다.

export class MyChatRoom extends DurableObject {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
  }

  async fetch(request: Request) {
    // 1. WebSocket 업그레이드 요청 확인
    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);

    // 2. 서버 측 소켓 수락 및 'Hibernation' 모드 활성화
    this.ctx.acceptWebSocket(server);

    return new Response(null, { status: 101, webSocket: client });
  }

  // 3. 메시지가 도착했을 때만 DO가 깨어나서 이 메서드를 실행합니다.
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // 메시지 처리 및 브로드캐스트
    this.ctx.getWebSockets().forEach(peer => {
      if (peer !== ws) peer.send(`상대방: ${message}`);
    });
  }

  // 4. 연결이 끊겼을 때 실행
  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
    console.log("연결 종료");
  }
}

상태(State) 관리 주의사항

Hibernation 모드에서는 DO가 수시로 메모리에서 내려갔다가(잠들었다가) 다시 올라옵니다. 따라서 클래스 멤버 변수에 데이터를 저장하면 안 됩니다.

  • 잘못된 예: this.users = [] (잠들면 초기화됨)
  • 올바른 예: this.ctx.storage.put() 또는 ws.serializeAttachment()를 사용하여 상태를 영속화해야 합니다.

See also

Favorite site