예약 만료 예약

CloudflareBeginner
지금 연습하기

소개

임시 예약은 아무도 애플리케이션을 다시 방문하지 않아도 자동으로 해제되어야 합니다. 메모리에 저장하는 JavaScript 타이머는 Worker가 타이머 실행 전에 유휴 상태가 되거나 재시작할 수 있으므로 안전하지 않습니다. 대신 Durable Object 알람은 객체의 영속 상태와 함께 앞으로 실행할 한 번의 기상 시각을 저장합니다. 해당 시각이 되면 Cloudflare가 객체를 깨우고 alarm() 메서드를 호출합니다.

알람은 최소 한 번 실행(at-least-once) 방식으로 동작합니다. Cloudflare는 실패한 핸들러를 다시 실행하므로 동일한 의도된 작업이 여러 번 시도될 수 있습니다. 따라서 만료 작업은 멱등적(idempotent) 이어야 합니다. 즉, 한 번 실행했을 때와 여러 번 실행했을 때 최종 상태가 같아야 합니다. 이 실습에서는 조건부 SQLite 업데이트를 사용해 held 상태의 예약만 expired로 변경합니다. 카운터도 같은 업데이트에서 증가하므로 재실행해도 다시 증가하지 않습니다.

이 실습에서는 예약마다 이름이 지정된 Durable Object 하나를 사용합니다. 따라서 각 예약이 해당 객체에서 사용할 수 있는 하나의 알람 슬롯을 소유합니다. 짧은 예약과 긴 예약을 설정하고, 한 알람이 실행되기 전에 로컬 런타임을 재시작하며, 만료 경로를 의도적으로 두 번 재실행합니다. 그런 다음 Cloudflare에서 실제 알람을 다시 테스트하고, Dashboard를 확인하며, 재배포한 후 정리합니다.

이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새 VM마다 자체 Wrangler 인증이 필요합니다. O01–O03에서 안정적인 객체 이름, RPC, SQLite 기반 상태 및 제한된 동시성의 개념을 이미 이해하고 있어야 합니다.

같은 객체에 setAlarm()을 다시 호출하면 해당 객체의 기존 알람이 새 알람으로 대체됩니다. 이름이 다른 객체는 각자의 알람을 유지합니다. 설정 과정에서는 /home/labex/project/reservation-expiry에 Node.js 22.22.0과 프로젝트 로컬 Wrangler 4.132.0을 설치합니다. Cloudflare 인증, 알람 생성 또는 Worker 배포는 수행하지 않습니다.

VM 인증 및 알람 네임스페이스 선언

이 단계에서는 새 VM을 인증하고 예약을 예약하기 위한 SQLite 기반 Durable Object 클래스를 하나 선언합니다.

프로젝트 디렉터리로 이동하고 고정된 Wrangler 버전을 확인한 다음, 새 VM을 인증합니다.

cd /home/labex/project/reservation-expiry
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler 버전이 4.132.0인지 확인합니다. 브라우저에서 표시된 Cloudflare URL을 열고, 짧은 코드를 입력한 다음, 올바른 학습 계정을 확인하고 인증합니다. 터미널이나 실습 환경에 비밀번호 또는 토큰을 직접 입력하지 마세요.

안전한 ID 정보만 읽고, 확인한 계정을 선택한 다음, 일회용 Worker에 사용할 고유한 이름을 만듭니다.

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o04-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

