effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화
한국어

Cloudflare Durable Objects WebSocket Hibernation: 실시간 채팅 비용 99% 절감

Cloudflare Durable Objects WebSocket Hibernation API 비용 최적화 아키텍처

DO로 실시간 채팅을 만들었는데 요금이 이상하다

Cloudflare Durable Objects(DO)는 실시간 채팅, 멀티플레이어 게임, 협업 편집기를 구현하기에 완벽한 플랫폼처럼 보인다. Workers의 무상태 한계를 넘어 영속 메모리와 WebSocket을 한 곳에서 관리할 수 있다.

그런데 채팅 앱을 배포하고 며칠 뒤 청구서를 보면 충격을 받는다. 동시 접속자가 수십 명밖에 없는데 비용이 예상의 10배다.

원인은 GB-seconds 과금 방식이다. 표준 WebSocket API를 사용하면 클라이언트가 연결만 하고 아무 메시지도 보내지 않아도 DO는 메모리에 살아있고 그 시간 내내 과금된다. 100명이 채팅방에 접속해 가끔만 메시지를 보내도 DO는 24시간 활성 상태로 유지된다.

Hibernation API가 이 문제의 해답이다. 클라이언트가 연결을 유지하는 동안에도 DO가 메모리에서 제거될 수 있게 한다. 새 메시지가 오면 자동으로 깨어나 처리하고 다시 잠든다. 비용은 실제로 코드가 실행되는 시간에만 발생한다.

이 글에서는 표준 WebSocket DO와 Hibernation API의 차이, 실제 채팅 앱 구현, serializeAttachment로 상태를 관리하는 방법, 그리고 비용 시뮬레이션을 다룬다.

DO 과금 구조 이해

Hibernation API를 쓰기 전에 과금 구조를 정확히 알아야 한다.

과금 항목 설명 단가
요청 (Requests) HTTP 요청, RPC 세션, WebSocket 메시지 100만 건당 $0.15
컴퓨트 기간 (Duration) DO가 메모리에 있는 시간 × 128MB GB-s당 $12.50
저장소 SQLite 읽기/쓰기, KV 저장 별도

핵심 규칙: WebSocket 메시지는 20:1 비율로 청구된다. 수신 WebSocket 메시지 100건 = 청구 요청 5건.

문제: Duration 과금이 폭발한다. DO는 마지막 이벤트 이후 최소 10초간 메모리에 유지되며, 표준 WebSocket은 이 타이머를 계속 리셋한다.

비용 시뮬레이션: 표준 WebSocket

100개 채팅방 × 50명 동시 접속, 하루 8시간 활성, 한 달 기준:

Duration: 100 rooms × 1 DO × 128MB × 8시간 × 30일
= 100 × 0.128GB × 8h × 30d × 3600s/h
= 11,059,200 GB-s

비용: 11,059,200 × $12.50 / 1,000,000
≈ $138/월 (Duration만)

Hibernation API 사용 시: 메시지가 없으면 DO는 10초 후 잠든다. 실제 메시지 처리 시간은 전체 연결 시간의 1-5% 수준.

활성 시간 2% 가정:
11,059,200 × 0.02 = 221,184 GB-s
비용: 221,184 × $12.50 / 1,000,000
≈ $2.76/월 (Duration)

동일한 트래픽에서 Duration 비용 98% 절감.

표준 WebSocket vs Hibernation API 비교

표준 WebSocket (문제 있는 패턴)

// src/chat-room.ts — 표준 WebSocket (비용 폭탄)
export class ChatRoom implements DurableObject {
  private sessions: Map<WebSocket, { userId: string; username: string }> = new Map();
  private state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

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

    const [client, server] = Object.values(new WebSocketPair());

    // 문제: server.accept()는 DO가 활성 상태로 유지되게 강제함
    server.accept();

    server.addEventListener("message", (event) => {
      this.handleMessage(server, event.data);
    });

    server.addEventListener("close", () => {
      this.sessions.delete(server);
    });

    const userId = new URL(request.url).searchParams.get("userId") ?? "anonymous";
    this.sessions.set(server, { userId, username: userId });

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

  private handleMessage(sender: WebSocket, message: string | ArrayBuffer) {
    // 모든 세션에 브로드캐스트
    const data = JSON.parse(message as string);
    this.sessions.forEach((info, ws) => {
      if (ws !== sender && ws.readyState === WebSocket.READY_STATE_OPEN) {
        ws.send(JSON.stringify({ ...data, from: info.userId }));
      }
    });
  }
}

