|
| 1 | +# 멀티 결과창 화면상태 동기화 기획안 (Frontend 중심) |
| 2 | + |
| 3 | +## 0. 문서 목적 |
| 4 | + |
| 5 | +- 게임 종료 후 `FinalResults` 화면과 `RoomView` 사이에서 플레이어의 현재 화면 상태(`IN_GAME`, `RESULT`, `ROOM`)를 실시간으로 동기화한다. |
| 6 | +- 현재 코드 구조를 기준으로, 구현 난이도 대비 리스크가 낮은 경로를 우선 채택한다. |
| 7 | +- 백엔드 문서 2종의 충돌 지점을 정리하고, 현 코드베이스에 맞는 단일 실행안을 제시한다. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## 1. 현재 플로우 재정리 (코드 기준) |
| 12 | + |
| 13 | +### 1-1. Room -> Game |
| 14 | + |
| 15 | +1. `RoomView.vue`에서 `useRoom.initializeRoom()` 실행 |
| 16 | +2. `roomWebSocketService.connectToRoom()`가 `/topic/room/{roomId}/playerList` 포함 방 채널 구독 |
| 17 | +3. 게임 시작 시 `navigateToSoloGame()`로 `BaseGameView.vue` 진입 |
| 18 | +4. 라우팅 직전 `prepareForGameNavigation()`으로 Room leave 이벤트는 스킵하고 room 채널만 해제 |
| 19 | + |
| 20 | +### 1-2. Game 진행 -> 종료 |
| 21 | + |
| 22 | +1. `BaseGameView.vue`에서 `useSoloGameFlow.initializeFromServerStart(roomId)` 실행 |
| 23 | +2. `soloGameWebSocket.js`가 `/topic/game/{roomId}/game/finished` 수신 |
| 24 | +3. `onGameFinish` 콜백에서 `showGameResults = true`로 `FinalResults` 표시 |
| 25 | + |
| 26 | +### 1-3. 종료 후 이동 |
| 27 | + |
| 28 | +1. `FinalResults`에서 "방으로 돌아가기" 클릭 시 `BaseGameView.restartGame()` 호출 |
| 29 | +2. Room detail API 조회 후 `RoomView`로 라우팅 |
| 30 | +3. 현재는 이 구간 어디에서도 "내 화면이 RESULT/ROOM인지"를 서버에 전송하지 않음 |
| 31 | + |
| 32 | +--- |
| 33 | + |
| 34 | +## 2. 핵심 문제점 |
| 35 | + |
| 36 | +1. **상태 이벤트 미전송** |
| 37 | + - 게임 종료 후/방 복귀 시점에 화면 상태를 발행하지 않으므로 상대 클라이언트가 상태를 알 수 없다. |
| 38 | + |
| 39 | +2. **수신 타입 미지원** |
| 40 | + - `roomWebSocket.service.js`의 `GAME_ROOM_NOTIFICATION_TYPES`에 `SCREEN_STATE_UPDATED`가 없어, 백엔드에서 내려도 기본 분기에서 무시된다. |
| 41 | + |
| 42 | +3. **플레이어 모델에 화면상태 필드 없음** |
| 43 | + - `useRoom.transformGameRoomPlayers()` 결과에 `screenState`, `screenStateSeq`, `screenStateUpdatedAt`가 없다. |
| 44 | + - 따라서 UI 배지/문구로 노출 불가. |
| 45 | + |
| 46 | +4. **역전 방지 규칙 부재** |
| 47 | + - 델타 이벤트와 전체 동기화가 섞일 때, 클라이언트에서 `seq` 기준 드롭 로직이 없어 상태 역전 가능성이 있다. |
| 48 | + |
| 49 | +5. **문서 스펙 충돌** |
| 50 | + - 문서 A: 기존 `/topic/room/{roomId}/playerList` 통합 |
| 51 | + - 문서 B: 신규 `/topic/game/{roomId}/screen/state` + snapshot |
| 52 | + - 현 코드 구조는 Room 채널 중심이므로 통합안이 변경 범위/리스크 측면에서 우세. |
| 53 | + |
| 54 | +--- |
| 55 | + |
| 56 | +## 3. 아키텍처 결정 (권고안) |
| 57 | + |
| 58 | +## 3-1. 채널 전략 |
| 59 | + |
| 60 | +- **P0 채택**: `playerList` 통합 전략 |
| 61 | + - 수신: `/topic/room/{roomId}/playerList` |
| 62 | + - 타입: `SCREEN_STATE_UPDATED`(delta) + `PLAYER_LIST_UPDATED`(full sync) |
| 63 | +- 이유: |
| 64 | + 1. RoomView가 이미 해당 채널을 구독 중 |
| 65 | + 2. 기존 알림 파이프라인(`GameRoomNotification`) 재사용 가능 |
| 66 | + 3. 신규 채널/권한/구독 lifecycle 추가 없이 구현 가능 |
| 67 | + |
| 68 | +## 3-2. 발행 전략 |
| 69 | + |
| 70 | +- 송신 endpoint는 백엔드 문서 기준으로 고정: |
| 71 | + - `/app/room.{roomId}.screen.state` |
| 72 | + - body: `{ state, clientSeq, clientTimestamp }` |
| 73 | + |
| 74 | +## 3-3. 상태 정합성 전략 |
| 75 | + |
| 76 | +- 클라이언트는 memberId 단위로 마지막 `screenStateSeq`를 유지 |
| 77 | +- 적용 규칙: |
| 78 | + - `incomingSeq < localSeq` -> drop |
| 79 | + - `incomingSeq === localSeq` -> no-op |
| 80 | + - `incomingSeq > localSeq` -> apply |
| 81 | +- `PLAYER_LIST_UPDATED`에서도 동일 규칙으로 멤버별 적용 |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## 4. Frontend 설계 상세 |
| 86 | + |
| 87 | +## 4-1. 데이터 모델 |
| 88 | + |
| 89 | +- 공통 enum: |
| 90 | + - `IN_GAME | RESULT | ROOM | DISCONNECTED` |
| 91 | +- P0 운영 상태: |
| 92 | + - 송신: `IN_GAME`, `RESULT`, `ROOM` |
| 93 | + - `DISCONNECTED`는 서버 leave 정책과 충돌 소지가 있어 P1 이후 검토 |
| 94 | + |
| 95 | +플레이어 객체 확장(최소): |
| 96 | + |
| 97 | +```ts |
| 98 | +interface RoomPlayer { |
| 99 | + id: string; |
| 100 | + memberId: string | number; |
| 101 | + nickname: string; |
| 102 | + // ...existing |
| 103 | + screenState?: "IN_GAME" | "RESULT" | "ROOM" | "DISCONNECTED"; |
| 104 | + screenStateSeq?: number; |
| 105 | + screenStateUpdatedAt?: number; |
| 106 | +} |
| 107 | +``` |
| 108 | + |
| 109 | +## 4-2. 상태 동기화 유틸 (신규) |
| 110 | + |
| 111 | +- 위치: `src/features/game/multiplayer/room/services/screenStateSync.service.js` (권장) |
| 112 | +- 책임: |
| 113 | + 1. `clientSeq` 증가기 관리(세션 단위) |
| 114 | + 2. `sendScreenState(roomId, state)` |
| 115 | + 3. 수신 이벤트 merge 유틸(`applyDelta`, `applyFullSync`) |
| 116 | + 4. 상태 문구 변환 유틸(`toLabel`) 제공 |
| 117 | + |
| 118 | +## 4-3. Room 수신 처리 |
| 119 | + |
| 120 | +- `webSocketChannels.js` |
| 121 | + - `GAME_ROOM_NOTIFICATION_TYPES.SCREEN_STATE_UPDATED` 추가 |
| 122 | +- `roomWebSocket.service.js` |
| 123 | + - `_handleGameRoomNotification`에 `SCREEN_STATE_UPDATED` 분기 추가 |
| 124 | + - payload를 `useRoom.handleGameRoomNotification`으로 전달 |
| 125 | +- `useRoom.js` |
| 126 | + - `transformGameRoomPlayers`에서 screen state 필드 매핑 |
| 127 | + - `handleGameRoomNotification`에 delta 머지 로직 추가 |
| 128 | + - `PLAYER_LIST_UPDATED`는 full sync + seq 가드 적용 |
| 129 | + |
| 130 | +## 4-4. Game 화면 송신 처리 |
| 131 | + |
| 132 | +- `BaseGameView.vue` 전송 타이밍: |
| 133 | + 1. 게임 화면 정상 진입 직후 `IN_GAME` 1회 전송 |
| 134 | + 2. `onGameFinish`에서 `showGameResults=true` 직후 `RESULT` 전송 |
| 135 | + 3. `restartGame()`에서 라우팅 직전 `ROOM` 전송 |
| 136 | +- 실패 정책: |
| 137 | + - 전송 실패해도 UX(라우팅/버튼)는 블로킹하지 않음 |
| 138 | + - 대신 RoomView 진입 후 `ROOM` 재전송으로 멱등 복구 |
| 139 | + |
| 140 | +## 4-5. Room 화면 송신 처리 |
| 141 | + |
| 142 | +- `RoomView.vue` 또는 `useRoom.initializeRoom()` 완료 직후 `ROOM` 1회 전송 |
| 143 | +- 목적: |
| 144 | + - 게임 화면에서 ROOM 전송이 누락돼도 Room 진입 시 최종 보정 |
| 145 | + |
| 146 | +## 4-6. UI 반영 |
| 147 | + |
| 148 | +### Result 화면 |
| 149 | + |
| 150 | +- `FinalResults.vue`에 "상대 화면 상태" 섹션 추가 |
| 151 | +- 문구 매핑: |
| 152 | + - `RESULT`: 상대가 결과 화면에 있습니다 |
| 153 | + - `ROOM`: 상대가 방으로 돌아왔습니다 |
| 154 | + - `IN_GAME`: 상대가 게임 화면에 있습니다 |
| 155 | + - `DISCONNECTED`: 상대 연결이 일시 끊겼습니다 |
| 156 | + |
| 157 | +### Room 화면 |
| 158 | + |
| 159 | +- `SoloWaitingList.vue`, `TeamWaitingList.vue`(또는 `shared Player/Card.vue`)에 상태 배지 추가 |
| 160 | +- 예시: |
| 161 | + - RESULT 배지: "결과 화면" |
| 162 | + - ROOM 배지: "방 대기" |
| 163 | + - IN_GAME 배지: "게임 중" |
| 164 | + |
| 165 | +--- |
| 166 | + |
| 167 | +## 5. 문서 충돌 해소안 |
| 168 | + |
| 169 | +## 5-1. 충돌 정리 |
| 170 | + |
| 171 | +- 문서 A(백엔드 재검증본): playerList 통합 + delta/full |
| 172 | +- 문서 B(프론트 명세): 전용 채널 + snapshot |
| 173 | + |
| 174 | +## 5-2. 최종 채택 |
| 175 | + |
| 176 | +- **P0는 문서 A 채택** (현 레포 구조와 일치) |
| 177 | +- 문서 B의 snapshot 개념은 **P1 옵션**으로 유지 |
| 178 | + - 조건: 백엔드가 snapshot endpoint를 실제 제공할 때만 활성화 |
| 179 | + |
| 180 | +## 5-3. 구현 시 주의 |
| 181 | + |
| 182 | +- FE 코드에서 채널 문자열 하드코딩 금지, constants 경유 |
| 183 | +- 타입 미등록 시 silent drop이 발생하므로 notification type 동기화를 우선 처리 |
| 184 | + |
| 185 | +--- |
| 186 | + |
| 187 | +## 6. 단계별 실행 계획 |
| 188 | + |
| 189 | +## P0 (필수) |
| 190 | + |
| 191 | +1. notification type/모델 확장 |
| 192 | +2. Room 수신 파이프라인에 `SCREEN_STATE_UPDATED` 반영 |
| 193 | +3. `BaseGameView`/`RoomView` 전송 타이밍 반영 |
| 194 | +4. Result/Room UI 최소 노출 반영 |
| 195 | +5. seq 역전 방지 로직 적용 |
| 196 | + |
| 197 | +완료 기준: |
| 198 | + |
| 199 | +- 2인 방에서 A가 결과창에 남고 B가 방으로 이동할 때, B 화면에서 A 상태가 1초 내 `RESULT`로 보인다. |
| 200 | +- A가 방으로 이동하면 B 화면에서 A 상태가 1초 내 `ROOM`으로 변경된다. |
| 201 | + |
| 202 | +## P1 (정합성 강화) |
| 203 | + |
| 204 | +1. 재연결 후 보정 전략 추가(가능 시 snapshot 요청) |
| 205 | +2. stale drop 카운트 로깅 |
| 206 | +3. join 이벤트 payload 재검증(필드 유실 여부) |
| 207 | + |
| 208 | +완료 기준: |
| 209 | + |
| 210 | +- 중복/지연 이벤트 주입 시 상태 역전이 재현되지 않는다. |
| 211 | + |
| 212 | +## P2 (운영 고도화) |
| 213 | + |
| 214 | +1. `DISCONNECTED` 정책을 leave 정책과 함께 재정의 |
| 215 | +2. 대규모 room 이벤트 부하에서 상태 반영 지연 측정 |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## 7. 테스트 시나리오 |
| 220 | + |
| 221 | +## 7-1. 기능 테스트 |
| 222 | + |
| 223 | +1. 게임 종료 직후 `RESULT` 전송/수신 확인 |
| 224 | +2. 방 복귀 클릭 직후 `ROOM` 전송/수신 확인 |
| 225 | +3. Room 최초 진입 시 `ROOM` 재전송 멱등 확인 |
| 226 | + |
| 227 | +## 7-2. 정합성 테스트 |
| 228 | + |
| 229 | +1. 동일 memberId에 `seq` 역전 이벤트 주입(`12 -> 11`) 시 drop |
| 230 | +2. 동일 `seq` 재수신 시 no-op |
| 231 | +3. delta 후 full sync, full sync 후 delta 순서 뒤바뀜 시 최종 상태 불변 |
| 232 | + |
| 233 | +## 7-3. 회귀 테스트 |
| 234 | + |
| 235 | +1. 기존 `PLAYER_LIST_UPDATED`/`PLAYER_JOINED`/`PLAYER_LEFT` 정상 동작 |
| 236 | +2. 더미 모드에서 화면 상태 기능 미활성 또는 안전 무시 |
| 237 | +3. 자동 퇴장(30초) 경로에서 leave/라우팅 회귀 없음 |
| 238 | + |
| 239 | +--- |
| 240 | + |
| 241 | +## 8. 리스크 및 대응 |
| 242 | + |
| 243 | +1. **백엔드-프론트 스펙 불일치** |
| 244 | + - 대응: 채널/타입/payload를 constants와 타입가드로 중앙화 |
| 245 | + |
| 246 | +2. **상태 역전으로 인한 UX 혼란** |
| 247 | + - 대응: memberId별 seq 가드 강제 |
| 248 | + |
| 249 | +3. **재연결 시 이벤트 유실** |
| 250 | + - 대응: P0는 full sync 복구, P1에서 snapshot 보강 |
| 251 | + |
| 252 | +4. **UI 과노출(배지 과다)** |
| 253 | + - 대응: Result/Room에서 상대 상태 중심으로 최소 노출 |
| 254 | + |
| 255 | +--- |
| 256 | + |
| 257 | +## 9. 작업 파일 제안 |
| 258 | + |
| 259 | +- `src/features/game/multiplayer/room/constants/webSocketChannels.js` |
| 260 | +- `src/features/game/multiplayer/room/services/roomWebSocket.service.js` |
| 261 | +- `src/features/game/multiplayer/room/composables/useRoom.js` |
| 262 | +- `src/features/game/multiplayer/roadview/views/BaseGameView.vue` |
| 263 | +- `src/features/game/multiplayer/roadview/components/results/FinalResults.vue` |
| 264 | +- `src/features/game/multiplayer/room/components/list/SoloWaitingList.vue` |
| 265 | +- `src/features/game/multiplayer/room/components/list/TeamWaitingList.vue` |
| 266 | +- `src/features/game/shared/components/Player/Card.vue` (팀모드 배지 공통화 시) |
| 267 | +- `src/features/game/multiplayer/room/services/screenStateSync.service.js` (신규) |
| 268 | + |
| 269 | +--- |
| 270 | + |
| 271 | +## 10. 최종 결론 |
| 272 | + |
| 273 | +- 현 코드베이스에서 성공 확률이 가장 높은 경로는 **playerList 통합 + SCREEN_STATE_UPDATED delta 적용**이다. |
| 274 | +- P0의 본질은 "전송 타이밍 보장"과 "클라이언트 seq 가드"이며, 이 둘이 없으면 상태 표시는 재현성 있게 깨진다. |
| 275 | +- 따라서 이번 이슈는 UI 추가보다 먼저, **Room 수신 파이프라인 정합성 확보**를 우선 구현해야 한다. |
0 commit comments