From 245bae860044ea9b0a9a32d45232c5d91c3b7650 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 18 Jun 2026 12:42:46 +0900 Subject: [PATCH] =?UTF-8?q?docs(user-creator-chat):=20WebSocket=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EA=B3=84=ED=9A=8D=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 596 ++++++++++++++++++ .../prd.md | 258 ++++++++ 2 files changed, 854 insertions(+) create mode 100644 docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md create mode 100644 docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md diff --git a/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md new file mode 100644 index 00000000..41b30969 --- /dev/null +++ b/docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md @@ -0,0 +1,596 @@ +# 유저-크리에이터 채팅 WebSocket 전환 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** 유저-크리에이터 1:1 채팅의 SSE 실시간 연결을 제거하고, 채팅방 화면 진입 중에만 유지되는 raw WebSocket + Redis presence/pub-sub 구조로 전환한다. + +**Architecture:** REST는 방 생성, 방 열기, 과거 메시지 조회, 음성 업로드에 유지하고 텍스트 실시간 송수신과 방 presence는 WebSocket으로 처리한다. 서버는 다중 인스턴스를 전제로 local WebSocket session registry와 Redis presence/pub-sub을 함께 사용한다. 상대방이 해당 `roomId`에 접속 중이면 WebSocket으로만 전달하고, 접속 중이 아니면 기존 FCM/APNs 푸시 흐름에 `roomId`/`chat_type` 이동 정보를 포함해 발송한다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring WebSocket, Spring Data Redis, Redis pub/sub, Spring Data JPA, JUnit 5, Mockito, Gradle Wrapper + +--- + +## 0. 구현 전 확정 사항 + +- 대상 PRD: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md` +- WebSocket endpoint: `/ws/v2/user-creator-chat` +- WebSocket protocol: STOMP 없는 raw JSON envelope +- WebSocket 연결 수명: 채팅방 화면 진입 중에만 유지 +- 서버 인스턴스 전제: 여러 대 +- presence 저장소: Redis +- 서버 간 메시지 전달: Redis pub/sub +- local memory에는 현재 서버에 붙은 WebSocket session만 저장 +- 기존 SSE는 완전히 제거 +- 기존 REST 유지: + - `POST /api/v2/user-creator-chat/rooms/create` + - `GET /api/v2/user-creator-chat/rooms/{roomId}/open` + - `GET /api/v2/user-creator-chat/rooms/{roomId}/messages` + - `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` +- 텍스트 메시지 전송은 WebSocket `SEND_TEXT`로 전환 +- 푸시 발송 기준: + - 상대방이 같은 `roomId`에 WebSocket presence 있음: 푸시 미발송 + - 상대방이 같은 `roomId`에 WebSocket presence 없음: 푸시 발송 +- 푸시 payload 필수값: + - `room_id` + - `message_id` + - `chat_type=USER_CREATOR` +- Redis key 기본안: + - `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}` + - `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions` + - `v2:user-creator-chat:ws:room:{roomId}` +- presence TTL 기본값: 90초 + +--- + +## 1. 파일 구조 계획 + +### 의존성/설정 +- Modify: `build.gradle.kts` + - `spring-boot-starter-websocket` 의존성을 추가한다. +- Modify: `src/main/resources/application.yml` + - lazy loading 의존 API 점검과 수정 후 `spring.jpa.open-in-view=false`를 명시한다. +- Modify: `src/test/resources/application.yml` + - 테스트에서 OSIV off 회귀를 확인할 수 있도록 동일 설정을 검토한다. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt` + - WebSocket handler endpoint를 등록한다. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` + - handshake에서 JWT 인증 정보를 추출한다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt` + +### WebSocket protocol +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessage.kt` + - request/response envelope와 message type enum을 둔다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketMessageTest.kt` + +### WebSocket session/presence +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistry.kt` + - local WebSocket session을 sessionId 기준으로 관리한다. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt` + - Redis presence 등록, 갱신, 제거, 조회를 담당한다. +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt` + - Redis pub/sub publish/subscribe와 local session 전송을 담당한다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketSessionRegistryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt` + +### WebSocket handler/application +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` + - WebSocket lifecycle과 JSON envelope dispatch를 담당한다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - WebSocket 텍스트 메시지 저장/전달용 application method를 추가하고 SSE 의존성을 제거한다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt` + +### 푸시 payload +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` + - 필요 시 `chatType` 또는 동등한 payload 값을 추가한다. +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` + - `chat_type` data payload를 추가한다. +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt` + +### SSE 제거 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt` + - `events`, `events/disconnect`, `messages/text` endpoint 제거 또는 WebSocket 전환에 맞춰 제거한다. +- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt` +- Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md` + - 클라이언트 연동 문서의 SSE 안내를 WebSocket 기준으로 갱신한다. + +### 클라이언트 반영 문서 +- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md` + - iOS/Android 앱 변경 사항을 PRD에 유지한다. +- Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - 서버 구현 task와 별도로 앱 반영 체크리스트를 유지한다. + +--- + +## 2. WebSocket 메시지 계약 초안 + +구현 전 클라이언트와 공유할 JSON envelope 기준이다. 필드명 변경이 필요하면 PRD와 이 계획 문서를 먼저 갱신한다. + +```json +{ + "type": "SEND_TEXT", + "requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7", + "roomId": 10, + "payload": { + "textMessage": "hello" + } +} +``` + +서버 응답 예시: + +```json +{ + "type": "MESSAGE", + "requestId": null, + "roomId": 10, + "payload": { + "messageId": 200, + "messageType": "TEXT", + "mine": false, + "createdAt": 1781690400000, + "textMessage": "hello", + "voiceMessageUrl": null, + "senderId": 2, + "senderNickname": "creator", + "senderProfileImageUrl": "https://cdn.test/profile/creator.png" + } +} +``` + +서버 ack 예시: + +```json +{ + "type": "SEND_ACK", + "requestId": "c7f1a263-0e4f-4f98-9bd4-70988575e9d7", + "roomId": 10, + "payload": { + "messageId": 201, + "messageType": "TEXT", + "mine": true, + "createdAt": 1781690401000, + "textMessage": "hello", + "voiceMessageUrl": null, + "senderId": 1, + "senderNickname": "user", + "senderProfileImageUrl": "https://cdn.test/profile/user.png" + } +} +``` + +--- + +## 3. iOS/Android 클라이언트 변경 사항 + +앱은 서버 배포와 같은 릴리스 범위에서 아래 변경을 반영해야 한다. 서버 구현자가 직접 앱 코드를 수정하지 않더라도, API 계약과 검증 기준은 이 문서에 유지한다. + +### 채팅방 진입 +- 기존 SSE 연결 생성 코드를 제거한다. +- 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`을 먼저 호출한다. +- `openRoom` 응답으로 기존 메시지 목록과 상대방 프로필/닉네임을 렌더링한다. +- 이후 WebSocket `/ws/v2/user-creator-chat`에 연결한다. +- handshake에는 `Authorization: Bearer ` 헤더를 포함한다. +- 연결 직후 아래 메시지를 보낸다. + +```json +{ + "type": "JOIN_ROOM", + "requestId": "client-request-id", + "roomId": 10, + "payload": {} +} +``` + +- `JOINED` 수신 전에는 텍스트 전송 버튼을 비활성화하거나 전송 대기 상태로 처리한다. + +### 텍스트 메시지 전송 +- 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거한다. +- 텍스트 메시지는 아래 WebSocket 메시지로 전송한다. + +```json +{ + "type": "SEND_TEXT", + "requestId": "client-request-id", + "roomId": 10, + "payload": { + "textMessage": "hello" + } +} +``` + +- 앱은 `requestId`를 pending 메시지와 매칭한다. +- `SEND_ACK`를 수신하면 pending 메시지를 서버 응답의 `messageId`, `createdAt`, `senderProfileImageUrl` 기준으로 확정한다. +- `ERROR` 또는 timeout이 발생하면 메시지를 실패 상태로 표시하고 재시도 UI를 제공한다. + +### 메시지 수신 +- `MESSAGE` 이벤트 수신 시 현재 열려 있는 `roomId`와 일치하는지 확인한 뒤 메시지 목록에 append한다. +- 현재 채팅방과 다른 `roomId`의 `MESSAGE`를 받으면 버리거나 로그로 남긴다. 이번 서버 설계에서는 같은 WebSocket session이 하나의 `roomId`만 활성 방으로 가지므로 정상 상황에서는 발생하지 않아야 한다. + +### 화면 이탈과 재연결 +- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 아래 메시지를 보낸 뒤 WebSocket을 close한다. + +```json +{ + "type": "LEAVE_ROOM", + "requestId": "client-request-id", + "roomId": 10, + "payload": {} +} +``` + +- 현재 채팅방 화면에 남아 있는 동안 WebSocket이 끊기면 지수 백오프로 재연결한다. +- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 `GET /api/v2/user-creator-chat/rooms/{roomId}/messages`로 누락 메시지를 동기화한다. +- access token이 refresh되면 기존 WebSocket을 닫고 새 token으로 다시 연결한다. + +### 푸시 이동 +- 푸시 payload의 `chat_type == "USER_CREATOR"`와 `room_id`를 확인한다. +- 사용자가 푸시를 터치하면 `room_id`에 해당하는 채팅방 화면으로 이동한다. +- 푸시 진입 후에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM`을 수행한다. +- `room_id`가 없거나 숫자로 파싱할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리로 fallback 한다. + +### 클라이언트 제거 대상 +- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출 +- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출 +- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출 +- SSE reconnect/retry 처리 코드 + +--- + +### Phase 0: OSIV 비활성화 사전 점검 + +- [ ] **Task 0.1: 현재 OSIV 설정과 위험 패턴 조사** + - Files: + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - TDD 예외 사유: 코드 변경 전 사전 조사 task로, 자동화 테스트보다 정적 검색과 결과 문서화가 목적이다. + - 대체 검증 방법: + - Run: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources` + - Expected: 현재 OSIV 명시 여부를 확인한다. + - Run: `rg -n "member\\?\\.auth|member\\.auth|member\\?\\.notification|member\\.notification|ApiResponse\\.ok\\([^\\n]*(repository|findBy|findAll)|ResponseEntity\\.ok\\([^\\n]*(repository|findBy|findAll)" src/main/kotlin/kr/co/vividnext/sodalive` + - Expected: controller 또는 응답 직렬화 단계에서 lazy loading이 일어날 수 있는 후보를 찾는다. + - Run: `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive` + - Expected: 트랜잭션 없는 service/facade/query method에서 LAZY 연관을 접근하는 후보를 찾는다. + - GREEN: 발견 항목을 이 문서의 `OSIV 점검 기록` 섹션에 API/파일/위험/수정 방향 형식으로 기록한다. + - 통과 확인: + - Run: `rg -n "OSIV 점검 기록|lazy loading|open-in-view" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - Expected: 조사 결과와 후속 조치가 문서에 기록되어야 한다. + +- [ ] **Task 0.2: OSIV off 테스트 실행 범위 선정** + - Files: + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - TDD 예외 사유: 구현 전 테스트 전략 정의 task다. + - 대체 검증 방법: + - Run: `find src/test/kotlin/kr/co/vividnext/sodalive -name '*ControllerTest.kt' | sort` + - Expected: OSIV off 영향이 드러날 가능성이 높은 controller 테스트 목록을 선별한다. + - GREEN: 인증 principal의 lazy 접근, entity 직접 반환, controller DTO 변환이 있는 API를 우선순위로 선정한다. + - 통과 확인: + - Run: `rg -n "OSIV off 우선 테스트|ControllerTest|MockMvc" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - Expected: 우선 실행할 테스트 목록 또는 기준이 문서에 기록되어야 한다. + +- [ ] **Task 0.3: OSIV off로 후보 테스트를 실행하고 실패를 분류** + - Files: + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - TDD 예외 사유: 설정 전환 영향 조사 task다. + - 대체 검증 방법: + - Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<선정한 ControllerTest 클래스명>'` + - Expected: 성공하면 해당 API는 우선 위험 낮음으로 기록한다. `LazyInitializationException`이 발생하면 파일/필드/API를 기록한다. + - GREEN: 실패를 다음 유형으로 분류한다. + - controller에서 인증 principal lazy 연관 접근 + - 응답 직렬화 중 entity lazy 연관 접근 + - service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근 + - 테스트 fixture가 `@Transactional`로 문제를 숨기는 경우 + - 통과 확인: + - Run: `rg -n "LazyInitializationException|OSIV off 실패|OSIV off 성공" docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - Expected: 테스트 결과와 분류가 문서에 기록되어야 한다. + +- [ ] **Task 0.4: lazy loading 의존 제거 후 OSIV off 명시** + - Files: + - Modify: `src/main/resources/application.yml` + - Modify: `src/test/resources/application.yml` + - Modify: lazy loading 의존 API별 service/repository/controller/test 파일 + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - RED: Task 0.3에서 확인한 `LazyInitializationException` 재현 테스트를 먼저 고정한다. + - 실패 확인: + - Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<실패 재현 테스트>'` + - Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다. + - GREEN: controller lazy 접근을 service/query 계층의 트랜잭션 안 DTO projection, fetch join, 명시 조회로 이동한다. + - GREEN: `application.yml`과 필요한 경우 `test application.yml`에 아래 설정을 명시한다. + +```yaml +spring: + jpa: + open-in-view: false +``` + + - 통과 확인: + - Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests '<수정한 테스트>'` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: WebSocket 전환 작업과 관계없는 API 스키마 변경은 하지 않는다. + +--- + +### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가 + +- [ ] **Task 1.1: WebSocket 의존성 추가** + - Files: + - Modify: `build.gradle.kts` + - RED: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt`를 추가해 `/ws/v2/user-creator-chat` handler bean 등록을 기대한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest` + - Expected: WebSocket 관련 타입 또는 config bean 부재로 실패한다. + - GREEN: `implementation("org.springframework.boot:spring-boot-starter-websocket")`를 추가한다. + - 통과 확인: + - Run: `./gradlew tasks --all` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 의존성 추가 외 다른 dependency 정렬/버전 변경은 하지 않는다. + +- [ ] **Task 1.2: WebSocket config와 handler 등록** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfig.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketConfigTest.kt` + - RED: config 테스트에서 handler가 `/ws/v2/user-creator-chat` 경로로 등록되는지 검증한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest` + - Expected: config class 부재로 실패한다. + - GREEN: `WebSocketConfigurer`를 구현하고 `TextWebSocketHandler` 기반 handler를 등록한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: CORS origin은 기존 `WebConfig`의 허용 origin 정책과 어긋나지 않게 제한한다. + +- [ ] **Task 1.3: WebSocket handshake JWT 인증 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptorTest.kt` + - RED: `Authorization: Bearer ` 헤더가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다. + - RED: 유효한 토큰이면 attributes에 `memberId`와 인증 principal이 저장되는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest` + - Expected: interceptor class 부재로 실패한다. + - GREEN: 기존 `TokenProvider.getAuthentication(token)`을 사용해 인증하고, `MemberAdapter.member.id`를 session attributes에 저장한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: JWT parsing 로직을 새로 만들지 않고 기존 `TokenProvider`를 재사용한다. + +--- + +### Phase 2: 메시지 프로토콜과 local session registry 추가 + +- [ ] **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` + - RED: `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG` enum 값과 JSON deserialize 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` + - Expected: message class 부재로 실패한다. + - GREEN: request/response envelope와 type enum을 추가한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: payload는 초기 구현에서 `JsonNode`로 받고, 타입별 request DTO 변환은 handler dispatch에서 수행한다. + +- [ ] **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` + - RED: `roomId/memberId/sessionId` 등록, 조회, 제거, 같은 session의 room 전환 시 기존 room 제거 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` + - Expected: registry class 부재로 실패한다. + - GREEN: `ConcurrentHashMap` 기반 registry를 추가한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketSessionRegistryTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: registry는 local session만 관리하고 Redis를 직접 호출하지 않는다. + +--- + +### Phase 3: Redis presence와 Redis pub/sub 추가 + +- [ ] **Task 3.1: Redis presence service 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt` + - RED: `markJoined`, `refresh`, `markLeft`, `hasPresence(roomId, memberId)` 동작을 embedded Redis 또는 mock RedisTemplate으로 검증한다. + - RED: TTL이 설정되는지 검증한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` + - Expected: presence service 부재로 실패한다. + - GREEN: Redis key/value와 session set index를 저장하고 TTL 90초를 적용한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: key prefix는 companion object 상수로 모으고 기존 SSE presence key와 섞이지 않게 `ws` segment를 포함한다. + +- [ ] **Task 3.2: Redis pub/sub room broker 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt` + - RED: publish 시 `v2:user-creator-chat:ws:room:{roomId}` channel로 메시지를 발행하는 테스트를 작성한다. + - RED: subscribe callback이 local registry에서 대상 member session만 찾아 전송하는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` + - Expected: broker class 부재로 실패한다. + - GREEN: Redis pub/sub publisher와 listener를 추가한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: broker는 DB 저장을 하지 않고 이미 만들어진 message DTO만 전달한다. + +--- + +### Phase 4: WebSocket handler와 메시지 저장/전달 + +- [ ] **Task 4.1: JOIN_ROOM 처리** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt` + - RED: 인증 member가 참여 중인 room에 `JOIN_ROOM`을 보내면 `JOINED` 응답과 local/Redis presence 등록이 수행되는 테스트를 작성한다. + - RED: 참여자가 아닌 room이면 `ERROR` 후 close 되는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest` + - Expected: JOIN_ROOM dispatch 부재로 실패한다. + - GREEN: handler에서 `JOIN_ROOM`을 처리하고 service의 참여자 검증 method를 호출한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기존 private `requireParticipant` 재사용이 필요하면 public/internal 검증 method로 최소 노출한다. + +- [ ] **Task 4.2: SEND_TEXT 저장, sender ack, 수신자 WebSocket 전달** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt` + - RED: 상대방 presence가 있으면 메시지를 저장하고 `broker.publish`를 호출하며 푸시 이벤트를 발행하지 않는 테스트를 작성한다. + - RED: sender에게 `SEND_ACK`가 전송되는 handler 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest` + - Expected: WebSocket send method 부재로 실패한다. + - GREEN: `sendTextMessage`의 저장 로직을 재사용하되 `UserCreatorChatPresenceService`와 `UserCreatorChatRoomMessageBroker` 기준으로 전달 여부를 판단한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기존 REST text endpoint 제거 전까지 중복 저장 로직이 생기지 않도록 private save method로만 분리한다. + +- [ ] **Task 4.3: 상대방 미접속 시 푸시 발송** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/fcm/FcmServiceTest.kt` + - RED: 상대방 presence가 없으면 `FcmEvent`가 `roomId`, `messageId`, `chatType=USER_CREATOR` 정보를 포함하는 테스트를 작성한다. + - RED: `FcmService`가 FCM data payload에 `chat_type`을 넣는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` + - Expected: chat_type payload 부재로 실패한다. + - GREEN: `FcmEvent` 또는 `FcmService.send`에 chat type을 추가하고 user-creator chat push 발행 시 채운다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기존 live/content/community deep link payload와 충돌하지 않게 `chat_type`은 message category에서만 채운다. + +- [ ] **Task 4.4: LEAVE_ROOM, close, heartbeat 처리** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt` + - RED: `LEAVE_ROOM`과 WebSocket close 시 local registry와 Redis presence가 제거되는 테스트를 작성한다. + - RED: `PING` 수신 시 presence TTL 갱신과 `PONG` 응답 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` + - Expected: LEAVE_ROOM/PING 처리 부재로 실패한다. + - GREEN: handler lifecycle callback과 message dispatch에 정리/heartbeat 처리를 추가한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: close와 LEAVE_ROOM 정리 로직은 같은 private method를 사용한다. + +--- + +### Phase 5: 기존 SSE 제거와 REST 경계 정리 + +- [ ] **Task 5.1: SSE controller endpoint 제거** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - RED: `disconnectRealtime` 관련 테스트를 제거하거나 WebSocket close/presence 테스트로 이동한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: 제거 대상 method 참조가 남아 있으면 실패한다. + - GREEN: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 제거한다. 텍스트 메시지는 WebSocket 전송만 허용한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 사용하지 않는 `MediaType.TEXT_EVENT_STREAM_VALUE` import를 제거한다. + +- [ ] **Task 5.2: `UserCreatorChatRealtimeService` 제거** + - Files: + - Delete: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatRealtimeService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` + - RED: service 테스트에서 SSE realtime mock 의존성을 제거하고 WebSocket presence/broker mock을 주입하도록 변경한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: constructor signature 불일치 또는 미구현 mock으로 실패한다. + - GREEN: `UserCreatorChatService` 생성자와 메시지 전달 로직에서 `UserCreatorChatRealtimeService`를 제거한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: `rg -n "SseEmitter|connectEvents|disconnectRealtime|TEXT_EVENT_STREAM|UserCreatorChatRealtimeService" src/main src/test`로 잔여 참조가 없는지 확인한다. + +- [ ] **Task 5.3: 클라이언트 연동 문서 갱신** + - Files: + - Modify: `docs/plan-task/20260513_유저크리에이터채팅방개편.md` + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - TDD 예외 사유: 문서 갱신은 자동화 테스트 작성 대상이 아니다. + - 대체 검증 방법: + - Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|chat_type" docs/20260618_유저크리에이터채팅_WebSocket전환` + - Expected: 제거 대상 API와 신규 WebSocket/푸시 계약이 모두 문서에 명시되어야 한다. + - GREEN: 클라이언트 연동 순서를 `openRoom` REST 호출 후 WebSocket connect + `JOIN_ROOM`으로 갱신한다. + - 통과 확인: + - Run: `./gradlew tasks --all` + - Expected: `BUILD SUCCESSFUL` + +- [ ] **Task 5.4: iOS/Android 앱 반영 체크리스트 확인** + - Files: + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md` + - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - TDD 예외 사유: 앱 코드가 현재 서버 저장소에 없으므로 자동화 테스트를 작성할 수 없다. + - 대체 검증 방법: + - Run: `rg -n "iOS|Android|JOIN_ROOM|SEND_ACK|MESSAGE|LEAVE_ROOM|푸시|room_id|chat_type" docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` + - Expected: 클라이언트 변경 사항이 PRD와 plan-task 양쪽에 기록되어야 한다. + - GREEN: 앱 담당자가 구현해야 할 진입, 전송, 수신, 이탈, 재연결, 푸시 이동, 제거 대상 API를 문서에 유지한다. + - 통과 확인: + - Run: `./gradlew tasks --all` + - Expected: `BUILD SUCCESSFUL` + +--- + +## 4. 최종 회귀 검증 + +- Run: `rg -n "open-in-view" src/main/resources src/test/resources` + - Expected: OSIV 정책이 명시되어 있어야 한다. +- Run: `./gradlew test -Dspring.jpa.open-in-view=false --tests ''` + - Expected: `BUILD SUCCESSFUL` +- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.*` + - Expected: `BUILD SUCCESSFUL` +- Run: `./gradlew test --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` + - Expected: `BUILD SUCCESSFUL` +- Run: `./gradlew ktlintCheck` + - Expected: `BUILD SUCCESSFUL` +- Run: `./gradlew test` + - Expected: `BUILD SUCCESSFUL` +- Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test` + - Expected: 검색 결과 없음 +- Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|chat_type|USER_CREATOR" build.gradle.kts src/main src/test` + - Expected: WebSocket 의존성, endpoint, 푸시 payload 관련 구현이 확인됨 + +--- + +## 5. 구현 후 검증 기록 + +아직 구현 전이다. 각 task 완료 즉시 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 이 섹션 또는 해당 task 아래에 누적한다. + +## 6. OSIV 점검 기록 + +아직 점검 전이다. Task 0.1부터 다음 형식으로 누적한다. + +```markdown +- API/기능: + - 파일: + - 위험 유형: + - lazy 접근 대상: + - OSIV off 테스트: + - 수정 방향: + - 처리 상태: +``` diff --git a/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md b/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md new file mode 100644 index 00000000..b9f532f8 --- /dev/null +++ b/docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md @@ -0,0 +1,258 @@ +# PRD: 유저-크리에이터 채팅 WebSocket 전환 + +## 1. Overview +유저-크리에이터 1:1 채팅의 실시간 전송 방식을 SSE에서 WebSocket으로 전환해, 네이티브 앱의 채팅방 화면 진입 중에는 실시간 메시지를 받고 푸시는 받지 않으며, 화면 밖에서는 푸시로 채팅방 이동 정보를 받을 수 있게 한다. + +--- + +## 2. Problem +- 현재 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결은 모바일 네이티브 앱의 HTTP connection pool과 충돌해, SSE 연결 중 다른 API 동작이 지연되거나 막히는 문제가 발생한다. +- SSE는 서버에서 클라이언트로만 이벤트를 보내는 단방향 방식이므로, 1:1 채팅의 접속 상태, 방 진입/이탈, 메시지 송수신 생명주기를 표현하기에 부적합하다. +- 기존 SSE presence는 서버 메모리와 Redis TTL을 함께 사용하지만, 여러 서버 인스턴스에서 메시지를 받는 사용자 세션이 어느 서버에 있는지 전달하는 구조가 없다. +- 클라이언트 요구사항은 "해당 채팅방 화면에 있으면 푸시 미발송, 화면에 없으면 푸시 발송 및 푸시 터치 시 해당 방 이동"이므로, 방 단위의 정확한 presence가 필요하다. + +--- + +## 3. Goals +- 유저-크리에이터 채팅 실시간 연결을 SSE에서 WebSocket으로 전환한다. +- 기존 SSE endpoint와 `SseEmitter` 기반 구현은 제거한다. +- WebSocket 연결은 앱 로그인 전체 수명이 아니라 채팅방 화면에 들어와 있을 때만 유지한다. +- 서버가 여러 대라고 가정하고 Redis를 사용해 방 단위 presence와 서버 간 메시지 전달을 처리한다. +- WebSocket 전환과 별도로 `spring.jpa.open-in-view=false` 적용 가능성을 점검하고, 트랜잭션 밖 lazy loading에 의존하는 API를 먼저 식별한다. +- 같은 `roomId` 채팅방 화면에 상대방이 접속 중이면 새 메시지를 WebSocket으로 전달하고 푸시는 발송하지 않는다. +- 상대방이 해당 `roomId` 채팅방 화면에 접속 중이 아니면 새 메시지 저장 후 푸시를 발송한다. +- 푸시 payload에는 앱이 해당 채팅방으로 이동할 수 있는 `roomId`와 채팅 타입 식별 정보를 포함한다. +- 기존 방 생성, 방 열기, 과거 메시지 조회, 음성 메시지 업로드 API는 유지한다. +- 텍스트 메시지는 WebSocket으로 전송하고, 서버는 저장 결과를 sender에게 ack로 돌려준다. + +--- + +## 4. Non-Goals +- 전체 앱 로그인 수명 동안 유지되는 글로벌 WebSocket 연결은 이번 범위에 포함하지 않는다. +- AI 캐릭터 채팅, 라이브 채팅, 기존 `/api/chat/room` 기능은 이번 범위에서 변경하지 않는다. +- 메시지 읽음 처리, typing indicator, 온라인 사용자 목록 노출은 이번 범위에 포함하지 않는다. +- 음성 파일 자체를 WebSocket binary로 전송하지 않는다. +- DB 스키마 변경은 이번 범위에 포함하지 않는다. +- STOMP broker 도입은 이번 범위에 포함하지 않는다. + +--- + +## 5. Target Users +- iOS/Android 네이티브 앱 사용자: 채팅방 화면에서 실시간으로 메시지를 주고받는 회원 +- 모바일 클라이언트: 채팅방 화면 진입/이탈 시 연결 생명주기와 푸시 이동 처리를 구현하는 클라이언트 +- 운영 서버: 여러 인스턴스에서 동일한 presence와 메시지 전달 정책을 유지해야 하는 서버 + +--- + +## 6. User Stories +- 사용자는 채팅방 화면에 들어와 있으면 새 메시지를 즉시 보고 싶고 같은 메시지의 푸시는 받고 싶지 않다. +- 사용자는 채팅방 화면 밖에 있으면 상대방 메시지를 푸시로 받고 싶다. +- 사용자는 푸시 알림을 터치하면 해당 유저-크리에이터 채팅방으로 바로 이동하고 싶다. +- 앱은 채팅방 진입 시 초기 메시지를 REST로 조회하고 이후 새 메시지는 WebSocket으로 받고 싶다. +- 서버는 여러 인스턴스 중 어느 인스턴스에 상대방 WebSocket session이 붙어 있어도 메시지를 전달하거나 푸시 여부를 올바르게 결정해야 한다. + +--- + +## 7. Core Features + +### Feature A. WebSocket 연결과 인증 + +#### Requirements +- WebSocket endpoint는 `/ws/v2/user-creator-chat`을 기본안으로 한다. +- 네이티브 앱은 WebSocket handshake에 기존 `Authorization: Bearer ` 헤더를 전달한다. +- 서버는 기존 JWT 검증 흐름을 재사용해 인증 회원을 식별한다. +- 인증 실패 시 WebSocket 연결을 수락하지 않는다. +- 연결은 특정 채팅방 화면에 들어왔을 때만 생성한다. +- 연결 직후 클라이언트는 `JOIN_ROOM` 메시지로 `roomId`를 전달한다. +- 서버는 `JOIN_ROOM` 처리 시 회원이 해당 방의 활성 참여자인지 검증한다. +- 검증 성공 시 서버는 해당 WebSocket session을 `roomId/memberId/sessionId/serverId` 기준으로 등록한다. +- 하나의 WebSocket session은 하나의 `roomId`만 활성 방으로 가진다. + +#### Edge Cases +- 인증은 성공했지만 `JOIN_ROOM`의 `roomId` 참여자가 아니면 error 메시지를 보내고 연결을 종료한다. +- 같은 회원이 같은 방을 여러 기기에서 열 수 있으므로 presence는 session 단위로 관리한다. +- 같은 session에서 다른 `roomId`로 다시 `JOIN_ROOM`을 보내면 기존 방 presence를 제거한 뒤 새 방으로 전환한다. + +### Feature B. WebSocket 메시지 프로토콜 + +#### Requirements +- WebSocket은 raw JSON message protocol을 사용한다. +- 공통 envelope는 다음 필드를 사용한다. + +```json +{ + "type": "JOIN_ROOM", + "requestId": "client-generated-id", + "roomId": 10, + "payload": {} +} +``` + +- 클라이언트에서 서버로 보내는 메시지 타입은 다음을 기본으로 한다. + - `JOIN_ROOM`: 방 입장 및 presence 등록 + - `SEND_TEXT`: 텍스트 메시지 저장 및 전달 + - `LEAVE_ROOM`: 방 이탈 및 presence 제거 + - `PING`: 연결 유지 확인 +- 서버에서 클라이언트로 보내는 메시지 타입은 다음을 기본으로 한다. + - `JOINED`: 방 입장 성공 + - `MESSAGE`: 새 메시지 수신 + - `SEND_ACK`: sender에게 메시지 저장 결과 전달 + - `ERROR`: 처리 실패 + - `PONG`: `PING` 응답 +- `SEND_TEXT` payload는 `{ "textMessage": "..." }`를 사용한다. +- `MESSAGE`와 `SEND_ACK` payload는 기존 `UserCreatorChatMessageItemDto`와 같은 메시지 item 구조를 사용한다. +- 빈 문자열 또는 공백뿐인 텍스트 메시지는 기존 `sendTextMessage`와 동일하게 `common.error.invalid_request` 의미의 error로 처리한다. + +#### Edge Cases +- 클라이언트가 `JOIN_ROOM` 전에 `SEND_TEXT`를 보내면 `ERROR`를 반환한다. +- 알 수 없는 `type`은 `ERROR`를 반환하되 서버 connection은 유지한다. +- 메시지 저장 성공 후 sender ack 전송이 실패해도 DB 저장은 롤백하지 않는다. + +### Feature C. Redis 기반 presence와 다중 서버 메시지 전달 + +#### Requirements +- 서버는 여러 대라고 가정한다. +- 각 서버 인스턴스는 고유한 `serverId`를 가진다. 기본값은 application start 시 생성한 UUID이며, 운영 환경에서는 env 기반 지정도 허용한다. +- local memory에는 현재 서버에 붙은 WebSocket session만 저장한다. +- Redis에는 방 단위 presence를 session 단위로 저장한다. +- Redis key 기본안: + - session presence: `v2:user-creator-chat:ws:presence:{roomId}:{memberId}:{sessionId}` + - room member index: `v2:user-creator-chat:ws:room:{roomId}:member:{memberId}:sessions` + - pub/sub channel: `v2:user-creator-chat:ws:room:{roomId}` +- presence value에는 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`을 포함한다. +- WebSocket 연결 유지 중 heartbeat 또는 메시지 송수신 시 presence TTL을 갱신한다. +- presence TTL 기본값은 90초로 한다. +- 서버는 메시지 저장 후 상대방의 해당 `roomId` presence가 Redis에 하나 이상 있는지 확인한다. +- 상대방 presence가 있으면 Redis pub/sub으로 room channel에 메시지를 발행한다. +- 각 서버는 자신에게 연결된 session 중 대상 `roomId/memberId` session에만 메시지를 전송한다. +- 상대방 presence가 없으면 푸시 이벤트를 발행한다. + +#### Edge Cases +- Redis presence는 남아 있지만 실제 WebSocket 전송이 실패하면 해당 local session을 정리한다. +- Redis pub/sub 전달 실패 또는 Redis 장애 시에는 presence 판단을 신뢰할 수 없으므로 푸시 발송 쪽으로 fail-open 한다. +- presence TTL 만료 전 앱이 비정상 종료되어도 최대 TTL 이후에는 오프라인으로 판단되어 푸시가 발송되어야 한다. + +### Feature D. 푸시 발송과 채팅방 이동 payload + +#### Requirements +- 상대방이 해당 채팅방 화면에 있지 않으면 기존 FCM/APNs 푸시 발송 흐름을 사용한다. +- 푸시 category는 기존 `PushNotificationCategory.MESSAGE`를 사용한다. +- 푸시 payload에는 최소 다음 값을 포함한다. + - `room_id`: 채팅방 ID + - `message_id`: 새 메시지 ID + - `chat_type`: `USER_CREATOR` + - `deep_link`: 앱이 채팅방으로 이동할 수 있는 값 +- 기존 `FcmEvent.roomId`, `FcmEvent.messageId`, `FcmService.putData("room_id", ...)`, `putData("message_id", ...)` 흐름은 유지한다. +- `chat_type` 또는 동등한 식별자가 현재 FCM payload에 없으면 추가한다. +- 앱은 푸시 터치 시 `room_id`와 `chat_type`을 기준으로 `GET /api/v2/user-creator-chat/rooms/{roomId}/open` 호출 후 WebSocket 연결을 시작한다. + +#### Edge Cases +- 푸시 발송 대상 push token이 없으면 메시지 저장은 성공하고 `pushSent == false` 의미의 내부 결과를 남긴다. +- 상대방이 여러 기기 중 하나에서 같은 방을 열고 있으면 푸시는 발송하지 않는다. +- 상대방이 다른 채팅방을 열고 있거나 앱의 다른 화면에 있으면 현재 방 presence가 아니므로 푸시를 발송한다. + +### Feature E. 기존 SSE 제거 + +#### Requirements +- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` endpoint를 제거한다. +- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` endpoint를 제거한다. +- `UserCreatorChatRealtimeService`의 `SseEmitter` 기반 구현을 제거한다. +- SSE 관련 DTO, 테스트, 문서 언급은 WebSocket 기준으로 갱신한다. +- 클라이언트 연동 문서에는 기존 SSE API가 더 이상 사용되지 않음을 명시한다. + +#### Edge Cases +- 제거된 SSE endpoint를 호출하면 Spring MVC 기본 404 또는 security 정책에 따른 기존 오류 흐름을 따른다. +- 기존 REST 메시지 조회/방 생성/open API는 제거하지 않는다. + +### Feature F. iOS/Android 클라이언트 변경 사항 + +#### Requirements +- 클라이언트는 채팅방 화면 진입 시 기존 SSE 연결을 열지 않는다. +- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 조회한다. +- `openRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다. +- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer ` 헤더로 전달한다. +- WebSocket 연결 직후 `JOIN_ROOM` 메시지를 전송한다. +- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다. +- 텍스트 메시지 전송은 기존 `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를 유지한다. +- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다. +- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다. +- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 REST `messages` API로 누락 메시지를 동기화한다. +- 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다. +- 푸시 알림을 터치하면 payload의 `chat_type == "USER_CREATOR"`와 `room_id`를 확인해 해당 채팅방 화면으로 이동한다. +- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결/`JOIN_ROOM`을 수행한다. +- 클라이언트는 제거된 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않는다. + +#### Edge Cases +- WebSocket 연결은 성공했지만 `JOIN_ROOM`이 실패하면 채팅방 화면에 오류를 표시하고 텍스트 전송을 막는다. +- `SEND_TEXT` 후 일정 시간 안에 `SEND_ACK`가 오지 않으면 메시지를 전송 실패 상태로 표시하고 재시도 UI를 제공한다. +- 재연결 전 pending 메시지를 자동 재전송할 경우 같은 `requestId`를 재사용해 중복 표시를 방지한다. +- 앱이 다른 채팅방으로 이동하면 기존 방 WebSocket session을 종료하고 새 방으로 다시 연결한다. +- 푸시 payload에 `room_id`가 없거나 숫자로 파싱할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리 흐름을 따른다. + +### Feature G. OSIV 비활성화 사전 점검 + +#### Requirements +- 현재 `spring.jpa.open-in-view` 설정이 명시되어 있는지 확인한다. +- 설정이 명시되어 있지 않으면 Spring Boot 2.7 기본값상 OSIV enabled로 동작할 수 있으므로, 운영 설정에 명시적으로 둘지 여부를 결정한다. +- `spring.jpa.open-in-view=false`를 바로 적용하기 전에 트랜잭션 밖 lazy loading 의존 API를 먼저 식별한다. +- 점검 대상은 다음 패턴을 포함한다. + - controller에서 `@AuthenticationPrincipal`로 받은 `Member`의 LAZY 연관(`auth`, `notification` 등)을 직접 접근하는 코드 + - controller가 JPA entity 또는 LAZY 연관을 가진 객체를 그대로 `ApiResponse.ok(...)`로 반환하는 코드 + - `@Transactional`이 없는 service/facade/query method에서 repository 조회 후 LAZY 연관을 DTO 변환에 사용하는 코드 + - Jackson 직렬화 시점에 JPA entity의 LAZY 연관이 열릴 수 있는 응답 + - self-invocation 때문에 기대한 `@Transactional`이 적용되지 않는 service 내부 호출 +- 발견된 항목은 API 경로, 파일 경로, lazy 접근 대상, 권장 수정 방향을 문서에 기록한다. +- 권장 수정 방향은 controller에서 lazy 접근을 하지 않고 service/query 계층의 트랜잭션 안에서 DTO projection, fetch join, 명시 조회로 필요한 값을 채우는 것이다. +- lazy loading 의존성이 해소된 뒤에만 `spring.jpa.open-in-view=false`를 application 설정에 명시한다. + +#### Edge Cases +- 테스트 코드가 `@Transactional`로 감싸져 있으면 OSIV off 문제를 가릴 수 있으므로 controller/MockMvc 또는 실제 HTTP 계층 테스트를 우선한다. +- 인증 principal의 `Member`는 JWT filter에서 로드된 엔티티이므로, controller에서 LAZY 연관을 직접 열면 OSIV off 후 실패할 수 있다. +- 관리자/크리에이터/사용자 API가 서로 다른 controller 패키지에 흩어져 있으므로 특정 패키지 검색만으로 점검을 끝내지 않는다. +- OSIV off 적용 후 일부 API가 실패하면 WebSocket 전환과 섞어 수정하지 않고, lazy loading 제거 task로 분리해 먼저 처리한다. + +--- + +## 8. UX / UI Expectations +- 채팅방 화면 진입 시 REST `openRoom` 응답으로 초기 화면을 그리고, WebSocket `JOINED` 이후 새 메시지를 실시간으로 append한다. +- 채팅방 화면에 있는 동안 같은 방의 메시지 푸시가 나타나지 않아야 한다. +- 채팅방 화면 밖에서 푸시를 터치하면 해당 `roomId`의 채팅방으로 이동해야 한다. +- WebSocket 재연결 중 사용자가 보낸 메시지는 앱에서 전송 실패 또는 재시도 상태로 표시할 수 있어야 한다. +- 앱 백그라운드 진입 또는 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 close한다. +- 앱은 기존 SSE 연결 코드와 `events/disconnect` 호출 코드를 제거한다. +- 앱은 텍스트 메시지 전송 성공 기준을 HTTP 200 응답이 아니라 WebSocket `SEND_ACK` 수신으로 변경한다. + +--- + +## 9. Technical Constraints +- Kotlin + Java 17 + Spring Boot 2.7.14 + Gradle Wrapper 구조를 유지한다. +- WebSocket 구현은 `spring-boot-starter-websocket`을 사용한다. +- STOMP는 도입하지 않고 raw WebSocket JSON protocol을 사용한다. +- Redis는 현재 연결된 인프라를 사용한다. +- RedisTemplate 또는 Redisson 중 기존 코드 패턴과 테스트 용이성을 기준으로 선택하되, presence TTL과 pub/sub을 모두 구현해야 한다. +- `spring.jpa.open-in-view=false`는 lazy loading 의존 API 점검과 수정이 끝난 뒤 명시한다. +- 공개 REST API 스키마는 필요한 범위 외에는 변경하지 않는다. +- 텍스트 메시지 저장은 기존 `UserCreatorChatMessage` 엔티티와 repository를 사용한다. +- 음성 메시지 업로드는 기존 REST multipart API를 유지한다. +- 기존 `FcmEvent`/`FcmService` 구조를 우선 재사용한다. +- iOS/Android 클라이언트는 WebSocket 전용 연결을 일반 REST API 호출과 분리해 관리한다. +- iOS/Android 클라이언트는 access token refresh 시 기존 WebSocket을 닫고 새 token으로 재연결한다. + +--- + +## 10. Metrics +- 채팅방 화면에 접속 중인 수신자에게 같은 방 메시지 푸시가 발송된 건수 0건 +- 채팅방 화면 밖 수신자에게 메시지 푸시가 누락된 건수 0건 +- WebSocket `JOIN_ROOM` 성공률 +- WebSocket 연결 중 메시지 전송 성공률 +- Redis presence TTL 만료로 정리된 orphan session 수 +- 제거된 SSE endpoint 호출량 + +--- + +## 11. Open Questions +- 없음. 이번 문서 기준 확정안은 채팅방 화면 진입 중에만 유지하는 raw WebSocket, Redis 기반 다중 서버 presence/pub-sub, 기존 SSE 완전 제거다.