이 코드의 문제: server.accept()를 사용하면 DO가 절대 Hibernate 되지 않는다. this.sessions은 in-memory Map이므로 모든 연결 세션이 항상 메모리에 유지된다.

Hibernation API (올바른 패턴)

// src/chat-room-hibernation.ts — Hibernation API
export class ChatRoom implements DurableObject {
  private state: DurableObjectState;
  private env: Env;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    this.env = env;
  }

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

    const url = new URL(request.url);
    const userId = url.searchParams.get("userId") ?? crypto.randomUUID();
    const username = url.searchParams.get("username") ?? "Anonymous";
    const roomId = url.searchParams.get("roomId") ?? "default";

    const [client, server] = Object.values(new WebSocketPair());

    // 핵심 차이: ctx.acceptWebSocket()이 Hibernation을 활성화
    this.state.acceptWebSocket(server);

    // serializeAttachment: 이 WebSocket 소켓에 메타데이터 첨부
    // Hibernate 후에도 각 소켓에 연결된 상태로 유지됨
    server.serializeAttachment({ userId, username, roomId });

    // 입장 메시지 브로드캐스트
    this.broadcast(server, {
      type: "system",
      message: `${username}님이 입장했습니다`,
      timestamp: Date.now(),
    });

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

  // Hibernation API의 이벤트 핸들러: 일반 메서드로 선언
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
    // deserializeAttachment: Hibernate 후 깨어났을 때 첨부 데이터 복원
    const { userId, username, roomId } = ws.deserializeAttachment() as {
      userId: string;
      username: string;
      roomId: string;
    };

    let data: any;
    try {
      data = JSON.parse(message as string);
    } catch {
      ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
      return;
    }

    // 채팅 메시지 처리
    if (data.type === "message") {
      const chatMessage = {
        type: "message",
        userId,
        username,
        content: data.content,
        timestamp: Date.now(),
        id: crypto.randomUUID(),
      };

      // 히스토리 저장 (DO 내장 SQLite)
      await this.state.storage.put(`msg:${chatMessage.id}`, chatMessage);

      // 방의 모든 참가자에게 브로드캐스트
      this.broadcast(ws, chatMessage);
    }
  }

  // WebSocket 연결 종료 핸들러
  async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
    const { username, roomId } = ws.deserializeAttachment() as {
      username: string;
      roomId: string;
    };

    this.broadcast(ws, {
      type: "system",
      message: `${username}님이 퇴장했습니다`,
      timestamp: Date.now(),
    });
  }

  // WebSocket 에러 핸들러
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error("WebSocket error:", error);
    ws.close(1011, "Internal Error");
  }

  // 현재 방의 모든 WebSocket에 브로드캐스트
  private broadcast(sender: WebSocket, data: object): void {
    const message = JSON.stringify(data);
    const senderAttachment = sender.deserializeAttachment() as { roomId: string };

    // getWebSockets(): Hibernate 상태에서도 현재 연결된 모든 소켓 반환
    for (const ws of this.state.getWebSockets()) {
      const attachment = ws.deserializeAttachment() as { roomId: string };
      // 같은 방의 소켓에만 전송 (발신자 제외)
      if (ws !== sender && attachment.roomId === senderAttachment.roomId) {
        try {
          ws.send(message);
        } catch {
          // 이미 닫힌 소켓은 무시
        }
      }
    }
  }
}

Hibernation API 핵심 개념

ctx.acceptWebSocket() vs server.accept()

server.accept() ctx.acceptWebSocket()
Hibernate 가능 ❌ 불가능 ✅ 가능
이벤트 핸들러 addEventListener webSocketMessage 메서드
세션 관리 in-memory Map getWebSockets()
상태 유지 메모리 유지 필수 serializeAttachment

serializeAttachment / deserializeAttachment

Hibernate는 DO를 메모리에서 완전히 제거한다. 새 메시지가 오면 새로운 인스턴스가 생성된다. 따라서 각 WebSocket 소켓에 필요한 메타데이터를 첨부해야 한다:

// 첨부 (Hibernate 전 자동 직렬화)
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "room-general",
  joinedAt: Date.now(),
  permissions: ["read", "write"],
});

// 복원 (Hibernate 후 깨어났을 때)
async webSocketMessage(ws: WebSocket, message: string): Promise<void> {
  const state = ws.deserializeAttachment() as {
    userId: string;
    username: string;
    roomId: string;
    joinedAt: number;
    permissions: string[];
  };

  // state.userId, state.username 등 안전하게 사용 가능
}

주의사항: serializeAttachment 데이터는 JSON 직렬화 가능해야 한다. 함수, 클래스 인스턴스, 순환 참조는 저장할 수 없다.

