# 유저-크리에이터 채팅 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 푸시 흐름에 `deep_link` 이동 정보만 포함해 발송한다. **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 필수값: - `deep_link`: 운영 `voiceon://chat/{roomId}`, 개발/테스트 `voiceon-test://chat/{roomId}` - v2 채팅 푸시에서는 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다. - 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` - presence TTL 기본값: 90초 - 운영 Redis는 AWS ElastiCache Serverless Valkey 7.2 또는 Redis OSS 7.1을 사용할 수 있다. - AWS ElastiCache Serverless는 Redis pattern subscribe에 필요한 `PSUBSCRIBE`를 지원하지 않으므로, Redis listener는 `PatternTopic` 대신 `ChannelTopic` 기반 고정 채널 `SUBSCRIBE`만 사용한다. - OCI Cache Redis/Valkey 호환성을 위해서도 Redis Pub/Sub은 `PUBLISH`/`SUBSCRIBE` 기본 명령만 사용하고, `roomId` 필터링은 channel name이 아니라 payload의 `roomId/memberId`로 수행한다. --- ## 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` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt` ### 푸시 payload - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmEvent.kt` - 필요 시 v2 채팅용 deep link 값을 추가한다. - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/fcm/FcmService.kt` - v2 채팅 푸시는 `deep_link`만 data payload에 포함하고 `room_id`, `message_id`, `chat_type`은 제외한다. - 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의 `deep_link`를 확인한다. - 사용자가 푸시를 터치하면 `voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`의 `{roomId}`에 해당하는 채팅방 화면으로 이동한다. - 푸시 진입 후에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM`을 수행한다. - `deep_link`가 없거나 채팅방 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 비활성화 사전 점검 - [x] **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: 조사 결과와 후속 조치가 문서에 기록되어야 한다. - 검증 기록: - 무엇: 현재 OSIV 명시 여부와 lazy loading 위험 후보를 정적 검색으로 조사했다. - 왜: `spring.jpa.open-in-view=false` 전환 전에 controller 응답 직렬화나 인증 principal 접근에서 트랜잭션 밖 lazy 접근 가능성을 확인하기 위해서다. - 어떻게: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources`, `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`, `rg -n "@Service|@Transactional|findByIdOrNull|findById\\(|findAll\\(|\\.member|\\.sender|\\.recipient|\\.auth|\\.chatRoom|\\.series|\\.audioContent" src/main/kotlin/kr/co/vividnext/sodalive`를 실행했다. - 결과: main/test `application.yml` 모두 `spring.jpa`는 있으나 `open-in-view`는 명시되어 있지 않았다. `EventController`, `UserActionController`, `AuditionController`, `CreatorAdminContentSeriesGenreController`, `AudioContentCommentController` 등에서 `MemberAdapter.member.auth` 직접 접근 후보가 확인되었다. QueryDSL repository의 `member.auth`, `series.member`, `audioContent.member` 접근은 쿼리 식 내부 경로가 대부분이라 즉시 lazy 직렬화 위험으로 분류하지 않았다. - [x] **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: 우선 실행할 테스트 목록 또는 기준이 문서에 기록되어야 한다. - OSIV off 우선 테스트: - `kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 실제 JPA/MockMvc 경계와 인증 principal 전달 표면을 확인할 수 있다. - `kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest`: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@Transactional`, `MemberAdapter`를 함께 사용해 인증 회원 id 기반 조회 표면을 확인할 수 있다. - `kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 controller가 `MemberAdapter.member`를 facade로 전달하는 표면 회귀를 확인할 수 있다. - `kr.co.vividnext.sodalive.v2.api.creator.channel.live.adapter.in.web.CreatorChannelLiveControllerTest`: `@WebMvcTest`라 JPA lazy loading 자체 검증은 제한적이지만 인증 principal 전달 표면 회귀를 확인할 수 있다. - `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`, `kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`: 현재 유저-크리에이터 채팅 전용 `ControllerTest`가 없어, service 트랜잭션 안 DTO 변환과 lazy 접근 안전성을 보조 확인한다. - 검증 기록: - 무엇: OSIV off 영향이 드러날 가능성이 높은 controller/service 테스트 범위를 선정했다. - 왜: Phase 0.3에서 무작위 전체 테스트가 아니라 인증 principal lazy 접근, entity 직접 반환, DTO 변환 경계가 있는 테스트를 우선 실행하기 위해서다. - 어떻게: `rg --files src/test/kotlin/kr/co/vividnext/sodalive | rg 'ControllerTest\\.kt$'`, `rg -n "@SpringBootTest|@AutoConfigureMockMvc|@Transactional|MockMvc|MemberAdapter" src/test/kotlin/kr/co/vividnext/sodalive -g "*ControllerTest.kt"`, `rg --files src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat`를 실행했다. - 결과: `HomeRecommendationControllerTest`, `CreatorRankingControllerTest`를 우선 통합 테스트로 선정하고, `CreatorChannelHomeControllerTest`, `CreatorChannelLiveControllerTest`, user-creator-chat service 테스트를 보조 범위로 선정했다. - [x] **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: 테스트 결과와 분류가 문서에 기록되어야 한다. - OSIV off 성공: - Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` - Result: `BUILD SUCCESSFUL in 1m 24s` - XML 확인: `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`. - 분류: 선정한 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에서는 확인된 `LazyInitializationException` 없음. user-creator-chat DTO 변환은 현재 service 트랜잭션 안에서 수행되는 것으로 판단했다. - [x] **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 스키마 변경은 하지 않는다. - 검증 기록: - 무엇: main/test 설정에 `spring.jpa.open-in-view=false`를 명시했다. - 왜: Phase 0.3에서 user-creator-chat service/integration, 홈/랭킹 controller 통합 테스트, 보조 WebMvc controller 테스트 범위에 `LazyInitializationException`이 없었고, OSIV 정책을 명시해야 이후 WebSocket 전환 작업의 트랜잭션 경계를 안전하게 유지할 수 있기 때문이다. - 어떻게: `src/main/resources/application.yml`, `src/test/resources/application.yml`의 `spring.jpa` 아래에 `open-in-view: false`를 추가했다. - 결과: 확인된 lazy loading 재현 실패가 없어 production code의 service/repository/controller 수정은 하지 않았다. 공개 API 스키마 변경도 없다. - [x] **Task 0.5: 운영 LazyInitializationException 회귀 보완** - Files: - Add: `src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt` - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md` - Modify: `docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` - RED: `ChatCharacterService.getCharacterDetail` 반환 후 `tagMappings.tag.tag`, `getOtherCharactersBySharedTags` 반환 후 `tagMappings.tag.tag`, `RankingRepository.getCreatorRankings` 반환 후 `Member.toExplorerSectionCreator`를 트랜잭션 밖에서 접근하는 테스트를 추가한다. - 실패 확인: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` - Expected: 기존 코드에서는 `LazyInitializationException` 또는 동등한 실패가 발생한다. - GREEN: 응답 조립에 필요한 `ChatCharacter.tagMappings.tag`, `Member.tags.tag`를 조회 쿼리에서 fetch join으로 선로딩한다. - 통과 확인: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` - Expected: `BUILD SUCCESSFUL` - REFACTOR: 공개 API 응답 스키마와 WebSocket 관련 구현은 변경하지 않는다. - 검증 기록: - 무엇: OSIV off 상태에서 운영 오류와 같은 lazy loading 경계를 재현하는 회귀 테스트를 추가하고, 필요한 연관을 fetch join으로 선로딩했다. - 왜: `ChatCharacterController.getCharacterDetail`에서 `ChatCharacterTagMapping.tag`, `HomeService.fetchData`에서 `Member.tags`가 트랜잭션 밖에서 열려 `LazyInitializationException`이 발생했기 때문이다. - 어떻게: `OsivLazyLoadingRegressionTest`를 추가해 `ChatCharacterService.getCharacterDetail`, `ChatCharacterService.getOtherCharactersBySharedTags`, `RankingRepository.getCreatorRankings` 반환 후 트랜잭션 밖 DTO 변환을 검증했다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest` 실행 결과 3개 테스트 모두 `LazyInitializationException`으로 실패했다. - GREEN: 같은 명령을 재실행해 `BUILD SUCCESSFUL in 1m 6s`로 통과했다. - 인접 회귀: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.osiv.OsivLazyLoadingRegressionTest --tests kr.co.vividnext.sodalive.api.home.HomeServiceTest --tests kr.co.vividnext.sodalive.chat.character.controller.ChatCharacterControllerTest`가 `BUILD SUCCESSFUL in 24s`로 통과했다. - 전체 테스트 중단: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`는 `UserCreatorChatRedisIntegrationTest` 실행 중 `OutOfMemoryError`가 발생해 즉시 중단했다. 이후 검증 범위는 OSIV 회귀와 인접 테스트로 간결화했다. - lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 14s`로 통과했다. - 정적 점검: `rg -n "toExplorerSectionCreator\\(|tagMappings\\.map|tagMappings\\.joinToString|\\.tagMappings" src/main/kotlin/kr/co/vividnext/sodalive -S`로 동일 패턴 후보를 확인했다. `ExplorerService`는 클래스 단위 `@Transactional(readOnly = true)` 안에서 변환하고, `HomeService`/`RankingService`는 공통 `RankingRepository.getCreatorRankings` 선로딩으로 보완했다. `TranslationSourceExtractor`와 관리자/원작 DTO 변환의 `tagMappings` 접근은 운영 stacktrace 표면이 아니므로 별도 회귀 후보로 남겼다. --- ### Phase 1: WebSocket 의존성과 인증 handshake 기반 추가 - [x] **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 정렬/버전 변경은 하지 않는다. - 검증 기록: - 무엇: `spring-boot-starter-websocket` 의존성을 추가했다. - 왜: raw WebSocket endpoint와 handshake interceptor 타입을 사용하기 위해서다. - 어떻게: WebSocket 타입 부재로 `compileTestKotlin` RED를 확인한 뒤 의존성을 추가하고 Phase 1 focused 테스트를 재실행했다. - 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketAuthInterceptorTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다. - [x] **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 정책과 어긋나지 않게 제한한다. - 검증 기록: - 무엇: `/ws/v2/user-creator-chat` endpoint를 `WebSocketConfigurer`로 등록하고 `TextWebSocketHandler` 기반 handler를 추가했다. - 왜: 채팅방 화면 진입 중 유지되는 raw WebSocket 연결 기반을 만들기 위해서다. - 어떻게: `UserCreatorChatWebSocketConfigTest`에서 endpoint path 등록을 검증했다. - 결과: Phase 1 focused 테스트가 `BUILD SUCCESSFUL`로 통과했다. allowed origin은 기존 `WebConfig` origin 목록과 동일하게 제한했다. - [x] **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`를 재사용한다. - 검증 기록: - 무엇: `Authorization: Bearer ` handshake 인증 interceptor를 추가했다. - 왜: WebSocket session attributes에 인증 member id와 authentication을 저장해 이후 room join/message 처리에서 사용할 수 있게 하기 위해서다. - 어떻게: `TokenProvider.validateToken(token)`과 `TokenProvider.getAuthentication(token)`을 재사용하고, `MemberAdapter.member.id`를 `memberId` attribute로 저장했다. - 결과: 유효 token 성공, Authorization header 누락 실패, invalid token 실패 테스트가 Phase 1 focused 테스트에서 통과했다. --- ### Phase 2: 메시지 프로토콜과 local session registry 추가 - [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` - 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에서 수행한다. - 검증 기록: - 무엇: 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`로 통과했다. - [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` - 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를 직접 호출하지 않는다. - 검증 기록: - 무엇: 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`로 통과했다. --- ### Phase 3: Redis presence와 Redis pub/sub 추가 - [x] **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를 포함한다. - 검증 기록: - 무엇: WebSocket session 단위 Redis presence service를 추가했다. - 왜: 다중 서버에서 상대방이 같은 `roomId`에 접속 중인지 판단하고, session별 join/refresh/leave 상태를 TTL 기반으로 관리하기 위해서다. - 어떻게: `StringRedisTemplate`으로 `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}` 키를 저장하고 90초 TTL을 적용했다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatPresenceService` unresolved reference로 `compileTestKotlin` 실패를 확인했다. - Reviewer RED: 남은 session id가 TTL 만료로 stale 상태일 때 마지막 live session이 leave 해도 member presence가 즉시 제거되지 않는다는 지적을 받고, stale session 테스트가 `ArgumentsAreDifferent`로 실패함을 확인했다. - 결과: stale session id를 presence key 기준으로 정리하고 live session이 없을 때 member/room presence를 제거하도록 수정했다. `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 31s`로 통과했다. - Reviewer 보강: PRD의 Redis presence value 계약에 맞춰 value를 문자열 `1`에서 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt` JSON으로 변경했다. `user-creator-chat.websocket.server-id`가 비어 있으면 애플리케이션 시작 시 UUID를 serverId로 사용한다. - [x] **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만 전달한다. - 검증 기록: - 무엇: Redis pub/sub room broker와 `RedisMessageListenerContainer` bean을 추가했다. - 왜: 서버 인스턴스 간 room 메시지를 Redis channel로 전달하고, 수신 인스턴스가 local registry에서 대상 member session만 찾아 WebSocket으로 전송하기 위해서다. - 어떻게: publish는 `v2:user-creator-chat:ws:room:{roomId}` channel에 `roomId`, `memberId`, `payload` JSON을 발행하고, listener는 `v2:user-creator-chat:ws:room:*` pattern topic을 구독해 대상 local session에 `TextMessage(payload)`를 전송한다. broker는 DB 저장을 하지 않는다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` 실행 시 `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다. - Reviewer RED: 같은 member의 local session 중 하나가 `IOException`으로 실패하면 이후 정상 session 전송이 중단되는 테스트가 기존 구현에서 실패함을 확인했다. - 결과: session별 전송 실패를 격리하고 실패 session만 local registry에서 제거하도록 수정했다. `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`로 통과했고, Phase 3 WebSocket focused 테스트가 `BUILD SUCCESSFUL in 21s`로 통과했다. - [x] **Task 3.3: Redis presence/pub-sub embedded Redis 통합 테스트 추가** - Files: - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt` - RED: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 실제 Redis에 `markJoined`를 호출하면 presence key, member session set, room set이 저장되고 presence key TTL이 0보다 큰지 검증한다. - RED: `markLeft` 호출 시 실제 Redis에서 session presence key와 마지막 member session set, room member entry가 정리되는지 검증한다. - RED: stale session id가 member session set에 남아 있고 presence key가 없으면 `hasPresence(roomId, memberId)`가 false를 반환하고 stale session id를 set에서 제거하는지 검증한다. - RED: `UserCreatorChatRoomMessageBroker.publish`가 실제 Redis pub/sub을 통해 listener까지 도달하고, local registry의 대상 member session에 payload를 전달하는지 검증한다. - 실패 확인: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` - Expected: 통합 테스트 파일 부재 또는 실제 Redis 경계 미검증으로 실패한다. - GREEN: production code 변경 없이 기존 `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `RedisMessageListenerContainer`가 embedded Redis에서 동작하도록 테스트를 추가한다. 실패가 있으면 Redis serialization/listener wiring에 필요한 최소 수정만 적용한다. - 통과 확인: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` - Expected: `BUILD SUCCESSFUL` - REFACTOR: Redis 통합 테스트는 `src/test/resources/META-INF/spring.factories` 전역 등록 없이 `EmbeddedRedisInitializer`를 명시적으로 opt-in 한다. - 범위 한계: - Phase 3에서는 Redis presence/pub-sub 인프라를 실제 Redis 기준으로 검증한다. - 실제 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `LEAVE_ROOM`, `PING`, 메시지 저장/ack/푸시 분기까지 포함한 end-to-end 흐름은 Phase 4에서 검증한다. - 검증 기록: - 무엇: embedded Redis 기반 `UserCreatorChatRedisIntegrationTest`를 추가했다. - 왜: mock 단위 테스트가 아니라 실제 Redis key/TTL/set 저장, stale session pruning, Redis pub/sub listener 전달 경계를 확인하기 위해서다. - 어떻게: `@SpringBootTest`와 `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`로 opt-in embedded Redis를 사용하고, `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatWebSocketSessionRegistry`, `StringRedisTemplate`을 실제 Spring context에서 주입받아 검증했다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` 실행 시 테스트 파일 부재로 `No tests found for given includes` 실패를 확인했다. - GREEN: 같은 focused 명령을 `cleanTest`와 함께 순차 재실행해 `BUILD SUCCESSFUL in 33s`로 통과했다. join presence key/member session set/room set/TTL, last session leave 정리, stale session pruning, Redis pub/sub listener를 통한 target local session payload 전달을 확인했다. - Reviewer 보강 GREEN: embedded Redis 테스트에서 `user-creator-chat.websocket.server-id=redis-test-server`를 주입하고 실제 Redis presence value JSON의 `serverId`, `memberId`, `roomId`, `sessionId`, `lastSeenAt`과 TTL을 함께 검증하도록 갱신했다. - [x] **Task 3.4: ElastiCache Serverless 호환 Redis pub/sub channel 보정** - Files: - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBroker.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRoomMessageBrokerTest.kt` - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatRedisIntegrationTest.kt` - Verify Docs: `docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md` - 배경: - 운영 Redis는 AWS ElastiCache Serverless Valkey 7.2 또는 Redis OSS 7.1이다. - 현재 `PatternTopic("v2:user-creator-chat:ws:room:*")`는 Redis `PSUBSCRIBE`를 사용한다. - AWS ElastiCache Serverless는 `PSUBSCRIBE`를 지원하지 않아 애플리케이션 시작 시 `redisMessageListenerContainer` bean 시작이 실패한다. - RED: broker 생성 테스트를 `PatternTopic` 검증에서 `ChannelTopic("v2:user-creator-chat:ws:room")` 검증으로 변경한다. - RED: publish 테스트를 room별 channel `v2:user-creator-chat:ws:room:{roomId}`가 아니라 고정 channel `v2:user-creator-chat:ws:room`에 `roomId`, `memberId`, `payload` JSON을 발행하는 검증으로 변경한다. - 실패 확인: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` - Expected: 기존 구현이 `PatternTopic("v2:user-creator-chat:ws:room:*")`와 room별 channel publish를 사용하므로 변경된 테스트가 실패한다. - GREEN: `UserCreatorChatRoomMessageBroker`에서 `PatternTopic` import와 room별 subscribe를 제거하고 `ChannelTopic("v2:user-creator-chat:ws:room")`만 등록한다. - GREEN: `publish(roomId, memberId, payload)`는 기존 `UserCreatorChatRoomPublishedMessage` payload 구조를 유지하되 고정 channel `v2:user-creator-chat:ws:room`으로만 발행한다. - GREEN: `onMessage`는 기존처럼 payload의 `roomId/memberId`로 local session을 필터링한다. - 통과 확인: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` - Expected: `BUILD SUCCESSFUL` - 통합 회귀: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` - Expected: `BUILD SUCCESSFUL`; embedded Redis pub/sub listener가 고정 channel 구독만으로 대상 local session에 payload를 전달한다. - 인접 회귀: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` - Expected: `BUILD SUCCESSFUL` - Lint: - Run: `./gradlew --no-daemon ktlintCheck` - Expected: `BUILD SUCCESSFUL` - REFACTOR: channel 상수명은 room별 channel이 아님을 드러내도록 `ROOM_CHANNEL` 또는 동등한 이름으로 정리한다. 기존 external WebSocket/REST API 계약은 변경하지 않는다. - 문서 확인: - Run: `rg -n "PatternTopic|PSUBSCRIBE|v2:user-creator-chat:ws:room:\\{roomId\\}|v2:user-creator-chat:ws:room:\\*" docs/20260618_유저크리에이터채팅_WebSocket전환` - Expected: 과거 검증 기록을 제외한 현재 요구사항/계획에는 `PatternTopic`, `PSUBSCRIBE`, room별 pub/sub channel 요구가 남아 있지 않다. - 문서 작성 기록: - 무엇: PRD의 Redis pub/sub channel 요구사항을 고정 channel `v2:user-creator-chat:ws:room` 기준으로 갱신하고, 계획 문서에 ElastiCache Serverless 호환 보정 Task를 추가했다. - 왜: AWS ElastiCache Serverless Valkey 7.2/Redis OSS 7.1에서 `PSUBSCRIBE`가 지원되지 않아 `PatternTopic` 기반 구현이 애플리케이션 시작 실패를 유발하기 때문이다. - 어떻게: `PatternTopic`/room별 channel을 제거하고 `ChannelTopic`/고정 channel을 사용하는 RED-GREEN 검증 절차, embedded Redis 통합 회귀, 인접 회귀, lint 검증 명령을 문서화했다. - 문서 규칙 검증 Run: `./gradlew --no-daemon tasks --all` - 문서 규칙 검증 Result: sandbox 권한 문제로 최초 실행은 `/Users/klaus/.gradle/.../gradle-8.1.1-bin.zip.lck (Operation not permitted)` 실패. 권한 승인 후 재실행해 `BUILD SUCCESSFUL in 6s`로 통과했다. - 검증 기록: - 무엇: `UserCreatorChatRoomMessageBroker`의 Redis pub/sub을 room별 `PatternTopic` 구독/room별 channel publish에서 고정 `ChannelTopic("v2:user-creator-chat:ws:room")` 구독/고정 channel publish로 변경했다. - 왜: AWS ElastiCache Serverless에서 `PSUBSCRIBE`가 지원되지 않으므로 Spring Data Redis `PatternTopic` 경로를 제거하고 `SUBSCRIBE` 기반 channel만 사용하기 위해서다. - RED: broker unit test를 먼저 고정 channel 기대값으로 변경한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`를 실행해 publish channel과 listener topic 검증이 `ArgumentsAreDifferent`로 실패함을 확인했다. - GREEN: `PatternTopic` import와 `roomChannel(roomId)`를 제거하고 `ChannelTopic(ROOM_CHANNEL)`, `convertAndSend(ROOM_CHANNEL, ...)`로 변경했다. 같은 focused test는 최초 120초 timeout 후 300초 timeout으로 재실행해 `BUILD SUCCESSFUL in 2m 45s`로 통과했다. - 통합 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`가 `BUILD SUCCESSFUL in 35s`로 통과했고, embedded Redis pub/sub listener가 고정 channel 구독만으로 대상 local session에 payload를 전달함을 확인했다. - 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 42s`로 통과했다. - Lint: `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 31s`로 통과했다. - 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`가 `BUILD SUCCESSFUL in 1m 32s`로 통과했다. - 문서 확인: `rg -n "PatternTopic|PSUBSCRIBE|v2:user-creator-chat:ws:room:\\{roomId\\}|v2:user-creator-chat:ws:room:\\*" docs/20260618_유저크리에이터채팅_WebSocket전환` 실행 결과, 남은 항목은 PRD의 현재 금지 요구사항과 Task 3.4/Phase 3 과거 기록 및 presence key 설명으로 확인했다. --- ### Phase 4: WebSocket handler와 메시지 저장/전달 - [x] **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로 최소 노출한다. - 검증 기록: - 무엇: WebSocket handler의 `JOIN_ROOM` dispatch, service 참여자 검증 method, local session registry 등록, Redis presence 등록, `JOINED`/`ERROR` envelope 응답을 추가했다. - 왜: 채팅방 화면 진입 시 인증 member가 해당 room 참여자인지 확인한 뒤 현재 서버 session과 Redis presence를 등록해야 하기 때문이다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `validateParticipant`, handler constructor dependency, dispatch 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다. - 인접 검증: `UserCreatorChatWebSocketConfigTest`, `UserCreatorChatPresenceServiceTest`, `UserCreatorChatRoomMessageBrokerTest` 포함 명령이 `BUILD SUCCESSFUL in 1m`로 통과했고, 이후 focused+인접 통합 명령이 `BUILD SUCCESSFUL in 3m 28s`로 통과했다. - Reviewer 보강 RED: 같은 session이 다른 room으로 다시 `JOIN_ROOM`할 때 기존 Redis presence가 제거되지 않고, WebSocket close 시 local session/Redis presence가 정리되지 않는 테스트가 기존 구현에서 실패함을 확인했다. - Reviewer 보강 GREEN: session attribute에 joined room을 저장하고, 재JOIN/close 시 `presenceService.markLeft`와 `sessionRegistry.remove`를 호출하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다. - [x] **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로만 분리한다. - 검증 기록: - 무엇: WebSocket 전용 `sendTextMessageByWebSocket`을 추가해 기존 텍스트 저장 로직을 private `saveTextMessage`로 재사용하고, 상대방 presence가 있으면 `UserCreatorChatRoomMessageBroker.publish`로 `MESSAGE` envelope를 발행하며 sender에게는 handler가 `SEND_ACK`를 응답하도록 했다. - 왜: REST text endpoint 제거 전까지 중복 저장 로직을 만들지 않고, WebSocket 송신 경로에서 저장/ack/수신자 전달을 처리하기 위해서다. - 범위: 상대방 미접속 시 push payload 보강은 Task 4.3 범위라 이번 task에서는 변경하지 않았다. 현재 계약은 v2 채팅 푸시 payload에 `deep_link`만 포함하는 것이다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` 실행 시 `sendTextMessageByWebSocket`, service WebSocket dependency, handler `SEND_TEXT` dispatch 부재로 `compileTestKotlin` 실패를 확인했다. - GREEN: focused 명령 재실행 결과 `BUILD SUCCESSFUL in 1m 1s`로 통과했다. 이후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketConfigTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 3m 28s`로 통과했다. - Reviewer 보강 RED: `JOIN_ROOM` 완료 전 `SEND_TEXT`를 보내도 메시지 저장 경로로 들어갈 수 있다는 테스트가 기존 구현에서 실패함을 확인했다. - Reviewer 보강 GREEN: `SEND_TEXT` 처리 전 session의 joined room id가 요청 `roomId`와 일치하는지 검증하고, 미JOIN/다른 room이면 `chat.room.join_required` `ERROR`를 응답하도록 수정했다. `UserCreatorChatWebSocketHandlerTest`가 `BUILD SUCCESSFUL in 1m`로 통과했고, focused+인접 WebSocket 테스트 묶음이 `BUILD SUCCESSFUL in 15s`로 통과했다. - [x] **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`가 v2 채팅용 `deep_link` 생성 정보를 포함하고 `roomId`, `messageId`, `chatType=USER_CREATOR` data payload를 포함하지 않는 테스트를 작성한다. - RED: `FcmService`가 FCM data payload에 `deep_link=voiceon[-test]://chat/{roomId}`만 넣는 테스트를 작성한다. - 실패 확인: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` - Expected: v2 채팅 deep_link payload 부재 또는 기존 `room_id`/`message_id`/`chat_type` 잔존으로 실패한다. - GREEN: v2 채팅 push 발행 시 `deep_link`만 채우고 기존 `room_id`, `message_id`, `chat_type` data payload는 제거한다. - 통과 확인: - 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와 충돌하지 않게 v2 채팅 deep link는 `voiceon[-test]://chat/{roomId}` 규칙으로만 생성한다. - 검증 기록: - 현재 요구사항 변경: v2 채팅 푸시 payload는 `deep_link`만 사용하고 `room_id`, `message_id`, `chat_type`은 제거한다. 기존 완료 기록은 이전 계약 기준 이력이며, 후속 구현에서 새 계약에 맞춰 수정한다. - 무엇: WebSocket 텍스트/REST 음성 전송에서 상대방 presence가 없으면 `FcmEvent.deepLinkValue=CHAT`, `deepLinkId=roomId`만 포함한 FCM 이벤트를 발행하도록 수정했다. FCM data payload 생성은 `deep_link=voiceon[-test]://chat/{roomId}`만 포함하고 `room_id`, `message_id`, `chat_type`을 제외한다. - 왜: 상대방이 같은 `roomId`에 접속 중이 아닐 때 푸시 터치로 유저-크리에이터 채팅방에 이동해야 하기 때문이다. - 이전 계약 기준 RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` 실행 시 `FcmEvent.chatType`, `FcmService.buildDataPayload` unresolved reference로 `compileTestKotlin` 실패를 확인했다. - 이전 계약 기준 GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 30s`로 통과했다. 현재 요구사항은 위 RED/GREEN 항목처럼 `deep_link` 단일 payload로 후속 수정한다. - Fresh 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --rerun-tasks --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` - Fresh 검증 Result: `BUILD SUCCESSFUL in 3m 45s`; focused 테스트 10 actionable tasks가 실제 실행됐다. - Fresh 정적 확인 Run: `rg -n "room_id|message_id|chat_type|deep_link|voiceon://chat|voiceon-test://chat" docs/20260618_유저크리에이터채팅_WebSocket전환 src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat src/test/kotlin/kr/co/vividnext/sodalive/fcm src/main/kotlin/kr/co/vividnext/sodalive/fcm` - Fresh 정적 확인 Result: v2 채팅 service/test 경로는 `deepLinkValue=CHAT`, `deepLinkId=roomId`, `deep_link` 테스트로 확인됐고, `room_id`/`message_id`/`chat_type`은 공통 FCM payload helper와 문서상 제외 조건에만 남아 있음을 확인했다. - 코드 리뷰 메모: `UserCreatorChatService.publishMessagePush`, `FcmSendListener.sendPush`, `FcmService.buildDeepLink/buildDataPayload`, 관련 단위 테스트를 대조했다. Task 4.3 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. - [x] **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를 사용한다. - 검증 기록: - 무엇: handler dispatch에 `LEAVE_ROOM`과 `PING`을 추가했다. `LEAVE_ROOM`은 기존 close cleanup 경로인 `clearJoinedRoom`을 재사용하고, `PING`은 joined room 검증 후 Redis presence TTL을 갱신하고 `PONG`을 응답한다. - 왜: 채팅방 화면 이탈 시 presence를 즉시 제거하고, 화면 유지 중 heartbeat로 90초 TTL presence를 연장하기 위해서다. - RED: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest` 실행 시 `LEAVE_ROOM`의 `markLeft`, `PING`의 `refresh`가 호출되지 않아 handler 테스트 2개가 실패했다. - GREEN: 같은 focused 명령 재실행 결과 `BUILD SUCCESSFUL in 4m 9s`로 통과했다. - [x] **Task 4.5: WebSocket handshake slice 테스트 추가** - Files: - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandshakeIntegrationTest.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` (필요한 경우에만) - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketAuthInterceptor.kt` (필요한 경우에만) - RED: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler에 `UserCreatorChatWebSocketAuthInterceptor`가 등록되어 있고, 등록된 interceptor가 handshake 인증 성공/실패를 판정하는 테스트를 작성한다. - RED: 유효한 `Authorization: Bearer ` header가 있으면 handshake가 성공하고, header가 없거나 유효하지 않으면 handshake가 실패하는 테스트를 작성한다. - RED: 전체 Spring Boot server/DB/Redis context를 띄우지 않고 `TokenProvider`는 mock으로 고정해 token parsing 세부 로직은 기존 interceptor 단위 테스트에 위임한다. - 실패 확인: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest` - Expected: 통합 테스트 파일 부재 또는 실제 client handshake 경계 미구현으로 실패한다. - GREEN: 테스트가 실패하면 최소 수정만 적용한다. 예를 들어 security filter가 `/ws/v2/user-creator-chat` handshake를 interceptor까지 통과시키지 못하는 경우에만 해당 경로 정책을 조정한다. - 통과 확인: - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest` - Expected: `BUILD SUCCESSFUL` - REFACTOR: 단위 테스트가 이미 검증하는 token parsing 세부 로직을 중복하지 않고, endpoint 등록과 auth interceptor handshake 성공/실패 연결 경계만 검증한다. - 검증 기록: - 무엇: WebSocket config slice에서 `/ws/v2/user-creator-chat` handler 등록과 등록된 `UserCreatorChatWebSocketAuthInterceptor`의 handshake 성공/실패를 검증하는 테스트를 추가했다. - 왜: `@SpringBootTest(webEnvironment = RANDOM_PORT)` 기반 실제 server/client 테스트가 전체 suite 말미에 `java.lang.OutOfMemoryError: Java heap space`를 유발해, 동일 Phase 4 경계를 더 가벼운 slice로 검증하기 위해서다. - 어떻게: `TokenProvider`는 mock으로 고정하고, 유효 `Authorization: Bearer `은 handshake 성공, header 누락과 invalid token은 handshake 실패를 검증했다. 실제 token parsing 세부 로직은 `UserCreatorChatWebSocketAuthInterceptorTest`에 맡겼다. - 결과: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest`가 `BUILD SUCCESSFUL in 1m 11s`로 통과했다. production `SecurityConfig`/interceptor 수정은 필요하지 않았다. --- ### Phase 5: 기존 SSE 제거와 REST 경계 정리 - [x] **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를 제거한다. - 검증 기록: - 무엇: `/events`, `/events/disconnect`, `/messages/text` REST endpoint를 controller에서 제거하고, 제거된 경로가 더 이상 매핑되지 않는 테스트를 추가했다. - 왜: 텍스트 메시지 송수신과 presence lifecycle을 WebSocket으로만 처리하기 위해서다. - RED: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서 매핑이 실행되어 `NestedServletException`으로 실패함을 확인했다. - GREEN: endpoint 제거 후 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`가 `BUILD SUCCESSFUL in 56s`로 통과했다. - [x] **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`로 잔여 참조가 없는지 확인한다. - 검증 기록: - 무엇: `UserCreatorChatRealtimeService` 파일과 `UserCreatorChatService`의 SSE 의존성, `connect`, `disconnectRealtime`, REST text service method를 제거했다. 음성 REST 전송은 유지하되 상대방 presence가 있으면 WebSocket broker로 `MESSAGE`를 발행하고, presence가 없으면 기존 푸시 이벤트를 발행하도록 정리했다. - 왜: 기존 SSE 구현을 완전히 제거하면서도 유지 대상인 음성 업로드 API의 실시간/푸시 분기 동작을 보존하기 위해서다. - RED: 제거 전 `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test`에서 `UserCreatorChatRealtimeService`, controller SSE method, service/test 참조가 확인되었다. - GREEN: `UserCreatorChatServiceTest`의 WebSocket text, voice REST WebSocket broker, voice REST push 분기 테스트가 포함된 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다. - 정적 확인: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService" src/main src/test` 결과가 없음을 확인했다. - [x] **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|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환` - Expected: 제거 대상 API와 신규 WebSocket/푸시 계약이 모두 문서에 명시되어야 한다. - GREEN: 클라이언트 연동 순서를 `openRoom` REST 호출 후 WebSocket connect + `JOIN_ROOM`으로 갱신한다. - 통과 확인: - Run: `./gradlew tasks --all` - Expected: `BUILD SUCCESSFUL` - 검증 기록: - 무엇: `docs/plan-task/20260513_유저크리에이터채팅방개편.md`의 클라이언트 연동 프롬프트를 SSE 연결/해제/REST text 전송에서 WebSocket `JOIN_ROOM`, `SEND_TEXT`, `MESSAGE`, `SEND_ACK`, `LEAVE_ROOM` 기준으로 갱신했다. - 왜: 과거 SSE 기준 연동 프롬프트가 현재 서버 구현과 충돌하지 않도록 클라이언트 안내를 최신 계약으로 맞추기 위해서다. - 어떻게: 유지 REST API는 방 생성/open/messages/voice로 남기고, 제거된 `/events`, `/events/disconnect`, `/messages/text` 호출과 SSE reconnect 처리 코드를 제거 대상 목록에 명시했다. - [x] **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|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환/prd.md docs/20260618_유저크리에이터채팅_WebSocket전환/plan-task.md` - Expected: 클라이언트 변경 사항이 PRD와 plan-task 양쪽에 기록되어야 한다. - GREEN: 앱 담당자가 구현해야 할 진입, 전송, 수신, 이탈, 재연결, 푸시 이동, 제거 대상 API를 문서에 유지한다. - 통과 확인: - Run: `./gradlew tasks --all` - Expected: `BUILD SUCCESSFUL` - 검증 기록: - 무엇: PRD와 plan-task에 iOS/Android 진입, 전송, 수신, 이탈, 재연결, 토큰 갱신, 푸시 이동, 제거 대상 API가 유지되는지 확인했다. - 왜: 서버 저장소에는 앱 코드가 없으므로 앱 반영 범위는 문서 계약으로 추적해야 하기 때문이다. - 결과: PRD는 기존 SSE 사용 현황과 WebSocket 전환 요구사항을 유지하고, plan-task는 앱 변경 체크리스트와 제거 대상 호출 목록을 유지한다. --- ## 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.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest` - 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|deep_link|USER_CREATOR" build.gradle.kts src/main src/test` - Expected: WebSocket 의존성, endpoint, 푸시 payload 관련 구현이 확인됨 --- ## 5. 구현 후 검증 기록 - Phase 4 코드 리뷰 및 전체 검증: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Result: `BUILD FAILED in 6m 31s`; 전체 suite 실행 중 `Gradle Test Executor 1`이 `java.lang.OutOfMemoryError: Java heap space`로 실패했다. 리포트 기준 Phase 4 관련 단위/통합 테스트 대부분은 `failures=0`, `errors=0`였으나 `UserCreatorChatWebSocketHandshakeIntegrationTest`는 suite 말미 context 초기화 중 OOM으로 `tests=0` 상태였다. - 조치: `UserCreatorChatWebSocketHandshakeIntegrationTest`를 `RANDOM_PORT` 전체 context/실제 `StandardWebSocketClient` 방식에서 WebSocket config slice + mock `TokenProvider` 방식으로 축소했다. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest` - Result: `BUILD SUCCESSFUL in 1m 11s`; 축소한 handshake slice 테스트 단독 실행이 통과했다. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Result: `BUILD FAILED in 1m 22s`; 이전 `OutOfMemoryError`는 재발하지 않았고 `UserCreatorChatWebSocketHandshakeIntegrationTest`도 OOM 원인으로 나타나지 않았다. 실패는 embedded Redis 시작 중 `127.0.0.1:16379: bind: Address already in use`로 발생했다. - 확인: `lsof -nP -iTCP:16379 -sTCP:LISTEN` 결과 `redis-ser` PID `99457`이 `127.0.0.1:16379`를 점유 중이었다. 외부 프로세스 종료는 작업 범위 밖이라 수행하지 않았다. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` - Result: `BUILD SUCCESSFUL in 46s`; Phase 4 WebSocket handler/service/presence/broker/Redis/handshake/FCM payload focused 테스트가 통과했다. - Run: `./gradlew --no-daemon ktlintCheck` - Result: `BUILD SUCCESSFUL in 7s`. - 코드 리뷰 메모: Phase 4 기능 경로에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. 다만 전체 suite OOM으로 인해 `./gradlew test` 전체 통과 상태는 아직 확보하지 못했다. - Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 36s`; 이번 실행에서는 전체 테스트 suite가 통과했다. - Fresh lint Run: `./gradlew --no-daemon ktlintCheck` - Fresh lint Result: `BUILD SUCCESSFUL in 7s`. - Fresh 정적 확인 Run: `rg -n "open-in-view" src/main/resources src/test/resources` - Fresh 정적 확인 Result: main/test 설정의 `spring.jpa.open-in-view=false` 명시를 확인했다. - Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test` - Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다. - Fresh 코드 리뷰 메모: `JOIN_ROOM`/`SEND_TEXT`/`LEAVE_ROOM`/`PING`, 미JOIN/다른 room 차단, malformed/unknown message error, 상대방 presence 유무에 따른 WebSocket publish/FCM push 분기, handshake slice 테스트 범위를 대조했다. Phase 4 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. - Phase 5: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` - RED Result: `UserCreatorChatControllerMappingTest.shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints` 추가 후 기존 코드에서는 제거 대상 endpoint가 아직 매핑되어 controller method가 실행되면서 `NestedServletException`으로 실패했다. - GREEN Result: endpoint/service 제거와 voice delivery WebSocket 전환 후 같은 focused 명령이 `BUILD SUCCESSFUL in 56s`로 통과했다. - Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test` - Result: 검색 결과 없음. SSE runtime 구현, REST text request DTO, 관련 테스트 참조가 제거되었음을 확인했다. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` - Result: `BUILD SUCCESSFUL in 5m 24s`; WebSocket handler/presence/broker/handshake와 FCM payload 인접 회귀가 통과했다. - Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md` - Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다. - Run: `./gradlew --no-daemon ktlintCheck` - Result: import 정렬 수정 후 `BUILD SUCCESSFUL in 29s`. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Result: `BUILD SUCCESSFUL in 3m 21s`; 전체 테스트 suite가 통과했다. - Fresh 코드 리뷰 메모: SSE controller endpoint 제거, `UserCreatorChatRealtimeService` 삭제, REST text request DTO 제거, 음성 REST 전송의 WebSocket broker/FCM push 분기, 클라이언트 연동 문서 갱신 범위를 대조했다. Phase 5 범위에서 즉시 수정이 필요한 production 코드 결함은 발견하지 못했다. - Fresh 정적 확인 Run: `rg -n "SseEmitter|TEXT_EVENT_STREAM|connectEvents|disconnectRealtime|UserCreatorChatRealtimeService|SendUserCreatorTextMessageRequest" src/main src/test` - Fresh 정적 확인 Result: 검색 결과 없음. `src/main`/`src/test`에 SSE runtime 구현, REST text request DTO, 관련 참조가 남아 있지 않다. - Fresh 정적 확인 Run: `rg -n "spring-boot-starter-websocket|/ws/v2/user-creator-chat|deep_link|USER_CREATOR" build.gradle.kts src/main src/test` - Fresh 정적 확인 Result: WebSocket 의존성, endpoint, FCM `deep_link` 구현과 테스트를 확인했다. v2 채팅 푸시 payload는 `deep_link` 단일 값이다. - Fresh 문서 확인 Run: `rg -n "GET /api/v2/user-creator-chat/rooms/\\{roomId\\}/events|events/disconnect|messages/text|JOIN_ROOM|SEND_TEXT|LEAVE_ROOM|deep_link" docs/20260618_유저크리에이터채팅_WebSocket전환 docs/plan-task/20260513_유저크리에이터채팅방개편.md` - Fresh 문서 확인 Result: 제거 대상 API와 신규 WebSocket/푸시 계약이 문서에 함께 명시되어 있음을 확인했다. - Fresh focused 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` - Fresh focused 검증 Result: `BUILD SUCCESSFUL in 44s`. - Fresh 인접 회귀 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandlerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketHandshakeIntegrationTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest` - Fresh 인접 회귀 Result: `BUILD SUCCESSFUL in 16s`. - Fresh lint Run: `./gradlew --no-daemon ktlintCheck` - Fresh lint Result: `BUILD SUCCESSFUL in 7s`. - Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process` - Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`. - 잔여 리스크 개선: - 대상: `UserCreatorChatService.deliverRealtime` - 무엇: Redis/WebSocket 전달 경계의 fail-open 처리 범위를 전체 `Exception`에서 Redis 접근 예외인 `DataAccessException`으로 좁히고, Redis 오류는 warn 로그를 남긴 뒤 푸시 발송으로 fail-open 하도록 정리했다. Redis 계층이 아닌 broker 예외는 숨기지 않고 전파한다. - 왜: Redis 장애 시 메시지 저장 후 푸시 발송 요구사항은 유지하되, 프로그래밍 오류나 예상하지 못한 런타임 오류까지 푸시 fallback으로 숨기지 않기 위해서다. - RED: `UserCreatorChatServiceTest.shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage`를 추가했다. 기존 `runCatching` 구현에서는 `IllegalStateException`이 전파되지 않아 `AssertionFailedError`로 실패했다. - GREEN: `DataAccessException`만 catch하도록 수정한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 3m 33s`로 통과했다. - 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*'`가 `BUILD SUCCESSFUL in 38s`로 통과했다. - Lint: import 정렬 수정 후 `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 21s`로 통과했다. - 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`가 `BUILD SUCCESSFUL in 4m 39s`로 통과했다. - Phase 3: - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest` - RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다. - GREEN Result: `BUILD SUCCESSFUL in 33s`; embedded Redis 기준 presence key/member session set/room set/TTL, markLeft 정리, stale session pruning, Redis pub/sub listener delivery 테스트 4개가 통과했다. - Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBrokerTest` - RED Result: `UserCreatorChatPresenceService`, `UserCreatorChatRoomMessageBroker`, `UserCreatorChatRoomPublishedMessage` unresolved reference로 `compileTestKotlin` 실패를 확인했다. - GREEN Result: `BUILD SUCCESSFUL in 3m 22s`; presence join/refresh/leave/hasPresence 테스트 4개와 broker publish/pattern subscribe/local target delivery 테스트 3개가 통과했다. - Reviewer 보강 RED: stale session id가 남은 상태에서 마지막 live session이 leave 하는 테스트가 기존 구현에서 `ArgumentsAreDifferent`로 실패했다. - Reviewer 보강 GREEN: `UserCreatorChatPresenceServiceTest`가 `BUILD SUCCESSFUL in 1m 22s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 31s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 50s`로 통과했다. - Broker 보강 RED: broken local session의 `sendMessage`가 `IOException`을 던질 때 같은 member의 healthy session 전송이 중단되어 `UserCreatorChatRoomMessageBrokerTest`가 실패했다. - Broker 보강 GREEN: `UserCreatorChatRoomMessageBrokerTest`가 `BUILD SUCCESSFUL in 21s`, WebSocket focused 테스트 묶음이 `BUILD SUCCESSFUL in 21s`, `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 37s`로 통과했다. - 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` 명시를 확인했다. - Run: `./gradlew --no-daemon cleanTest test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests '*CreatorChannelHomeControllerTest' --tests '*CreatorChannelLiveControllerTest' --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.CreatorRankingControllerTest` - Result: `BUILD SUCCESSFUL in 1m 24s`; XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개, `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개, `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0`. ## 6. OSIV 점검 기록 - API/기능: - 파일: `src/main/resources/application.yml`, `src/test/resources/application.yml` - 위험 유형: OSIV 정책 미명시 - lazy 접근 대상: 해당 없음 - OSIV off 테스트: `rg -n "open-in-view|spring.jpa" src/main/resources src/test/resources` - 수정 방향: main/test `spring.jpa.open-in-view=false` 명시 - 처리 상태: 완료 - API/기능: 유저-크리에이터 채팅 room open/messages/voice service 경계 - 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt` - 위험 유형: service/facade 트랜잭션 밖 DTO 변환 중 lazy 연관 접근 가능성 - lazy 접근 대상: `UserCreatorChatParticipant.member`, `UserCreatorChatMessage.participant` - OSIV off 테스트: Phase 0 묶음 검증 명령에 포함 - 수정 방향: 현재 범위에서는 DTO 변환이 service 트랜잭션 안에서 수행되어 추가 수정 없음 - 처리 상태: XML 기준 `UserCreatorChatServiceIntegrationTest` 2개, `UserCreatorChatServiceTest` 8개 모두 `failures=0`, `errors=0` - API/기능: 홈 추천/크리에이터 랭킹 controller 통합 테스트 - 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/CreatorRankingControllerTest.kt` - 위험 유형: controller 요청 처리 또는 응답 직렬화 중 lazy 연관 접근 가능성 - lazy 접근 대상: `MemberAdapter.member.auth`, 홈/랭킹 조회 응답 DTO 관련 entity 연관 - OSIV off 테스트: Phase 0 묶음 검증 명령에 포함 - 수정 방향: 확인된 `LazyInitializationException` 없음. 추가 lazy 수정 없음 - 처리 상태: XML 기준 `HomeRecommendationControllerTest` 21개, `CreatorRankingControllerTest` 3개 모두 `failures=0`, `errors=0` - API/기능: 크리에이터 채널 home/live WebMvc controller 표면 - 파일: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/adapter/in/web/CreatorChannelLiveControllerTest.kt` - 위험 유형: controller에서 인증 principal을 facade로 전달하는 표면 회귀 가능성 - lazy 접근 대상: `MemberAdapter.member` - OSIV off 테스트: Phase 0 묶음 검증 명령에 포함 - 수정 방향: JPA lazy loading 직접 검증은 아니며, WebMvc 표면 회귀만 확인 - 처리 상태: XML 기준 `CreatorChannelHomeControllerTest` 2개, `CreatorChannelLiveControllerTest` 5개 모두 `failures=0`, `errors=0` - API/기능: 캐릭터 상세/홈 크리에이터 랭킹 운영 회귀 - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/controller/ChatCharacterController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/api/home/HomeService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt` - 위험 유형: service/repository 반환 후 controller/service DTO 변환 중 nested lazy proxy 접근 - lazy 접근 대상: `ChatCharacter.tagMappings.tag`, `Member.tags.tag` - OSIV off 테스트: `OsivLazyLoadingRegressionTest` - 수정 방향: 상세/공유 태그 캐릭터 조회와 크리에이터 랭킹 조회에서 필요한 연관을 fetch join으로 선로딩 - 처리 상태: `OsivLazyLoadingRegressionTest` 3개 모두 통과