소개
실행 중인 JavaScript 객체는 클래스 속성에 값을 저장할 수 있지만, 런타임이 재시작되거나 충돌하거나 비활성 객체를 메모리에서 제거하면 해당 값은 사라집니다. 활동 로그는 이러한 위험을 감수할 수 없습니다. Room 구성원은 애플리케이션 코드를 재배포한 후에도 어제의 이벤트를 계속 확인할 수 있어야 합니다.
이 실습에서는 검증된 각 Room 이름이 하나의 Durable Object를 선택합니다. 해당 객체는 활동 이벤트를 저장하는 전용 SQLite 데이터베이스를 소유합니다. 외부 Worker는 RPC를 통해 객체를 호출하므로 클라이언트가 스토리지에 직접 접근하지 않습니다. 로컬 런타임을 중지했다가 재시작한 후 클라우드 Worker를 재배포하고 새 연결을 엽니다. 두 경우 모두 이전에 기록한 행이 계속 조회되어야 합니다. 두 번째 Room을 사용하면 스토리지가 전체 네임스페이스가 아니라 하나의 객체 ID에 속한다는 사실도 확인할 수 있습니다.
또한 다음 두 가지 상태를 비교합니다.
- 메모리 내 상태는 JavaScript 속성에 저장되며 임시 캐시로만 유용합니다.
- 영구 상태는 요청이 완료되기 전에 객체의 스토리지에 기록되며 런타임이 교체되어도 유지됩니다.
이 과정을 바로 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새 VM마다 자체 Wrangler 인증이 필요합니다. 이전 실습을 통해 Worker 요청 핸들러, Durable Object 이름, 바인딩 및 RPC를 이미 이해하고 있어야 합니다. 기본 SQL 키와 정렬된 쿼리는 해당 내용이 등장하는 곳에서 설명합니다.
현재 Cloudflare는 Workers Free에서 SQLite 기반 Durable Objects를 지원합니다. 이 실습에서는 삭제 가능한 클래스 네임스페이스 하나, 이름이 지정된 작은 객체 몇 개, 제한된 요청만 생성합니다. 설정 과정에서는 /home/labex/project/room-activity-log에 Node.js 22.22.0과 프로젝트 로컬 Wrangler 4.132.0을 설치하지만, Cloudflare 인증, 네임스페이스 생성, Worker 배포 또는 학습자 활동 레코드 기록은 수행하지 않습니다.
VM 인증 및 Room 네임스페이스 구성
이 단계에서는 새 VM을 인증하고, 학습 계정을 선택한 다음, SQLite 기반 Durable Object 클래스 하나를 설명합니다. VM이 브라우저 세션에 접근할 수 없기 때문에 Dashboard 로그인과 VM 인증은 별도로 수행합니다.
준비된 프로젝트로 이동한 후 고정된 Wrangler 버전을 확인합니다.
cd /home/labex/project/room-activity-log
npx wrangler --version
4.132.0이 표시되어야 합니다. 디바이스 인증을 시작합니다.
npx wrangler login --device --browser=false
브라우저에서 표시된 URL을 열고 짧은 코드를 입력합니다. 선택된 계정과 권한을 확인한 후 인증을 승인합니다. 브라우저와 Wrangler 양쪽에서 성공을 보고한 다음에만 터미널로 돌아옵니다. 실습 환경에 비밀번호나 토큰을 붙여 넣지 마세요.
구조화된 ID 정보를 읽고 사용할 계정 ID를 셸 변수에 저장합니다.
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"
첫 번째 jq 표현식은 안전한 ID 필드만 표시합니다. 두 번째 명령은 계정 ID를 출력하지 않고 셸 변수에 저장합니다. 전용 학습 계정의 표시 이름이 다르면 확인한 이름으로 바꿉니다.
고유한 Worker 이름을 생성합니다.
RUN="labex-c10-o02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
구성 파일을 생성합니다. 따옴표로 묶지 않은 JSON 구분자는 $RUN과 $ACCOUNT_ID를 확장하고, \$schema는 JSON 키를 그대로 유지합니다.
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": "ROOMS", "class_name": "RoomActivity" }
]
},
"exports": {
"RoomActivity": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
ROOMS는 Worker가 네임스페이스에 접근할 때 사용하는 핸들입니다. exports 항목은 모든 RoomActivity 객체가 각자 SQLite 데이터베이스를 사용하도록 Cloudflare에 알립니다. 이 파일만으로는 클라우드 리소스가 생성되지 않으며, 배포는 나중에 수행합니다.
SQLite에 Room 이벤트 저장
이 단계에서는 Room이 소유하는 테이블과 두 개의 RPC 메서드를 구현합니다. 하나는 이벤트를 추가하고, 다른 하나는 정렬된 기록을 반환합니다.
활동 이벤트에는 변경되지 않는 텍스트 키, 짧은 유형, 사람이 읽을 수 있는 세부 정보와 서버 타임스탬프가 포함됩니다. PRIMARY KEY 제약 조건은 하나의 Room 안에서 두 행이 같은 이벤트 ID를 사용하지 못하게 합니다. AUTOINCREMENT는 단조 증가하는 sequence를 할당합니다. 따라서 타임스탬프가 같을 수 있는 상황에 의존하지 않고 읽기 쿼리에서 삽입 순서를 유지할 수 있습니다.
Worker 진입점을 생성합니다.
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
export class RoomActivity extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS activity_events (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
event_type TEXT NOT NULL,
detail TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
appendEvent(event) {
const createdAt = Date.now();
return this.ctx.storage.sql.exec(
`INSERT INTO activity_events (event_id, event_type, detail, created_at)
VALUES (?, ?, ?, ?)
RETURNING sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt`,
event.eventId,
event.type,
event.detail,
createdAt
).one();
}
listEvents() {
return this.ctx.storage.sql.exec(
`SELECT sequence, event_id AS eventId, event_type AS type, detail, created_at AS createdAt
FROM activity_events
ORDER BY sequence`
).toArray();
}
}
function json(data, status = 200) {
return Response.json(data, { status });
}
function roomRoute(pathname) {
const match = pathname.match(/^\/rooms\/([^/]+)\/events$/);
if (!match) return { error: "not_found", status: 404 };
let room;
try {
room = decodeURIComponent(match[1]);
} catch {
return { error: "invalid_room_name", status: 400 };
}
if (!/^[a-z][a-z0-9-]{0,31}$/.test(room)) {
return { error: "invalid_room_name", status: 400 };
}
return { room };
}
function validEvent(value) {
return value &&
/^[a-z][a-z0-9-]{2,31}$/.test(value.eventId) &&
/^[a-z][a-z0-9_]{2,31}$/.test(value.type) &&
typeof value.detail === "string" &&
value.detail.length >= 1 && value.detail.length <= 160;
}
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 = roomRoute(url.pathname);
if (parsed.error) return json({ error: parsed.error }, parsed.status);
if (request.method !== "GET" && request.method !== "POST") {
return json({ error: "method_not_allowed" }, 405);
}
const room = parsed.room;
let body;
if (request.method === "POST") {
try {
body = await request.json();
} catch {
return json({ error: "invalid_json" }, 400);
}
if (!validEvent(body)) return json({ error: "invalid_event" }, 400);
}
const stub = env.ROOMS.getByName(room);
try {
if (request.method === "POST") {
const event = await stub.appendEvent(body);
console.log(JSON.stringify({ event: "room_activity_appended", room, eventId: event.eventId, sequence: event.sequence }));
return json({ room, event }, 201);
}
const events = await stub.listEvents();
console.log(JSON.stringify({ event: "room_activity_listed", room, count: events.length }));
return json({ room, events });
} catch (error) {
if (String(error).includes("UNIQUE constraint failed")) {
return json({ error: "duplicate_event_id" }, 409);
}
throw error;
}
}
};
JS
blockConcurrencyWhile()는 스키마 생성에만 사용합니다. 테이블이 생성될 때까지 요청을 지연하지만, 일반 트래픽이나 외부 I/O를 감싸지는 않습니다. 중요한 애플리케이션 상태는 클래스 속성에만 저장되지 않습니다. appendEvent()는 반환하기 전에 행을 SQLite에 기록합니다.
제공된 결정적 HTTP 라우팅 테스트와 실제 Wrangler 번들 검사를 실행합니다.
NODE_NO_WARNINGS=1 node --experimental-loader ./test/cloudflare-loader.mjs --test test/worker.test.mjs
npx wrangler deploy --dry-run
테스트 두 개가 통과하고 dry run이 성공해야 합니다. 이 검사는 원격 배포를 수행하지 않습니다.
재시작 후 로컬 데이터 유지 확인
이 단계에서는 planning Room에 이벤트 두 개를 기록하고 로컬 Workers 런타임을 완전히 중지합니다. 그런 다음 같은 로컬 스토리지 디렉터리를 사용해 새 런타임을 시작하고 행을 다시 조회합니다.
Wrangler는 일반적으로 로컬 바인딩 데이터를 .wrangler/state 아래에 저장합니다. 이 실습에서는 유지 경계를 명확하게 확인할 수 있도록 .labex/local-state 디렉터리를 명시적으로 사용합니다. 이 디렉터리는 로컬 개발 데이터만 나타내며 Cloudflare 스토리지와는 별개입니다.
첫 번째 로컬 런타임을 시작합니다.
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
planning에 이벤트 두 개를 추가합니다. --data는 JSON 본문을 보내고, content-type 헤더는 Worker가 본문을 해석하는 방법을 알려 줍니다.
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
--header 'content-type: application/json' \
--data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST http://127.0.0.1:8787/rooms/planning/events \
--header 'content-type: application/json' \
--data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq
Room의 내용을 읽고 1과 2 순서가 표시되는지 확인합니다.
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
이제 해당 런타임을 종료하고 프로세스가 끝날 때까지 기다립니다.
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
planning을 다시 읽은 다음, 아직 이벤트를 받은 적이 없는 다른 Room을 읽습니다.
curl --silent http://127.0.0.1:8787/rooms/planning/events | jq
curl --silent http://127.0.0.1:8787/rooms/support/events | jq
새 런타임은 두 planning 이벤트를 순서대로 반환하고, support는 빈 events 배열을 반환해야 합니다. 재시작으로 모든 JavaScript 클래스 인스턴스는 제거되었지만 SQLite 행은 제거되지 않았습니다. 두 번째 Room이 비어 있다는 사실은 이름이 지정된 각 객체가 전용 스토리지를 소유한다는 것을 보여 줍니다.
배포하고 클라우드 활동 기록
이 단계에서는 로컬 프로세스를 중지하고 클래스 네임스페이스를 배포한 다음, 작은 클라우드 활동 기록을 작성합니다.
나중에 로컬 응답을 클라우드 응답으로 잘못 해석하지 않도록 재시작한 로컬 런타임을 중지합니다.
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
일반 터미널 출력을 저장하면서 Worker를 배포합니다. tee /dev/tty는 출력을 계속 표시하고, $(...)는 해당 출력을 셸 변수에 저장합니다.
DEPLOY_OUTPUT="$(npx wrangler deploy 2>&1 | tee /dev/tty)"
첫 번째 배포에서는 RoomActivity export를 조정하고 SQLite 기반 네임스페이스를 생성합니다. 다른 학습자가 같은 서브도메인을 사용한다고 가정하지 않고, 출력된 workers.dev URL을 추출합니다.
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"
grep -Eo는 일치하는 URL 텍스트만 출력하고, tail -1은 다른 정보 줄에 링크가 포함된 경우 마지막 주소를 선택합니다.
성공적인 배포 후 Worker 코드와 새 Durable Object 네임스페이스가 모든 edge에서 접근 가능해질 때까지 몇 초가 걸릴 수 있습니다. 아직 비어 있는 support 객체에서 예상한 JSON이 반환될 때까지 기다린 다음 쓰기 작업을 보냅니다.
for attempt in $(seq 1 30); do
if curl --silent --fail "$APP_URL/rooms/support/events" |
jq -e '.room == "support" and .events == []' >/dev/null; then
break
fi
sleep 1
done
curl --silent --fail "$APP_URL/rooms/support/events" |
jq -e '.room == "support" and .events == []'
sleep 5
마지막 읽기 명령은 준비 상태를 명확하게 확인합니다. Durable Object 경로가 아직 유효한 JSON을 반환하지 않으면 edge 오류 페이지를 이후 명령으로 전달하지 않고 여기서 실습을 중지합니다. 짧은 대기 시간은 새로 조정된 네임스페이스가 edge 전체에 전파되는 동안 두 번째 이름 있는 객체가 생성되는 것도 방지합니다.
같은 논리적 planning 이벤트 두 개를 클라우드 스토리지에 기록합니다. 로컬 Durable Object 데이터베이스와 원격 Durable Object 데이터베이스는 의도적으로 분리된 환경이므로 클라우드 Room은 비어 있는 상태에서 시작합니다.
curl --silent --request POST "$APP_URL/rooms/planning/events" \
--header 'content-type: application/json' \
--data '{"eventId":"evt-opening","type":"room_opened","detail":"Planning room opened"}' | jq
curl --silent --request POST "$APP_URL/rooms/planning/events" \
--header 'content-type: application/json' \
--data '{"eventId":"evt-notes","type":"note_added","detail":"Release notes drafted"}' | jq
planning과 아직 수정하지 않은 support Room을 모두 읽습니다.
curl --silent "$APP_URL/rooms/planning/events" | jq
curl --silent "$APP_URL/rooms/support/events" | jq
클라우드의 planning 객체에는 두 행이 포함되고 support는 계속 비어 있어야 합니다. 이 결과는 배포 교체를 테스트하기 전에 클라우드 ID와 스토리지 격리가 제대로 동작한다는 것을 증명합니다.
재배포하고 영구 상태 확인
이 단계에서는 같은 Worker 이름과 클래스 선언을 사용해 다시 배포합니다. 그런 다음 새 HTTP 연결을 통해 기존 행을 읽고, 런타임 결과를 Dashboard 정보와 연결해 확인합니다.
변경하지 않은 애플리케이션을 다시 배포합니다.
npx wrangler deploy
코드 배포로 실행 중인 Durable Object 인스턴스가 교체될 수 있으므로 클래스 속성은 초기화됩니다. 하지만 동일한 활성 RoomActivity export가 계속 선언되어 있으면 네임스페이스는 교체되지 않습니다. 새 요청을 열고 planning 기록을 읽습니다.
curl --silent "$APP_URL/rooms/planning/events" | jq
evt-opening과 evt-notes 행이 순서대로 계속 표시되어야 합니다. 이것이 임시 메모리 내 배열과 SQLite 기반 영구 상태의 중요한 차이입니다.
Cloudflare Dashboard를 열고 같은 계정을 선택합니다. Workers & Pages로 이동해 정확한 labex-c10-o02-... Worker를 찾은 다음, Durable Object 바인딩 이름이 ROOMS이고 대상이 RoomActivity인지 확인합니다. 그런 다음 Durable Objects를 열고 해당 네임스페이스를 선택한 후 Overview를 확인합니다. 네임스페이스 이름은 배포된 Worker와 클래스를 식별하고, Storage: SQL은 wrangler.jsonc에서 선택한 백엔드가 사용 중임을 확인합니다.