getWebSockets() — 현재 연결된 소켓 조회

// 방별 접속자 수 조회
getConnectedUsers(roomId: string): number {
  return this.state.getWebSockets()
    .filter(ws => {
      const att = ws.deserializeAttachment() as { roomId: string };
      return att.roomId === roomId;
    })
    .length;
}

// 특정 사용자에게만 메시지 전송
sendToUser(targetUserId: string, data: object): void {
  for (const ws of this.state.getWebSockets()) {
    const { userId } = ws.deserializeAttachment() as { userId: string };
    if (userId === targetUserId) {
      ws.send(JSON.stringify(data));
      break;
    }
  }
}

완전한 채팅 앱 구현

Workers 진입점

// src/index.ts
import { Hono } from "hono";
import { ChatRoom } from "./chat-room-hibernation";

type Env = {
  CHAT_ROOM: DurableObjectNamespace;
};

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

// WebSocket 업그레이드 핸들러
app.get("/ws", async (c) => {
  const roomId = c.req.query("roomId") ?? "general";
  const userId = c.req.query("userId") ?? crypto.randomUUID();
  const username = c.req.query("username") ?? "Anonymous";

  // 방 ID 기반으로 DO 인스턴스 선택 (방마다 1개 DO)
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);

  // DO로 요청 포워딩
  return room.fetch(c.req.raw);
});

// 방 정보 조회 API
app.get("/rooms/:roomId/info", async (c) => {
  const roomId = c.req.param("roomId");
  const id = c.env.CHAT_ROOM.idFromName(roomId);
  const room = c.env.CHAT_ROOM.get(id);
  return room.fetch(
    new Request(`https://internal/info?roomId=${roomId}`)
  );
});

export default app;
export { ChatRoom };

wrangler.jsonc 설정

// wrangler.jsonc
{
  "name": "realtime-chat",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "durable_objects": {
    "bindings": [
      {
        "name": "CHAT_ROOM",
        "class_name": "ChatRoom"
      }
    ]
  },

  "migrations": [
    {
      "tag": "v1",
      "new_classes": ["ChatRoom"]
    }
  ],

  // OTel 트레이싱도 함께 활성화 (비용 모니터링)
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.1
    }
  }
}

클라이언트 구현 (Hono + Vanilla JS)

// public/client.ts
class ChatClient {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnects = 5;

  connect(roomId: string, userId: string, username: string): void {
    const url = new URL("/ws", window.location.href);
    url.protocol = url.protocol.replace("http", "ws");
    url.searchParams.set("roomId", roomId);
    url.searchParams.set("userId", userId);
    url.searchParams.set("username", username);

    this.ws = new WebSocket(url.toString());

    this.ws.addEventListener("open", () => {
      console.log("Connected to chat room");
      this.reconnectAttempts = 0;
    });

    this.ws.addEventListener("message", (event) => {
      const data = JSON.parse(event.data);
      this.onMessage(data);
    });

    this.ws.addEventListener("close", (event) => {
      console.log(`Disconnected: ${event.code} ${event.reason}`);
      // 자동 재연결 (지수 백오프)
      if (this.reconnectAttempts < this.maxReconnects) {
        const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect(roomId, userId, username);
        }, delay);
      }
    });
  }

  sendMessage(content: string): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: "message", content }));
    }
  }

  private onMessage(data: any): void {
    // UI 업데이트 로직
    const event = new CustomEvent("chat-message", { detail: data });
    window.dispatchEvent(event);
  }

  disconnect(): void {
    this.ws?.close(1000, "User disconnected");
  }
}

export const chatClient = new ChatClient();

히스토리 관리: DO 내장 SQLite

Hibernate 후에도 채팅 히스토리를 유지하려면 DO의 영속 스토리지를 사용한다:

// 채팅 히스토리 저장 및 조회
export class ChatRoom implements DurableObject {
  // 메시지 저장
  private async saveMessage(msg: ChatMessage): Promise<void> {
    // DO 내장 SQLite (알파): 구조화된 쿼리 가능
    await this.state.storage.put(`msg:${msg.timestamp}:${msg.id}`, msg);

    // 최근 100개 메시지만 유지 (비용 절감)
    const allKeys = await this.state.storage.list({ prefix: "msg:", limit: 200 });
    if (allKeys.size > 100) {
      // 오래된 메시지 삭제
      const keysToDelete = [...allKeys.keys()].slice(0, allKeys.size - 100);
      await this.state.storage.delete(keysToDelete);
    }
  }

