개발 취재 노트

웹소켓 방장이 나갔다 들어오면 권한이 사라진다 — localStorage 토큰과 서버 해시로 되찾기

무용 연습실에서 한 사람(리더)이 영상을 재생하면 나머지가 각자 폰으로 같은 지점의 영상과 대형을 따라 보는 웹앱(formation)을 Cloudflare Workers의 WebSocket 방 구조로 옮기고 있었다. 방 만들기·참여·동선 첨부까지 로컬에서 잘 돌았는데, 리더가 방을 한 번 나가면 다시 리더로 들어올 방법이 없었다 . 방은 살아 있는데 조종할 사람이 없는 상태가 된다. 이 글은 Cloudflare Durable Objects의 순수 WebSocket 기준이지만, "토큰은 브라우저에, 서버엔 해시만 두고 재접속 때 대조한다"는 원리는 socket.io 같은 다른 실시간 서버에도 그대로 옮길 수 있다.

결론

방장을 "방을 만든 연결"로 정하지 말고 "비밀 토큰을 가진 사람"으로 정한다. 그리고 그 토큰으로 돌아올 UI 입구까지 만든다.

  1. 방을 만들 때 클라이언트가 랜덤 토큰을 만들어 localStorage에 두고, 서버에는 해시만 보낸다
  2. 리더 페이지는 (재)접속할 때 토큰을 함께 보낸다. 서버는 해시를 대조해 맞으면 리더, 틀리거나 없으면 뷰어로 강등한다
  3. 방 목록·응답에는 해시를 절대 싣지 않는다
  4. 로비는 localStorage에 토큰이 있는 방(=내가 만든 방)에 "리더로 재입장" 버튼을, 나머지 방에는 "참여" 버튼을 띄운다
  5. 서버는 "연결 끊김"(방 유지)과 "명시적 종료"(방 삭제)를 구분한다. 끊김이 곧 종료면 돌아올 방이 없다

아래는 세션에 코드가 남아 있지 않아 일반적인 구현 예로 쓴 것이다. 세션에서 확인된 것은 해시에 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';                                    // 틀리면 끊지 말고 강등

상황

증상

원인

두 가지가 동시에 빠져 있었다.

  1. 권한 증명 수단이 없었다. 리더 역할이 방을 처음 만든 연결에 붙어 있었고, 새 연결이 "내가 그 리더다"라고 증명할 방법이 없었다. 토큰을 넣으면서 "아무나 리더 못 되게"가 함께 해결 목록에 올랐다
  2. 돌아올 입구가 없었다. 서버 쪽 URL은 있었지만 로비가 "내가 만든 방"을 알아볼 방법이 없으니 버튼도 없었다. 로비가 그걸 알아볼 단서는 브라우저에 남은 토큰뿐이다

이 기능은 첫 설계표에 "1h 내 leaderToken 제시하며 재연결 → 리더 권한 회수"로 있었다 . 그런데 서버 중계를 만든 뒤 "leaderToken 재접속은 Phase 4로" 미뤘고 , 다음 정리에서는 "(선택 — 지금은 재접속 시 영상 재선택으로 이어감)"으로 적혔다 . 설계에 있던 기능이 두 번 미뤄지는 사이 테스트하던 사람이 먼저 빈 곳을 찾았다.

해결

커밋 하나에 다섯 군데를 고쳤다 .

위치변경
전송 래퍼(live.js)토큰 생성·해시 유틸 추가
리더 페이지방을 만들 때 토큰 생성·저장, 연결할 때 제시, 역할 거부(뷰어 강등) 처리
Lobby 객체토큰 해시를 받아 저장하되 공개 목록에서는 뺀다
Room 객체리더 토큰 검증, 불일치면 뷰어로 강등, 해시는 클라이언트에 절대 보내지 않음
로비토큰이 있는 방은 "리더로 재입장"(→ leader.html), 없는 방은 "참여"

검증:

재입장해도 영상 파일은 다시 골라야 한다. 파일은 서버가 아니라 각자 기기에 있기 때문이다 .

덤 — 토큰 해시는 https(또는 localhost)에서만 돈다

crypto.subtle은 보안 컨텍스트(https 또는 localhost)에서만 존재한다. 그래서 http://<PC IP>:8787처럼 LAN IP로 폰에서 접속하면 토큰 해시(와 영상 지문)가 실패해 방 생성이 안 된다는 판단이 나왔고, 폰 테스트는 https 배포 주소로 하기로 했다 . 배포 후에는 secure context: true, crypto.subtle: true가 확인됐다 .

