동시 예약 조정

CloudflareBeginner
지금 연습하기

소개

워크숍에 좌석이 네 개 남아 있지만, 열 명이 거의 동시에 Reserve를 클릭할 수 있습니다. 모든 요청이 먼저 reserved = 0을 읽고 잠시 기다린 다음 reserved = 1을 기록하면, 애플리케이션은 성공한 예약을 잃게 됩니다. 또 다른 잘못된 설계에서는 워크숍의 실제 좌석 수보다 많은 예약을 승인할 수 있습니다. 완료되지 않은 비동기 작업이 서로 겹치는 현상을 **인터리빙(interleaving)**이라고 합니다.

이 실습에서는 검증된 워크숍 이름마다 하나의 Durable Object가 선택됩니다. 해당 객체는 용량 행과 모든 시도에 대한 영속 레코드를 관리합니다. 예약 메서드는 용량 확인, 카운터 업데이트, 시도 기록을 하나의 동기식 SQLite 트랜잭션 안에서 수행합니다. 여러 호출자가 동시에 도착할 수 있지만, 어느 호출자도 진행 중인 상태 변화를 중간 상태로 관찰할 수 없습니다.

다음 세 가지 관련 경계를 배웁니다.

  • **동시성(Concurrency)**은 같은 기간에 여러 작업이 진행 중인 상태를 의미하며, JavaScript 스레드가 여러 개여야 하는 것은 아닙니다.
  • **원자성(Atomicity)**은 다른 작업이 전체 상태 변경을 보거나, 변경이 전혀 일어나지 않은 상태를 보도록 보장합니다.
  • 안전한 초기화는 없는 행을 생성하지만, 이미 예약이 포함된 행을 덮어쓰지 않습니다.

로컬 및 클라우드 픽스처를 동시에 실행하고, 승인 및 거부된 총계를 영속 상태와 비교합니다. 그런 다음 로컬 런타임을 재시작하고 클라우드 Worker를 다시 배포하며 Dashboard를 확인한 뒤, 임시 리소스를 모두 삭제합니다.

이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료해야 합니다. 새 VM마다 자체 Wrangler 인증이 필요합니다. 또한 O01–O02에서 안정적인 Durable Object 이름, RPC, SQLite 기반 상태를 이미 이해하고 있어야 합니다.

Cloudflare는 현재 Workers Free에서 SQLite 기반 Durable Object를 지원합니다. 이 실습에서는 삭제 가능한 클래스 네임스페이스 하나, 작은 이름 지정 객체 여러 개, 제한된 요청 배치를 생성합니다. 설정 과정에서 /home/labex/project/concurrent-reservations에 Node.js 22.22.0과 프로젝트 로컬 Wrangler 4.132.0을 설치하지만, Cloudflare 인증, 네임스페이스 생성, Worker 배포 또는 예약 생성은 수행하지 않습니다.

VM 인증 및 워크숍 네임스페이스 구성

이 단계에서는 새 VM을 인증하고 SQLite 기반 Durable Object 클래스를 하나 선언합니다. 각 워크숍 이름은 이 네임스페이스에서 서로 다른 객체를 선택합니다.

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

cd /home/labex/project/concurrent-reservations
npx wrangler --version
npx wrangler login --device --browser=false

Wrangler 버전이 4.132.0으로 표시되어야 합니다. 브라우저에서 표시된 Cloudflare URL을 열고 짧은 코드를 입력한 뒤, 사용할 학습 계정을 확인하고 인증합니다. Wrangler가 성공을 보고할 때까지 기다린 후 돌아옵니다. 실습 환경에 비밀번호나 토큰을 붙여 넣지 마세요.

