# 02. 시스템 아키텍처 ## 1. 핵심 결정 | 결정 | 선택 | 이유 | |---|---|---| | 언어 | TypeScript (strict) 하나로 서버·클라이언트·게임 규칙 | 규칙 코드와 타입을 서버/클라이언트가 같이 씀 | | 서버 런타임 | Bun 1.3+ (`Bun.serve` HTTP + WebSocket) | 내장 WebSocket pub/sub, 높은 처리량, 내장 SQLite·테스트 러너 | | HTTP 라우팅 | Hono | 작고 검증된 라우터, 쿠키·미들웨어 | | 실시간 | WebSocket + JSON, 서버 권위(authoritative) | 모든 판정은 서버, 클라이언트는 화면만 | | 검증 | zod (공용 스키마) | 들어오는 모든 메시지를 스키마로 검사 | | DB | SQLite (WAL 모드, `bun:sqlite`) | 단일 서버에서 가장 단순하고 안정적, 백업 쉬움 | | 웹 | React 19 + Vite + react-router + zustand | 익숙하고 작음, 게임 화면은 lazy import로 분리 | | 스타일 | CSS 변수 + CSS Modules, UI 라이브러리 미사용 | 가벼움, 글자 크기·테마 전환이 쉬움 | | 보드 그리기 | SVG(보드·말) + DOM(카드·버튼) | 선명함, 확대·접근성·클릭 판정이 쉬움. Canvas는 그림 맞히기에서만 | | 글꼴 | Pretendard(오픈 라이선스) 자체 호스팅, 한글 서브셋 | 가독성, 외부 CDN 의존 없음 | | 배포 | Docker Compose: app 컨테이너 + Caddy(HTTPS, 압축, 정적 파일) | 단순, 자동 인증서 | ## 2. 전체 구조 ``` 브라우저 (React) │ HTTPS: 정적 파일, /api/* (로그인, 계정, 방 만들기/조회) │ WSS: /ws (방 입장, 행동, 상태 수신, 채팅) ▼ Caddy ── 정적 파일(/assets 장기 캐시) ── gzip/zstd 압축 │ reverse proxy ▼ Bun 서버 (단일 프로세스) ├─ HTTP API (Hono) : auth, me, rooms, games 목록 ├─ WS 게이트웨이 : 연결 인증, 메시지 검증, 속도 제한, 하트비트 ├─ RoomManager : 방 생성/조회/정리, 6자리 코드 발급 │ └─ Room (방 하나) : 자리, 준비 상태, 채팅, 타이머, GameRunner │ └─ GameRunner : GameDefinition 호출(validate/apply/view), 시퀀스 번호, 저장 ├─ Persistence : SQLite (users, sessions, rooms, room_log, results) └─ games/* : 게임별 순수 규칙 모듈 (I/O 없음) ``` ## 3. 저장소 구조 (bun workspaces 모노레포) ``` / ├─ apps/ │ ├─ server/src/ │ │ ├─ index.ts 서버 시작, 종료 신호 처리 │ │ ├─ http/ Hono 라우트 (auth, me, rooms) │ │ ├─ ws/ 게이트웨이, 연결 상태, 속도 제한 │ │ ├─ rooms/ RoomManager, Room, GameRunner, 타이머 │ │ ├─ auth/ 디스코드 OAuth, 게스트, 세션 │ │ └─ db/ 스키마, 마이그레이션, 쿼리 │ └─ web/src/ │ ├─ main.tsx, app/ 라우터, 레이아웃, 설정(글자 크기·테마) │ ├─ pages/ 홈, 게임 상세, 방(대기실/게임), 계정, 참여(/j/:code) │ ├─ net/ WS 클라이언트(재연결, 시퀀스), API 클라이언트 │ ├─ components/ 버튼, 카드, 모달, 토스트, 타이머 링, 채팅 │ └─ games// 게임별 화면 (lazy) ├─ packages/ │ ├─ shared/ 프로토콜 타입, zod 스키마, 공용 상수(닉네임 규칙 등) │ ├─ engine/ GameDefinition 타입, 시드 RNG, 카드/덱 유틸, 테스트 도구 │ └─ games/src// 게임 규칙 (index.ts, rules/*.ts, *.test.ts) ├─ deploy/ Dockerfile, docker-compose.yml, Caddyfile └─ docs/ ``` 규칙: - `packages/games`는 I/O, 시간(`Date.now`), `Math.random`을 쓰지 않는다(린트로 금지). 시간과 난수는 `ctx`로만 받는다. - 웹은 숨겨진 정보가 있는 게임의 규칙 코드를 import하지 않는다(서버가 준 view만 사용). 완전 정보 게임(오목·바둑·체스 등)은 둘 수 있는 곳 표시를 위해 규칙 코드를 import할 수 있다. - 서버와 웹은 `packages/shared`의 타입·스키마로만 통신한다. ## 4. 방 하나의 처리 흐름 1. 클라이언트가 `{t:"act", room, clientSeq, action}` 전송. 2. 게이트웨이: 세션 확인 → 메시지 크기·속도 제한 → zod 스키마 검사. 3. Room: 보낸 사람이 그 방의 플레이어인지, 게임이 진행 중인지 확인. 4. GameRunner: `validate()` → 실패면 그 사람에게만 `{t:"reject", clientSeq, reason}`. 5. 성공이면 `apply()` → 새 상태 + 이벤트. **먼저 DB에 행동 기록을 쓰고(같은 틱 안의 동기 SQLite 쓰기)**, 그다음 방 시퀀스 번호를 올리고 브로드캐스트. 6. 각 접속자에게 `view(state, 그 사람)`을 계산해 `{t:"state", seq, view, events}` 전송. 관전자는 `view(state, null)` 하나를 계산해 공유. 7. 타이머 재설정(`deadline()`), 끝났으면 `result()` 저장. 방 하나의 처리는 자바스크립트 단일 스레드에서 순서대로 실행되므로 같은 방에서 경쟁 상태가 생기지 않는다. 한 행동 처리(검증+적용+저장+전송)는 1~5ms 안에 끝나야 하며, 무거운 계산(예: 마작 점수 계산)도 이 안에 들어오도록 테스트한다. ## 5. 왜 단일 프로세스인가 - 목표 규모(동시 2,000명, 방 500개)는 Bun 프로세스 하나로 충분하다(방당 초당 행동 수가 매우 적음). - 프로세스가 하나면 방 상태를 메모리에 두고 잠금 없이 처리할 수 있어 버그가 줄고 빠르다. - 확장이 필요해지면: 방 코드 기준으로 여러 프로세스에 나누고(코드 → 워커 매핑), Caddy가 `/ws?room=` 기준으로 라우팅. 이건 로드맵 M9 이후 검토(`12-roadmap.md`). ## 6. 의존성 원칙 - 런타임 의존성은 최소화. 추가 전에 "직접 50줄로 되는가?"를 먼저 따진다. - 허용 목록(초기): hono, zod, react, react-dom, react-router, zustand, qrcode(작은 QR 생성기). 체스는 `chess.js` 사용 여부를 `games/chess.md`에서 결정. - 버전은 lockfile로 고정하고, 업데이트는 테스트 통과 후에만.