Wintersalmon | Blog

보드게임 v2 마이그레이션: 한 클라이언트에서 게임별 런타임으로

4 min read

보드게임 v2 마이그레이션의 핵심은 “화면은 공유하되 규칙 런타임은 게임별로 분리한다”는 결정이었습니다. board-game-client는 여전히 하나의 SPA로 배포되지만, 실제 게임 진행은 /api/v2/game-platformboard-game-rules-*-v2 서비스 조합으로 흘러갑니다. 2026년 6월 12일 기준 docs/task-log/20260608-board-game-v2-separate-spa-migration/16-game-status-dashboard.md는 16개 게임 모두 [V2 PROD LIVE]로 표시합니다.

  • 클라이언트 URL은 /v2/games/<game>로 분리했고, prod catalog가 노출 여부를 결정합니다.
  • 각 게임 규칙은 /health, /manifest, /transition을 가진 stateless rule service가 담당합니다.
  • game-api-goclientCommandIdexpectedSequenceNumber를 검사한 뒤, Mongo에 event/state/viewer-state를 원자적으로 기록합니다.
  • hidden-information 게임은 public event와 private viewer-states를 분리했습니다.
  • 마이그레이션은 기존 v1 route를 남겨 둔 채 additive하게 진행했고, 마지막 YINSH closeout은 PR #1078로 닫았습니다.

배포 단위가 문제였고, URL은 해결책의 일부였습니다

처음 문제는 간단했습니다. 게임은 많아졌고, 모든 게임의 클라이언트와 엔진과 검증 로직이 하나의 배포 버전에 묶였습니다. Black and White의 작은 UI 수정도 YINSH, Horror Race, Chess가 같은 client image를 함께 타야 했습니다.

처음에는 게임별 독립 SPA를 생각했습니다. 하지만 실제 구현을 검토하니 로그인, 로비, 방 입장, WebSocket 연결, observer UI는 거의 모두 공통이었습니다. 그래서 최종 선택은 “공통 client shell + path-scoped game runtime”이었습니다. apps/board-game-client/src/router.ts/v2/games/<game>를 해석하고, go/internal/game/catalog.go가 그 game이 어떤 rule service를 써야 하는지 결정합니다.

서버는 규칙을 알지만, 상태를 직접 발명하지 않습니다

v2에서 서버의 역할은 더 명확해졌습니다. 클라이언트가 command를 보내면 go/cmd/game-api/handlers/platform_v2.go가 현재 상태와 command를 묶어 rule service의 /transition으로 보냅니다. rule service는 유효성, 다음 상태, 저장할 event payload, 필요하면 viewer-state까지 계산해 돌려줍니다.

핵심은 이 요청이 stateless라는 점입니다. 예를 들어 YINSH rule service는 apps/game-validator/src/rule-services/yinsh-server.ts entrypoint로 떠 있고, currentState + command -> transition result만 계산합니다. Cascadia, Warchest, Celestia 같은 다른 게임도 같은 형태로 배치됩니다. replay, 테스트, rollback이 쉬워진 이유가 여기에 있습니다.

private viewer-state가 hidden game을 열었습니다

공개 정보 게임은 비교적 단순했습니다. Cascadia나 Chess는 모든 참가자가 같은 canonical state를 봐도 됩니다. 반면 black-and-white, indian-poker, minus-auction, fruit-shop-v2, fish-shop-v2, warchest, one-night-ultimate-werewolf, celestia는 각 플레이어가 볼 수 있는 정보가 다릅니다.

v2는 이 차이를 catalog의 projectionPolicy로 표현합니다. public 게임은 event log와 current state를 그대로 보여 줄 수 있습니다. private 게임은 rule service가 viewerStates를 만들어 player와 observer별로 다른 projection을 저장합니다. 이 설계가 없었다면 hidden game의 prod promotion은 계속 “나중에”로 밀렸을 것입니다.

WebSocket은 상태를 소유하지 않고 변경을 전파합니다

stateful해야 하는 부분은 WebSocket 연결뿐입니다. ws-relay-go는 규칙을 실행하지 않습니다. Mongo change stream을 보고 연결된 클라이언트에게 event/state 변경을 fan-out합니다. 그래서 실제 state transition은 HTTP submit 경로 하나로 모이고, WS는 실시간 배달 계층으로 남습니다.

이 분리는 테스트에도 영향을 줬습니다. L1은 engine/rule-service, L2는 catalog와 transition contract, L3는 backend-free replay, L4는 host/guest/observer live flow가 맡습니다. 마지막 YINSH 검증에서는 prod와 staging 모두에서 direct /transition(init)RING_PLACEMENT를 반환했고, 세 브라우저가 /v2/games/yinsh/rooms/<roomId>에 도달했습니다.

마이그레이션은 병렬이었지만, 노출은 직렬이었습니다

전체 작업은 게임별 세션으로 나눴습니다. 한 세션은 rule-service와 client route-mode를 만들고, 다른 세션은 공통 catalog와 Kubernetes pin을 다뤘습니다. 하지만 production catalog 노출은 한 번에 하나씩 닫았습니다. docs/task-log/20260608-board-game-v2-separate-spa-migration/13-version-changelog.md가 그 순서를 기록합니다.

이 방식은 느려 보이지만 실제로는 회복력이 좋았습니다. 문제가 생기면 catalog에서 game 하나를 빼거나, game-api-go 또는 해당 board-game-rules-*-v2 image만 되돌리면 됩니다. PR #1054는 final-wave 게임 4개를 prod로 올렸고, PR #1078은 마지막 YINSH route-mode gap을 닫았습니다.

다음 정리는 v1 URL 승격입니다

지금 /v2/games/<game>은 마이그레이션 중 안전장치였습니다. 다음 단계는 v2를 기존 public URL로 승격하는 것입니다. 예를 들어 /v2/games/chess/rooms/<id>가 아니라 기존 사용자가 아는 /chess/games/<id>가 platform-backed v2 방을 열어야 합니다.

이 작업은 삭제부터 하면 안 됩니다. 먼저 root-level route를 v2 semantics로 바꾸고, /v2는 alias 또는 redirect로 남겨야 합니다. 그 다음 /api/v2/games legacy 호출량이 0이 되는지 보고, 마지막으로 legacy route-mode와 old validator deployment를 제거합니다. 계획은 docs/task-log/20260608-board-game-v2-separate-spa-migration/21-v1-cleanup-url-cutover-plan.md에 따로 분리했습니다.

#board-game #architecture #migration

AI 워크플로우 메모

이번 글은 Codex 세션이 16-game-status-dashboard.md, 13-version-changelog.md, 그리고 YINSH closeout 로그를 읽고 초안을 구성했습니다. 효과가 있었던 프롬프트 패턴은 “완료 상태, 실제 배포 artifact, 다음 cleanup 계획을 한 글 안에 연결하라”였습니다. 실패하기 쉬운 지점은 architecture를 과장해서 쓰는 것이었고, 그래서 PR 번호, image tag, 파일 경로를 문장마다 고정점으로 넣었습니다. 글은 먼저 DRAFT- 파일로 리뷰한 뒤, v1 cleanup 계획이 준비된 다음 정식 post로 승격했습니다.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.