안전하게 확인할 수 있는 ID 필드를 읽고, 확인한 계정 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-o03-$(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": "WORKSHOPS", "class_name": "WorkshopReservations" }
    ]
  },
  "exports": {
    "WorkshopReservations": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

WORKSHOPS는 외부에서 Worker가 사용하는 네임스페이스 바인딩입니다. 클래스 export는 이름이 지정된 각 워크숍에 전용 SQLite 데이터베이스를 제공합니다. 배포하기 전에는 클라우드 리소스가 생성되지 않습니다.

원자적 예약 상태 전환 구현

이 단계에서는 영속적인 용량 테이블과 시도 테이블을 생성한 다음, 하나의 원자적 예약 상태 전환을 구현합니다.

생성자는 Cloudflare가 메모리상의 클래스 인스턴스를 생성하거나 재시작할 때마다 실행됩니다. CREATE TABLE IF NOT EXISTS는 누락된 스키마를 안전하게 다시 생성합니다. INSERT ... ON CONFLICT DO NOTHING 문은 네 좌석 용량 행이 없을 때만 삽입하며, 기존 reserved 값을 0으로 재설정하지 않습니다.

Worker 진입점을 생성합니다.

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

export class WorkshopReservations extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS workshop_state (
          singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
          capacity INTEGER NOT NULL CHECK (capacity > 0),
          reserved INTEGER NOT NULL CHECK (reserved >= 0 AND reserved <= capacity)
        )
      `);
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS reservation_attempts (
          request_id TEXT PRIMARY KEY,
          seats INTEGER NOT NULL CHECK (seats > 0),
          status TEXT NOT NULL CHECK (status IN ('accepted', 'rejected')),
          reserved_after INTEGER NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
      this.ctx.storage.sql.exec(`
        INSERT INTO workshop_state (singleton, capacity, reserved)
        VALUES (1, 4, 0)
        ON CONFLICT(singleton) DO NOTHING
      `);
    });
  }

  reserve(requestId, seats) {
    return this.ctx.storage.transactionSync(() => {
      const previous = this.ctx.storage.sql.exec(
        `SELECT request_id AS requestId, seats, status, reserved_after AS reserved
         FROM reservation_attempts WHERE request_id = ?`,
        requestId
      ).toArray()[0];
      if (previous) {
        const state = this.ctx.storage.sql.exec(
          `SELECT capacity FROM workshop_state WHERE singleton = 1`
        ).one();
        return { ...previous, capacity: state.capacity, replayed: true };
      }

      const updated = this.ctx.storage.sql.exec(
        `UPDATE workshop_state
         SET reserved = reserved + ?
         WHERE singleton = 1 AND reserved + ? <= capacity
         RETURNING capacity, reserved`,
        seats,
        seats
      ).toArray();
      const accepted = updated.length === 1;
      const state = accepted ? updated[0] : this.ctx.storage.sql.exec(
        `SELECT capacity, reserved FROM workshop_state WHERE singleton = 1`
      ).one();
      const status = accepted ? "accepted" : "rejected";

      this.ctx.storage.sql.exec(
        `INSERT INTO reservation_attempts
         (request_id, seats, status, reserved_after, created_at)
         VALUES (?, ?, ?, ?, ?)`,
        requestId,
        seats,
        status,
        state.reserved,
        Date.now()
      );
      return { requestId, seats, status, capacity: state.capacity, reserved: state.reserved, replayed: false };
    });
  }

  getStatus() {
    return this.ctx.storage.sql.exec(`
      SELECT
        s.capacity,
        s.reserved,
        COUNT(CASE WHEN a.status = 'accepted' THEN 1 END) AS acceptedRequests,
        COUNT(CASE WHEN a.status = 'rejected' THEN 1 END) AS rejectedRequests,
        COALESCE(SUM(CASE WHEN a.status = 'accepted' THEN a.seats ELSE 0 END), 0) AS acceptedSeats
      FROM workshop_state AS s
      LEFT JOIN reservation_attempts AS a ON 1 = 1
      WHERE s.singleton = 1
      GROUP BY s.capacity, s.reserved
    `).one();
  }
}

function json(data, status = 200) {
  return Response.json(data, { status });
}

function workshopRoute(pathname) {
  const match = pathname.match(/^\/workshops\/([^/]+)\/(reservations|status)$/);
  if (!match) return { error: "not_found", status: 404 };
  let workshop;
  try {
    workshop = decodeURIComponent(match[1]);
  } catch {
    return { error: "invalid_workshop_name", status: 400 };
  }
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(workshop)) {
    return { error: "invalid_workshop_name", status: 400 };
  }
  return { workshop, action: match[2] };
}

function validReservation(value) {
  return value &&
    /^[a-z][a-z0-9-]{2,47}$/.test(value.requestId) &&
    Number.isInteger(value.seats) &&
    value.seats >= 1 && value.seats <= 4;
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (request.method === "GET" && url.pathname === "/health") {
      return json({ status: "ok" });
    }

    const parsed = workshopRoute(url.pathname);
    if (parsed.error) return json({ error: parsed.error }, parsed.status);

    if (request.method === "GET" && parsed.action === "status") {
      const stub = env.WORKSHOPS.getByName(parsed.workshop);
      const state = await stub.getStatus();
      return json({ workshop: parsed.workshop, ...state });
    }
    if (request.method === "POST" && parsed.action === "reservations") {
      let body;
      try {
        body = await request.json();
      } catch {
        return json({ error: "invalid_json" }, 400);
      }
      if (!validReservation(body)) return json({ error: "invalid_reservation" }, 400);
      const stub = env.WORKSHOPS.getByName(parsed.workshop);
      const result = await stub.reserve(body.requestId, body.seats);
      console.log(JSON.stringify({ event: "reservation_decided", workshop: parsed.workshop, requestId: body.requestId, status: result.status, reserved: result.reserved }));
      return json({ workshop: parsed.workshop, ...result }, result.status === "accepted" ? 201 : 409);
    }
    return json({ error: "method_not_allowed" }, 405);
  }
};
JS

transactionSync()는 동기식 스토리지 작업만 허용합니다. 조건이 포함된 UPDATE는 요청한 좌석이 아직 용량 안에 들어갈 때만 카운터를 변경하며, RETURNING은 같은 문장이 생성한 값을 읽습니다. 시도 행도 같은 트랜잭션에서 커밋됩니다. 동일한 requestId를 반복해서 보내면 용량을 두 번 차감하지 않고 첫 번째 결정을 반환합니다.

결정적인 라우팅 테스트와 실제 번들 확인을 실행합니다.

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

두 테스트가 통과하고 dry run이 성공해야 합니다. 원격 리소스는 생성되지 않습니다.

로컬 요청 10개를 동시에 전송

이 단계에서는 네 좌석 워크숍 하나에 대한 예약 명령 10개가 함께 진행됩니다. xargs -P 10은 최대 10개의 셸 프로세스를 동시에 시작하며, 완료 순서는 의도적으로 정해져 있지 않습니다.

명시적인 영속화 디렉터리를 사용해 로컬 런타임을 시작합니다.

npx wrangler dev --port 8787 --persist-to .labex/local-state > .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/health && break
  sleep 1
done

동일한 안정적 객체에 좌석 한 개짜리 시도 10개를 전송합니다. 각 프로세스가 별도의 응답 파일에 기록하므로, 동시에 실행되는 터미널 출력이 서로 섞이지 않습니다.

rm -f .labex/local-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
  curl --silent \
    --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
    --header "content-type: application/json" \
    --data "{\"requestId\":\"request-$1\",\"seats\":1}" \
    > ".labex/local-response-$1.json"
' _ {}

거부된 예약에는 HTTP 409가 예상되는 애플리케이션 응답입니다. curl --silent는 JSON 본문을 계속 저장하므로, 워크숍이 가득 찼더라도 전송 오류로 처리하지 않고 모든 결정을 확인할 수 있습니다. 모든 결정을 하나의 배열로 확인합니다.

jq -s 'sort_by(.requestId)' .labex/local-response-*.json
jq -s '{
  accepted: map(select(.status == "accepted")) | length,
  rejected: map(select(.status == "rejected")) | length,
  highestReserved: map(.reserved) | max
}' .labex/local-response-*.json

어떤 요청 ID가 승인될지는 도착 순서가 보장되지 않으므로 달라질 수 있습니다. 하지만 불변 조건은 달라지지 않습니다. 정확히 네 개가 승인되고 여섯 개가 거부되며, 어떤 응답도 예약 좌석 수를 4보다 크게 보고하지 않아야 합니다.

객체에서 영속 총계를 읽습니다.

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

용량은 4, 예약 수는 4, 승인된 요청은 네 개, 거부된 요청은 여섯 개, 승인된 좌석은 네 개여야 합니다. 응답 파일은 개별 결과를 보여 주고, 상태 행은 해당 총계가 영속 상태와 일치함을 증명합니다.

용량을 재설정하지 않고 재시작

이 단계에서는 Wrangler를 중지하여 메모리상의 클래스 인스턴스를 제거하고, 같은 데이터베이스를 사용하는 새 런타임을 시작한 다음, 초기화 과정에서 용량이 복원되지 않는다는 것을 확인합니다.

프로세스를 중지하고 다시 시작합니다.

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler dev --port 8787 --persist-to .labex/local-state > .labex/dev-restarted.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/health && break
  sleep 1
done

새로운 결정을 내리기 전에 워크숍 상태를 읽습니다.

curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

여전히 reserved: 4를 보고해야 합니다. 생성자가 다시 실행되었지만 ON CONFLICT DO NOTHING이 기존 행을 보존했습니다.

첫 번째 요청 ID를 재생한 다음, 워크숍이 가득 찬 상태에서 새로운 요청 하나를 제출합니다.

curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
  --header 'content-type: application/json' \
  --data '{"requestId":"request-1","seats":1}' | jq
curl --silent --request POST http://127.0.0.1:8787/workshops/launch-day/reservations \
  --header 'content-type: application/json' \
  --data '{"requestId":"request-after-restart","seats":1}' | jq
curl --silent http://127.0.0.1:8787/workshops/launch-day/status | jq

재생된 응답에는 replayed: true가 포함되고 새로운 시도가 추가되지 않습니다. 새 ID를 사용한 요청은 한 번 거부됩니다. 최종 총계는 승인된 좌석 네 개를 유지하고, 거부된 요청은 일곱 개가 됩니다.

클라우드 동시성 실행

이 단계에서는 로컬 런타임을 중지하고 네임스페이스를 배포한 다음, Cloudflare를 대상으로 제한된 동시성 테스트를 반복합니다.

로컬 프로세스를 중지하고 배포합니다.

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"

Worker 라우트와 새로 조정된 Durable Object 네임스페이스는 서로 다른 시점에 사용할 수 있게 됩니다. 먼저 예상 JSON 계약에 맞는 실제 객체 읽기를 폴링한 다음, 다른 객체를 만들기 전에 테스트된 짧은 안정화 시간을 기다립니다.

for attempt in $(seq 1 30); do
  if curl --silent --fail "$APP_URL/workshops/readiness/status" |
    jq -e '.capacity == 4 and .reserved == 0' >/dev/null; then
    break
  fi
  sleep 1
done
curl --silent --fail "$APP_URL/workshops/readiness/status" |
  jq -e '.capacity == 4 and .reserved == 0'
sleep 5

cloud-launch에 클라우드 요청 10개를 동시에 전송합니다.

rm -f .labex/cloud-response-*.json
seq 1 10 | xargs -P 10 -I{} sh -c '
  curl --silent \
    --request POST "$0/workshops/cloud-launch/reservations" \
    --header "content-type: application/json" \
    --data "{\"requestId\":\"cloud-request-$1\",\"seats\":1}" \
    > ".labex/cloud-response-$1.json"
' "$APP_URL" {}

응답 총계와 영속 상태를 비교합니다.

jq -s '{
  accepted: map(select(.status == "accepted")) | length,
  rejected: map(select(.status == "rejected")) | length,
  highestReserved: map(.reserved) | max
}' .labex/cloud-response-*.json
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq

클라우드에서도 로컬과 같은 불변 조건이 성립해야 합니다. 승인 네 개, 거부 여섯 개, reserved: 4입니다. 독립 확인을 실행합니다. 이 확인은 사용 중인 바인딩과 네임스페이스를 검사하고 cloud-launch를 검증한 다음, 별도의 실행별 고유 워크숍에 요청 12개를 동시에 전송합니다.

python3 .labex/verify.py deployed

재배포하고 예약 조정 상태 확인

이 단계에서는 변경하지 않은 Worker를 다시 배포합니다. 그러면 메모리상의 클래스 인스턴스가 교체될 수 있으므로 생성자가 다시 실행될 수 있습니다. 영속적인 용량 행은 가득 찬 상태로 유지되어야 합니다.

다시 배포한 다음 새 요청으로 cloud-launch를 읽습니다.

npx wrangler deploy
curl --silent "$APP_URL/workshops/cloud-launch/status" | jq

용량 4, 예약 4, 승인된 요청 네 개, 거부된 요청 여섯 개가 그대로 표시되어야 합니다. 이 클라우드 재시작 확인은 로컬 재시작과 같은 결론을 보여 줍니다. 안전한 초기화는 누락된 상태를 생성하지만, 이미 설정된 상태를 덮어쓰지 않습니다.

Cloudflare Dashboard를 열고 동일한 계정을 선택합니다. Workers & Pages로 이동하여 정확한 labex-c10-o03-... Worker를 연 다음 Bindings를 선택합니다. WORKSHOPS가 테스트한 WorkshopReservations 네임스페이스를 대상으로 하는지 확인합니다.

승인된 Worker가 WORKSHOPS를 WorkshopReservations Durable Object 네임스페이스에 연결합니다

스크린샷에 표시된 접미사는 승인된 작성 실행에 사용된 값입니다. 생성한 접미사는 다릅니다. 바인딩 이름, 유형, 대상 클래스가 일치해야 하는 필드입니다.

네임스페이스를 열고 Overview를 선택합니다. Storage: SQL은 용량 테이블과 시도 테이블을 관리하는 백엔드를 나타냅니다.

WorkshopReservations 네임스페이스 개요에서 SQL 스토리지를 확인합니다

이제 Logs를 엽니다. 성공한 WorkshopReservations.jsrpc 행은 동시 배치와 검증기가 호출한 객체 메서드입니다. readiness, cloud-launch, 실행별 고유 검증 워크숍이 의도적으로 분리되어 있으므로 여러 객체 ID가 표시됩니다. 로그에는 호출과 오류가 표시되며, 용량이 지켜졌다는 최종 증거는 HTTP 총계입니다.

분리된 Durable Object ID 전반에 성공적인 예약 RPC 호출이 표시됩니다

재배포 후 독립적인 클라우드 확인을 한 번 더 실행합니다.

python3 .labex/verify.py deployed

예약 네임스페이스 삭제 및 로그아웃

이 단계에서는 삭제 가능한 네임스페이스와 워크숍 데이터베이스를 영구적으로 제거하고, 남아 있는 Worker를 삭제한 다음, 이 VM의 인증을 해제합니다.

$RUNlabex-c10-o03-으로 시작하는지 확인합니다. 상태가 없는 정리 진입점을 생성합니다.

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

정확히 같은 Worker와 계정을 대상으로 정리 구성을 생성합니다. state: "deleted" tombstone은 WorkshopReservations 클래스 네임스페이스만 영구적으로 삭제합니다.

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": {
    "WorkshopReservations": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

조정 출력에 Deleted: WorkshopReservations가 표시되어야 합니다. 남아 있는 상태 없는 Worker를 삭제하고, 정확히 생성된 이름만 삭제되는지 확인합니다.

npx wrangler delete --config wrangler.cleanup.jsonc

로그아웃하기 전에 인증된 리소스 부재를 증명합니다.

python3 .labex/verify.py deleted

PASS: deleted가 표시된 후에만 VM 인증을 해제하고 구조화된 상태를 확인합니다.

npx wrangler logout
npx wrangler whoami --json

최종 JSON에는 "loggedIn": false가 포함되어야 합니다. 네트워크 오류나 인증 오류는 정리가 완료되었다는 증거가 아닙니다.

요약

각 워크숍 이름이 하나의 Durable Object를 선택하는 제한된 예약 서비스를 구축했습니다. 동기식 SQLite 트랜잭션은 용량 확인, 카운터 변경, 시도 기록을 하나의 분할할 수 없는 상태 전환으로 결합했습니다. 동시에 실행된 요청 10개는 어떤 순서로 완료되어도 정확히 네 좌석만 승인되며, 용량을 초과하는 응답은 발생하지 않습니다.

또한 ON CONFLICT DO NOTHING으로 초기화를 안전하게 만들고, 안정적인 요청 ID 하나를 재생해 중복 예약을 방지했으며, 로컬 재시작과 클라우드 재배포 이후에도 동일한 영속 총계가 유지됨을 확인했습니다. 마지막으로 Dashboard에서 런타임 증거를 바인딩, SQL 네임스페이스, RPC 로그와 연결해 확인한 다음, 로그아웃하기 전에 정확한 네임스페이스와 Worker를 삭제했습니다.