Cloudflare Durable Objects로 상태 있는 서버리스 만들기

Cloudflare Durable Objects로 상태 있는 서버리스 만들기

Cloudflare Workers로 API를 만들다 보면 어느 순간 이런 벽에 부딪힙니다. “실시간 조회수를 세고 싶은데, 이 상태를 대체 어디에 둬야 하지?” 🤔

Workers는 무상태(stateless)입니다. 요청이 들어올 때마다 새로 뜨고 처리가 끝나면 사라지기 때문에, 요청과 요청 사이에 무언가를 기억하지 못합니다. 그래서 채팅방, 실시간 협업 문서, 온라인 게임 방, 조회수 카운터, 레이트 리미터처럼 “여러 요청이 같은 상태를 공유하고 조율해야 하는” 기능을 만들려고 하면 곧바로 막히게 됩니다.

그럼 Workers KV 같은 저장소에 넣으면 되지 않을까요? 아쉽게도 KV는 최종 일관성(eventual consistency)을 따르기 때문에, 두 요청이 동시에 조회수를 1씩 올리면 서로의 갱신을 덮어써서 값이 하나만 오르는 일이 생깁니다. “읽고 → 1 더하고 → 쓰기”를 원자적으로 조율해줄 주체가 없거든요.

Cloudflare Durable Objects는 바로 이 문제를 풀기 위한 도구입니다. 이번 글에서는 Durable Objects가 어떤 개념인지, 어떻게 코드로 상태를 다루고 실시간 통신과 예약 작업까지 붙이는지 하나씩 살펴보겠습니다.

Durable Objects란?

Durable Object는 고유한 이름을 가진 실행 객체와 전용 저장 공간을 하나로 묶은 서비스입니다. 같은 이름으로 접근하면 전 세계 어디서 요청하든 항상 같은 인스턴스 하나로 연결됩니다. 그리고 그 인스턴스에 들어오는 모든 요청은 단일 스레드에서 순서대로(직렬로) 처리됩니다.

이 두 가지 성질이 핵심입니다. 전역에서 유일한 인스턴스이기 때문에 상태가 한 곳에 모이고, 요청이 직렬로 처리되기 때문에 경쟁 상태(race condition) 없이 안전하게 값을 읽고 쓸 수 있습니다. 앞에서 KV로는 안 됐던 “동시에 조회수 올리기”가 Durable Objects에서는 그냥 됩니다. 같은 카운터로 향하는 요청이 줄을 서서 하나씩 처리되니까요.

Workers, KV와 비교하면 위치가 좀 더 분명해집니다.

WorkersWorkers KVDurable Objects
상태없음 (무상태)전역 키-값 저장인스턴스별 전용 저장
일관성최종 일관성강한 일관성
인스턴스요청마다 새로이름당 전역에 하나
대표 용도API 라우팅, 로직캐시, 설정값채팅방, 카운터, 상태 조율

이 모델은 분산 시스템에서 오래 검증된 액터 모델(Actor model)과 거의 같습니다. 각 Durable Object가 하나의 액터가 되어, 메시지(요청)를 받아 자기만의 단일 스레드 안에서 처리하고, 필요하면 다른 액터에게 메시지를 보내는 구조죠. Erlang이나 Akka를 써보셨다면 익숙한 사고방식일 거예요.

그래서 Durable Objects를 설계할 때 가장 중요한 질문은 “무엇을 하나의 객체로 볼 것인가”입니다. 채팅방 하나, 게임 세션 하나, 문서 하나, 사용자 한 명처럼 조율이 필요한 단위 하나를 객체 하나로 잡는 것이 정석입니다.

어떻게 동작하나?

동작 흐름은 생각보다 단순합니다.

클라이언트 요청
  -> Cloudflare Worker
      -> idFromName("room-1")   // 이름 -> 고유 ID
      -> get(id)                // ID -> 인스턴스 스텁(stub)
          -> Durable Object 인스턴스 (전용 스토리지 포함)

