개발 취재 노트
웹소켓 방장이 나갔다 들어오면 권한이 사라진다 — localStorage 토큰과 서버 해시로 되찾기
무용 연습실에서 한 사람(리더)이 영상을 재생하면 나머지가 각자 폰으로 같은 지점의 영상과 대형을 따라 보는 웹앱(formation)을 Cloudflare Workers의 WebSocket 방 구조로 옮기고 있었다. 방 만들기·참여·동선 첨부까지 로컬에서 잘 돌았는데, 리더가 방을 한 번 나가면 다시 리더로 들어올 방법이 없었다 . 방은 살아 있는데 조종할 사람이 없는 상태가 된다. 이 글은 Cloudflare Durable Objects의 순수 WebSocket 기준이지만, "토큰은 브라우저에, 서버엔 해시만 두고 재접속 때 대조한다"는 원리는 socket.io 같은 다른 실시간 서버에도 그대로 옮길 수 있다.
결론
방장을 "방을 만든 연결"로 정하지 말고 "비밀 토큰을 가진 사람"으로 정한다. 그리고 그 토큰으로 돌아올 UI 입구까지 만든다.
- 방을 만들 때 클라이언트가 랜덤 토큰을 만들어 localStorage에 두고, 서버에는 해시만 보낸다
- 리더 페이지는 (재)접속할 때 토큰을 함께 보낸다. 서버는 해시를 대조해 맞으면 리더, 틀리거나 없으면 뷰어로 강등한다
- 방 목록·응답에는 해시를 절대 싣지 않는다
- 로비는 localStorage에 토큰이 있는 방(=내가 만든 방)에 "리더로 재입장" 버튼을, 나머지 방에는 "참여" 버튼을 띄운다
- 서버는 "연결 끊김"(방 유지)과 "명시적 종료"(방 삭제)를 구분한다. 끊김이 곧 종료면 돌아올 방이 없다
아래는 세션에 코드가 남아 있지 않아 일반적인 구현 예로 쓴 것이다. 세션에서 확인된 것은 해시에 crypto.subtle.digest를 쓴다는 점까지다 . 키 이름·토큰 길이·전달 위치는 예시다.
// 클라이언트 — 방 만들 때 (일반적인 구현 예)
const token = [...crypto.getRandomValues(new Uint8Array(32))]
.map(b => b.toString(16).padStart(2, '0')).join('');
async function sha256Hex(s) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('');
}
localStorage.setItem(`leaderToken:${roomId}`, token); // 원본은 브라우저에만
await createRoom({ name, leaderTokenHash: await sha256Hex(token) }); // 서버엔 해시만
// 서버(방 객체) — 접속 시 (일반적인 구현 예)
const presented = url.searchParams.get('token');
const role = presented && (await sha256Hex(presented)) === this.leaderTokenHash
? 'leader' : 'viewer'; // 틀리면 끊지 말고 강등
상황
- 구조: 브라우저 ↔ Worker ↔ Lobby 객체 1개(활성 방 목록) + 방마다 Room 객체 1개(실시간 상태와 WebSocket 중계). Cloudflare Durable Objects를 썼다
- 리더가 재생·정지·이동을 보내면 Room이 모든 뷰어에게 뿌린다. 영상 파일은 각자 폰에 있고, 동선 데이터(formation.json)만 Room이 보관한다
- 방 수명: 리더 연결이 끊겨도 방은 유지되고, 1시간 동안 조작이 없으면 알람이 방을 지운다. 리더가
close를 보내면 즉시 삭제되고 뷰어 소켓도 닫힌다
증상
- 로컬에서 방 만들기·참여·동선 첨부까지 확인한 뒤, "리더가 방을 나갔다가 다시 리더로서 방에 들어오는 방법이 없는 것 같네"
- 로비의 "참여" 버튼은 항상 뷰어 페이지(
viewer.html)로만 보냈다. 리더 재접속 URL(leader.html?room=코드)은 있었지만 거기로 가는 UI 경로가 없었다 - 방은 끊김에도 살아남게 설계했으므로, 결과는 "방이 살아 있는데 조종 불가 상태"다. 설계 초기 상태표에 이미 "리더 재접속" 항목이 있었다
원인
두 가지가 동시에 빠져 있었다.
- 권한 증명 수단이 없었다. 리더 역할이 방을 처음 만든 연결에 붙어 있었고, 새 연결이 "내가 그 리더다"라고 증명할 방법이 없었다. 토큰을 넣으면서 "아무나 리더 못 되게"가 함께 해결 목록에 올랐다
- 돌아올 입구가 없었다. 서버 쪽 URL은 있었지만 로비가 "내가 만든 방"을 알아볼 방법이 없으니 버튼도 없었다. 로비가 그걸 알아볼 단서는 브라우저에 남은 토큰뿐이다
이 기능은 첫 설계표에 "1h 내 leaderToken 제시하며 재연결 → 리더 권한 회수"로 있었다 . 그런데 서버 중계를 만든 뒤 "leaderToken 재접속은 Phase 4로" 미뤘고 , 다음 정리에서는 "(선택 — 지금은 재접속 시 영상 재선택으로 이어감)"으로 적혔다 . 설계에 있던 기능이 두 번 미뤄지는 사이 테스트하던 사람이 먼저 빈 곳을 찾았다.
해결
커밋 하나에 다섯 군데를 고쳤다 .
| 위치 | 변경 |
|---|---|
전송 래퍼(live.js) | 토큰 생성·해시 유틸 추가 |
| 리더 페이지 | 방을 만들 때 토큰 생성·저장, 연결할 때 제시, 역할 거부(뷰어 강등) 처리 |
| Lobby 객체 | 토큰 해시를 받아 저장하되 공개 목록에서는 뺀다 |
| Room 객체 | 리더 토큰 검증, 불일치면 뷰어로 강등, 해시는 클라이언트에 절대 보내지 않음 |
| 로비 | 토큰이 있는 방은 "리더로 재입장"(→ leader.html), 없는 방은 "참여" |
검증:
- 로컬: 올바른 토큰 → 리더, 틀린/없는 토큰 → 뷰어 강등, 해시는 목록·응답에 노출 안 됨 . 처음엔 로비 페이지에
live.js가 로드돼 있지 않아 검증 스크립트가 실패했고, 헬퍼를 인라인으로 넣어 다시 돌렸다 - 배포 후(https): 토큰 해시·방 생성, wss 리더 접속 →
role leader확인 - 실기기에서 "리더로 재입장"을 눌러 본 결과는 기록에 없다. 테스트 목록에만 올라 있다
재입장해도 영상 파일은 다시 골라야 한다. 파일은 서버가 아니라 각자 기기에 있기 때문이다 .
덤 — 토큰 해시는 https(또는 localhost)에서만 돈다
crypto.subtle은 보안 컨텍스트(https 또는 localhost)에서만 존재한다. 그래서 http://<PC IP>:8787처럼 LAN IP로 폰에서 접속하면 토큰 해시(와 영상 지문)가 실패해 방 생성이 안 된다는 판단이 나왔고, 폰 테스트는 https 배포 주소로 하기로 했다 . 배포 후에는 secure context: true, crypto.subtle: true가 확인됐다 .
이 설계의 한계로 보이는 점(세션에서 논의되지 않음): 토큰이 한 브라우저의 localStorage에만 있으므로, 다른 기기·다른 브라우저·시크릿 창이나 저장소를 지운 뒤에는 리더로 돌아올 수 없다.
취재 후기 — URL은 있었는데 문이 없었다
leader.html?room=코드 주소는 원래 있잖아. 거기로 바로 가면 리더지role leader 받는 것까지만 확인했어 crypto.subtle은 https나 localhost에서만 살아 있어서, http에 IP로 들어가면 방 만들기부터 막혀