전용 학습 계정의 표시 이름이 다르면, 방금 확인한 이름으로 바꿉니다. 이제 설정 파일을 만듭니다.

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true, "head_sampling_rate": 1 },
  "durable_objects": { "bindings": [
    { "name": "RESERVATIONS", "class_name": "ReservationExpiry" }
  ] },
  "exports": {
    "ReservationExpiry": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

RESERVATIONS를 사용하면 외부 Worker가 예약 ID로 객체를 선택할 수 있습니다. 클래스 export를 통해 선택된 각 객체에 전용 SQLite 저장소와 하나의 알람 슬롯이 생깁니다. 배포하기 전까지는 클라우드에 아무것도 생성되지 않습니다.

영속적이고 멱등적인 만료 구현

이 단계에서는 영속적인 예약 상태, 알람 예약 및 재실행에 안전한 만료 전환을 구현합니다.

애플리케이션을 만듭니다. 중요한 부분은 HTTP 처리 코드가 아니라 조건부 UPDATE입니다.

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";

const NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/;
const json = (body, status = 200) => Response.json(body, { status });

async function readBody(request) {
  try { return await request.json(); } catch { return null; }
}
function parsePath(pathname) {
  const match = pathname.match(/^\/reservations\/([^/]+)(?:\/(replay-alarm))?$/);
  if (!match || !NAME_PATTERN.test(match[1])) return null;
  return { reservationId: match[1], action: match[2] ?? null };
}

export class ReservationExpiry extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS reservation (
          singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
          status TEXT NOT NULL CHECK (status IN ('held', 'expired')),
          created_at INTEGER NOT NULL,
          expires_at INTEGER NOT NULL,
          expired_at INTEGER,
          expiration_count INTEGER NOT NULL DEFAULT 0
        )
      `);
    });
  }

  row() {
    return this.ctx.storage.sql.exec(`
      SELECT status, created_at, expires_at, expired_at, expiration_count
      FROM reservation WHERE singleton = 1
    `).one();
  }

  async createReservation(ttlSeconds) {
    const createdAt = Date.now();
    const expiresAt = createdAt + ttlSeconds * 1000;
    this.ctx.storage.sql.exec(`
      INSERT INTO reservation
        (singleton, status, created_at, expires_at, expired_at, expiration_count)
      VALUES (1, 'held', ?, ?, NULL, 0)
      ON CONFLICT(singleton) DO UPDATE SET
        status = 'held', created_at = excluded.created_at,
        expires_at = excluded.expires_at, expired_at = NULL,
        expiration_count = 0
    `, createdAt, expiresAt);
    await this.ctx.storage.setAlarm(expiresAt);
    return this.getStatus();
  }

  async getStatus() {
    const record = this.row();
    if (!record) return { status: "missing", alarmAt: await this.ctx.storage.getAlarm() };
    return {
      status: record.status,
      createdAt: record.created_at,
      expiresAt: record.expires_at,
      expiredAt: record.expired_at,
      expirationCount: record.expiration_count,
      alarmAt: await this.ctx.storage.getAlarm()
    };
  }

  async processExpiry(now = Date.now()) {
    const result = this.ctx.storage.sql.exec(`
      UPDATE reservation
      SET status = 'expired', expired_at = ?,
          expiration_count = expiration_count + 1
      WHERE status = 'held' AND expires_at <= ?
    `, now, now);
    const changed = result.rowsWritten === 1;
    if (changed) await this.ctx.storage.deleteAlarm();
    return { ...(await this.getStatus()), changed };
  }

  async alarm(alarmInfo) {
    console.log(JSON.stringify({
      event: "reservation-alarm",
      isRetry: alarmInfo?.isRetry ?? false,
      retryCount: alarmInfo?.retryCount ?? 0
    }));
    await this.processExpiry(Date.now());
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/") return json({ service: "reservation-expiry" });
    const parsed = parsePath(url.pathname);
    if (!parsed) return json({ error: "Use a lowercase reservation ID containing 3-40 letters, digits, or hyphens." }, 400);

    let body = null;
    if (request.method === "POST") {
      body = await readBody(request);
      if (!body) return json({ error: "Send a JSON request body." }, 400);
    }
    if (!parsed.action && request.method === "POST") {
      if (!Number.isInteger(body.ttlSeconds) || body.ttlSeconds < 5 || body.ttlSeconds > 3600) {
        return json({ error: "ttlSeconds must be an integer from 5 through 3600." }, 400);
      }
    } else if (parsed.action === "replay-alarm" && request.method === "POST") {
      if (!Number.isSafeInteger(body.now) || body.now < 1) return json({ error: "now must be a positive integer timestamp." }, 400);
    } else if (parsed.action || request.method !== "GET") {
      return json({ error: "Method not allowed." }, 405);
    }

    const stub = env.RESERVATIONS.getByName(parsed.reservationId);
    if (request.method === "GET") return json({ reservationId: parsed.reservationId, ...(await stub.getStatus()) });
    if (parsed.action === "replay-alarm") return json({ reservationId: parsed.reservationId, ...(await stub.processExpiry(body.now)) });
    return json({ reservationId: parsed.reservationId, ...(await stub.createReservation(body.ttlSeconds)) }, 201);
  }
};
JS

setAlarm(expiresAt)은 절대 Unix 타임스탬프를 저장합니다. getAlarm()으로 예약된 시각을 확인할 수 있습니다. 만료 시각이 되면 SQL 문 하나가 heldexpired로 바꾸고 카운터를 증가시킵니다. 재시도에서는 상태가 이미 expired이므로 WHERE status = 'held' 조건이 어떤 행과도 일치하지 않습니다.

/replay-alarm 경로는 의도적으로 만든 테스트 지점입니다. alarm()이 사용하는 것과 동일한 메서드를 명시적인 시각과 함께 호출합니다. Cloudflare 장애를 인위적으로 만들지 않고도 즉시 재실행 안전성을 확인할 수 있습니다.

결정적인 라우팅 테스트와 Wrangler 빌드를 실행합니다.

NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run --outdir /tmp/o04-dry-run

로컬 재시작 후에도 알람이 유지되는지 확인

이 단계에서는 로컬에서 두 개의 예약을 설정하고 Wrangler를 재시작한 다음, 만료 시각이 지난 예약만 만료되는지 확인합니다.

Wrangler의 로컬 Durable Object 런타임을 시작하고 실행될 때까지 기다립니다.

rm -rf .wrangler/state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | jq

30초 동안 유지되는 예약과, 서로 관련 없는 1시간짜리 예약을 만듭니다. 짧은 예약의 시간이 충분히 길기 때문에 알람 시각이 되기 전에 Wrangler를 중지할 수 있습니다.

curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-expiring \
  --header 'content-type: application/json' --data '{"ttlSeconds":30}' | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/local-safe \
  --header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq

두 응답 모두 held, expirationCount: 0, 숫자 형식의 alarmAt을 표시해야 합니다. 첫 번째 알람 시각이 되기 전에 런타임을 중지한 다음, 같은 로컬 Durable Object 저장소를 다시 엽니다.

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --local --ip 127.0.0.1 --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
for attempt in $(seq 1 50); do
  STATE="$(curl --silent --fail http://127.0.0.1:8787/reservations/local-expiring)"
  test "$(printf '%s' "$STATE" | jq -r .status)" = expired && break
  sleep 1
done
printf '%s\n' "$STATE" | jq
curl --silent --fail http://127.0.0.1:8787/reservations/local-safe | jq

짧은 예약은 정확히 한 번만 만료되고 알람은 null이 되어야 합니다. 서로 관련 없는 객체는 자체 알람을 유지한 채 held 상태로 남아 있어야 합니다. 이것이 영속 알람과 setTimeout()의 차이입니다.

만료를 두 번 재실행해도 중복 적용하지 않기

이 단계에서는 동일한 논리적 만료 시각을 사용해 만료 경로를 두 번 호출하고 두 결과를 비교합니다.

결정적인 확인을 수행하는 동안 실제 알람이 실행되지 않도록 긴 예약을 만듭니다. 저장된 만료 시각을 가져옵니다.

REPLAY="$(curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof \
  --header 'content-type: application/json' --data '{"ttlSeconds":180}')"
printf '%s\n' "$REPLAY" | jq
REPLAY_NOW="$(printf '%s\n' "$REPLAY" | jq '.expiresAt + 1')"

만료 시각 직후의 시각을 사용해 같은 만료 메서드를 두 번 호출합니다.

curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
  --header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
  | tee .labex/replay-first.json | jq
curl --silent --fail --request POST http://127.0.0.1:8787/reservations/replay-proof/replay-alarm \
  --header 'content-type: application/json' --data "{\"now\":$REPLAY_NOW}" \
  | tee .labex/replay-second.json | jq

첫 번째 응답에는 changed: true가 표시되고, 두 번째 응답에는 changed: false가 표시됩니다. 두 응답 모두 최종 상태는 expired이며 expirationCount: 1이어야 합니다. 이것이 실용적인 멱등성입니다. 플랫폼이 이전 시도가 완료되었는지 알 수 없더라도 재시도를 안전하게 처리할 수 있습니다.

Cloudflare에서 실제 알람 실행

이 단계에서는 일회용 네임스페이스를 배포하고 실제 Cloudflare 알람이 독립적으로 동작하는지 확인합니다.

로컬 런타임을 중지하고 일회용 Worker를 배포합니다.

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
APP_URL="$(printf '%s\n' "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-z0-9.-]+\.workers\.dev' | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url

Worker 경로와 네임스페이스는 서로 다른 시점에 활성화될 수 있습니다. 먼저 안전한 읽기 요청을 반복한 다음, 짧은 예약과 긴 예약을 각각 하나씩 만듭니다.

for attempt in $(seq 1 30); do
  curl --silent --fail "$APP_URL/" >/dev/null && break
  sleep 1
done
curl --silent --fail --request POST "$APP_URL/reservations/cloud-expiring" \
  --header 'content-type: application/json' --data '{"ttlSeconds":12}' | jq
curl --silent --fail --request POST "$APP_URL/reservations/cloud-safe" \
  --header 'content-type: application/json' --data '{"ttlSeconds":3600}' | jq
for attempt in $(seq 1 50); do
  CLOUD_STATE="$(curl --silent --fail "$APP_URL/reservations/cloud-expiring")"
  test "$(printf '%s' "$CLOUD_STATE" | jq -r .status)" = expired && break
  sleep 1
done
printf '%s\n' "$CLOUD_STATE" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq

Cloudflare의 짧은 예약은 정확히 한 번만 만료되어야 하며, 서로 관련 없는 긴 예약은 유효한 상태로 남아 있어야 합니다. 아래 확인 과정에서는 실행마다 고유한 새 객체도 만들고 실제 알람 및 재실행 테스트를 각각 독립적으로 반복합니다.

네임스페이스, 바인딩 및 알람 로그 확인

이 단계에서는 런타임에서 얻은 결과를 Dashboard 화면과 연결하고 재배포 후에도 상태가 유지되는지 확인합니다.

Cloudflare Dashboard에서 Workers & Pages를 열고, $RUN에 저장된 정확한 이름의 Worker를 선택한 다음 Settings > Bindings를 엽니다. RESERVATIONS 행이 ReservationExpiry를 가리키는지 확인합니다. 바인딩은 외부 Worker가 네임스페이스로 진입하는 경로이며, 개별 예약 하나를 가리키는 것이 아닙니다.

RESERVATIONS Durable Object 바인딩이 ReservationExpiry를 가리킵니다

Durable Objects를 열고 같은 Worker와 연결된 네임스페이스를 선택한 다음 SQLite 저장소를 사용하는지 확인합니다. 이 네임스페이스는 이 실습에서 생성한 이름이 지정된 모든 예약 객체의 모음입니다.

소유한 ReservationExpiry 네임스페이스가 SQLite 저장소를 사용합니다

네임스페이스의 Logs 탭을 열고 세부 정보에 eventType: "alarm"이 표시되는 행을 선택합니다. 테스트한 이벤트에는 entrypoint: "ReservationExpiry"outcome: "ok"도 표시됩니다. 이를 통해 예약된 기상이 작성한 클래스와 연결되었음을 확인할 수 있습니다. 핸들러 자체의 retryCountisRetry 로그 필드는 장애를 진단할 때 도움이 될 수 있지만, 정확성은 첫 번째 시도가 성공한다고 가정하는 것이 아니라 영속적인 조건부 상태에서 보장됩니다.

성공한 알람 이벤트에 ReservationExpiry 진입점이 표시됩니다

Dashboard 데이터는 요청 후 늦게 도착할 수 있습니다. 런타임 및 API 확인 결과가 계속해서 기준이 됩니다. 위 이미지는 테스트한 일회용 실행에서 가져온 방향 안내용 자료이므로, 여러분의 접미사, 타임스탬프 및 트래픽 합계는 다릅니다.

코드를 변경하지 않고 재배포한 다음 두 상태가 유지되는지 확인합니다.

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/reservations/cloud-expiring" | jq
curl --silent --fail "$APP_URL/reservations/cloud-safe" | jq

알람 네임스페이스 삭제

이 단계에서는 VM이 아직 인증된 상태에서 정확한 일회용 네임스페이스와 Worker를 삭제합니다. 백엔드 확인이 실행될 때까지 인증을 유지하면 LabEx가 삭제가 확인된 경우와 네트워크 또는 인증 오류를 구분할 수 있습니다.

$RUNlabex-c10-o04-로 시작하는지 확인합니다. 상태가 없는 정리용 진입점을 만듭니다.

cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

정확히 같은 Worker와 계정을 대상으로 정리 설정을 만듭니다. state: "deleted" 삭제 표시는 이 실습의 클래스 네임스페이스만 제거하며, 해당 네임스페이스의 일회용 객체와 남아 있는 긴 알람도 함께 제거합니다.

ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "ReservationExpiry": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

조정 결과에 Deleted: ReservationExpiry가 표시되어야 합니다. 이제 상태가 없는 Worker를 삭제합니다. 삭제한 후에는 되돌릴 수 없으므로 Wrangler가 확인을 요청합니다. 표시된 이름이 $RUN 값과 정확히 일치하는지 확인한 후에만 승인합니다.

npx wrangler delete --config wrangler.cleanup.jsonc

확인 메시지가 표시되면 y를 입력하고 Enter 키를 누릅니다. 명령이 생성된 Worker 이름 뒤에 Successfully deleted를 표시하며 완료되어야 합니다.

이 단계의 마지막 확인이 끝날 때까지 이 VM의 인증을 유지합니다. Wrangler가 여전히 인증된 세션을 보고하는지 확인합니다.

npx wrangler whoami --json | jq '{loggedIn, authType}'

JSON에 "loggedIn": true가 포함되어야 합니다. 이제 LabEx가 선택한 계정을 조회하여 Worker와 해당 Durable Object 네임스페이스가 모두 없는지 확인할 수 있습니다. 네트워크 오류나 인증 오류만으로는 정리가 완료되었다고 판단할 수 없습니다.

이 VM의 Wrangler 인증 취소

이 단계에서는 클라우드 리소스 삭제가 확인된 후, 이 새 VM에만 저장된 OAuth 인증을 취소합니다.

wrangler logout은 로컬 인증 정보를 제거합니다. 일반적인 사람이 읽는 출력은 모호할 수 있으므로 구조화된 whoami --json 확인이 중요합니다. loggedIn 필드가 최종 결과를 나타냅니다.

npx wrangler logout
npx wrangler whoami --json

최종 JSON에 "loggedIn": false가 포함되어야 합니다. 이 작업은 브라우저에서 Cloudflare 학습 계정을 삭제하거나 로그아웃하지 않습니다. 이 VM이 이후 인증된 Wrangler 요청을 수행하지 못하도록 할 뿐입니다.

요약

이름이 지정된 Durable Object마다 일회용 예약 하나를 만들고, 각 객체에 영속적인 알람 하나를 설정했습니다. 로컬 런타임을 재시작해도 예약된 만료가 유지되는 것을 확인했고, 별도의 예약이 유효한 상태로 남는지 검증했으며, 조건부 SQLite 전환으로 만료 작업을 반복해도 안전하게 만들었습니다. Cloudflare에서 실제 알람 동작을 반복하고, 바인딩과 네임스페이스 및 로그를 확인했으며, 재배포 후 상태가 유지되는지 검증한 다음 정확한 일회용 네임스페이스를 삭제하고 로그아웃했습니다.

재사용할 수 있는 설계 원칙은 다음과 같습니다. 미래 작업은 영속적으로 예약하고, 해당 작업이 다시 실행될 수 있다고 가정하며, 작업 자체가 이미 처리되었는지 판단할 수 있도록 충분한 상태를 저장합니다.