Files
joke-app/docs/03-realtime-protocol.md
EJClaw 17b1d0fcda M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy
- 웹(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>
2026-10-04 04:37:33 +09:00

8.3 KiB
Raw Permalink Blame History

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절)한 다음 연결을 받는다.