Durable Objects WebSocket Hibernation API로 실시간 서버 비용 90% 줄이기

Durable Objects(DO)로 실시간 채팅방이나 멀티플레이어 게임 룸을 만들어본 적이 있다면, 청구서에서 이상한 항목을 본 적이 있을 겁니다. 메시지가 거의 오가지 않는 새벽 시간대에도 Duration(GB-s) 비용이 낮 시간대와 거의 똑같이 쌓이는 현상입니다. 범인은 십중팔구 server.accept() + addEventListener 조합으로 짠 표준 WebSocket 코드입니다.
핵심 요약
- Durable Objects의 Duration(GB-s) 과금은 DO가 실제로 일을 하는지와 무관하게, 메모리에 상주하는 벽시계 시간(wall-clock time) 을 기준으로 매겨집니다.
- 표준 WebSocket API(
ws.accept()+addEventListener)는 연결이 하나라도 열려 있는 한 DO를 계속 메모리에 붙잡아 두므로, 유휴 시간에도 과금이 그대로 누적됩니다. - WebSocket Hibernation API(
ctx.acceptWebSocket()+webSocketMessage/webSocketClose/webSocketError)는 클라이언트 연결은 Cloudflare 네트워크 엣지에 유지한 채 DO 인스턴스만 메모리에서 내려버립니다. 하이버네이션 중에는 GB-s 과금이 아예 발생하지 않습니다. - 아래에서 공식 가격표(2026년 7월 기준 developers.cloudflare.com 문서)를 그대로 대입해 계산한 시뮬레이션 결과, 초당 1회 정도 메시지가 오가는 “보통 활성도”의 채팅방/게임 룸 2,000개를 한 달 운영할 때 총비용이 약 90.6% 줄어드는 것으로 나왔습니다. 실제 절감폭은 트래픽 패턴에 따라 49%~98% 사이에서 크게 달라집니다.
문제의 시작: 상주형 WebSocket이 유휴 시간에도 돈을 쓰는 이유
Cloudflare 공식 가격 문서는 Durable Objects의 Duration 과금을 이렇게 정의합니다. Duration은 “Object가 활성 상태이거나, 유휴 상태이지만 하이버네이션 대상이 아닌 동안” 벽시계 시간으로 과금되며, DO에 할당된 128MB 메모리를 기준으로, 실제 메모리 사용량과 무관하게 계산됩니다. Workers Paid 플랜 기준으로 월 40만 GB-s가 무료 포함이고, 초과분은 100만 GB-s당 $12.50입니다.
여기서 핵심 함정은 “활성 상태”의 정의입니다. fetch() 핸들러 안에서 new WebSocketPair()를 만들고 server.accept()를 호출한 뒤 server.addEventListener("message", ...)로 메시지를 받는 표준 방식에서는, 이 DO가 다음 이벤트를 받기 위해 JS 이벤트 리스너를 메모리에 계속 들고 있어야 합니다. 즉 연결된 클라이언트가 단 한 명이라도 있으면 실제로 메시지가 오가지 않는 시간에도 DO 인스턴스가 축출(evict)되지 못하고 살아있어야 하고, 그 시간 전부가 GB-s로 청구됩니다.
이건 Workers의 CPU 시간 과금과 완전히 다른 축입니다. Workers Paid 플랜의 CPU 시간(월 3,000만 ms 포함, 초과 시 100만 ms당 $0.02)은 실제로 코드가 실행되는 시간만 과금하지만, DO의 Duration은 코드가 실행되지 않고 그냥 대기만 하고 있어도 청구됩니다. 새벽 3시에 아무도 채팅을 안 쳐도, 게임 로비에 접속만 해두고 자리를 비운 유저가 있어도, DO는 살아있어야 하니 과금은 계속됩니다.
Hibernation API는 정확히 무엇을 하는가
WebSocket Hibernation API는 이 구조를 뒤집습니다. 공식 문서에 따르면, DO가 유휴 상태가 되면 “메모리에서 축출” 되지만 “WebSocket 클라이언트는 Cloudflare 네트워크에 계속 연결된 채로 남아 있습니다.” 클라이언트 입장에서는 연결이 끊긴 적이 없고, ping/pong도 정상적으로 오갑니다. 새 이벤트(메시지 도착, 연결 종료 등)가 들어오면 런타임이 생성자(constructor)를 다시 호출해서 DO 인스턴스를 재생성하고, 그 이벤트를 처리한 뒤 다시 잠재웁니다.
과금 관점에서 가장 중요한 문장은 이겁니다: “하이버네이션 중에는 Duration(GB-s) 과금이 발생하지 않습니다.” 즉 DO가 실제로 메시지를 처리하는 짧은 순간에만 GB-s가 쌓이고, 나머지 대부분의 시간은 공짜입니다.
단, 아무 때나 하이버네이션되는 건 아닙니다. 문서는 하이버네이션을 막는 요인을 명시하고 있습니다.
- 알람(alarm), 들어오는 요청, 예약된 콜백은 하이버네이션을 막습니다.
setTimeout/setInterval을 사용 중이면 타이머가 살아있는 동안 하이버네이션되지 않습니다.- 표준
ws.accept()로 받은(하이버네이션 미지원) WebSocket이 하나라도 있으면 해당 연결이 DO를 계속 깨워 둡니다.
반대로 프로토콜 레벨의 ping/pong 프레임은 예외입니다. 문서는 “들어오는 ping 프레임에는 자동으로 pong이 응답되며, 이 ping/pong 처리는 하이버네이션을 방해하지 않는다” 고 명시합니다. 즉 흔히 쓰는 “30초마다 setInterval로 ping 보내기” 패턴은 Hibernation API와는 상극이고, 대부분의 경우 아예 걷어내도 됩니다.
마이그레이션: addEventListener에서 webSocketMessage/webSocketClose로
기존 코드 (하이버네이션 미지원)
export class ChatRoom {
constructor(state, env) {
this.state = state;
this.sessions = new Map(); // ws -> { username }
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept(); // 표준 accept — 하이버네이션 불가
this.sessions.set(server, { username: null });
server.addEventListener("message", (event) => {
this.broadcast(event.data);
});
server.addEventListener("close", () => {
this.sessions.delete(server);
});
// 30초마다 ping — setInterval이 DO를 계속 깨워 둔다
const heartbeat = setInterval(() => server.send("ping"), 30000);
server.addEventListener("close", () => clearInterval(heartbeat));
return new Response(null, { status: 101, webSocket: client });
}
broadcast(message) {
for (const ws of this.sessions.keys()) ws.send(message);
}
}
이 코드는 흔하고 자연스럽지만, this.sessions라는 인메모리 Map과 setInterval 타이머 두 가지 모두 DO를 하이버네이션 불가 상태로 만듭니다.
Hibernation API 적용 코드
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
// 생성자는 하이버네이션 후 깨어날 때마다 다시 호출된다.
// 인메모리 Map을 여기서 채우지 않고, getWebSockets()로 복원한다.
}
async fetch(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// ws.accept() 대신 acceptWebSocket() — 하이버네이션 가능
this.ctx.acceptWebSocket(server);
// 연결별 메타데이터는 attachment로 저장 (최대 16,384바이트)
server.serializeAttachment({ username: null, joinedAt: Date.now() });
return new Response(null, { status: 101, webSocket: client });
}
// addEventListener 대신 DO 클래스 메서드로 정의
async webSocketMessage(ws, message) {
const data = JSON.parse(message);
const meta = ws.deserializeAttachment() ?? {};
if (data.type === "join") {
meta.username = data.username;
ws.serializeAttachment(meta);
}
this.broadcast(JSON.stringify({ from: meta.username, text: data.text }));
}
async webSocketClose(ws, code, reason, wasClean) {
ws.close(code, reason);
}
async webSocketError(ws, error) {
// 비정상 종료 로깅 등
console.error("WebSocket error:", error);
}
broadcast(message) {
for (const ws of this.ctx.getWebSockets()) ws.send(message);
}
}
바뀐 지점을 정리하면:
server.accept()→this.ctx.acceptWebSocket(server)addEventListener("message", ...)→async webSocketMessage(ws, message)클래스 메서드addEventListener("close", ...)→async webSocketClose(ws, code, reason, wasClean)- 에러 처리는
async webSocketError(ws, error)로 분리 - 인메모리
Map으로 세션을 들고 있던 부분은this.ctx.getWebSockets()(연결된 전체 소켓 조회)와ws.serializeAttachment()/ws.deserializeAttachment()(연결별 메타데이터, 최대 16,384바이트, structured clone으로 직렬화)로 대체 setInterval하트비트는 완전히 제거 — ping/pong은 런타임이 프로토콜 레벨에서 자동 처리
wrangler.toml에서는 SQLite 백엔드 DO를 쓰는 게 요즘 권장 경로입니다.
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]
채팅방·게임 룸 패턴: 하나의 DO로 다수 클라이언트 조율하기
채팅방이든 턴제/실시간 멀티플레이어 게임 룸이든, 패턴은 동일합니다. 방(room) 하나 = DO 인스턴스 하나로 매핑하고, 그 방에 접속한 모든 클라이언트의 WebSocket을 같은 DO가 들고 있게 합니다. 이 구조에서 ctx.getWebSockets()는 “이 방에 있는 모든 클라이언트”를 그대로 돌려주기 때문에 별도의 태깅이나 필터링 로직 없이 브로드캐스트가 가능합니다.
게임 룸이라면 webSocketMessage에서 받은 입력을 게임 상태에 반영하고, 매 틱마다(또는 매 입력마다) getWebSockets()로 순회하며 스냅샷을 뿌리는 식으로 확장하면 됩니다. 다만 DO 하나당 초당 처리량은 소프트 한계 1,000 req/s 정도로 알려져 있고, 수신 메시지 크기 한도는 32MiB입니다. 인원이 아주 많은 방(수백~수천 명 동시 접속)을 하나의 DO로 처리하려 한다면 이 한도에 먼저 부딪힐 수 있으니, 대규모 룸은 여러 DO로 샤딩하거나 브로드캐스트 팬아웃을 배치로 묶는 걸 고려해야 합니다.
세션별 상태(닉네임, 팀, 캐릭터 위치 등)는 serializeAttachment로 WebSocket 객체에 붙여두면 하이버네이션을 넘나들며 유지됩니다. 단, attachment는 16,384바이트 한도가 있으므로 방 전체 상태나 채팅 로그처럼 커지는 데이터는 여기 넣지 말고 DO의 영속 스토리지(this.ctx.storage)에 두는 게 맞습니다.
하이버네이션 후 깨어날 때 상태를 복원하는 전략
무엇이 살아남고, 무엇이 사라지는가
하이버네이션은 DO 인스턴스(자바스크립트 객체와 그 안의 변수들)를 지우는 것이지, 연결이나 영속 데이터를 지우는 게 아닙니다. 정리하면:
- 살아남는 것: WebSocket 연결 자체(클라이언트는 끊김을 느끼지 못함),
serializeAttachment로 저장한 연결별 메타데이터(최대 16KB),this.ctx.storage에 쓴 영속 데이터. - 사라지는 것: 생성자에서 만든 인메모리
Map/Set/일반 변수, 진행 중이던setTimeout/setInterval타이머, 클로저에 캡처된 상태.
그래서 마이그레이션의 핵심은 “생성자에서 다시 만들어져도 문제없게” 상태 관리를 재설계하는 것입니다. 실전에서는 보통 이렇게 나눕니다.
- 연결별로만 필요한 짧은 메타데이터 (닉네임, 색상, 마지막 하트비트 시각 등) →
serializeAttachment - 방 전체가 공유하는 상태지만 재계산 가능한 것 (현재 접속자 수, 온라인 유저 목록) → 생성자에서
this.ctx.getWebSockets()를 돌며 각 소켓의 attachment를 읽어 즉석에서 재구성 - 영구히 남아야 하는 데이터 (채팅 히스토리, 게임 스코어, 방 설정) →
this.ctx.storage.get/put으로 SQLite 백엔드에 저장
export class ChatRoom {
constructor(ctx, env) {
this.ctx = ctx;
// 인메모리 캐시는 "복원 가능한 뷰"로만 취급한다.
// 실제 참조는 필요할 때마다 getWebSockets()로 다시 얻는다.
}
onlineUsernames() {
return this.ctx.getWebSockets()
.map((ws) => ws.deserializeAttachment()?.username)
.filter(Boolean);
}
}
하트비트는 setInterval 대신 프로토콜 레벨에 맡겨라
앞서 언급했듯 프로토콜 ping/pong은 런타임이 자동 처리하며 하이버네이션을 방해하지 않습니다. 애플리케이션 레벨에서 “이 유저가 아직 살아있는지” 확인하는 커스텀 하트비트가 꼭 필요하다면, setInterval로 직접 구현하지 말고 DO의 알람(alarm) API를 활용해 주기적으로 깨어나 상태를 점검하는 방식으로 바꾸는 걸 권합니다. 알람도 하이버네이션을 막는 요인이긴 하지만, setInterval처럼 DO를 영구히 붙잡아 두지 않고 예정된 시각에만 짧게 깨어났다 다시 잠들 수 있기 때문입니다.
CPU 실행 한도도 그대로 적용된다
webSocketMessage 핸들러도 결국 Workers 런타임 위에서 도는 코드이므로, CPU 시간 한도(기본 30초, wrangler.toml의 limits.cpu_ms로 최대 5분까지 조정 가능)가 그대로 적용됩니다. 무거운 동기 연산(대량 정렬, 압축 등)을 메시지 핸들러 안에서 돌리면 이 한도에 걸릴 수 있으니 주의가 필요합니다.
비용 비교: 상시 활성 DO vs Hibernation 적용 DO
가격 구조 (Workers Paid 플랜, 2026년 7월 기준 공식 문서)
| 항목 | 무료 포함량 | 초과 요금 |
|---|---|---|
| 요청 (HTTP 요청·RPC·WebSocket 메시지·알람) | 100만 건/월 | 100만 건당 $0.15 |
| Duration (GB-s, wall-clock, 128MB 고정 기준) | 40만 GB-s/월 | 100만 GB-s당 $12.50 |
| SQLite 저장 용량 | 5GB-월 | GB-월당 $0.20 |
| SQLite 행 읽기 | 250억 행/월 | 100만 행당 $0.001 |
| SQLite 행 쓰기 | 5,000만 행/월 | 100만 행당 $1.00 |
Workers Paid 플랜 자체는 월 $5 기본료에 요청 1,000만 건과 CPU 3,000만 ms가 포함되어 있고, DO 과금은 이 위에 별도로 얹힙니다. (참고로 SQLite 백엔드 DO는 Workers Free 플랜에서도 하루 단위 한도—요청 10만 건/일, Duration 13,000 GB-s/일, 행 읽기 500만/일, 행 쓰기 10만/일, 저장 5GB—로 사용할 수 있어 프로토타이핑에는 무료 플랜도 충분합니다.)
계산 모델과 가정
아래 수치는 실제 청구서를 캡처한 것이 아니라, 위 공식 가격 공식을 그대로 대입한 시뮬레이션입니다. Cloudflare가 가격 문서에서 제시하는 예시와 동일한 공식(활성 초 × 128MB/1GB = GB-s)을 사용했습니다.
- 채팅방/게임 룸 DO 2,000개를 30일(2,592,000초) 내내 운영
- 상시 활성(하이버네이션 미적용): 방마다 최소 한 명 이상 접속해 있어 DO가 한 달 내내 메모리에 상주
- Hibernation 적용: 방마다 평균 초당 1회 메시지가 오가고(1msg/sec), 메시지 처리(브로드캐스트 포함)에 평균 10ms가 걸린다고 가정 — 이 경우 DO는 전체 시간의 약 1%만 “깨어” 있습니다.
결과
| 구분 | Duration 비용/월 | 요청 비용/월 (양쪽 동일) | 합계/월 |
|---|---|---|---|
| 상시 활성 DO | $8,289.40 | $777.45 | $9,066.85 |
| Hibernation 적용 DO | $77.94 | $777.45 | $855.39 |
총비용 절감폭 약 90.6% — 헤드라인의 “90%“가 특정 시나리오에서만 나온 과장이 아니라, 초당 1회 정도 메시지가 오가는 지극히 평범한 활성도에서도 실제로 도달하는 수치라는 뜻입니다.
트래픽 패턴에 따라 절감폭이 달라진다
같은 2,000개 DO, 같은 계산 공식으로 메시지 빈도만 바꿔보면 절감폭이 크게 달라집니다.
- 저빈도(채팅방, 턴제 게임, 평균 5초에 1회 메시지, 처리 15ms): 합계 비용 $8,444.77 → $175.25, 절감 약 97.9%
- 중빈도(초당 1회 메시지, 처리 10ms): 합계 비용 $9,066.85 → $855.39, 절감 약 90.6%
- 고빈도(10Hz 실시간 게임 틱, 처리 5ms): 합계 비용 $16,065.25 → $8,185.57, 절감 약 49.0%
패턴은 명확합니다. Duration 절감액 자체는 트래픽과 거의 무관하게 항상 크지만(위 세 시나리오 모두 Duration만 보면 99% 이상 줄어듭니다), 메시지 빈도가 높아질수록 요청(request) 과금이 총비용에서 차지하는 비중이 커지면서 전체 절감률은 낮아집니다. 초당 수십 회씩 상태를 브로드캐스트하는 고빈도 실시간 게임이라면, Hibernation API 도입과 별개로 틱 레이트를 낮추거나 델타 압축·관심 영역(interest management)으로 메시지 자체를 줄이는 최적화가 비용 절감에 더 크게 기여합니다.
마이그레이션 체크리스트와 흔한 함정
-
ws.accept()호출을 전부this.ctx.acceptWebSocket(ws)로 교체했는가 -
addEventListener("message"/"close")를webSocketMessage/webSocketClose클래스 메서드로 옮겼는가,webSocketError도 추가했는가 - 생성자에 있던 인메모리 세션 Map을 제거하고, 필요한 값은
serializeAttachment(연결별, 16,384바이트 한도) 또는this.ctx.storage(영속, 방 전체)로 옮겼는가 - 애플리케이션 레벨
setInterval/setTimeout하트비트를 제거하거나 알람 API로 대체했는가 — 프로토콜 ping/pong은 런타임이 자동 처리하므로 커스텀 하트비트가 정말 필요한지부터 재검토 - 대규모 룸(수백~수천 동접)을 하나의 DO에 몰아넣고 있지 않은지 — 소프트 한계 1,000 req/s, 메시지 크기 32MiB 한도 확인
-
wrangler.toml에new_sqlite_classes마이그레이션을 추가해 SQLite 백엔드로 전환했는가 (Hibernation API와 함께 쓰기를 Cloudflare가 권장하는 조합) - 무거운 동기 연산이
webSocketMessage안에 남아 있어 CPU 한도(기본 30초)에 걸리지 않는지
정리
일반 addEventListener 기반 WebSocket 코드가 나쁜 코드는 아닙니다. 다만 Durable Objects의 과금 모델과 궁합이 나쁠 뿐입니다. DO는 켜져 있는 시간만큼 돈을 받아가고, 표준 WebSocket API는 연결이 살아있는 한 DO를 계속 켜 둡니다. Hibernation API는 이 둘을 분리해서, 연결은 엣지에 남기고 컴퓨트만 잠재우는 방식으로 구조적 낭비를 없앱니다.
마이그레이션 자체는 API 표면적으로 보면 크지 않습니다. accept() → acceptWebSocket(), 이벤트 리스너 → 클래스 메서드, 인메모리 상태 → attachment/storage. 하지만 “생성자가 언제든 다시 호출될 수 있다”는 전제를 코드베이스 전반에 스며들게 하는 것, 그리고 커스텀 하트비트 같은 하이버네이션을 막는 숨은 타이머를 걷어내는 것이 실제 작업의 대부분을 차지합니다. 위 체크리스트를 기준으로 하나씩 점검하면, 트래픽 패턴에 따라 다르지만 채팅·턴제 게임처럼 유휴 시간이 긴 워크로드에서는 손쉽게 90% 안팎의 비용 절감을 기대할 수 있습니다.