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

6.9 KiB
Raw Permalink Blame History

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로 안내 → 재시작(수 초) → 자동 재연결·복구.