- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장), 링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정), 오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내), 결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동) - WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리, ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리 - @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip - e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더 - deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example - 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
102 lines
8.3 KiB
Markdown
102 lines
8.3 KiB
Markdown
# 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절)한 다음 연결을 받는다.
|