이 설계의 한계로 보이는 점(세션에서 논의되지 않음): 토큰이 한 브라우저의 localStorage에만 있으므로, 다른 기기·다른 브라우저·시크릿 창이나 저장소를 지운 뒤에는 리더로 돌아올 수 없다.

취재 후기 — URL은 있었는데 문이 없었다

박도은
연습실에서 리더가 영상 틀면 다들 자기 폰으로 따라 보는 그 웹앱, 방 만들고 동선 붙이는 것까진 다 됐어. 근데 리더 창을 닫았다가 다시 들어가려니까 리더로 들어갈 길이 없어
정바다
그거 URL만 알면 되는 거 아냐? leader.html?room=코드 주소는 원래 있잖아. 거기로 바로 가면 리더지
박도은
주소가 있는 건 아는데, 로비에서 방을 누르면 전부 "참여"야. 참여는 무조건 뷰어 화면으로 보내고
정하늘
그리고 바다 말대로 주소만 치면 리더가 되는 거면 그게 더 문제야. 뷰어 중 누구든 그 주소를 치면 영상을 멋대로 멈출 수 있다는 거잖아
정바다
음… 그럼 방 만든 사람 IP를 기억해 뒀다가 같은 IP면 리더로 돌려주자
박도현
폰은 와이파이에서 LTE로만 넘어가도 IP가 바뀌어. 그리고 같은 연습실 공유기면 다들 IP가 똑같아 보일 수도 있어. 사람을 네트워크로 알아보는 건 비싸고 틀리기 쉬워
정바다
인정. 그럼 방 만들 때 비밀번호 같은 걸 하나 쥐여 주면 되겠네
정하늘
맞아, 그걸 처음 설계할 때 이미 적어 뒀어. 방 만들 때 브라우저가 랜덤 토큰을 만들어 보관하고, 다시 붙을 때 그걸 내밀게 하자고
박도은
그렇게 적어 놨는데 왜 지금까지 없었어?
정하늘
중계 서버 만들고 나서 "다음 단계로" 미뤘고, 그다음엔 "선택 사항"으로 내려갔거든
박도현
그래서 네가 먼저 찾아낸 거야. 적어 둔 기능을 선택으로 미루면, 쓰는 사람이 먼저 걸려 넘어져
박도은
알겠어. 그럼 토큰을 서버에도 그대로 저장하면 되지? 들어올 때 서로 똑같은지 비교하게
정하늘
원본은 브라우저에만 두고 서버엔 해시만 둬. 해시는 원래 값으로 되돌릴 수 없는 지문 같은 거야. 서버 쪽 값이 새도 그걸로 리더 행세는 못 해
정바다
새긴 어디로 새. 방 목록은 이름이랑 인원만 보여 주는데
정하늘
그 목록을 만드는 로비가 해시를 같이 들고 있어. 그래서 저장은 하되 공개 목록에선 빼라고 따로 챙긴 거야
정바다
그럼 틀린 토큰으로 들어오면 소켓을 바로 끊어 버리면 깔끔하겠다
박도현
끊으면 토큰 없는 사람은 영상도 못 봐. 틀리면 뷰어로 내려서 보기만 하게 하는 게 방 안 모두에게 싸
박도은
로비 버튼은 어떻게 나눠? 서버는 이 방을 누가 만들었는지 나한테 안 알려 주잖아
정바다
네 브라우저가 알잖아. 그 방 토큰이 localStorage, 그러니까 브라우저 안에 남는 작은 저장칸에 있으면 네가 만든 방이야. 거기만 "리더로 재입장"을 띄우면 돼
박도은
해 봤어. 맞는 토큰은 리더, 틀리거나 없는 토큰은 뷰어로 떨어지고, 목록이랑 응답 어디에도 해시가 안 보여
박도현
그래서 폰에서 나갔다가 다시 리더로 붙는 것까지 된 거야?
박도은
아직 거기까진 못 봤어. 배포 주소에서 리더가 토큰으로 role leader 받는 것까지만 확인했어
정바다
그냥 PC IP로 폰에서 들어가 보면 되잖아. 배포까지 기다릴 필요 없이
정하늘
그건 안 돼. 해시에 쓰는 crypto.subtle은 https나 localhost에서만 살아 있어서, http에 IP로 들어가면 방 만들기부터 막혀
박도은
아, 그래서 폰 테스트는 https로 배포한 주소로만 했구나. 재입장 버튼 확인은 다음 연습실 목록에 올려 둘게
박도현
정리하면, 방장은 방을 만든 연결이 아니라 비밀을 가진 사람이야. 그리고 서버에 길이 있어도 화면에 문이 없으면 그 기능은 없는 거야