From afa57b70deb409a5aa870635fbcea26956a348ea Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 18 Jun 2026 17:06:59 +0900 Subject: [PATCH] =?UTF-8?q?docs(user-creator-chat):=20WebSocket=20Phase=20?= =?UTF-8?q?2=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 22 +++++++++++++++++-- .../prd.md | 21 +++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md index 0d54ecb3..9a94f921 100644 --- a/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md +++ b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md @@ -403,7 +403,7 @@ spring: ### Phase 2: 메시지 프로토콜과 local session registry 추가 -- [ ] **Task 2.1: WebSocket message envelope 정의** +- [x] **Task 2.1: WebSocket message envelope 정의** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt` @@ -416,8 +416,14 @@ spring: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` - Expected: `BUILD SUCCESSFUL` - REFACTOR: payload는 초기 구현에서 `JsonNode`로 받고, 타입별 request DTO 변환은 handler dispatch에서 수행한다. + - 검증 기록: + - 무엇: WebSocket request/response envelope와 message type enum을 추가했다. + - 왜: Phase 4 handler dispatch에서 raw JSON 메시지를 공통 계약으로 역직렬화하기 위해서다. + - 어떻게: `UserCreatorChatWebSocketMessage`는 `type`, `requestId`, `roomId`, `payload: JsonNode`를 갖는 data class로 두고, `UserCreatorChatWebSocketMessageType`에 `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`을 정의했다. + - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` 실행 시 `UserCreatorChatWebSocketMessageType`, `UserCreatorChatWebSocketMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다. + - 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 4m 30s`로 통과했다. -- [ ] **Task 2.2: local WebSocket session registry 추가** +- [x] **Task 2.2: local WebSocket session registry 추가** - Files: - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt` @@ -430,6 +436,15 @@ spring: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` - Expected: `BUILD SUCCESSFUL` - REFACTOR: registry는 local session만 관리하고 Redis를 직접 호출하지 않는다. + - 검증 기록: + - 무엇: local WebSocket session registry를 추가했다. + - 왜: 다중 인스턴스 구조에서 현재 서버에 붙은 session만 roomId/memberId/sessionId 기준으로 관리하기 위해서다. + - 어떻게: `ConcurrentHashMap`으로 room/member별 session map과 sessionId index를 관리하고, 같은 session이 다른 room으로 등록되면 기존 room mapping을 제거하도록 했다. 동시 같은 session room 전환은 sessionId hash 기반 고정 striped lock으로 `register`/`remove`를 직렬화했다. Redis 호출은 포함하지 않았다. + - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` 실행 시 `UserCreatorChatWebSocketSessionRegistry` unresolved reference로 `compileTestKotlin` 실패를 확인했다. + - RED: reviewer 지적 후 추가한 동시 같은 session room 전환 테스트가 기존 구현에서 `Expected concurrent same-session room switch to leave exactly one active room mapping` assertion으로 실패함을 확인했다. + - RED: 운영 트래픽에서 sessionId별 lock map이 누적될 수 있다는 리뷰를 반영해 추가한 `sessionId별 lock map을 유지하지 않는다` 테스트가 기존 구현에서 실패함을 확인했다. + - 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 3m 41s`로 통과했다. + - 결과: 실제 실행한 Phase 2 focused 명령 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest`가 `BUILD SUCCESSFUL in 1m 46s`로 통과했다. --- @@ -640,6 +655,9 @@ spring: ## 5. 구현 후 검증 기록 +- Phase 2: + - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` + - Result: `BUILD SUCCESSFUL in 1m 46s`; message envelope enum/JsonNode 역직렬화 테스트 3개와 local session registry 등록/조회/제거/room 전환/동시 같은 session 전환/session lock map 비유지 테스트 6개가 통과했다. - Phase 0: - Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources` - Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다. diff --git a/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md b/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md index b9f532f8..3b016dae 100644 --- a/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md +++ b/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md @@ -166,6 +166,11 @@ ### Feature F. iOS/Android 클라이언트 변경 사항 +#### Current Native App Usage +- 개발 중 테스트 중이던 iOS/Android 네이티브 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용하고 있었다. +- 해당 앱은 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 등 실시간 수신을 중단해야 하는 시점에 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출하고 있었다. +- WebSocket 전환 후에는 위 두 API가 제거되므로, 서버 배포와 같은 릴리스 범위에서 네이티브 앱의 SSE 연결/해제 코드도 함께 변경되어야 한다. + #### Requirements - 클라이언트는 채팅방 화면 진입 시 기존 SSE 연결을 열지 않는다. - 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 조회한다. @@ -173,18 +178,33 @@ - WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer ` 헤더로 전달한다. - WebSocket 연결 직후 `JOIN_ROOM` 메시지를 전송한다. - `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다. +- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다. +- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다. - 텍스트 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다. - 텍스트 메시지 전송 UI는 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다. - `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다. - 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다. - 음성 메시지는 기존 multipart REST API를 유지한다. +- 기존 `events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다. - 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다. - 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다. - 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 REST `messages` API로 누락 메시지를 동기화한다. +- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다. - 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다. - 푸시 알림을 터치하면 payload의 `chat_type == "USER_CREATOR"`와 `room_id`를 확인해 해당 채팅방 화면으로 이동한다. - 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결/`JOIN_ROOM`을 수행한다. - 클라이언트는 제거된 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않는다. +- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다. 텍스트 전송 성공 여부는 WebSocket `SEND_ACK`/`ERROR`/timeout으로 판단한다. + +#### Native App Migration Checklist +- SSE client 또는 `EventSource` wrapper 제거: `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다. +- 연결 확인 기준 변경: SSE `connected` 이벤트 수신 완료를 WebSocket `JOINED` 수신 완료로 대체한다. +- 메시지 수신 기준 변경: SSE `message` event payload append를 WebSocket `MESSAGE` envelope payload append로 대체한다. +- 연결 해제 기준 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 제거하고, 같은 lifecycle 위치에서 `LEAVE_ROOM` 메시지를 보낸 뒤 WebSocket을 close한다. +- 텍스트 전송 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거하고, `SEND_TEXT` 메시지와 `SEND_ACK` 매칭 방식으로 pending/성공/실패 상태를 관리한다. +- 음성 전송 유지: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출은 유지하되, 음성 전송 후 상대방 실시간 수신 여부는 서버 정책에 따른다. +- 토큰 갱신 처리 변경: access token refresh 시 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다. +- 푸시 이동 처리 확인: `chat_type == "USER_CREATOR"`와 `room_id`를 기준으로 채팅방에 진입하고, 진입 후 `openRoom` 호출과 WebSocket `JOIN_ROOM`을 일반 진입과 동일하게 수행한다. #### Edge Cases - WebSocket 연결은 성공했지만 `JOIN_ROOM`이 실패하면 채팅방 화면에 오류를 표시하고 텍스트 전송을 막는다. @@ -250,7 +270,6 @@ - WebSocket `JOIN_ROOM` 성공률 - WebSocket 연결 중 메시지 전송 성공률 - Redis presence TTL 만료로 정리된 orphan session 수 -- 제거된 SSE endpoint 호출량 ---