Files
joke-app/docs/04-stability-security.md
EJClaw aa7f6bffef M2 안정성: 부하·장애 주입 테스트 통과 + 재시작 후 시간 초과 몰림 수정
- 2,000 연결 / 500 방 / 초당 248 행동: p95 20ms, p99 37ms, CPU 0.2코어, 메모리 141MB, 오류 0
- 부하 중 SIGKILL→재시작: 2,000 연결 자동 복구, 수 유실 0
- 복구된 방 마감을 30초 + 0~30초 무작위로 분산(몰림으로 p99 336ms → 40ms)
- 보고서: docs/reports/load-20261004.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:15:06 +09:00

69 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04. 안정성과 보안
## 1. 목표
- 어떤 상황에서도 진행 중인 게임 상태를 잃지 않는다(프로세스 충돌, 배포, 서버 재부팅).
- 한 방의 버그가 다른 방이나 서버 전체를 멈추지 않는다.
- 클라이언트를 조작해도 규칙을 어기거나 남의 패를 볼 수 없다.
## 2. 상태 저장 방식
- 방 상태는 메모리에 있고, **행동이 하나 처리될 때마다** 같은 동기 트랜잭션으로 SQLite에 기록한다.
- `room_log`에 행동 한 줄 추가(누가, 무엇을, 서버 시각, seq) — 기록 보기·리플레이·디버깅용.
- `room_state`에 최신 전체 상태(게임 상태 + RNG 상태 + 타이머 마감 + 자리 정보) 덮어쓰기.
- 상태는 작으므로(대부분 수 KB) 매번 전체 저장이 가장 단순하고 확실하다. SQLite WAL + `synchronous=NORMAL`로 한 번 쓰기 1ms 미만 목표(테스트로 확인).
- 저장이 끝난 다음에만 다른 사람에게 결과를 보낸다(보낸 상태 = 저장된 상태).
- `synchronous=NORMAL`은 프로세스가 죽어도 데이터가 남는다. 정전 같은 OS 단위 장애에서는 마지막 몇 개 행동을 잃을 수 있고, 이는 허용한다.
## 3. 서버 시작 시 복구
1. `rooms`에서 `status IN ('lobby','playing')`인 방을 읽는다.
2. 각 방의 `room_state`를 불러 메모리 Room을 만든다. 모든 플레이어는 "연결 끊김" 상태로 시작.
3. 저장된 `deadline`이 이미 지났다면 복구 직후 일괄 처리하지 않고, **복구 시각 + 30초 + 0~30초 무작위**로 마감을 미뤄 준다(재접속할 시간, 많은 방이 한순간에 시간 초과되는 몰림 방지).
4. 복구 실패(스키마 불일치, 게임 코드 변경으로 상태가 안 맞음)한 방은 `status='broken'`으로 표시하고, 들어온 사람에게 "서버 업데이트로 이 게임을 이어갈 수 없어요. 새로 시작해 주세요"를 보여 준다.
5. 게임 규칙 코드가 바뀌어 저장 상태 형식이 달라지면, 그 게임 모듈의 `stateVersion`을 올리고 `migrate(oldState)`를 제공하거나 위 4번으로 처리한다.
## 4. 장애 격리
- 게임 모듈의 `apply/view` 호출은 try/catch로 감싼다. 예외가 나면:
- 그 행동은 적용하지 않음(상태는 직전 그대로), 행동한 사람에게 `reject`("알 수 없는 오류가 발생했어요").
- 오류 로그에 방 코드, 게임, seq, 행동, 스택을 남긴다.
- 같은 방에서 오류가 3번 연속 나면 방을 "일시 정지"하고 방장에게 "게임 끝내기(무효)"를 제공.
- `view()`에서 예외가 나면 그 사람에게만 오류 화면, 다른 사람에게는 정상 전송.
- 프로세스 수준 `uncaughtException`/`unhandledRejection`은 로그 후 종료 → Docker `restart: unless-stopped`가 재시작 → 3절로 복구.
- 무한 루프 방지: 게임 규칙 함수에 단위 테스트 + 무작위 대국 테스트로 처리 시간 상한(5ms) 확인.
## 5. 숨겨진 정보와 공정성
- 클라이언트에는 `view(state, viewer)` 결과만 보낸다. 덱 순서, 남의 패, 마피아 직업, RNG 시드는 절대 보내지 않는다.
- 게임마다 "정보 유출 테스트": 무작위 대국을 돌리며 매 순간 플레이어 A의 view(JSON 문자열)에 다른 사람만 알아야 하는 값(카드 ID 등)이 들어 있지 않은지 검사.
- 셔플: 게임 시작 시 `crypto.getRandomValues`로 128비트 시드를 만들고, 상태 안의 시드 PRNG(sfc32)로 Fisher–Yates 셔플.
- **공정성 확인(선택 표시)**: 게임 시작 때 시드의 SHA-256 해시를 모두에게 공개하고, 게임이 끝나면 시드를 공개한다. 결과 화면의 "공정성 확인"을 누르면 같은 시드로 덱을 다시 만들어 실제 배분과 같은지 브라우저가 확인한다.
- 관전자에게는 공개 정보만. 포커·섯다처럼 관전이 정보 누설 통로가 될 수 있는 게임은 기본적으로 "관전 지연 표시 없음 + 공개 정보만"이고, 같은 사람이 플레이어와 관전자를 동시에 할 수 없다.
## 6. 사행성·법적 주의
- 포커·섯다·고스톱 등은 **방 안에서만 쓰는 가상 칩/점수**를 쓴다. 방이 끝나면 사라지고, 계정에 쌓이지 않으며, 사고팔거나 바꿀 수 없다. 결제·유료 아이템·충전도 없다.
- 기록에는 "몇 판 이겼는지"만 남기고 칩 잔액은 남기지 않는다.
- 이 구조는 게임산업진흥법의 사행성 관련 위험을 피하기 위한 것이다(법률 자문 아님). 수익화를 하게 되면 그 전에 반드시 법률 검토를 받는다.
- 상표: 루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 모두의 마블 등은 상표다. 공개 서비스로 열기 전에 화면 표시 이름을 일반 명칭으로 바꿀지 사용자 결정을 받는다(`games/README.md` 4절). 카드·화투 그림은 직접 만든 SVG만 쓴다.
## 7. 웹 보안
| 위협 | 대응 |
|---|---|
| 세션 탈취 | 세션 토큰은 32바이트 무작위, DB에는 SHA-256 해시만 저장. 쿠키 `HttpOnly; Secure; SameSite=Lax; Path=/` |
| CSRF | 상태를 바꾸는 HTTP API는 POST + `Origin` 검사 + `SameSite=Lax` |
| CSWSH | WS 업그레이드 때 `Origin` 검사 |
| XSS | React 기본 이스케이프, `dangerouslySetInnerHTML` 금지(린트), 닉네임·채팅은 일반 텍스트. CSP: `default-src 'self'; img-src 'self' https://cdn.discordapp.com data:; connect-src 'self' wss://<도메인>; frame-ancestors 'none'` |
| 메시지 폭주 | 3장 8절의 속도·크기 제한, 사용자당 동시 연결 5개, IP당 분당 방 생성 10개 |
| 방 코드 무작위 대입 | 코드 입력 API는 IP당 분당 30회 제한, 6자리(100만 개) 중 사용 중인 코드는 수백 개 수준 |
| 잘못된 입력 | 모든 메시지 zod 검사, 게임 `validate()`가 한 번 더 검사 |
| 비밀값 | 디스코드 client secret, 세션 서명 키는 `.env`(저장소에 올리지 않음) |
## 8. 채팅 안전(어린이 사용자 고려)
- 방 옵션: 채팅 켜기/끄기(기본 켜짐), 욕설 필터(기본 켜짐, 단어 목록 기반 `***` 치환).
- 각 사용자는 특정 사람의 채팅을 "숨기기" 가능(내 화면에서만).
- 빠른 반응(이모트)은 고정 목록이라 항상 안전.
- 닉네임에도 같은 욕설 필터 적용.
## 9. 운영 안정성
- 로그: JSON 한 줄 로그(시간, 레벨, 방, 사용자, 이벤트). 게임 행동 내용은 `room_log`에만.
- 지표: `/internal/metrics`(외부 차단) — 연결 수, 방 수, 행동 처리 시간 히스토그램, 저장 시간, 오류 수, 메모리.
- 백업: 매일 SQLite `VACUUM INTO`로 백업 파일 생성, 14일 보관. 복구 절차는 `11-deployment-ops.md`.
- 정리: 아무도 없는 대기실 10분 후 삭제, 끝난 방 30분 후 메모리에서 내림(기록은 DB에 남음), 오래된 `room_log`는 90일 후 삭제.
- 무중단에 가까운 배포: 새 버전 시작 전 옛 버전이 `bye: restart`로 안내 → 재시작(수 초) → 자동 재연결·복구.