개발 취재 노트

Durable Objects 방 목록은 따로 관리하고, 퇴장 인원은 다시 세었다

방별 상태는 Room Durable Object(DO)에, 참가자가 고르는 활성 방 목록은 별도 Lobby DO에 뒀다. 만료는 alarm으로 예약하고, 퇴장 인원 오류는 닫히는 소켓을 집계에서 제외해 고쳤다. 안무 연습용 웹앱을 노트북 서버에서 Cloudflare Workers로 옮기며 내린 결정이다. 이 앱은 각자 준비한 영상을 리더의 조작에 맞춰 재생한다. 방 목록·1시간 무조작 만료를 붙인 뒤, 로컬 검증에서 뷰어가 나가도 인원이 2로 남는 문제가 드러났다.

결론

  1. DO 객체 목록과 서비스의 활성 방 목록은 구분한다. 이 앱은 방 이름·영상 정보·인원·활동 시각을 Lobby DO에서 관리하도록 설계했다. Cloudflare에는 네임스페이스의 객체를 나열하는 관리 API가 실제로 있다. 다만 반환되는 id, hasStoredData는 앱의 방 메타데이터가 아니다. 따라서 객체 목록을 그대로 로비 화면으로 쓰기에는 정보가 부족하다. Cloudflare List Objects
  2. 리더 조작마다 storage.setAlarm(Date.now()+3600_000)으로 만료 예약을 갱신한다. 만료 시 소켓 정리·Lobby 삭제 통지·저장소 정리를 하도록 설계했다. 단, 이 작업에서 alarm의 실제 발화까지 검증한 것은 아니다.
  3. 퇴장 처리 중인 소켓이 집계에 남는지 확인한다. 당시 로컬 검증에서는 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초로 바꿔 아래를 확인한다.

  1. 방을 만들고 조작 없이 TTL+여유만큼 기다린다 → 목록에서 사라지고 뷰어 소켓이 닫히는지.
  2. TTL의 절반 시점에 리더가 조작한다 → 원래 시각에 방이 남아 있고, 조작 시점부터 TTL 뒤에 사라지는지.
  3. removeRoom이 실패하도록 일부러 예외를 던져 본다 → alarm이 재시도돼 결국 목록에서 지워지는지, 두 번 실행돼도 오류가 없는지.
  4. 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 검증은 없음

취재 후기 — 방 상태와 목록 상태를 따로 본다

아래는 확인된 사건과 설계를 캐릭터 대화로 재구성한 것이다. 질문과 가설은 설명을 위한 것이며, 실제 측정 결과는 인용한 범위로 한정한다.

박도은
각자 폰에 준비한 안무 영상을 리더 조작에 맞춰 재생하는 앱을 옮기고 있어. 노트북 서버 없이 접속하고, 열린 연습방을 골라 들어가게 만드는 중이야.
정바다
방마다 상태와 연결을 맡는 Durable Object를 하나 두면 되겠네. 그 객체 목록을 그대로 가져오면 참가자에게 보여줄 방 목록도 해결되지 않을까?
정하늘
목록에 어떤 정보가 필요한지부터 보자. 방 이름과 영상 정보, 인원을 보여줘야 하니까 객체 ID만 있어서는 부족해.
박도은
그래서 목록 전용 Lobby를 두고, 방 안의 통신은 Room에 맡겼어. 방 개설과 목록 조회 API는 로컬에서 검증됐어.
박도현
객체를 찾는 일과 사람이 참여할 방을 고르는 일은 다르네. 로비가 보여줄 정보와 갱신 책임부터 정해야겠어.
정바다
방 삭제도 필요하지? 리더가 한 시간 조작하지 않으면 닫기로 했으니, 주기적으로 오래된 방을 훑는 방식부터 생각하게 돼.
정하늘
이번 설계는 alarm, 나중에 실행할 작업을 예약하는 기능을 썼어. 리더 조작마다 setAlarm(Date.now()+3600_000)으로 만료 예약을 갱신하는 거야.
박도은
만료 때 소켓을 정리하고 Lobby에 삭제를 알리는 구조야. 다만 지금 확인한 건 구현까지고, 한 시간 뒤 실제로 울리는 테스트는 남아 있어.
정바다
Room이 삭제 통지를 못 보내면 로비에는 끝난 방이 남을 수 있겠네. 목록에서도 오래된 항목을 확인해야 하지 않을까?
정하늘
그래서 조회할 때 마지막 활동 시각을 보고 정리하도록 설계했어. 기준은 한 시간에 여유를 더한 값이야. 활동 시각이 최신인지도 함께 확인해야 해.
박도현
방을 끝내는 처리와 목록에서 지우는 처리는 따로구나. 타이머 구현만 끝났다고 목록의 정확성까지 확인한 셈은 아니야.
*로컬 개발 서버에서 방의 실시간 연결과 퇴장을 검증했다.*
박도은
wrangler dev라는 로컬 개발 서버에서 WebSocket, 양방향 메시지 연결을 붙여 봤어. 뷰어 한 명이 나갔는데도 목록 인원이 2로 남았어.
정바다
퇴장했는데 숫자가 그대로라면, 닫히는 연결까지 세고 있을 수도 있겠네. 연결 종료 처리와 집계 시점이 맞는지 봐야겠어.
정하늘
webSocketClose는 연결이 닫힐 때 실행되는 핸들러야. 거기서 현재 처리 중인 소켓을 집계에서 빼고, 퇴장 뒤 숫자가 달라지는지 다시 보자.
박도은
당시 진단도 그거였어. 핸들러 안의 getWebSockets(), 연결 목록에 닫히는 소켓이 남아 있었고, 그 소켓을 제외한 뒤 2에서 1로 줄었어.
정하늘
그럼 이번 로컬 재검증에서는 집계 수정이 효과가 있었네. 이 결과를 배포 환경이나 모든 호환성 설정의 동작으로 넓히지는 말자.
박도현
퇴장 이벤트가 왔다는 사실과, 집계 대상에서 이미 빠졌다는 사실은 따로 확인해야겠네. 숫자를 만드는 쪽의 대상을 봐야 해.
박도은
종료 테스트에서도 하나 걸렸어. 리더가 방을 닫으니 목록에서는 사라졌는데, 뷰어 연결이 닫혔다는 결과는 1초 안에 잡히지 않았어.
정바다
그 결과만 보면 소켓 정리가 빠진 것 같기도 해. 하지만 짧은 대기 때문에 닫힘을 놓쳤을 가능성도 남아 있네.
정하늘
두 경우를 가르려면 시간을 늘려 같은 종료 흐름을 보자. 재검증에서는 대기를 3초로 늘렸어.
박도은
다시 보니 뷰어 연결은 readyState=3, 닫힘 상태였어. 방 제거와 소켓 종료가 모두 확인됐어.
정하늘
이번에는 종료 누락으로 볼 근거가 없어졌네. 인원 집계 오류와 종료 확인 타이밍을 같은 문제로 묶으면 안 되겠어.
박도현
방 하나를 관리하는 코드가 있어도 목록과 인원수가 저절로 정확해지진 않아. 각 상태가 언제 갱신되는지 정하고, 그 결과를 따로 검증해야 해.