하나의 API, N개의 게임: 통합 게임 클라이언트를 위한 클라이언트 권한 이벤트 소싱
클러스터에 새 보드게임을 추가하는 일은 예전엔 3주짜리 백엔드 작업이었습니다. 이번 재설계 후 YINSH는 주말 하나, 오후 한 번, 그리고 레지스트리에 코드 열 줄로 끝났습니다. 핵심 전환은 이것입니다: 서버는 게임을 이해하지 않고 불투명한 이벤트를 순서대로 저장하며, 클라이언트가 그것을 재생합니다. 클러스터 포스트 이후로, “새 게임 출시”는 클라이언트 단독 프로젝트가 되었습니다.
- 체스 전용 백엔드는 Alkagi, Horror Race, Fruit Shop를 위해 네 개의 병렬 서비스로 갈라지기 직전이었습니다.
- 2026-02-24 로드맵에 박힌 전환: 클라이언트 권한 시뮬레이션 + 서버는 타입드 이벤트 릴레이.
- 다섯 단계: 인증 API, v2 게임 API, 엔진의 클라이언트 이전,
board-game.wintersalmon.com의 통합board-game-client. - 2026-04-25에 합류한 YINSH는
gameType값 하나, 레지스트리 항목 하나, 검증기 switch case 하나를 추가했습니다. 새 엔드포인트는 0개.
체스 모양은 두 번째 게임을 만나자마자 무너졌습니다
체스 서비스는 게임별 엔드포인트(POST /api/v1/chess/move, POST /api/v1/chess/lobby/create), functions/chess/ 아래의 Knative 함수 8개, 그리고 체스 이벤트를 알고 있는 전용 SSE 릴레이(game-event-relay-service)를 가지고 있었습니다. 서버는 검증을 위해 @cloudnest/chess-engine을 import했습니다. 같은 방식으로 Alkagi를 추가한다는 건 평행한 functions/alkagi/ 트리, alkagi 서버 측 검증기, 같은 바이너리 안의 두 번째 엔진을 의미했습니다. 게임 4개면 포크 4개입니다.
더 깊은 실패 모드는 릴리스 결합이었습니다. 체스 엔진을 올리면 Alkagi 서버까지 출시되는 상황. 포크는 매번 한 발자국 앞에 있었습니다.
서버는 게임 엔진을 import하지 않습니다
docs/task-log/archive/2026/20260224-game-platform-api-rearchitecture-roadmap.md에 담긴 두 가지 설계 결정입니다.
- 클라이언트 권한 시뮬레이션. 엔진은 클라이언트에서 동작합니다. 서버는 절대 import하지 않습니다.
- 타입드 이벤트 릴레이로서의 서버. 불투명 이벤트를 순서대로 저장하고 SSE로 재방송합니다. 상태는
seq 0부터 이벤트를 재생한 결과입니다.
새 게임 추가는 이제 gameType 유니온에 값 하나, 클라이언트 측 레지스트리에 항목 하나를 더하는 일입니다. 로드맵의 단계 순서는 의도된 것이었습니다. 인증은 어떤 것이 그것을 재사용하기 전에 단단해야 했고, 릴레이는 적어도 한 게임이 v2 계약을 증명하기 전까지는 만들 가치가 없었습니다.
Phase 1은 클라이언트가 문자열을 파싱하기 전에 errorCode를 고정했습니다
/api/v1/auth/* 아래 4개 엔드포인트(register/login/me/logout)는 11개의 errorCode 값으로 에러 봉투를 잠갔습니다: INVALID_JSON_BODY, VALIDATION_FAILED, EMAIL_ALREADY_REGISTERED, INVALID_CREDENTIALS, UNAUTHORIZED, REGISTRATION_DISABLED, METHOD_NOT_ALLOWED, REQUEST_BODY_TOO_LARGE, ACCOUNT_LOCKED, RATE_LIMITED, INTERNAL_SERVER_ERROR. 프론트엔드는 사람이 읽는 문자열이 아니라 코드로 분기합니다. 토큰은 httpOnly 쿠키에 담겼고(accessToken 1d, refreshToken 7d), CSRF는 double-submit cookie + X-CSRF-Token 헤더로 처리했습니다.
재사용 가능한 손잡이는 packages/shared-auth-client의 authPathPrefix 설정이었습니다. 앱들은 하위 호환을 위해 /auth를 기본값으로 두되 새 표면을 치려면 /api/v1/auth를 전달합니다. 모든 클라이언트를 한 번에 다시 쓰지 않고도 새 인증을 롤아웃할 수 있었던 유일한 이유가 이 설정 하나입니다. playground.wintersalmon.com의 auth-playground는 검증용 하네스였습니다. 멀티플레이어 체스 UI를 통해 인증 버그를 디버깅하는 건 자학입니다.
v2 표면은 의도적으로 게임에 무관합니다
GET /api/v2/games list rooms
POST /api/v2/games create (body: gameType)
POST /api/v2/games/:id/join
POST /api/v2/games/:id/start
POST /api/v2/games/:id/submit emit event
GET /api/v2/games/:id/events SSE replay
GET /api/v2/players/me/stats
Phase 3의 완료 조건은 grep이었습니다. 체스 클라이언트의 API 모듈에 /api/v1/ 참조가 0개여야 합니다. 마지막까지 남았던 v1 잔재는 augmented-chess-client/game-api.ts:147의 GET /api/v1/players/me/stats 호출이었습니다. 이를 잘라내려면 functions/game/player/get-stats/와 shared-game-client/room-api.ts의 getMyStats(gameType?)이 필요했습니다.
Phase 3은 또한 실제 SSE 버그 하나를 고쳤습니다. 매치 중 끊김, 상대가 둠, 재접속 — 빠진 이벤트가 사라졌습니다. 수정안은 isFirstConnectRef와, 첫 번째 이후 모든 재접속에서 영속된 시퀀스 번호로부터 명시적으로 replayEvents()를 호출하는 것이었습니다. augmented-chess-client/GameScreen.tsx와 alkagi-client/MultiplayerGame.tsx에 같은 패턴. 수동 재현: 끊고, 상대가 두고, 재접속하면 빠졌던 수가 나타납니다.
새 게임이 실제로 꽂히는 곳은 레지스트리입니다
board-game.wintersalmon.com의 apps/board-game-client/는 인증 흐름 하나, 로비 하나, 게임 룸 화면 하나입니다. 라우트는 /<gameType>/... 접두사를 사용합니다. LobbyScreen과 GameRoomScreen은 gameType으로 매개변수화된 제네릭이고, 게임별 UI는 lazy-loaded 되는 각 GameScreen 안에만 존재합니다.
chess: {
gameType: "chess",
displayName: "Augmented Chess",
engine: "@cloudnest/augmented-chess-engine",
CreateGameForm: ChessCreateGameForm,
GameScreen: lazy(() => import("./games/chess/GameScreen")),
},
K8s 배포는 표준 FluxCD 패턴 그대로였습니다. ImageRepository + ImagePolicy, board-game.wintersalmon.com을 위한 인그레스, 기존 인증서에 추가된 TLS SAN, 9개의 워크스페이스 의존성을 위한 두 단계 Dockerfile COPY. 클러스터의 다른 모든 클라이언트 앱과 같은 모양입니다.
YINSH가 주말 하나로 아키텍처를 검증했습니다
YINSH는 2026-04-25에 들어왔습니다. 79칸 육각 보드, 다섯 게임 페이즈, 다섯 액션 타입, 11개 파일에 걸친 58개 단위 테스트(packages/yinsh-engine/). 멀티플레이어 통합은 약 열 줄이었습니다 — 레지스트리에 육각 아이콘과 함께 gameType: "yinsh"를 등록하고, GameScreen을 lazy-load하고, 멀티플레이어 스토어를 createRoomApi("yinsh")로 향하게 합니다. 서버 검증기는 apps/game-validator/src/validators/yinsh.ts에 switch case 하나를 추가했습니다. 새 엔드포인트, 새 인그레스 규칙, 새 TLS SAN, 새 이미지 자동화 — 전부 0개. board-game.wintersalmon.com/games/yinsh는 거저 라이브가 되었습니다. YINSH 전용 서버 코드는 불투명 이벤트 검증뿐입니다 — 서버는 여전히 ring이나 marker가 무엇인지 모릅니다.
#game-platform #event-sourcing
AI 워크플로우 메모
Claude는 어떤 코드도 출시되기 전에 다섯 단계 로드맵을 기획 문서로 작성했습니다. planner 에이전트에게 Phase 1이 시작되기 전 리스크를 열거하도록 요청했고, 그것이 표면화한 두 가지 — 클라이언트 권한 시뮬레이션의 치트 표면, 그리고 “게임별 서버 훅 하나만 더”라는 스코프 크리프 — 가 v2 계약(불투명 페이로드, 서버는 게임 엔진을 절대 import하지 않는다는 강한 규칙)을 그대로 빚었습니다. 진짜 중요한 규율은 다음 단계로 가기 전에 각 단계를 끝에서 끝까지 마치는 것이었습니다. 인증은 v2 표면이 시작되기 전에 작동하는 플레이그라운드를 가졌고, v2 표면은 엔진이 서버를 떠나기 전에 실제 클라이언트를 가졌습니다. 나중에 더 작은 기능에서 그 규칙을 어겼을 때 디버깅 비용이 “절약했다”고 느꼈던 시간보다 더 들었습니다.
