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

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 활성화:
-
server.accept()→this.state.acceptWebSocket(server)전환 - 이벤트 리스너 →
webSocketMessage,webSocketClose,webSocketError메서드로 전환 -
Map<WebSocket, data>→serializeAttachment/deserializeAttachment전환 -
this.sessionsin-memory 저장 제거
WebSocket 메시지 최적화:
- 불필요한 핑/폰 메시지 제거 (클라이언트 → 서버 방향)
- Cloudflare가 핑을 자동 처리하므로 서버 측 heartbeat 불필요
- 메시지 배치 처리로 요청 수 감소
스토리지 절감:
- 메시지 히스토리 상한 설정 (100-200개)
- Alarm API로 비활성 방 정기 정리
- 대용량 페이로드는 R2 저장, DO에는 참조만 유지
모니터링:
- Cloudflare 대시보드에서 DO Duration 지표 주간 확인
- OTel으로 실제 활성 시간 측정 (Active Duration / Total Connection Time 비율)
- 방별 접속자 수 메트릭 수집
트러블슈팅: 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를 포함한 전체 시스템의 분산 트레이싱 설정 방법을 확인하자.