# 03. 실시간 통신 프로토콜 ## 1. 원칙 - 연결은 사용자당 브라우저 탭 하나에 WebSocket 하나(`wss://<도메인>/ws`). - **서버가 유일한 진실**. 클라이언트는 행동을 "요청"만 하고, 화면은 서버가 보낸 상태로만 그린다. - 서버는 매번 **그 사람이 볼 수 있는 전체 화면 상태(view)** 를 보낸다(차이(diff) 전송 안 함). 보드게임 상태는 작아서(대부분 2~10KB) 전체 전송이 단순하고 안전하다. 메시지를 잃어버려도 다음 메시지 하나로 화면이 완전히 맞춰진다. - 애니메이션용 `events`는 덤이다. 놓쳐도 상태는 맞다. - 프로토콜 버전 `PROTOCOL = 1`. 서버와 버전이 다르면 클라이언트는 새로고침 안내를 띄운다(배포 직후 대비). ## 2. 연결과 인증 1. 클라이언트가 `/ws`로 업그레이드 요청. 브라우저가 세션 쿠키(`sid`, httpOnly)를 같이 보낸다. 2. 서버는 업그레이드 단계에서 세션을 확인하고, 없으면 401로 거절(클라이언트는 닉네임 화면으로). 3. `Origin` 헤더가 우리 도메인이 아니면 거절(CSWSH 방지). 4. 연결되면 서버가 `welcome`을 보낸다. 사용자가 이미 들어가 있는 방이 있으면 `welcome.activeRoom`에 코드를 넣어 준다(→ "진행 중인 게임으로 돌아가기"). ## 3. 메시지 형식 모든 메시지는 JSON 객체이며 `t`(type) 필드를 가진다. 모든 클라이언트 메시지는 `packages/shared`의 zod 스키마로 검사한다. ### 3.1 클라이언트 → 서버 | t | 필드 | 설명 | |---|---|---| | `join` | `code`, `as: "player"\|"spectator"` | 방 입장. 이미 자리 있는 방이면 재입장 처리 | | `leave` | — | 방 나가기(게임 중이면 확인 후, 기권 처리 규칙은 6절) | | `seat` | `seat?: number` | 자리에 앉기/자리 바꾸기(대기실에서만) | | `unseat` | — | 관전자로 일어나기(대기실에서만) | | `ready` | `ready: boolean` | 준비 | | `config` | `gameId?`, `options?`, `maxPlayers?`, `visibility?`, `allowSpectators?`, `chatEnabled?`, `chatFilter?` | 방장: 게임/옵션/방 설정 변경(대기실·게임 끝난 뒤에만) | | `start` | — | 방장: 시작 | | `kick` | `userId` | 방장: 내보내기 | | `host` | `userId` | 방장 넘기기 | | `act` | `cs: number`, `a: Action` | 게임 행동. `cs`는 클라이언트 행동 번호(1부터 증가) | | `chat` | `text`(1~200자) | 채팅 | | `emote` | `id` | 빠른 반응(👍, 😂, "잘했어요!" 등 고정 목록) | | `rematch` | — | 게임 끝난 뒤 "한 판 더" 투표 | | `void` | — | 방장: 오류로 멈춘 게임을 무효로 끝내기 | | `sync` | — | 전체 상태 다시 요청 | | `ping` | `ts` | 지연 측정(10초마다) | ### 3.2 서버 → 클라이언트 | t | 필드 | 설명 | |---|---|---| | `welcome` | `me`, `serverTime`, `protocol`, `activeRoom?` | 연결 직후 | | `room` | `seq`, `room: RoomView` | 대기실/자리/옵션/접속 상태 전체 | | `state` | `seq`, `view`, `events`, `active: userId[]`, `deadline: number\|null`, `cs?` | 게임 상태. `cs`는 이 상태를 만든 행동이 내 것일 때만 포함(= 성공 응답) | | `reject` | `cs`, `reason` | 내 행동 거절(한국어 이유). 화면은 바꾸지 않고 안내만 표시 | | `chat` | `from`, `text`, `at`, `channel?` | 채팅(마피아 등은 채널 구분) | | `emote` | `from`, `id` | 반응 | | `result` | `seq`, `result` | 게임 결과 | | `notice` | `code`, `message` | "OO님이 연결이 끊겼어요" 같은 알림 | | `error` | `code`, `message` | 잘못된 요청, 방 없음 등 | | `pong` | `ts`, `serverTime` | 지연·시계 보정 | | `bye` | `reason` | 서버 종료 예정(`restart`), 다른 창에서 접속(`replaced`), 강퇴(`kicked`) | ## 4. 순서 보장 (seq) - 방마다 `seq`(정수)가 있고, 방에서 무엇이든 바뀌면 1 증가한다(자리, 옵션, 게임 행동 모두). - 클라이언트는 받은 `seq`가 가지고 있는 값보다 작거나 같으면 버린다(늦게 도착한 옛 메시지 무시). - `seq`가 건너뛰어도(중간 메시지 누락) 전체 상태이므로 그대로 적용한다. 단 `events` 애니메이션은 건너뛴다. ## 5. 행동 처리와 중복 방지 - 클라이언트는 `act`를 보내면 해당 버튼을 잠그고 "처리 중" 표시(150ms 이상 걸릴 때만 보이게). - 서버는 `(userId, room, cs)`를 기억해 같은 `cs`가 다시 오면 무시한다(재연결 직후 재전송 대비). 재연결 시 클라이언트는 응답 못 받은 행동을 **다시 보내지 않고** `sync`로 상태를 받은 뒤 사용자가 다시 판단하게 한다(이미 처리됐을 수 있으므로). - 낙관적 업데이트는 하지 않는다(숨겨진 정보 게임에서 오히려 혼란). 예외: 루미큐브 타일 정렬, 그림 맞히기 붓질처럼 "내 화면 안에서만의 조작"은 즉시 그린다. - 2초 안에 응답이 없으면 "연결 확인 중" 표시 후 `ping`으로 연결 점검. ## 6. 연결 끊김과 재접속 ### 6.1 클라이언트 - 끊기면 자동 재연결: 0.5s → 1s → 2s → 4s → 8s → 이후 10s 간격, 각 ±30% 무작위 지연(서버 재시작 시 몰림 방지). - 화면 위에 작은 띠로 "다시 연결하는 중…" 표시. 게임 화면은 그대로 두되 조작은 잠근다. - 탭이 다시 보이거나(`visibilitychange`) 네트워크가 돌아오면(`online`) 즉시 재시도. - 다시 연결되면 마지막 방에 자동 `join` → 서버가 `room` + `state` 전체를 보냄. ### 6.2 서버 - 연결이 끊긴 플레이어는 **자리를 유지**하고 다른 사람에게 "연결 끊김" 표시. - 턴 타이머는 그대로 흐른다. 끊긴 사람의 차례에 시간이 다 되면 게임의 `onTimeout` 자동 행동(예: 포커 다이/체크, 오목 무작위 착수가 아니라 **시간패** 등 게임별 문서에 정의). - 끊긴 지 **60초**(방 옵션 30~300초)가 지나면 "자리 비움" 상태: 자기 차례가 오면 기다리지 않고 바로 자동 행동. - 자동 행동이 연속 3번이거나 끊긴 지 5분이 지나면 방장에게 "내보내기" 버튼을 보여 준다. 2인 대전 게임은 상대에게 "승리로 끝내기" 선택권을 준다. - 같은 사용자가 새 탭으로 같은 방에 들어오면 새 연결이 이기고, 옛 연결에는 `bye: replaced`. ## 7. 타이머와 시계 - 모든 마감 시각은 서버 시각(epoch ms)으로 보낸다. - 클라이언트는 `pong.serverTime`과 왕복 시간으로 시계 차이를 추정(최근 5개 중간값)하고 남은 시간을 그린다. - 시간 판정은 서버만 한다. 서버 타이머는 방마다 하나(`setTimeout`)이며, 서버 재시작 후에는 저장된 `deadline`으로 다시 건다. ## 8. 하트비트·제한 | 항목 | 값 | |---|---| | 서버 idle timeout | 40초 (Bun `idleTimeout`), 서버가 25초마다 ping 프레임 | | 클라이언트 ping | 10초마다, 5초 안에 pong이 없으면 연결을 끊고 재연결 | | 메시지 최대 크기 | 16KB (그림 맞히기 붓질 메시지만 32KB) | | 속도 제한 | 연결당 토큰 버킷 초당 20개, 최대 40개. 채팅은 0.5초에 1개. 넘으면 버리고 계속 넘으면 연결 종료 | | 압축 | permessage-deflate 사용(1KB 이상 메시지만 효과) | | 서버 송신 버퍼 | 연결당 1MB 넘게 밀리면(느린 클라이언트) 연결을 끊음 → 재연결 시 전체 상태로 회복 | ## 9. 실시간 경쟁 게임(할리갈리, 도블 등) - 먼저 누른 사람은 **서버 도착 순서**로 정한다. 같은 시점에 판정 창을 열고, 첫 도착을 처리한 뒤 30ms 동안 들어온 같은 종류 행동은 "늦음"으로 알려 준다. - 각자의 왕복 지연(RTT)을 화면 구석에 표시하고, 방 옵션으로 "지연 보정"(클라이언트가 누른 시각 − RTT/2로 순서 결정, 단 100ms 이내 차이만 보정)을 켤 수 있다. 기본은 꺼짐(조작 가능성 때문에). 세부는 각 게임 문서. ## 10. 서버 재시작 시 1. 종료 신호(SIGTERM)를 받으면 새 연결을 받지 않고, 모든 연결에 `bye: restart`를 보낸 뒤 DB를 정리하고 종료(최대 10초). 2. 클라이언트는 `restart`를 받으면 "잠시 후 자동으로 다시 연결돼요" 표시 후 1~3초 무작위 대기 후 재연결. 3. 새 서버는 시작할 때 진행 중이던 방을 DB에서 복구(`04-stability-security.md` 3절)한 다음 연결을 받는다.