  // 신규 접속자에게 최근 히스토리 전송
  private async sendHistory(ws: WebSocket): Promise<void> {
    const history = await this.state.storage.list({
      prefix: "msg:",
      limit: 50,
      reverse: true,  // 최신순
    });

    const messages = [...history.values()].reverse();
    ws.send(JSON.stringify({ type: "history", messages }));
  }
}

Alarm API: 비활성 방 자동 정리

오래된 채팅방의 DO 인스턴스를 주기적으로 정리하면 스토리지 비용도 절감된다:

export class ChatRoom implements DurableObject {
  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
    // Alarm 설정: 24시간 후 비활성 확인
    this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }

  // Alarm이 발동되면 호출
  async alarm(): Promise<void> {
    const activeConnections = this.state.getWebSockets().length;

    if (activeConnections === 0) {
      // 연결이 없으면 오래된 메시지 정리
      const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7일 전
      const allMsgs = await this.state.storage.list({ prefix: "msg:" });

      const toDelete: string[] = [];
      for (const [key, value] of allMsgs) {
        const msg = value as ChatMessage;
        if (msg.timestamp < cutoff) {
          toDelete.push(key);
        }
      }

      if (toDelete.length > 0) {
        await this.state.storage.delete(toDelete);
        console.log(`Cleaned ${toDelete.length} old messages`);
      }
    }

    // 다음 Alarm 예약
    await this.state.storage.setAlarm(Date.now() + 24 * 60 * 60 * 1000);
  }
}

비용 최적화 체크리스트

실제 프로덕션에서 적용할 최적화 목록:

Hibernation 활성화:

WebSocket 메시지 최적화:

스토리지 절감:

모니터링:

트러블슈팅: Hibernation이 작동하지 않는 경우

증상: Duration 비용이 여전히 높음

원인 1: server.accept() 대신 ctx.acceptWebSocket()을 사용하지 않음
원인 2: webSocketMessage 핸들러 내부에서 실행 시간이 긴 비동기 작업 실행
원인 3: DO의 이벤트 루프를 점유하는 타이머나 반복 작업 존재

// ❌ 잘못된 패턴: setInterval이 DO를 깨어있게 유지
constructor(state: DurableObjectState) {
  this.state = state;
  setInterval(() => this.cleanup(), 60000); // Hibernate 방해
}

// ✅ 올바른 패턴: Alarm API 사용
constructor(state: DurableObjectState) {
  this.state = state;
  // 생성자에서 무거운 작업 금지
}

async fetch(request: Request): Promise<Response> {
  // 첫 연결 시에만 Alarm 설정
  const alarmTime = await this.state.storage.getAlarm();
  if (!alarmTime) {
    await this.state.storage.setAlarm(Date.now() + 60000);
  }
  // ...
}

증상: Hibernate 후 deserializeAttachment 오류

원인: serializeAttachment에 JSON 직렬화 불가능한 값 포함
해결: 순수 JSON 호환 객체만 첨부

// ❌ 직렬화 불가
server.serializeAttachment({
  userId: "user-123",
  handler: () => {}, // 함수는 직렬화 불가
  socket: server,   // 순환 참조
});

// ✅ 직렬화 가능
server.serializeAttachment({
  userId: "user-123",
  username: "Alice",
  roomId: "general",
  permissions: ["read", "write"],
  joinedAt: Date.now(), // 숫자, 문자열, 배열만
});

결론

Cloudflare Durable Objects는 실시간 WebSocket 앱을 위한 최고의 에지 런타임이다. 단, 표준 WebSocket API와 Hibernation API의 차이를 이해하지 못하면 비용이 폭발한다.

핵심 전환점:

변경 전 변경 후 효과
server.accept() ctx.acceptWebSocket() Hibernate 활성화
Map<WebSocket, data> serializeAttachment 메모리 상태 제거
addEventListener webSocketMessage 메서드 DO 이벤트 모델 전환
setInterval Alarm API 주기적 작업 비용 절감

이 4가지 패턴 변경만으로 Duration 과금이 95-99% 줄어든다. 클라이언트가 연결을 유지하는 동안 대화가 없다면 DO는 잠들고, 메시지가 도착하면 깨어나 처리하고 다시 잠든다.

월 수백만 WebSocket 연결을 처리하는 프로덕션 앱에서 이 차이는 월 수백 달러의 비용 절감으로 이어진다.

관련 글: Cloudflare Workers OpenTelemetry: OTLP 옵저버빌리티 비용 절감에서 DO를 포함한 전체 시스템의 분산 트레이싱 설정 방법을 확인하자.