개발 취재 노트
Durable Objects 방 목록은 따로 관리하고, 퇴장 인원은 다시 세었다
방별 상태는 Room Durable Object(DO)에, 참가자가 고르는 활성 방 목록은 별도 Lobby DO에 뒀다. 만료는 alarm으로 예약하고, 퇴장 인원 오류는 닫히는 소켓을 집계에서 제외해 고쳤다. 안무 연습용 웹앱을 노트북 서버에서 Cloudflare Workers로 옮기며 내린 결정이다. 이 앱은 각자 준비한 영상을 리더의 조작에 맞춰 재생한다. 방 목록·1시간 무조작 만료를 붙인 뒤, 로컬 검증에서 뷰어가 나가도 인원이 2로 남는 문제가 드러났다.
결론
- DO 객체 목록과 서비스의 활성 방 목록은 구분한다. 이 앱은 방 이름·영상 정보·인원·활동 시각을 Lobby DO에서 관리하도록 설계했다. Cloudflare에는 네임스페이스의 객체를 나열하는 관리 API가 실제로 있다. 다만 반환되는
id,hasStoredData는 앱의 방 메타데이터가 아니다. 따라서 객체 목록을 그대로 로비 화면으로 쓰기에는 정보가 부족하다. Cloudflare List Objects - 리더 조작마다
storage.setAlarm(Date.now()+3600_000)으로 만료 예약을 갱신한다. 만료 시 소켓 정리·Lobby 삭제 통지·저장소 정리를 하도록 설계했다. 단, 이 작업에서 alarm의 실제 발화까지 검증한 것은 아니다. - 퇴장 처리 중인 소켓이 집계에 남는지 확인한다. 당시 로컬 검증에서는
webSocketClose시점의getWebSockets()에 닫히는 소켓이 포함된다고 진단했고, 그 소켓을 제외한 뒤 인원이 2→1로 줄었다. 모든 런타임에서 항상 같은 상태라는 뜻은 아니다.
상황
리더가 재생·정지·탐색을 조작하면 참가자들이 각자 기기에 준비한 같은 영상을 따라 보는 연습용 웹앱이었다. 영상 자체는 미리 공유하고, 클라우드는 동기화 신호를 중계하는 방향으로 정했다. 매번 노트북에 서버를 띄우고 같은 와이파이의 IP 주소로 접속하는 번거로움을 없애려는 목적이었다.
기존 Node 서버를 유지하는 VM 방안도 검토했지만 Cloudflare로 진행했다. 기존 SSE는 서버가 브라우저에 이벤트를 보내는 연결 방식이었다. 새 구조에서는 양방향 메시지 연결인 WebSocket으로 바꾸고, sync·formation·mark·reload 메시지 형식을 유지하는 설계를 택했다.
브라우저
└─ Worker: 페이지·방 API·WebSocket 연결 라우팅
├─ Lobby DO 1개: 활성 방 목록과 메타데이터
└─ 방마다 Room DO 1개: 동기화 상태·소켓·만료 예약
이 구조에서 Lobby는 방을 찾는 입구이고, Room은 방 안의 실시간 통신을 맡는다. 외부 DB 대신 DO 내장 저장소로 방 목록을 관리하는 방향이었다.
방 목록 — 객체를 나열하는 것만으로는 부족하다
설계 대화에는 “모든 DO 나열 API가 없다”는 설명이 등장한다. 그러나 현재 공식 문서에는 네임스페이스별 객체 목록 관리 API가 명시돼 있다. 이 설명을 플랫폼의 제약으로 그대로 인용하면 틀린다. 관리 API가 제공하는 객체 ID·저장 데이터 유무와, 참가자가 필요로 하는 방 이름·영상·인원·참여 가능 여부는 서로 다른 정보다. Cloudflare List Objects
이 앱은 Lobby에 방 코드를 발급하고 메타데이터를 보관하게 했다. Room에서 인원이나 활동 상태가 바뀌면 Lobby에 알리고, 종료 시 목록에서 제거하는 설계다. 이후 Lobby의 방 개설·목록 API와 조회 시 오래된 항목을 정리하는 기능의 구현이 보고됐다.
따라서 이 사례의 선택은 앱의 활성 방 목록을 직접 관리하는 것이다. Lobby 하나면 어떤 규모에서든 충분하다거나, Lobby 장애가 있어도 진행 중인 방에 영향이 전혀 없다는 보장까지 검증된 것은 아니다.
방 만료 — alarm 예약과 목록 정리는 별개다
요구사항은 리더의 조작이 1시간 없으면 방을 삭제하는 것이었다. 조작할 때마다 alarm을 다음처럼 다시 예약하고, 만료 처리에서 소켓·목록·저장소를 정리하도록 설계했다.
// 설계 대화에 제시된 예약식. 완성된 Room 구현은 아니다.
storage.setAlarm(Date.now()+3600_000);
alarm은 DO를 나중에 깨워 핸들러를 실행하는 기능이다. 공식 문서는 최소 한 번 실행과 실패 시 재시도를 설명하므로, 실제 정리 코드는 재실행도 고려해야 한다. Cloudflare Alarms
별도로, 삭제 통지를 놓쳐 목록에 남는 방을 정리하려고 Lobby 조회 시 lastActivity를 확인하는 lazy 프루닝을 설계했다. 원문의 기준은 1시간에 여유 시간을 더한 값이다. 정확한 여유 시간은 기록에 없다.
이 설계를 적용할 때는 Lobby의 활동 시각도 Room의 실제 활동을 따라가야 한다. 오래된 목록 값만 보고 정리하면 사용 중인 방을 숨길 수 있기 때문이다. 이는 설계상 확인할 조건이며, 해당 장기 동작을 실측했다는 기록은 없다. alarm 역시 완료 보고에 “구현, 실시간 발화는 배포 후 확인”으로 남아 있다.
만료 예약을 끝까지 이어 붙이면
원문 기록에는 예약식 한 줄과 "구현 완료" 보고만 있다. 아래는 이 글이 그 부분을 이어 붙인 최소 예시이며 원문 패치가 아니다. 메서드 이름(touch, lobby, removeRoom)은 설명용이다.
const TTL = 3600_000;
export class Room extends DurableObject {
// 1) 리더 조작마다: 활동 시각 저장 + 예약 갱신 + Lobby에 알림
async touch() {
const now = Date.now();
await this.ctx.storage.put("lastActivity", now);
await this.ctx.storage.setAlarm(now + TTL); // DO당 예약은 하나라서 덮어쓴다
await this.lobby().touchActivity(this.code, now); // 목록의 lastActivity도 같이 갱신
}
// 2) 예약 시각에 실행. 최소 한 번 실행되므로 다시 돌아도 안전해야 한다
async alarm() {
const last = (await this.ctx.storage.get("lastActivity")) ?? 0;
if (Date.now() - last < TTL) { // 그 사이 조작이 있었다면 정리하지 않고 재예약
await this.ctx.storage.setAlarm(last + TTL);
return;
}
for (const ws of this.ctx.getWebSockets()) ws.close(1000, "expired");
await this.lobby().removeRoom(this.code); // 이미 지워졌어도 오류 없이 끝나게(멱등)
await this.ctx.storage.deleteAll(); // 마지막에: 실패해 재시도돼도 앞 단계는 다시 해도 무해
}
}
순서에 이유가 있다. 저장소를 먼저 지우면 재시도 때 lastActivity가 사라져 판단이 틀어진다. Lobby 삭제와 소켓 닫기는 여러 번 실행돼도 결과가 같게 둔다.
검증 절차. 한 시간을 기다릴 필요는 없다. 개발 환경에서만 TTL을 예컨대 5초로 바꿔 아래를 확인한다.
- 방을 만들고 조작 없이 TTL+여유만큼 기다린다 → 목록에서 사라지고 뷰어 소켓이 닫히는지.
- TTL의 절반 시점에 리더가 조작한다 → 원래 시각에 방이 남아 있고, 조작 시점부터 TTL 뒤에 사라지는지.
removeRoom이 실패하도록 일부러 예외를 던져 본다 → alarm이 재시도돼 결국 목록에서 지워지는지, 두 번 실행돼도 오류가 없는지.- Lobby 조회의 lazy 프루닝은 삭제 통지만 막은 상태에서 오래된 항목이 조회 때 지워지는지 따로 본다.
이 절차를 이 작업에서 실행한 것은 아니다. 원 세션에서도 실시간 발화는 배포 후 확인 대상으로 남았다.
퇴장 인원 — 닫힘 이벤트와 집계 대상을 맞춘다
wrangler dev에서 WebSocket 릴레이를 검증하던 중, 뷰어 한 명이 나가도 목록 인원이 2로 유지됐다. 당시 진단은 닫힘 핸들러가 실행될 때 닫히는 소켓이 getWebSockets() 결과에 남는다는 것이었다. 해당 소켓을 빼고 세도록 수정한 뒤 2→1 감소가 확인됐다.
// 수정 원리를 설명하는 재구성 예시. 원문 패치가 아니다.
// ws는 현재 퇴장 처리 중인 소켓, ctx는 Durable Object의 상태 객체다.
const remainingSockets = ctx.getWebSockets().filter(s => s !== ws);
이 한 줄은 집계 대상에서 현재 소켓을 제외하는 원리만 보여준다. 리더를 포함하는 전체 접속 수인지 뷰어만 세는지, 같은 사람이 여러 연결을 열었을 때 어떻게 셀지는 별도 정책이다. 기록만으로 실제 역할 필터와 전체 집계 코드를 복원할 수 없으므로, remainingSockets.length를 곧바로 사용자 수라고 단정하지 않는다.
또한 webSocketClose 안에서 닫히는 소켓이 항상 남는다고 일반화하면 안 된다. 현재 문서는 getWebSockets()가 종료 진행 중인 연결을 반환할 수 있다고 설명한다. web_socket_auto_reply_to_close 호환성 플래그가 켜지면 닫힘 핸들러 전에 상태가 CLOSED로 바뀌며, 이 플래그는 호환성 날짜가 2026-04-07 이상일 때 기본 적용된다. 당시 앱의 설정은 이 기록에서 확인하지 못했다. Durable Object State · Durable Object Base Class
정책을 정해 세면 이렇게 된다
정책 하나를 가정한 재구성 예시다. 뷰어 수 = 리더를 뺀, 서로 다른 뷰어 ID 수로 정한다. 소켓 접속 시 serializeAttachment({role, clientId})로 역할과 기기 ID를 붙여 뒀다고 전제한다. 실제 앱의 필드 이름은 기록에서 확인되지 않는다.
async webSocketClose(ws, code, reason) {
ws.close(code, reason); // 닫힘 응답을 마무리
await this.reportViewers(ws);
}
async reportViewers(closing) {
const ids = new Set();
for (const s of this.ctx.getWebSockets()) {
if (s === closing) continue; // 퇴장 중인 소켓 제외
const a = s.deserializeAttachment();
if (a?.role === "viewer") ids.add(a.clientId); // 같은 기기의 중복 연결은 한 명
}
await this.lobby().setViewers(this.code, ids.size, Date.now());
}
webSocketError에서도 같은 함수를 부르고, 접속 직후에도 호출해 늘어나는 쪽도 맞춘다. 중복 연결 정책을 "연결 수"로 바꾸려면 Set 대신 카운터만 쓰면 된다. 화면에 보이는 숫자의 의미이므로 먼저 정한다.
검증 절차. 리더 1, 뷰어 2로 접속해 목록 인원이 2인지 → 뷰어 한 명이 나가면 1 → 같은 clientId로 두 번 접속해도 1로 유지되는지(중복 정책) → 리더만 나갈 때 뷰어 수가 바뀌지 않는지 → 대기는 3초 이상 두고 본다. 원 세션의 로컬 검증에서 확인된 것은 앞의 2→1까지이고, 나머지는 이 글이 제안하는 확인 항목이다.
같은 검증에서는 리더가 방을 종료해 목록에서 사라졌는데도, 뷰어 소켓의 닫힘이 1초 안에 잡히지 않았다. 대기를 3초로 늘려 다시 확인하자 readyState=3으로 닫혀 있었다. 이 재검증에서는 종료가 확인됐다는 뜻이며, 앞으로 어떤 종료 지연도 버그가 아니라는 뜻은 아니다.
어디까지 확인했나
| 항목 | 기록으로 확인되는 범위 |
|---|---|
| Lobby | 개설·목록 API 검증과 lazy 프루닝 구현 보고 |
| 실시간 중계 | 리더 에코 제외, sync 전달, 신규 뷰어에 마지막 상태·마크 전달 |
| 퇴장·종료 | 로컬에서 2→1 감소, 리더 종료 시 목록 제거·뷰어 소켓 닫힘 |
| 1시간 만료 | 구현 보고만 있음. 실제 alarm 발화는 미확인 |
| 배포 후 | 보안 컨텍스트·토큰 인증·wss 연결·sync 중계 확인. 이 보고에 인원 감소·alarm 검증은 없음 |
취재 후기 — 방 상태와 목록 상태를 따로 본다
아래는 확인된 사건과 설계를 캐릭터 대화로 재구성한 것이다. 질문과 가설은 설명을 위한 것이며, 실제 측정 결과는 인용한 범위로 한정한다.
setAlarm(Date.now()+3600_000)으로 만료 예약을 갱신하는 거야. *로컬 개발 서버에서 방의 실시간 연결과 퇴장을 검증했다.*
wrangler dev라는 로컬 개발 서버에서 WebSocket, 양방향 메시지 연결을 붙여 봤어. 뷰어 한 명이 나갔는데도 목록 인원이 2로 남았어. webSocketClose는 연결이 닫힐 때 실행되는 핸들러야. 거기서 현재 처리 중인 소켓을 집계에서 빼고, 퇴장 뒤 숫자가 달라지는지 다시 보자. getWebSockets(), 연결 목록에 닫히는 소켓이 남아 있었고, 그 소켓을 제외한 뒤 2에서 1로 줄었어. readyState=3, 닫힘 상태였어. 방 제거와 소켓 종료가 모두 확인됐어.