Worker가 요청을 받으면 idFromName()에 이름을 넘겨 고유한 ID를 얻고, get()으로 그 ID에 해당하는 인스턴스의 스텁(stub)을 가져옵니다. 여기서 이름이 곧 신원이라는 점이 중요합니다. "room-1"이라는 같은 이름을 쓰면 서울에서 접근하든 런던에서 접근하든 언제나 동일한 인스턴스로 연결됩니다.

스텁은 실제 인스턴스를 가리키는 원격 핸들입니다. 스텁의 메서드를 호출하면 그 호출이 인스턴스로 전달되어 실행되고 결과가 돌아옵니다. 인스턴스가 지구 반대편에 있어도, 우리 코드에서는 그냥 객체의 메서드를 부르는 것처럼 쓸 수 있어요.

기본 코드

가장 간단한 조회수 카운터로 시작해보겠습니다. Durable Object는 cloudflare:workers가 제공하는 DurableObject 클래스를 상속해서 정의합니다.

src/index.ts
import { DurableObject } from "cloudflare:workers";

interface Env {
  COUNTER: DurableObjectNamespace<Counter>;
}

export class Counter extends DurableObject<Env> {
  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // 인스턴스가 처음 만들어질 때 테이블을 준비
    this.ctx.storage.sql.exec(
      `CREATE TABLE IF NOT EXISTS counter (name TEXT PRIMARY KEY, value INTEGER)`,
    );
  }

  // 공개 메서드는 그대로 RPC 엔드포인트가 됨
  async increment(name: string): Promise<number> {
    const row = this.ctx.storage.sql
      .exec<{ value: number }>(
        `INSERT INTO counter (name, value) VALUES (?, 1)
         ON CONFLICT(name) DO UPDATE SET value = value + 1
         RETURNING value`,
        name,
      )
      .one();
    return row.value;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const key = url.pathname.slice(1) || "home";

    // 이름이 같으면 전 세계에서 언제나 같은 인스턴스
    const id = env.COUNTER.idFromName(key);
    const stub = env.COUNTER.get(id);

    // 스텁의 메서드를 원격 호출 (RPC)
    const value = await stub.increment(key);
    return Response.json({ key, value });
  },
};

여기서 눈여겨볼 부분은 Worker가 stub.increment()를 마치 로컬 메서드처럼 호출한다는 점입니다. 이것이 원격 프로시저 호출(RPC) 방식인데요. 2024-04-03 이후의 호환성 날짜(compatibility date)를 쓰는 프로젝트라면, 예전처럼 fetch() 핸들러에 요청을 실어 보내는 대신 이렇게 메서드를 직접 부르는 편이 훨씬 깔끔하고 타입 안정성도 좋습니다.

그리고 increment() 안에서 “읽고 더하고 쓰는” 과정이 한 SQL 문으로 처리되지만, 설령 여러 단계로 나눠 쓰더라도 안전합니다. 같은 카운터로 오는 호출이 이 인스턴스 안에서 직렬로 실행되므로, 두 요청이 값을 동시에 건드려 덮어쓰는 일이 애초에 일어나지 않기 때문이에요.

설정 파일

Durable Object를 Worker에 연결하려면 Wrangler 설정에 바인딩과 마이그레이션을 추가해야 합니다.