스크린샷은 테스트한 실행 결과를 보여 줍니다. 생성된 접미사는 다르지만 바인딩 유형, 이름과 대상 클래스는 구성과 일치해야 합니다.

Dashboard가 네임스페이스 지표를 집계하는 데 시간이 걸릴 수 있으므로, 두 행이 유지되었다는 결정적인 증거는 HTTP 응답입니다. Overview는 현재 상태를 파악하기 위한 확인 지점이며 런타임 읽기를 대신하지 않습니다.
네임스페이스의 Logs 보기를 엽니다. 성공한 RoomActivity.jsrpc 행은 Cloudflare가 RPC를 통해 클래스를 호출했다는 것을 확인해 줍니다. 반복되는 객체 ID는 같은 객체에 대한 반복 호출을 나타내고, 다른 ID는 다른 Room과 검증기에서 사용하는 실행마다 고유한 Room에서 생성됩니다. 이 ID는 Cloudflare가 생성한 예시이며 복사해서 Room 이름으로 사용하면 안 됩니다. 로그는 호출 사실을 증명하고, 정렬된 HTTP 응답은 저장된 활동 내용을 증명합니다.

배포된 독립 검사를 한 번 더 실행합니다. 이 검사는 바인딩과 소유 네임스페이스를 확인하고, 보존된 planning 행을 읽고, 비어 있는 support Room을 확인하며, 별도의 고유한 검증 Room을 생성합니다.
python3 .labex/verify.py deployed
네임스페이스 삭제 및 VM 접근 권한 취소
이 단계에서는 남아 있는 Worker를 삭제하고 로그아웃하기 전에 Durable Object 네임스페이스와 모든 Room 데이터베이스를 제거합니다.
Worker만 삭제해도 Durable Object 클래스가 명시적으로 폐기되지는 않습니다. 선언적 수명 주기에서는 deleted tombstone을 사용합니다. 이 작업은 해당 클래스 네임스페이스를 영구적으로 삭제하며 휴지통이 없으므로, 계속하기 전에 $RUN이 labex-c10-o02-로 시작하는지 확인합니다.
상태를 저장하지 않는 정리용 진입점을 생성합니다.
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
정확히 같은 Worker와 계정을 대상으로 정리 구성을 생성합니다. 바인딩을 제거하고 RoomActivity만 삭제된 상태로 표시합니다.
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": {
"RoomActivity": { "type": "durable-object", "state": "deleted" }
}
}
JSON
tombstone을 배포하고 조정 결과를 확인합니다.
npx wrangler deploy --config wrangler.cleanup.jsonc
RoomActivity가 삭제되었다는 내용이 표시되어야 합니다. 이 작업은 planning, support, 검증기에서 사용하는 임시 Room과 해당 실습 소유 네임스페이스의 모든 SQLite 행을 제거합니다. 남아 있는 상태 없는 Worker를 삭제합니다.
npx wrangler delete --config wrangler.cleanup.jsonc
생성된 정확한 Worker만 삭제되었는지 확인합니다. 인증이 아직 유효할 때 인증된 부재 검사를 실행합니다.
python3 .labex/verify.py deleted
PASS: deleted가 출력된 후에만 로그아웃하고 구조화된 로그아웃 상태를 확인합니다.
npx wrangler logout
npx wrangler whoami --json
최종 출력에는 loggedIn: false가 표시되어야 합니다. 네트워크 오류만으로 리소스 삭제나 로그아웃을 확인할 수는 없습니다.
요약
각 고정된 Room 이름이 하나의 Durable Object와 하나의 전용 SQLite 데이터베이스를 선택하는 Room 활동 서비스를 구축했습니다. 키와 순서를 사용하는 이벤트 테이블을 만들고, RPC를 통해 추가 및 조회 작업을 제공했으며, 객체를 선택하기 전에 요청을 검증했습니다. 또한 두 번째 Room이 다른 Room의 기록을 상속하지 않는다는 사실도 확인했습니다.
로컬 런타임 재시작과 클라우드 재배포 후에도 같은 행을 읽어 임시 JavaScript 메모리와 영구 스토리지의 차이를 확인했습니다. 마지막으로 Dashboard에서 바인딩, 네임스페이스, 저장된 행과 로그를 검사한 후, 정확한 네임스페이스와 Worker를 삭제하고 VM의 인증을 취소했습니다.



