WebSocket 연결은 살아있고, Duration 비용은 내지 않는다 — Hono + Cloudflare Durable Objects Hibernation API 적용기
실시간 채팅이나 협업 도구를 Cloudflare Workers 위에서 처음 구현해봤을 때 저도 똑같은 고민을 했습니다. "연결이 살아있는 동안 Durable Object가 메모리에 상주하면... 아무도 채팅 안 하는 새벽 4시에도 비용이 나가는 거잖아?" 맞습니다. 기존 WebSocket API를 그냥 쓰면 그렇게 됩니다. Durable Object(이하 DO)는 인스턴스가 메모리에 살아있는 동안 GB-초 단위의 Duration 비용이 발생하는데, 클라이언트가 연결만 맺어놓고 아무 말도 안 해도 이 비용이 쌓입니다.
여기서 짚고 넘어갈 점이 하나 있습니다. Duration 비용은 DO 고유의 과금 항목이지, 일반 Workers의 과금 방식이 아닙니다. Workers는 요청 횟수와 CPU 시간 기준으로 청구되고, DO는 여기에 더해 인스턴스가 살아있는 시간(Wall clock)까지 셈합니다. 두 과금 체계를 뭉뚱그려 생각하면 오해가 생기기 쉬워서 미리 갈라두는 게 좋습니다.
WebSocket Hibernation API는 이 DO 쪽 비용 문제를 정면으로 해결합니다. 핵심은 단순합니다. 메시지가 없을 때 DO가 메모리에서 내려가되(hibernate) WebSocket 연결 자체는 클라이언트 쪽에서 끊기지 않습니다. 새 메시지가 오면 DO가 자동으로 깨어나 처리하고, 다시 조용해지면 다시 잠듭니다. Duration 비용은 실제로 CPU와 메모리를 쓰는 순간에만 발생합니다.
이 글에서는 Hono를 엔트리포인트로 쓰면서 DO에서 Hibernation API를 직접 구현하는 실제 패턴을 다룹니다. Hono의 공식 Cloudflare 어댑터가 아직 Hibernation을 직접 추상화하지 않는다는 점, 인메모리 상태가 wake-up 때마다 사라진다는 점처럼 조금 까다로운 부분도 같이 살펴봅니다.
왜 지금 이 조합인가
Hono가 Workers 생태계에서 빠르게 자리잡은 배경
2026년 기준으로 Cloudflare Workers 환경에서 Hono는 빠르게 자리잡고 있는 프레임워크입니다. Workers + Hono + D1 + R2 조합의 서버리스 풀스택 패턴이 커뮤니티에서 활발하게 공유되고 있고, Hono의 upgradeWebSocket() 헬퍼는 WebSocket 업그레이드 요청을 깔끔하게 처리해줍니다.
그런데 Hono로 WebSocket을 구현할 때 한 가지 구조적인 선택이 필요합니다. Hono 자체는 상태가 없는(stateless) 라우터입니다. 여러 클라이언트가 같은 "채널"에 연결되어 서로 메시지를 주고받으려면, 그 채널의 상태를 관리하는 무언가가 필요합니다. 그 역할을 하는 것이 Durable Object입니다.
DO 과금 모델과 Hibernation의 궁합
DO는 인스턴스가 활성 상태로 유지되는 시간(Active duration)에 비례해 비용이 청구됩니다. 반대로 Hibernate된 동안에는 이 Duration 청구가 발생하지 않습니다. 그래서 유휴 연결이 많은 워크로드일수록 Hibernation의 효과가 커집니다. 정확한 요율과 항목은 DO 가격 정책 공식 문서에서 확인할 수 있습니다.
Liveblocks는 AWS 기반에서 WebSocket 서버 확장의 어려움을 겪다가 Cloudflare Durable Objects + Hibernation API로 이전했습니다. 협업 룸에 아무런 활동이 없을 때 DO가 hibernate 상태로 전환되어 연결을 유지하면서도 비용을 절감합니다.
아키텍처: Worker와 Durable Object의 역할 분담
Hono Worker와 Durable Object는 명확하게 역할을 나눕니다.
- Hono Worker: 공개 엔트리포인트입니다. URL 파싱, 인증 확인, 채널 ID 추출 등 라우팅 로직을 처리하고, 해당 채널에 대응하는 DO 스텁으로 요청을 포워딩합니다.
- Durable Object: 채널(룸) 단위의 상태와 실제 WebSocket 연결을 관리합니다.
ctx.acceptWebSocket(server)한 줄로 Hibernation이 자동 활성화됩니다.
이 이중 레이어 구조가 처음에는 복잡해 보일 수 있지만, DO가 채널 단위로 격리된 상태를 가지고 Worker가 그 앞단에서 라우팅을 담당하는 구조는 실제로 꽤 명료합니다.
실제 구현: wrangler 설정부터 Hibernation 핸들러까지
1. wrangler.toml 설정
name = "my-realtime-app"
main = "src/index.ts"
# compatibility_date는 프로젝트 시점에 맞춰 최신값을 사용하세요.
# WebSocket 관련 플래그와 권장 날짜는 아래 공식 문서에서 확인할 수 있습니다.
# https://developers.cloudflare.com/workers/configuration/compatibility-flags/
compatibility_date = "2024-09-23"
[[durable_objects.bindings]]
name = "CHAT_ROOM"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]compatibility_date는 실제 프로젝트 시작 시점의 권장값을 공식 문서에서 확인해 지정하세요. WebSocket Close 프레임 처리와 관련된 플래그가 어떤 날짜에 묶여 있는지, 그 시점에 어떤 기본 동작이 활성화되는지는 Compatibility Flags 문서에 정리돼 있습니다. new_sqlite_classes는 DO 스토리지를 SQLite 백엔드로 사용하기 위한 설정으로, SQLite-backed Durable Objects는 2024년에 베타로 공개되었습니다.
2. Hono Worker 진입점
// src/index.ts
import { Hono } from 'hono'
type Bindings = {
CHAT_ROOM: DurableObjectNamespace
}
const app = new Hono<{ Bindings: Bindings }>()
app.get('/ws/:channelId', async (c) => {
const channelId = c.req.param('channelId')
const upgradeHeader = c.req.header('Upgrade')
if (upgradeHeader !== 'websocket') {
return c.text('WebSocket 연결만 허용됩니다', 426)
}
const id = c.env.CHAT_ROOM.idFromName(channelId)
const stub = c.env.CHAT_ROOM.get(id)
return stub.fetch(c.req.raw)
})
export default app
export { ChatRoom } from './chat-room'여기서 주목할 점은 Hono의 upgradeWebSocket() 헬퍼를 쓰지 않는다는 점입니다. Hibernation API는 DO 클래스 안에서 ctx.acceptWebSocket()으로 활성화해야 하고, Hono의 공식 Cloudflare 어댑터는 아직 이를 직접 추상화하지 않습니다(GitHub Issue #4506). 그래서 Worker에서는 단순히 요청을 DO로 포워딩하는 역할만 합니다.
3. Durable Object에 Hibernation 적용
// src/chat-room.ts
import { DurableObject } from 'cloudflare:workers'
type MessageData = {
type: 'message' | 'join' | 'leave'
userId: string
content?: string
}
export class ChatRoom extends DurableObject {
async fetch(request: Request): Promise<Response> {
// Cloudflare 공식 관용구: 튜플 destructuring이 타입 안전합니다.
const { 0: client, 1: server } = new WebSocketPair()
const url = new URL(request.url)
const userId = url.searchParams.get('userId') ?? 'anonymous'
this.ctx.acceptWebSocket(server)
// Hibernate 후 wake-up 시에도 참조 가능한 소켓 메타데이터
server.serializeAttachment({ userId })
const joinMessage: MessageData = { type: 'join', userId }
this.broadcast(server, JSON.stringify(joinMessage))
return new Response(null, { status: 101, webSocket: client })
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const attachment = ws.deserializeAttachment() as { userId: string }
const parsed: MessageData = JSON.parse(message as string)
const broadcast: MessageData = {
type: 'message',
userId: attachment.userId,
content: parsed.content,
}
this.broadcast(ws, JSON.stringify(broadcast))
}
// Cloudflare 공식 시그니처: wasClean 파라미터까지 받아야 타입이 맞습니다.
async webSocketClose(
ws: WebSocket,
code: number,
reason: string,
wasClean: boolean,
): Promise<void> {
const attachment = ws.deserializeAttachment() as { userId: string }
const leaveMessage: MessageData = { type: 'leave', userId: attachment.userId }
this.broadcast(ws, JSON.stringify(leaveMessage))
ws.close(code, reason)
}
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
console.error('WebSocket 오류:', error, 'wasClean?', false)
ws.close(1011, 'Internal error')
}
// 발신자를 제외하고 브로드캐스트합니다.
// 클라이언트에서 로컬 에코가 필요하면 send 직후 UI를 낙관적으로 업데이트하세요.
private broadcast(sender: WebSocket, message: string): void {
for (const ws of this.ctx.getWebSockets()) {
if (ws !== sender && ws.readyState === WebSocket.READY_STATE_OPEN) {
ws.send(message)
}
}
}
}serializeAttachment / deserializeAttachment는 Hibernation API에서 제공하는 중요한 기능입니다. 각 WebSocket 소켓에 작은 데이터를 붙여두면, DO가 hibernate 후 wake-up 되었을 때도 그 소켓이 누구 것인지 알 수 있습니다.
broadcast 헬퍼는 의도적으로 발신자(sender)를 제외합니다. 채팅 UI에서 내 메시지는 서버 응답을 기다리지 않고 클라이언트가 즉시 렌더링하는 게 자연스럽기 때문인데, 이 정책을 유지하려면 클라이언트에서 로컬 에코를 반드시 구현해야 합니다. 모든 참여자에게 동일하게 뿌리고 싶다면 ws !== sender 조건을 빼면 됩니다.
Hibernation 생명주기: 실제로 어떻게 동작하는가
클라이언트 입장에서는 연결이 끊겼다가 다시 붙는 게 아닙니다. DO가 잠들었다 깨어나는 과정이 Cloudflare 인프라 레이어에서 투명하게 처리됩니다. 재연결 로직이 필요 없는 이유가 여기에 있습니다.
인메모리 상태 손실: 가장 주의해야 할 함정
Hibernation의 큰 장점이 있으면 반드시 따라오는 제약이 있습니다. Hibernate 중에는 DO의 인메모리 상태가 사라집니다. 클래스 필드에 저장해두었던 데이터가 wake-up 후에는 없어집니다.
예를 들어, 채팅 메시지 히스토리를 인메모리 배열에 쌓아두었다면 wake-up 때마다 사라집니다. 이런 상태는 DO Storage에 저장해야 합니다.
// 잘못된 패턴: 인메모리 상태에만 의존
export class ChatRoom extends DurableObject {
private messages: string[] = [] // Hibernate 후 사라짐
async webSocketMessage(ws: WebSocket, message: string) {
this.messages.push(message) // wake-up 때마다 리셋됨
}
}DO Storage에 영속화하는 방향은 두 가지로 나뉩니다. 개념 이해용으로는 단일 키에 배열을 담는 형태가 가장 직관적이지만, DO Storage의 단일 값 크기 제한(약 128 KiB) 때문에 프로덕션에서는 위험합니다. 메시지가 늘어나면 어느 순간 저장이 실패합니다.
// 개념적 예시: 최근 N개만 유지하도록 슬라이스하는 단순 패턴 (프로덕션 부적합)
export class ChatRoom extends DurableObject {
async webSocketMessage(ws: WebSocket, message: string) {
const history = (await this.ctx.storage.get<string[]>('history')) ?? []
history.push(message)
if (history.length > 100) history.splice(0, history.length - 100)
await this.ctx.storage.put('history', history)
this.broadcast(ws, message)
}
}실제 서비스에서는 키-퍼-메시지 구조로 분산 저장하는 편이 안전합니다. 정렬 가능한 키를 쓰면 범위 조회로 최근 히스토리를 페이징할 수 있고, 단일 값 크기 제한에 걸리지 않습니다.
// 프로덕션 지향 예시: 메시지 하나당 키 하나
export class ChatRoom extends DurableObject {
async webSocketMessage(ws: WebSocket, message: string) {
// 정렬 가능한 키: ISO 타임스탬프 + 순번
const key = `msg:${Date.now().toString().padStart(16, '0')}`
await this.ctx.storage.put(key, message)
this.broadcast(ws, message)
}
async recentHistory(limit = 50): Promise<string[]> {
const map = await this.ctx.storage.list<string>({
prefix: 'msg:',
reverse: true,
limit,
})
return Array.from(map.values()).reverse()
}
}SQLite-backed DO를 쓰고 있다면 ctx.storage.sql로 인덱스 있는 테이블에 저장하는 방식이 더 자연스럽습니다.
트레이드오프: 언제 Hibernation이 효과적이고, 언제 그렇지 않은가
| 항목 | Hibernation API | 표준 WebSocket API |
|---|---|---|
| 유휴 연결 Duration 비용 | 없음 | 연결 유지 시간만큼 청구 |
| 인메모리 상태 | Wake-up 시 손실됨 | 유지됨 |
| 클라이언트 재연결 | 불필요 | 불필요 |
| 아웃바운드 WebSocket | 미지원 | 지원 |
| 활성화 복잡도 | acceptWebSocket 한 줄 |
표준 방식 그대로 |
| 적합한 케이스 | 메시지 빈도 낮은 채널, 협업 툴, 채팅 | 초당 수십 메시지 발생하는 케이스 |
메시지 빈도가 낮을수록 Hibernation의 비용 절감 효과가 극대화됩니다. 반대로 게임처럼 초당 수십 개의 메시지가 오가는 채널이라면 DO가 거의 항상 깨어있게 되어 효과가 줄어듭니다.
실무에서 흔히 만나는 함정들
- Close 프레임 미처리:
webSocketClose핸들러에서ws.close(code, reason)를 명시적으로 호출하지 않으면 1006 비정상 종료가 발생하기 쉽습니다. 시그니처도(ws, code, reason, wasClean)네 개 파라미터를 모두 받아야 최신 타입 정의와 일치합니다. - 아웃바운드 WebSocket 착각: Hibernation API는 클라이언트가 서버로 맺은 인바운드 연결에만 적용됩니다. DO에서 외부 서버로 연결하는 아웃바운드 WebSocket은 Hibernation 대상이 아닙니다.
- 스토리지 읽기 비용 과소 평가: 인메모리 대신 Storage를 쓰면 매 wake-up 때마다 읽기 작업이 발생합니다. 읽기/쓰기 패턴을 잘 설계하지 않으면 다른 비용이 늘어날 수 있습니다.
- 동시 연결 제한: 플랜별 WebSocket 동시 연결 제한은 Cloudflare Workers 공식 limits 문서에서 최신 수치를 확인하세요. 대규모 서비스라면 사전에 검토가 필요합니다.
마무리: wrangler dev 로그에서 관찰할 수 있는 것
이 아키텍처가 처음이면 이해가 뚝뚝 끊길 수 있는데, 그 감각을 가장 빨리 좁혀주는 건 로그입니다. wrangler dev로 로컬에서 채팅 룸 하나를 띄우고, DO 클래스의 fetch, webSocketMessage, webSocketClose 초입에 console.log를 하나씩 심어봅니다. 두 개의 브라우저 탭에서 연결한 뒤 메시지를 한 번 주고받고, 30초쯤 조용히 기다렸다가 다시 메시지를 보내보세요.
이때 관찰할 지점이 세 가지입니다.
- 두 번째 메시지가 도착하면
webSocketMessage는 호출되지만fetch는 호출되지 않습니다. 클라이언트 관점에서 새 연결이 아니라는 증거입니다. - 클래스 필드에 카운터를 하나 두고 매 메시지마다
++해보면, 유휴 구간이 있었던 경우 wake-up 후 값이 0으로 리셋되는 걸 확인할 수 있습니다. 인메모리 상태 손실이 추상적인 이야기가 아니라는 것이 눈에 들어옵니다. ctx.getWebSockets()가 반환하는 소켓 목록은 hibernate 전후에도 동일하게 유지됩니다. 이 지점이 "연결은 살아있고, 프로세스만 잠든다"는 문장의 실체입니다.
이 세 가지를 로컬에서 한 번 눈으로 확인하고 나면, Hibernation의 생명주기가 문서상의 개념이 아니라 손에 잡히는 감각으로 바뀝니다. 그다음부터는 스토리지 설계든, 인증 레이어든, 프로덕션에서 만날 문제들이 훨씬 구체적으로 보이기 시작합니다.
참고 자료
- Cloudflare Durable Objects - WebSocket 모범 사례 공식 문서
- Cloudflare Durable Objects 라이프사이클 공식 문서
- Cloudflare Durable Objects 가격 정책
- Cloudflare Workers Compatibility Flags 문서
- Cloudflare Workers Limits 문서
- SQLite in Durable Objects 발표 블로그 (2024)
- Hono 공식 WebSocket 헬퍼 문서
- Cloudflare Workers WebSocket 예제 공식 문서
- Hono WebSocket Hibernation API 지원 요청 이슈 (GitHub)
- Liveblocks Cloudflare 도입 사례 연구
- Cloudflare Workers 신규 가격 정책 발표 블로그