wrangler.jsonc
{
  "name": "my-counter",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-01",
  "durable_objects": {
    "bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
}

durable_objects.bindingsname은 Worker 코드에서 env.COUNTER로 접근할 때 쓰는 이름이고, class_name은 우리가 정의한 클래스 이름입니다.

migrationsnew_sqlite_classes가 조금 낯설 수 있는데요. 이 항목이 해당 클래스를 SQLite 스토리지 백엔드로 만들어줍니다. 신규 Durable Object는 SQLite 백엔드가 권장되며, 이 백엔드에서만 ctx.storage.sql의 SQL API를 쓸 수 있습니다. 클래스를 추가하거나 이름을 바꾸는 등 구조가 달라질 때마다 새로운 tag로 마이그레이션을 하나씩 쌓아 올리면 됩니다.

로컬에서 돌려보는 것도 간단합니다.

bunx wrangler dev

상태를 저장하는 두 가지 층

Durable Objects의 상태는 성격이 다른 두 층으로 나뉩니다. 이 차이를 이해해야 데이터를 잃지 않는 코드를 짤 수 있어요.

종류속도지속성용도
인메모리 (클래스 속성)가장 빠름인스턴스 정지/충돌 시 사라짐캐시, 활성 연결 관리
SQLite 스토리지빠름재시작에도 유지됨실제 데이터 보관

인메모리 상태는 클래스의 필드에 그냥 값을 담아두는 방식이라 제일 빠릅니다. 다만 인스턴스가 유휴 상태로 메모리에서 내려가거나, 코드 배포로 재시작되거나, 처리되지 않은 예외로 죽으면 몽땅 사라집니다. 그래서 잃으면 안 되는 데이터는 반드시 SQLite 스토리지에 먼저 써야 합니다.

// 나쁜 예: 카운터를 클래스 필드에만 저장 -> 재시작하면 0으로 초기화
export class Counter extends DurableObject<Env> {
  count = 0; // 인메모리라서 언젠가 날아감

  increment() {
    return ++this.count;
  }
}

SQLite 스토리지는 각 Durable Object마다 전용 데이터베이스를 하나씩 갖는 구조입니다. ctx.storage.sql.exec()로 평범한 SQL을 실행할 수 있고, 인덱스와 트랜잭션도 그대로 쓸 수 있어요. 같은 SQLite를 서버리스로 쓴다는 점에서 Cloudflare D1과 닮았지만, D1이 여러 Worker가 공유하는 하나의 데이터베이스라면, Durable Objects의 SQLite는 인스턴스마다 격리된 나만의 데이터베이스라는 점이 다릅니다. 그래서 “채팅방 하나당 메시지 테이블 하나”처럼 자연스럽게 데이터를 나눌 수 있습니다.

인메모리 캐시는 어디까지나 SQLite에 있는 데이터를 빠르게 다시 꺼내기 위한 보조 수단으로만 쓰는 것이 안전합니다.

실시간 통신: WebSocket

Durable Objects가 특히 빛나는 영역이 실시간 통신입니다. 하나의 인스턴스가 수많은 클라이언트의 WebSocket 연결을 동시에 붙들고 서로를 중개할 수 있거든요. 채팅방을 예로 들면, 방 하나를 Durable Object 하나로 잡고 그 방의 모든 참가자 연결을 한 인스턴스가 관리하는 식입니다.

이때 Hibernation(휴면) WebSocket API를 쓰는 것이 권장됩니다. 연결은 유지한 채로 인스턴스가 잠들 수 있어서, 아무도 말하지 않는 동안에는 과금이 멈추기 때문이에요.

src/index.ts
import { DurableObject } from "cloudflare:workers";

export class ChatRoom extends DurableObject {
  async fetch(request: Request): Promise<Response> {
    const [client, server] = Object.values(new WebSocketPair());

    // Hibernation API로 연결을 수락 -> 유휴 시 잠들어도 연결은 유지
    this.ctx.acceptWebSocket(server);

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

  // 메시지가 오면 호출됨 (휴면에서 깨어난 뒤에도 동작)
  webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    // 보낸 사람을 뺀 모든 접속자에게 브로드캐스트
    for (const client of this.ctx.getWebSockets()) {
      if (client !== ws) {
        client.send(message);
      }
    }
  }

  // 연결이 끊기면 호출됨
  webSocketClose(ws: WebSocket, code: number, reason: string) {
    ws.close(code, reason);
  }
}

일반적인 WebSocket 서버였다면 addEventListener로 이벤트를 직접 구독해야 했을 텐데요. Hibernation API에서는 webSocketMessage, webSocketClose 같은 메서드를 클래스에 구현해두면 런타임이 알아서 콜백으로 불러줍니다. 인스턴스가 휴면에 들어갔다 깨어나도 이 메서드들이 계속 호출되기 때문에, 접속자가 아무 말 없이 몇 시간을 있어도 서버 비용 걱정 없이 방을 열어둘 수 있습니다.

접속 중인 모든 연결은 ctx.getWebSockets()로 한 번에 가져올 수 있어서, 위 예제처럼 메시지를 다른 참가자들에게 뿌리는 브로드캐스트를 몇 줄로 구현할 수 있어요.

알람으로 예약 작업 돌리기

Durable Objects는 요청이 없어도 스스로 깨어나 일할 수 있습니다. 알람(Alarms) API로 미래의 특정 시각을 예약해두면, 그 시각에 런타임이 alarm() 메서드를 호출해주거든요.

export class Reminder extends DurableObject {
  // 지금부터 ms 밀리초 뒤에 alarm()이 실행되도록 예약
  async scheduleIn(ms: number) {
    await this.ctx.storage.setAlarm(Date.now() + ms);
  }

  // 예약한 시각이 되면 런타임이 호출
  async alarm() {
    // 여기서 배치 처리, 정리 작업, 알림 발송 등을 수행
    console.log("예약된 작업 실행!");

    // 알람은 자동으로 반복되지 않으므로, 주기 작업이라면 다시 예약
    // await this.ctx.storage.setAlarm(Date.now() + 60_000);
  }
}

한 가지 기억할 점은 알람이 한 번 울리고 나면 자동으로 다시 예약되지 않는다는 것입니다. 주기적으로 돌려야 한다면 alarm() 안에서 setAlarm()을 한 번 더 호출해 다음 실행을 걸어줘야 합니다.

이 기능을 인메모리 상태나 SQLite 스토리지와 조합하면, 일정 시간 동안 메시지를 모았다가 한꺼번에 처리하는 배치, 큐, 간단한 워크플로 같은 것도 별도의 스케줄러 인프라 없이 만들 수 있습니다.

언제 쓰면 좋을까?

Durable Objects는 모든 상황을 위한 만능 저장소가 아닙니다. 읽기가 대부분인 캐시나 설정값이라면 Workers KV가, 여러 Worker가 공유하는 관계형 데이터라면 Cloudflare D1이 더 단순하고 잘 맞습니다.

Durable Objects가 진짜 필요한 순간은 “여러 요청이나 연결이 한 지점에 모여 조율되어야 할 때”입니다.

우선 실시간 협업이 대표적입니다. 채팅방, 공동 편집 문서, 화이트보드처럼 여러 사용자가 같은 상태를 실시간으로 주고받아야 하는 서비스에 딱 맞습니다.

동시성 조율이 필요한 기능에도 잘 어울립니다. 정확한 조회수 카운터, 재고 차감, 사용자별 레이트 리미터처럼 “동시에 들어오는 요청을 순서대로 처리해야 하는” 로직을 락(lock) 없이 안전하게 구현할 수 있어요.

사실 우리가 앞서 다룬 다른 제품들도 내부적으로 Durable Objects 위에 서 있습니다. Cloudflare Containers는 컨테이너 인스턴스마다 Durable Object를 하나씩 붙여 사이드카처럼 생명주기를 관리하고, Cloudflare Sandbox도 샌드박스 하나하나를 Durable Object로 표현합니다. “상태를 기억하고 스스로 다시 깨어나는 AI 애플리케이션”을 만들고 싶다면 이 구조를 추상화한 Cloudflare Agents SDK를 사용할 수 있습니다. “인스턴스별로 격리된 상태와 단일 조율 지점”이라는 성질이 이런 제품들의 바탕이 되는 셈이죠.

마치며

Durable Objects는 무상태였던 서버리스 세계에 “전역에서 유일한 인스턴스”라는 조율 지점을 더해줍니다. 같은 이름은 언제나 같은 인스턴스로 모이고, 그 안에서 요청이 직렬로 처리되며, SQLite 스토리지에 상태가 안전하게 남습니다.

여기에 WebSocket과 알람까지 얹으면 실시간 채팅, 협업 편집, 게임 방, 예약 배치 작업을 별도의 상태 서버나 스케줄러 없이 Cloudflare 위에서 온전히 만들 수 있습니다. Workers로 API를 만들다가 “이 상태를 어디에 두지?”라는 벽을 만났다면, 그때가 바로 Durable Objects를 꺼낼 순간입니다.

더 자세한 내용은 Cloudflare Durable Objects 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord