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

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

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

Durable Objects(DO)로 실시간 채팅방이나 멀티플레이어 게임 룸을 만들어본 적이 있다면, 청구서에서 이상한 항목을 본 적이 있을 겁니다. 메시지가 거의 오가지 않는 새벽 시간대에도 Duration(GB-s) 비용이 낮 시간대와 거의 똑같이 쌓이는 현상입니다. 범인은 십중팔구 server.accept() + addEventListener 조합으로 짠 표준 WebSocket 코드입니다.

핵심 요약

문제의 시작: 상주형 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가 쌓이고, 나머지 대부분의 시간은 공짜입니다.

단, 아무 때나 하이버네이션되는 건 아닙니다. 문서는 하이버네이션을 막는 요인을 명시하고 있습니다.

반대로 프로토콜 레벨의 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);
  }
}

바뀐 지점을 정리하면:

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 인스턴스(자바스크립트 객체와 그 안의 변수들)를 지우는 것이지, 연결이나 영속 데이터를 지우는 게 아닙니다. 정리하면:

그래서 마이그레이션의 핵심은 “생성자에서 다시 만들어져도 문제없게” 상태 관리를 재설계하는 것입니다. 실전에서는 보통 이렇게 나눕니다.

  1. 연결별로만 필요한 짧은 메타데이터 (닉네임, 색상, 마지막 하트비트 시각 등) → serializeAttachment
  2. 방 전체가 공유하는 상태지만 재계산 가능한 것 (현재 접속자 수, 온라인 유저 목록) → 생성자에서 this.ctx.getWebSockets()를 돌며 각 소켓의 attachment를 읽어 즉석에서 재구성
  3. 영구히 남아야 하는 데이터 (채팅 히스토리, 게임 스코어, 방 설정) → 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.tomllimits.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)을 사용했습니다.

결과

구분 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, 같은 계산 공식으로 메시지 빈도만 바꿔보면 절감폭이 크게 달라집니다.

패턴은 명확합니다. Duration 절감액 자체는 트래픽과 거의 무관하게 항상 크지만(위 세 시나리오 모두 Duration만 보면 99% 이상 줄어듭니다), 메시지 빈도가 높아질수록 요청(request) 과금이 총비용에서 차지하는 비중이 커지면서 전체 절감률은 낮아집니다. 초당 수십 회씩 상태를 브로드캐스트하는 고빈도 실시간 게임이라면, Hibernation API 도입과 별개로 틱 레이트를 낮추거나 델타 압축·관심 영역(interest management)으로 메시지 자체를 줄이는 최적화가 비용 절감에 더 크게 기여합니다.

마이그레이션 체크리스트와 흔한 함정

정리

일반 addEventListener 기반 WebSocket 코드가 나쁜 코드는 아닙니다. 다만 Durable Objects의 과금 모델과 궁합이 나쁠 뿐입니다. DO는 켜져 있는 시간만큼 돈을 받아가고, 표준 WebSocket API는 연결이 살아있는 한 DO를 계속 켜 둡니다. Hibernation API는 이 둘을 분리해서, 연결은 엣지에 남기고 컴퓨트만 잠재우는 방식으로 구조적 낭비를 없앱니다.

마이그레이션 자체는 API 표면적으로 보면 크지 않습니다. accept()acceptWebSocket(), 이벤트 리스너 → 클래스 메서드, 인메모리 상태 → attachment/storage. 하지만 “생성자가 언제든 다시 호출될 수 있다”는 전제를 코드베이스 전반에 스며들게 하는 것, 그리고 커스텀 하트비트 같은 하이버네이션을 막는 숨은 타이머를 걷어내는 것이 실제 작업의 대부분을 차지합니다. 위 체크리스트를 기준으로 하나씩 점검하면, 트래픽 패턴에 따라 다르지만 채팅·턴제 게임처럼 유휴 시간이 긴 워크로드에서는 손쉽게 90% 안팎의 비용 절감을 기대할 수 있습니다.