Compare commits

...

32 Commits

Author SHA1 Message Date
74c112f128 docs(user-creator-chat): 이전 SSE 계획 문서에 대체 기준을 명시한다 2026-06-19 05:36:18 +09:00
6c252ee008 fix(user-creator-chat): Redis 전달 예외 fallback 범위를 좁힌다 2026-06-19 05:35:53 +09:00
07b93f3219 fix(user-action): 리워드 인증 여부를 서비스에서 조회한다 2026-06-19 05:35:32 +09:00
be6f324fb1 fix(event): 이벤트 성인 여부 조회 기준을 인증 저장소로 변경한다 2026-06-19 05:35:05 +09:00
341020788b fix(creator): 장르 성인 여부 조회 기준을 인증 저장소로 변경한다 2026-06-19 05:34:43 +09:00
fe8bf73e6e fix(audition): 성인 여부 조회 기준을 인증 저장소로 변경한다 2026-06-19 05:34:35 +09:00
5d18f478ab docs(user-creator-chat): 채팅 푸시 deep link 계약을 기록한다 2026-06-19 03:57:56 +09:00
8b80ca6344 feat(user-creator-chat): 미접속 채팅 푸시 deep link를 적용한다 2026-06-19 03:57:25 +09:00
7f13cccde0 feat(fcm): 채팅 deep link payload를 정리한다 2026-06-19 03:57:12 +09:00
0811f92bf5 docs(user-creator-chat): 클라이언트 WebSocket 연동 안내를 갱신한다 2026-06-19 02:45:51 +09:00
84e9c18ae1 docs(user-creator-chat): WebSocket Phase 5 기록을 갱신한다 2026-06-19 02:45:34 +09:00
8fa8d12667 feat(user-creator-chat): SSE REST 경계를 제거한다 2026-06-19 02:45:17 +09:00
6949d3e482 docs(user-creator-chat): WebSocket Phase 4 기록을 갱신한다 2026-06-19 01:56:44 +09:00
9e58131167 test(user-creator-chat): WebSocket handshake slice 검증을 추가한다 2026-06-19 01:56:28 +09:00
54c9a7d5a5 feat(user-creator-chat): WebSocket 퇴장과 heartbeat를 처리한다 2026-06-19 01:55:55 +09:00
b7c1bb8c20 feat(user-creator-chat): 미접속 상대 푸시를 발행한다 2026-06-19 01:55:30 +09:00
743020d6bf feat(fcm): 채팅 푸시 payload를 확장한다 2026-06-19 01:55:22 +09:00
562a4b2077 docs(user-creator-chat): WebSocket Phase 4 기록을 갱신한다 2026-06-18 23:01:05 +09:00
7080a03166 feat(user-creator-chat): WebSocket room handler를 구현한다 2026-06-18 23:00:43 +09:00
2d13f8dee7 docs(user-creator-chat): WebSocket Phase 3 기록을 갱신한다 2026-06-18 19:09:42 +09:00
282bc078e5 test(user-creator-chat): WebSocket Redis 통합 검증을 추가한다 2026-06-18 19:08:59 +09:00
f44ea58ca2 feat(user-creator-chat): WebSocket Redis room broker를 추가한다 2026-06-18 19:08:16 +09:00
216850c07a feat(user-creator-chat): WebSocket Redis presence를 추가한다 2026-06-18 19:07:54 +09:00
afa57b70de docs(user-creator-chat): WebSocket Phase 2 기록을 갱신한다 2026-06-18 17:06:59 +09:00
af1e9b565a feat(user-creator-chat): WebSocket 세션 레지스트리를 추가한다 2026-06-18 17:06:32 +09:00
fefd62c63a feat(user-creator-chat): WebSocket 메시지 계약을 추가한다 2026-06-18 17:06:25 +09:00
d506ad9c39 docs(user-creator-chat): WebSocket Phase 1 기록을 갱신한다 2026-06-18 16:08:24 +09:00
a170c82a92 feat(user-creator-chat): WebSocket 인증 핸드셰이크를 추가한다 2026-06-18 16:08:14 +09:00
5cab3558c0 build(config): WebSocket 의존성을 추가한다 2026-06-18 16:07:56 +09:00
a81987c3f7 docs(user-creator-chat): OSIV 전환 검증 기록을 갱신한다 2026-06-18 14:47:02 +09:00
3af958fdcb chore(config): OSIV 비활성화를 명시한다 2026-06-18 14:46:56 +09:00
245bae8600 docs(user-creator-chat): WebSocket 전환 계획 문서를 추가한다 2026-06-18 12:42:46 +09:00
47 changed files with 3377 additions and 255 deletions

View File

@@ -32,6 +32,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis") implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.springframework.retry:spring-retry") implementation("org.springframework.retry:spring-retry")
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")

View File

@@ -0,0 +1,864 @@
# 유저-크리에이터 채팅 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:{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`
- 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 <accessToken>` 헤더를 포함한다.
- 연결 직후 아래 메시지를 보낸다.
```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 스키마 변경도 없다.
---
### 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 <token>` 헤더가 없거나 유효하지 않으면 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 <token>` 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을 함께 검증하도록 갱신했다.
---
### 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 <accessToken>` 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 <token>`은 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 '<OSIV off 우선 테스트 목록>'`
- 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`

View File

@@ -0,0 +1,275 @@
# 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에는 앱이 해당 채팅방으로 이동할 수 있는 `deep_link`만 포함한다.
- 기존 방 생성, 방 열기, 과거 메시지 조회, 음성 메시지 업로드 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 <accessToken>` 헤더를 전달한다.
- 서버는 기존 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에는 `deep_link`만 포함한다.
- 운영: `voiceon://chat/{roomId}`
- 개발/테스트: `voiceon-test://chat/{roomId}`
- v2 채팅 푸시에서는 기존 `room_id`, `message_id`, `chat_type` data payload를 사용하지 않는다.
- 앱은 푸시 터치 시 `deep_link`에서 `{roomId}`를 해석해 `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 클라이언트 변경 사항
#### Current Native App Usage
- 개발 중 테스트 중이던 iOS/Android 네이티브 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용하고 있었다.
- 해당 앱은 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 등 실시간 수신을 중단해야 하는 시점에 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출하고 있었다.
- WebSocket 전환 후에는 위 두 API가 제거되므로, 서버 배포와 같은 릴리스 범위에서 네이티브 앱의 SSE 연결/해제 코드도 함께 변경되어야 한다.
#### Requirements
- 클라이언트는 채팅방 화면 진입 시 기존 SSE 연결을 열지 않는다.
- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 조회한다.
- `openRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
- WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer <accessToken>` 헤더로 전달한다.
- WebSocket 연결 직후 `JOIN_ROOM` 메시지를 전송한다.
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
- 텍스트 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
- 텍스트 메시지 전송 UI는 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다.
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
- 음성 메시지는 기존 multipart REST API를 유지한다.
- 기존 `events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고, 필요하면 REST `messages` API로 누락 메시지를 동기화한다.
- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
- 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다.
- 푸시 알림을 터치하면 payload의 `deep_link`를 확인해 해당 채팅방 화면으로 이동한다.
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `openRoom` 호출 후 WebSocket 연결/`JOIN_ROOM`을 수행한다.
- 클라이언트는 제거된 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않는다.
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다. 텍스트 전송 성공 여부는 WebSocket `SEND_ACK`/`ERROR`/timeout으로 판단한다.
#### Native App Migration Checklist
- SSE client 또는 `EventSource` wrapper 제거: `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다.
- 연결 확인 기준 변경: SSE `connected` 이벤트 수신 완료를 WebSocket `JOINED` 수신 완료로 대체한다.
- 메시지 수신 기준 변경: SSE `message` event payload append를 WebSocket `MESSAGE` envelope payload append로 대체한다.
- 연결 해제 기준 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 제거하고, 같은 lifecycle 위치에서 `LEAVE_ROOM` 메시지를 보낸 뒤 WebSocket을 close한다.
- 텍스트 전송 변경: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 호출을 제거하고, `SEND_TEXT` 메시지와 `SEND_ACK` 매칭 방식으로 pending/성공/실패 상태를 관리한다.
- 음성 전송 유지: `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출은 유지하되, 음성 전송 후 상대방 실시간 수신 여부는 서버 정책에 따른다.
- 토큰 갱신 처리 변경: access token refresh 시 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
- 푸시 이동 처리 확인: `deep_link``voiceon://chat/{roomId}` 또는 `voiceon-test://chat/{roomId}`를 기준으로 채팅방에 진입하고, 진입 후 `openRoom` 호출과 WebSocket `JOIN_ROOM`을 일반 진입과 동일하게 수행한다.
#### Edge Cases
- WebSocket 연결은 성공했지만 `JOIN_ROOM`이 실패하면 채팅방 화면에 오류를 표시하고 텍스트 전송을 막는다.
- `SEND_TEXT` 후 일정 시간 안에 `SEND_ACK`가 오지 않으면 메시지를 전송 실패 상태로 표시하고 재시도 UI를 제공한다.
- 재연결 전 pending 메시지를 자동 재전송할 경우 같은 `requestId`를 재사용해 중복 표시를 방지한다.
- 앱이 다른 채팅방으로 이동하면 기존 방 WebSocket session을 종료하고 새 방으로 다시 연결한다.
- 푸시 payload에 `deep_link`가 없거나 `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}` 형식에서 `roomId`를 해석할 수 없으면 채팅방 직접 이동을 하지 않고 앱 기본 알림 처리 흐름을 따른다.
### 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한다.
- 채팅방 화면에 있는 동안 같은 방의 메시지 푸시가 나타나지 않아야 한다.
- 채팅방 화면 밖에서 푸시를 터치하면 `deep_link``{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을 모두 구현해야 한다.
- Redis presence TTL, session set 정리, pub/sub listener 전달은 mock 검증만으로 완료하지 않고 embedded Redis 또는 동등한 실제 Redis 테스트 인프라로 통합 검증한다.
- `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 수
---
## 11. Open Questions
- 없음. 이번 문서 기준 확정안은 채팅방 화면 진입 중에만 유지하는 raw WebSocket, Redis 기반 다중 서버 presence/pub-sub, 기존 SSE 완전 제거다.

View File

@@ -2,6 +2,8 @@
> 이 문서는 현재 요청 범위인 문서 작성만 다룬다. 실제 구현 시에는 이 계획의 구현 항목을 기준으로 세부 API, 엔티티, 테스트를 별도 작업 단위로 쪼갠다. > 이 문서는 현재 요청 범위인 문서 작성만 다룬다. 실제 구현 시에는 이 계획의 구현 항목을 기준으로 세부 API, 엔티티, 테스트를 별도 작업 단위로 쪼갠다.
> 최신화: 실시간 전송 정책은 `docs/20260618_유저크리에이터채팅_WebSocket전환/` 문서로 대체되었다. 이 문서의 SSE(`SseEmitter`) 결정/연동 문구는 2026-05 초기 계획의 역사적 기록이며, 현재 구현 기준은 raw WebSocket + Redis presence/pub-sub이다.
## 결정 요약 ## 결정 요약
- 새 기능부터 유저-크리에이터 채팅방을 제공한다. - 새 기능부터 유저-크리에이터 채팅방을 제공한다.
@@ -201,8 +203,10 @@ CREATE TABLE user_creator_chat_message (
- 모든 요청은 `Authorization: Bearer <accessToken>` 헤더를 포함한다. - 모든 요청은 `Authorization: Bearer <accessToken>` 헤더를 포함한다.
- API prefix는 `/api/v2/user-creator-chat/rooms`를 사용한다. - API prefix는 `/api/v2/user-creator-chat/rooms`를 사용한다.
- 메시지 타입은 `TEXT`, `VOICE`만 처리한다. - 메시지 타입은 `TEXT`, `VOICE`만 처리한다.
- 앱이 백그라운드로 전환되거나 채팅방 화면에서 나가면 `POST /{roomId}/events/disconnect`를 호출하고 SSE 연결을 종료한다. - 채팅방 화면 진입 시 `openRoom` REST 호출 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다.
- SSE 연결이 끊기면 3초부터 재연결하고, 연속 실패 시 3초, 6초, 12초, 24초, 최대 30초까지 지수 백오프한다. - WebSocket 연결 직후 `JOIN_ROOM`을 보내고, `JOINED` 수신 후 텍스트 전송을 허용한다.
- 앱이 백그라운드로 전환되거나 채팅방 화면에서 나가면 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
- 현재 채팅방 화면에 남아 있는 동안 WebSocket이 끊기면 지수 백오프로 재연결하고 `JOIN_ROOM`을 다시 보낸다.
연동할 API: 연동할 API:
0. 채팅방 리스트 조회 0. 채팅방 리스트 조회
@@ -225,16 +229,16 @@ CREATE TABLE user_creator_chat_message (
- `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20` - `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20`
- response data: `{ "messages", "hasMore", "nextCursor" }` - response data: `{ "messages", "hasMore", "nextCursor" }`
4. SSE 연결 4. WebSocket 연결
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` - endpoint: `/ws/v2/user-creator-chat`
- Accept: `text/event-stream` - handshake header: `Authorization: Bearer <accessToken>`
- 이벤트 이름 `message`를 수신하면 payload를 현재 채팅방 메시지 목록에 append한다. - 연결 직후 `{ "type": "JOIN_ROOM", "requestId": "client-request-id", "roomId": roomId, "payload": {} }`를 전송한다.
- 이벤트 이름 `connected`는 연결 확인용으로만 사용한다. - `JOINED`를 수신하면 현재 방 실시간 수신 상태로 판단한다.
5. 텍스트 메시지 전송 5. 텍스트 메시지 전송(WebSocket)
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` - `{ "type": "SEND_TEXT", "requestId": "client-request-id", "roomId": roomId, "payload": { "textMessage": string } }`를 전송한다.
- body: `{ "textMessage": string }` - `SEND_ACK` 수신 시 pending 메시지를 서버 응답의 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
- response data: `{ "message", "deliveredRealtime", "pushSent" }` - `MESSAGE` 수신 시 현재 채팅방 `roomId`와 일치하면 메시지 목록에 append한다.
6. 음성 메시지 전송 6. 음성 메시지 전송
- `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` - `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
@@ -243,9 +247,15 @@ CREATE TABLE user_creator_chat_message (
- part `request`: `{}` JSON 문자열 - part `request`: `{}` JSON 문자열
- response data: `{ "message", "deliveredRealtime", "pushSent" }` - response data: `{ "message", "deliveredRealtime", "pushSent" }`
7. 실시간 연결 해제 7. 실시간 연결 해제(WebSocket)
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` - `{ "type": "LEAVE_ROOM", "requestId": "client-request-id", "roomId": roomId, "payload": {} }`를 전송한 뒤 WebSocket을 close한다.
- DB 참여자를 삭제하거나 비활성화하지 않고 SSE/presence 상태만 해제한다. - DB 참여자를 삭제하거나 비활성화하지 않고 WebSocket presence 상태만 해제한다.
제거된 호출:
- `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 처리 코드
메시지 DTO 필드: 메시지 DTO 필드:
- `messageId`: number - `messageId`: number

View File

@@ -1,5 +1,7 @@
# PRD: 유저-크리에이터 채팅방 개편 # PRD: 유저-크리에이터 채팅방 개편
> 최신화: 실시간 전송 정책은 `docs/20260618_유저크리에이터채팅_WebSocket전환/` 문서로 대체되었다. 이 문서의 SSE(`SseEmitter`) 요구사항은 2026-05 초기 계획의 역사적 기록이며, 현재 구현 기준은 raw WebSocket + Redis presence/pub-sub이다.
## 1. Overview ## 1. Overview
유저와 크리에이터가 주고받는 신규 메시지를 채팅방 형태로 제공하고, 텍스트/음성 메시지, 실시간 수신, 조건부 푸시 알림을 지원한다. 유저와 크리에이터가 주고받는 신규 메시지를 채팅방 형태로 제공하고, 텍스트/음성 메시지, 실시간 수신, 조건부 푸시 알림을 지원한다.

View File

@@ -22,7 +22,7 @@ class AuditionController(private val service: AuditionService) {
service.getAuditionList( service.getAuditionList(
offset = pageable.offset, offset = pageable.offset,
limit = pageable.pageSize.toLong(), limit = pageable.pageSize.toLong(),
isAdult = member?.auth != null memberId = member?.id
) )
) )
} }

View File

@@ -1,12 +1,14 @@
package kr.co.vividnext.sodalive.audition package kr.co.vividnext.sodalive.audition
import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class AuditionService( class AuditionService(
private val repository: AuditionRepository, private val repository: AuditionRepository,
private val roleRepository: AuditionRoleRepository private val roleRepository: AuditionRoleRepository,
private val authRepository: AuthRepository
) { ) {
fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): GetAuditionListResponse { fun getAuditionList(offset: Long, limit: Long, isAdult: Boolean): GetAuditionListResponse {
val inProgressCount = repository.getInProgressAuditionCount(isAdult = isAdult) val inProgressCount = repository.getInProgressAuditionCount(isAdult = isAdult)
@@ -16,6 +18,11 @@ class AuditionService(
return GetAuditionListResponse(inProgressCount, completedCount, items) return GetAuditionListResponse(inProgressCount, completedCount, items)
} }
fun getAuditionList(offset: Long, limit: Long, memberId: Long?): GetAuditionListResponse {
val isAdult = memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
return getAuditionList(offset = offset, limit = limit, isAdult = isAdult)
}
fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse { fun getAuditionDetail(auditionId: Long): GetAuditionDetailResponse {
val auditionDetail = repository.getAuditionDetail(auditionId = auditionId) val auditionDetail = repository.getAuditionDetail(auditionId = auditionId)
val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId) val roleList = roleRepository.getAuditionRoleListByAuditionId(auditionId = auditionId)

View File

@@ -14,6 +14,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory
import org.springframework.data.redis.core.RedisTemplate import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.listener.RedisMessageListenerContainer
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories import org.springframework.data.redis.repository.configuration.EnableRedisRepositories
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer
import org.springframework.data.redis.serializer.RedisSerializationContext import org.springframework.data.redis.serializer.RedisSerializationContext
@@ -63,6 +64,13 @@ class RedisConfig(
return redisTemplate return redisTemplate
} }
@Bean
fun redisMessageListenerContainer(redisConnectionFactory: RedisConnectionFactory): RedisMessageListenerContainer {
val container = RedisMessageListenerContainer()
container.setConnectionFactory(redisConnectionFactory)
return container
}
@Bean @Bean
fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager { fun cacheManager(redisConnectionFactory: RedisConnectionFactory): RedisCacheManager {
val defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig() val defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig()

View File

@@ -39,14 +39,12 @@ class AudioContentCommentController(
try { try {
userActionService.recordAction( userActionService.recordAction(
memberId = member.id!!, memberId = member.id!!,
isAuth = member.auth != null,
actionType = ActionType.CONTENT_COMMENT, actionType = ActionType.CONTENT_COMMENT,
contentCommentId = commentId contentCommentId = commentId
) )
userActionService.recordAction( userActionService.recordAction(
memberId = member.id!!, memberId = member.id!!,
isAuth = member.auth != null,
actionType = ActionType.ORDER_CONTENT_COMMENT, actionType = ActionType.ORDER_CONTENT_COMMENT,
contentId = request.contentId, contentId = request.contentId,
contentCommentId = commentId contentCommentId = commentId

View File

@@ -19,6 +19,6 @@ class CreatorAdminContentSeriesGenreController(private val service: CreatorAdmin
) = run { ) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.getGenreList(isAdult = member.auth != null)) ApiResponse.ok(service.getGenreList(memberId = member.id!!))
} }
} }

View File

@@ -1,10 +1,19 @@
package kr.co.vividnext.sodalive.creator.admin.content.series.genre package kr.co.vividnext.sodalive.creator.admin.content.series.genre
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
class CreatorAdminContentSeriesGenreService(private val repository: CreatorAdminContentSeriesGenreRepository) { class CreatorAdminContentSeriesGenreService(
private val repository: CreatorAdminContentSeriesGenreRepository,
private val authRepository: AuthRepository
) {
fun getGenreList(isAdult: Boolean): List<GetGenreListResponse> { fun getGenreList(isAdult: Boolean): List<GetGenreListResponse> {
return repository.getGenreList(isAdult = isAdult) return repository.getGenreList(isAdult = isAdult)
} }
fun getGenreList(memberId: Long): List<GetGenreListResponse> {
val isAdult = authRepository.getAuthIdByMemberId(memberId) != null
return getGenreList(isAdult = isAdult)
}
} }

View File

@@ -2,7 +2,6 @@ package kr.co.vividnext.sodalive.event
import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.DeleteMapping
@@ -24,11 +23,8 @@ class EventController(private val service: EventService) {
) = run { ) = run {
ApiResponse.ok( ApiResponse.ok(
service.getEventList( service.getEventList(
if (member?.role == MemberRole.ADMIN) { memberId = member?.id,
null memberRole = member?.role
} else {
member?.auth != null
}
) )
) )
} }
@@ -36,7 +32,7 @@ class EventController(private val service: EventService) {
@GetMapping("/popup") @GetMapping("/popup")
fun getEventPopup( fun getEventPopup(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.getEventPopup(member?.auth != null)) ) = ApiResponse.ok(service.getEventPopup(memberId = member?.id))
@PostMapping @PostMapping
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")

View File

@@ -3,6 +3,8 @@ package kr.co.vividnext.sodalive.event
import com.amazonaws.services.s3.model.ObjectMetadata import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -17,6 +19,7 @@ import java.time.format.DateTimeFormatter
class EventService( class EventService(
private val repository: EventRepository, private val repository: EventRepository,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
private val authRepository: AuthRepository,
@Value("\${cloud.aws.s3.bucket}") @Value("\${cloud.aws.s3.bucket}")
private val bucket: String, private val bucket: String,
@@ -45,6 +48,16 @@ class EventService(
return GetEventResponse(0, eventList) return GetEventResponse(0, eventList)
} }
@Transactional(readOnly = true)
fun getEventList(memberId: Long?, memberRole: MemberRole?): GetEventResponse {
val isAdult = if (memberRole == MemberRole.ADMIN) {
null
} else {
memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
}
return getEventList(isAdult)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getEventPopup(isAdult: Boolean): EventItem? { fun getEventPopup(isAdult: Boolean): EventItem? {
val eventPopup = repository.getMainEventPopup(isAdult) val eventPopup = repository.getMainEventPopup(isAdult)
@@ -66,6 +79,12 @@ class EventService(
return eventPopup return eventPopup
} }
@Transactional(readOnly = true)
fun getEventPopup(memberId: Long?): EventItem? {
val isAdult = memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
return getEventPopup(isAdult)
}
@Transactional @Transactional
fun save( fun save(
thumbnail: MultipartFile, thumbnail: MultipartFile,

View File

@@ -24,7 +24,8 @@ enum class FcmDeepLinkValue(val value: String) {
CONTENT("content"), CONTENT("content"),
SERIES("series"), SERIES("series"),
AUDITION("audition"), AUDITION("audition"),
COMMUNITY("community") COMMUNITY("community"),
CHAT("chat")
} }
class FcmEvent( class FcmEvent(
@@ -45,6 +46,7 @@ class FcmEvent(
val roomId: Long? = null, val roomId: Long? = null,
val contentId: Long? = null, val contentId: Long? = null,
val messageId: Long? = null, val messageId: Long? = null,
val chatType: String? = null,
val creatorId: Long? = null, val creatorId: Long? = null,
val auditionId: Long? = null, val auditionId: Long? = null,
val deepLinkValue: FcmDeepLinkValue? = null, val deepLinkValue: FcmDeepLinkValue? = null,
@@ -191,6 +193,7 @@ class FcmSendListener(
roomId = roomId ?: fcmEvent.roomId, roomId = roomId ?: fcmEvent.roomId,
contentId = contentId ?: fcmEvent.contentId, contentId = contentId ?: fcmEvent.contentId,
messageId = messageId ?: fcmEvent.messageId, messageId = messageId ?: fcmEvent.messageId,
chatType = fcmEvent.chatType,
creatorId = creatorId ?: fcmEvent.creatorId, creatorId = creatorId ?: fcmEvent.creatorId,
auditionId = auditionId ?: fcmEvent.auditionId, auditionId = auditionId ?: fcmEvent.auditionId,
deepLinkValue = fcmEvent.deepLinkValue, deepLinkValue = fcmEvent.deepLinkValue,

View File

@@ -33,7 +33,8 @@ class FcmService(
auditionId: Long? = null, auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null, deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Long? = null, deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null deepLinkCommentPostId: Long? = null,
chatType: String? = null
) { ) {
if (tokens.isEmpty()) return if (tokens.isEmpty()) return
logger.info("os: $container") logger.info("os: $container")
@@ -70,30 +71,17 @@ class FcmService(
.build() .build()
) )
if (roomId != null) { multicastMessage.putAllData(
multicastMessage.putData("room_id", roomId.toString()) buildDataPayload(
} roomId = roomId,
messageId = messageId,
if (messageId != null) { contentId = contentId,
multicastMessage.putData("message_id", messageId.toString()) creatorId = creatorId,
} auditionId = auditionId,
deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId),
if (contentId != null) { chatType = chatType
multicastMessage.putData("content_id", contentId.toString()) )
} )
if (creatorId != null) {
multicastMessage.putData("channel_id", creatorId.toString())
}
if (auditionId != null) {
multicastMessage.putData("audition_id", auditionId.toString())
}
val deepLink = createDeepLink(deepLinkValue, deepLinkId, deepLinkCommentPostId)
if (deepLink != null) {
multicastMessage.putData("deep_link", deepLink)
}
val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build()) val response = FirebaseMessaging.getInstance().sendEachForMulticast(multicastMessage.build())
val failedTokens = mutableListOf<String>() val failedTokens = mutableListOf<String>()
@@ -226,5 +214,29 @@ class FcmService(
return baseDeepLink return baseDeepLink
} }
fun buildDataPayload(
roomId: Long? = null,
messageId: Long? = null,
contentId: Long? = null,
creatorId: Long? = null,
auditionId: Long? = null,
deepLinkValue: FcmDeepLinkValue? = null,
deepLinkId: Long? = null,
deepLinkCommentPostId: Long? = null,
deepLink: String? = null,
chatType: String? = null
): Map<String, String> {
val payload = mutableMapOf<String, String>()
if (roomId != null) payload["room_id"] = roomId.toString()
if (messageId != null) payload["message_id"] = messageId.toString()
if (chatType != null) payload["chat_type"] = chatType
if (contentId != null) payload["content_id"] = contentId.toString()
if (creatorId != null) payload["channel_id"] = creatorId.toString()
if (auditionId != null) payload["audition_id"] = auditionId.toString()
val resolvedDeepLink = deepLink ?: buildDeepLink("", deepLinkValue, deepLinkId, deepLinkCommentPostId)
if (resolvedDeepLink != null) payload["deep_link"] = resolvedDeepLink
return payload
}
} }
} }

View File

@@ -21,7 +21,6 @@ class UserActionController(private val service: UserActionService) {
service.recordAction( service.recordAction(
memberId = member.id!!, memberId = member.id!!,
isAuth = member.auth != null,
actionType = request.actionType actionType = request.actionType
) )

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kr.co.vividnext.sodalive.content.order.Order import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.point.MemberPoint import kr.co.vividnext.sodalive.point.MemberPoint
import kr.co.vividnext.sodalive.point.MemberPointRepository import kr.co.vividnext.sodalive.point.MemberPointRepository
import kr.co.vividnext.sodalive.point.PointGrantLog import kr.co.vividnext.sodalive.point.PointGrantLog
@@ -26,7 +27,8 @@ class UserActionService(
private val policyRepository: PointRewardPolicyRepository, private val policyRepository: PointRewardPolicyRepository,
private val grantLogRepository: PointGrantLogRepository, private val grantLogRepository: PointGrantLogRepository,
private val memberPointRepository: MemberPointRepository, private val memberPointRepository: MemberPointRepository,
private val transactionTemplate: TransactionTemplate private val transactionTemplate: TransactionTemplate,
private val authRepository: AuthRepository
) { ) {
private val coroutineScope = CoroutineScope( private val coroutineScope = CoroutineScope(
@@ -160,6 +162,21 @@ class UserActionService(
} }
} }
fun recordAction(
memberId: Long,
actionType: ActionType,
contentId: Long? = null,
contentCommentId: Long? = null
) {
recordAction(
memberId = memberId,
isAuth = authRepository.getAuthIdByMemberId(memberId) != null,
actionType = actionType,
contentId = contentId,
contentCommentId = contentCommentId
)
}
@PreDestroy @PreDestroy
fun onDestroy() { fun onDestroy() {
coroutineScope.cancel("UserActionService 종료") coroutineScope.cancel("UserActionService 종료")

View File

@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
@@ -42,16 +41,6 @@ class UserCreatorChatController(
ApiResponse.ok(service.openRoom(member, roomId, limit)) ApiResponse.ok(service.openRoom(member, roomId, limit))
} }
@PostMapping("/{roomId}/events/disconnect")
fun disconnectRealtime(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
service.disconnectRealtime(member, roomId)
ApiResponse.ok(true)
}
@GetMapping("/{roomId}/messages") @GetMapping("/{roomId}/messages")
fun getMessages( fun getMessages(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@@ -63,25 +52,6 @@ class UserCreatorChatController(
ApiResponse.ok(service.getMessages(member, roomId, cursor, limit)) ApiResponse.ok(service.getMessages(member, roomId, cursor, limit))
} }
@GetMapping("/{roomId}/events", produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun connectEvents(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
service.connect(member, roomId)
}
@PostMapping("/{roomId}/messages/text")
fun sendTextMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable roomId: Long,
@RequestBody request: SendUserCreatorTextMessageRequest
) = run {
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
ApiResponse.ok(service.sendTextMessage(member, roomId, request))
}
@PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PostMapping("/{roomId}/messages/voice", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun sendVoiceMessage( fun sendVoiceMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,

View File

@@ -8,10 +8,6 @@ data class CreateUserCreatorChatRoomResponse(
val roomId: Long val roomId: Long
) )
data class SendUserCreatorTextMessageRequest(
val textMessage: String
)
data class SendUserCreatorVoiceMessageRequest( data class SendUserCreatorVoiceMessageRequest(
val recipientId: Long? = null val recipientId: Long? = null
) )

View File

@@ -1,87 +0,0 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.service
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Service
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
import java.io.IOException
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
@Service
class UserCreatorChatRealtimeService(
private val stringRedisTemplate: StringRedisTemplate
) {
private val emitters = ConcurrentHashMap<String, SseEmitter>()
fun connect(roomId: Long, memberId: Long): SseEmitter {
val emitter = SseEmitter(SSE_TIMEOUT_MILLIS)
val key = emitterKey(roomId, memberId)
emitters[key] = emitter
markPresent(roomId, memberId)
emitter.onCompletion { disconnect(roomId, memberId) }
emitter.onTimeout { disconnect(roomId, memberId) }
emitter.onError { disconnect(roomId, memberId) }
sendConnectEvent(emitter)
return emitter
}
fun disconnect(roomId: Long, memberId: Long) {
emitters.remove(emitterKey(roomId, memberId))
stringRedisTemplate.delete(presenceKey(roomId, memberId))
}
fun isMemberInRoom(roomId: Long, memberId: Long): Boolean {
return stringRedisTemplate.hasKey(presenceKey(roomId, memberId))
}
fun sendMessage(roomId: Long, memberId: Long, message: UserCreatorChatMessageItemDto): Boolean {
val emitter = emitters[emitterKey(roomId, memberId)] ?: return false
return try {
emitter.send(
SseEmitter.event()
.id(message.messageId.toString())
.name("message")
.reconnectTime(SSE_RECONNECT_MILLIS)
.data(message)
)
markPresent(roomId, memberId)
true
} catch (_: IOException) {
disconnect(roomId, memberId)
false
} catch (_: IllegalStateException) {
disconnect(roomId, memberId)
false
}
}
private fun sendConnectEvent(emitter: SseEmitter) {
try {
emitter.send(
SseEmitter.event()
.name("connected")
.reconnectTime(SSE_RECONNECT_MILLIS)
.data("connected")
)
} catch (e: IOException) {
emitter.completeWithError(e)
}
}
private fun markPresent(roomId: Long, memberId: Long) {
stringRedisTemplate.opsForValue().set(presenceKey(roomId, memberId), "1", Duration.ofSeconds(PRESENCE_TTL_SECONDS))
}
private fun emitterKey(roomId: Long, memberId: Long) = "$roomId:$memberId"
private fun presenceKey(roomId: Long, memberId: Long) = "v2:user-creator-chat:presence:$roomId:$memberId"
companion object {
private const val SSE_TIMEOUT_MILLIS = 30L * 60L * 1000L
private const val SSE_RECONNECT_MILLIS = 3000L
private const val PRESENCE_TTL_SECONDS = 60L
}
}

View File

@@ -4,6 +4,7 @@ import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory import kr.co.vividnext.sodalive.fcm.notification.PushNotificationCategory
@@ -18,7 +19,6 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatRoom
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.CreateUserCreatorChatRoomResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorChatMessageResponse
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorVoiceMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessagesPageResponse
@@ -26,8 +26,13 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenRe
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DataAccessException
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
@@ -42,7 +47,8 @@ class UserCreatorChatService(
private val messageRepository: UserCreatorChatMessageRepository, private val messageRepository: UserCreatorChatMessageRepository,
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val blockMemberRepository: BlockMemberRepository, private val blockMemberRepository: BlockMemberRepository,
private val realtimeService: UserCreatorChatRealtimeService, private val presenceService: UserCreatorChatPresenceService,
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
private val applicationEventPublisher: ApplicationEventPublisher, private val applicationEventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper, private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader, private val s3Uploader: S3Uploader,
@@ -107,22 +113,19 @@ class UserCreatorChatService(
} }
@Transactional @Transactional
fun sendTextMessage( fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto {
member: Member, if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
roomId: Long, val senderParticipant = validateParticipant(roomId, memberId)
request: SendUserCreatorTextMessageRequest val sender = senderParticipant.member
): SendUserCreatorChatMessageResponse { val context = resolveSendContext(sender, roomId)
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request") val message = saveTextMessage(context, textMessage)
val context = resolveSendContext(member, roomId) val senderMessage = toMessageItemDto(message, sender)
val message = messageRepository.save( val opponent = context.opponentParticipant.member
UserCreatorChatMessage( val deliveredRealtime = deliverRealtime(message, opponent)
chatRoom = context.room, if (!deliveredRealtime) {
participant = context.senderParticipant, publishMessagePush(message, sender, opponent)
messageType = UserCreatorChatMessageType.TEXT, }
textMessage = request.textMessage return senderMessage
)
)
return deliverMessage(message, member, context.opponentParticipant)
} }
@Transactional @Transactional
@@ -149,17 +152,22 @@ class UserCreatorChatService(
filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}", filePath = "user_creator_chat_voice/${message.id}/${generateFileName(prefix = "${message.id}-message-")}",
metadata = metadata metadata = metadata
) )
return deliverMessage(message, member, context.opponentParticipant) return deliverRestMessage(message, member, context.opponentParticipant)
} }
fun connect(member: Member, roomId: Long) = run { fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
requireParticipant(roomId, member.id!!) return requireParticipant(roomId, memberId)
realtimeService.connect(roomId, member.id!!)
} }
fun disconnectRealtime(member: Member, roomId: Long) { private fun saveTextMessage(context: SendContext, textMessage: String): UserCreatorChatMessage {
requireParticipant(roomId, member.id!!) return messageRepository.save(
realtimeService.disconnect(roomId, member.id!!) UserCreatorChatMessage(
chatRoom = context.room,
participant = context.senderParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = textMessage
)
)
} }
private fun resolveSendContext(member: Member, roomId: Long): SendContext { private fun resolveSendContext(member: Member, roomId: Long): SendContext {
@@ -171,23 +179,47 @@ class UserCreatorChatService(
return SendContext(room, senderParticipant, opponentParticipant) return SendContext(room, senderParticipant, opponentParticipant)
} }
private fun deliverMessage( private fun deliverRestMessage(
message: UserCreatorChatMessage, message: UserCreatorChatMessage,
member: Member, member: Member,
opponentParticipant: UserCreatorChatParticipant opponentParticipant: UserCreatorChatParticipant
): SendUserCreatorChatMessageResponse { ): SendUserCreatorChatMessageResponse {
val opponent = opponentParticipant.member val opponent = opponentParticipant.member
val item = toMessageItemDto(message, member) val item = toMessageItemDto(message, member)
val opponentPresent = realtimeService.isMemberInRoom(message.chatRoom.id!!, opponent.id!!) val deliveredRealtime = deliverRealtime(message, opponent)
if (opponentPresent) { if (deliveredRealtime) {
val delivered = realtimeService.sendMessage(message.chatRoom.id!!, opponent.id!!, item) return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false)
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = delivered, pushSent = false)
} }
publishMessagePush(message, member, opponent) publishMessagePush(message, member, opponent)
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true) return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
} }
private fun deliverRealtime(message: UserCreatorChatMessage, opponent: Member): Boolean {
val roomId = message.chatRoom.id!!
val opponentId = opponent.id!!
return try {
if (!presenceService.hasPresence(roomId, opponentId)) {
return false
}
val opponentMessage = toMessageItemDto(message, opponent)
roomMessageBroker.publish(
roomId = roomId,
memberId = opponentId,
payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage)
)
true
} catch (e: DataAccessException) {
logger.warn(
"유저-크리에이터 채팅 실시간 전달 Redis 오류로 푸시 fail-open 처리: roomId={}, opponentId={}, cause={}",
roomId,
opponentId,
e.message
)
false
}
}
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) { private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) { val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
"message.fcm.voice_received" "message.fcm.voice_received"
@@ -203,7 +235,23 @@ class UserCreatorChatService(
senderMemberId = sender.id, senderMemberId = sender.id,
args = listOf(sender.nickname), args = listOf(sender.nickname),
recipients = listOf(opponent.id!!), recipients = listOf(opponent.id!!),
messageId = message.id deepLinkValue = FcmDeepLinkValue.CHAT,
deepLinkId = message.chatRoom.id
)
)
}
private fun websocketMessagePayload(
type: UserCreatorChatWebSocketMessageType,
roomId: Long,
payload: UserCreatorChatMessageItemDto
): String {
return objectMapper.writeValueAsString(
mapOf(
"type" to type,
"requestId" to null,
"roomId" to roomId,
"payload" to payload
) )
) )
} }
@@ -250,4 +298,8 @@ class UserCreatorChatService(
val senderParticipant: UserCreatorChatParticipant, val senderParticipant: UserCreatorChatParticipant,
val opponentParticipant: UserCreatorChatParticipant val opponentParticipant: UserCreatorChatParticipant
) )
companion object {
private val logger = LoggerFactory.getLogger(UserCreatorChatService::class.java)
}
} }

View File

@@ -0,0 +1,104 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.stereotype.Service
import java.time.Duration
import java.time.Instant
@Service
class UserCreatorChatPresenceService(
private val stringRedisTemplate: StringRedisTemplate,
private val objectMapper: ObjectMapper,
@Qualifier("userCreatorChatWebSocketServerId") private val serverId: String
) {
fun markJoined(roomId: Long, memberId: Long, sessionId: String) {
stringRedisTemplate.opsForValue().set(
presenceKey(roomId, memberId, sessionId),
presenceJson(roomId, memberId, sessionId),
PRESENCE_TTL
)
stringRedisTemplate.opsForSet().add(memberSessionsKey(roomId, memberId), sessionId)
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
stringRedisTemplate.opsForSet().add(roomKey(roomId), memberId.toString())
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
}
fun refresh(roomId: Long, memberId: Long, sessionId: String) {
stringRedisTemplate.opsForValue().set(
presenceKey(roomId, memberId, sessionId),
presenceJson(roomId, memberId, sessionId),
PRESENCE_TTL
)
stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL)
stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL)
}
fun markLeft(roomId: Long, memberId: Long, sessionId: String) {
stringRedisTemplate.delete(presenceKey(roomId, memberId, sessionId))
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
removeMemberPresenceIfNoLiveSession(roomId, memberId)
}
fun hasPresence(roomId: Long, memberId: Long): Boolean {
return findLiveSessionIds(roomId, memberId).isNotEmpty()
}
private fun removeMemberPresenceIfNoLiveSession(roomId: Long, memberId: Long) {
if (findLiveSessionIds(roomId, memberId).isNotEmpty()) {
return
}
stringRedisTemplate.delete(memberSessionsKey(roomId, memberId))
stringRedisTemplate.opsForSet().remove(roomKey(roomId), memberId.toString())
}
private fun findLiveSessionIds(roomId: Long, memberId: Long): Set<String> {
val sessionIds = stringRedisTemplate.opsForSet().members(memberSessionsKey(roomId, memberId)) ?: emptySet()
return sessionIds.filterTo(mutableSetOf()) { sessionId ->
val isLive = stringRedisTemplate.hasKey(presenceKey(roomId, memberId, sessionId)) == true
if (!isLive) {
stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId)
}
isLive
}
}
private fun presenceJson(roomId: Long, memberId: Long, sessionId: String): String {
return objectMapper.writeValueAsString(
UserCreatorChatRedisPresence(
serverId = serverId,
memberId = memberId,
roomId = roomId,
sessionId = sessionId,
lastSeenAt = Instant.now()
)
)
}
companion object {
private val PRESENCE_TTL = Duration.ofSeconds(90)
fun presenceKey(roomId: Long, memberId: Long, sessionId: String): String {
return "v2:user-creator-chat:ws:presence:$roomId:$memberId:$sessionId"
}
fun memberSessionsKey(roomId: Long, memberId: Long): String {
return "v2:user-creator-chat:ws:room:$roomId:member:$memberId:sessions"
}
fun roomKey(roomId: Long): String {
return "v2:user-creator-chat:ws:room:$roomId"
}
}
}
data class UserCreatorChatRedisPresence(
val serverId: String,
val memberId: Long,
val roomId: Long,
val sessionId: String,
val lastSeenAt: Instant
)

View File

@@ -0,0 +1,65 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.data.redis.connection.Message
import org.springframework.data.redis.connection.MessageListener
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.listener.PatternTopic
import org.springframework.data.redis.listener.RedisMessageListenerContainer
import org.springframework.stereotype.Component
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import java.nio.charset.StandardCharsets
@Component
class UserCreatorChatRoomMessageBroker(
private val stringRedisTemplate: StringRedisTemplate,
private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry,
private val objectMapper: ObjectMapper,
listenerContainer: RedisMessageListenerContainer
) : MessageListener {
init {
listenerContainer.addMessageListener(this, PatternTopic("$ROOM_CHANNEL_PREFIX:*"))
}
fun publish(roomId: Long, memberId: Long, payload: String) {
val message = UserCreatorChatRoomPublishedMessage(
roomId = roomId,
memberId = memberId,
payload = payload
)
stringRedisTemplate.convertAndSend(roomChannel(roomId), objectMapper.writeValueAsString(message))
}
override fun onMessage(message: Message, pattern: ByteArray?) {
val published = objectMapper.readValue(
String(message.body, StandardCharsets.UTF_8),
UserCreatorChatRoomPublishedMessage::class.java
)
sessionRegistry.findSessions(published.roomId, published.memberId)
.filter { session -> session.isOpen }
.forEach { session -> sendMessage(session, published.payload) }
}
private fun sendMessage(session: WebSocketSession, payload: String) {
try {
session.sendMessage(TextMessage(payload))
} catch (_: Exception) {
sessionRegistry.remove(session.id)
}
}
companion object {
private const val ROOM_CHANNEL_PREFIX = "v2:user-creator-chat:ws:room"
fun roomChannel(roomId: Long): String {
return "$ROOM_CHANNEL_PREFIX:$roomId"
}
}
}
data class UserCreatorChatRoomPublishedMessage(
val roomId: Long,
val memberId: Long,
val payload: String
)

View File

@@ -0,0 +1,63 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import kr.co.vividnext.sodalive.jwt.JwtFilter
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.MemberAdapter
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.stereotype.Component
import org.springframework.util.StringUtils
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.HandshakeInterceptor
@Component
class UserCreatorChatWebSocketAuthInterceptor(
private val tokenProvider: TokenProvider
) : HandshakeInterceptor {
override fun beforeHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
attributes: MutableMap<String, Any>
): Boolean {
val token = resolveToken(request) ?: return false
if (!tokenProvider.validateToken(token)) {
return false
}
val authentication = try {
tokenProvider.getAuthentication(token)
} catch (e: RuntimeException) {
return false
}
val principal = authentication.principal as? MemberAdapter ?: return false
val memberId = principal.member.id ?: return false
attributes[MEMBER_ID_ATTRIBUTE] = memberId
attributes[AUTHENTICATION_ATTRIBUTE] = authentication
return true
}
override fun afterHandshake(
request: ServerHttpRequest,
response: ServerHttpResponse,
wsHandler: WebSocketHandler,
exception: Exception?
) {
}
private fun resolveToken(request: ServerHttpRequest): String? {
val bearerToken = request.headers.getFirst(JwtFilter.AUTHORIZATION_HEADER)
if (StringUtils.hasText(bearerToken) && bearerToken!!.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length)
}
return null
}
companion object {
const val MEMBER_ID_ATTRIBUTE = "memberId"
const val AUTHENTICATION_ATTRIBUTE = "authentication"
private const val BEARER_PREFIX = "Bearer "
}
}

View File

@@ -0,0 +1,29 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry
@Configuration
@EnableWebSocket
class UserCreatorChatWebSocketConfig(
private val handler: UserCreatorChatWebSocketHandler,
private val authInterceptor: UserCreatorChatWebSocketAuthInterceptor
) : WebSocketConfigurer {
override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(handler, ENDPOINT)
.addInterceptors(authInterceptor)
.setAllowedOrigins(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
)
}
companion object {
const val ENDPOINT = "/ws/v2/user-creator-chat"
}
}

View File

@@ -0,0 +1,163 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.TextWebSocketHandler
@Component
class UserCreatorChatWebSocketHandler(
private val service: UserCreatorChatService,
private val presenceService: UserCreatorChatPresenceService,
private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry,
private val objectMapper: ObjectMapper
) : TextWebSocketHandler() {
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
val request = try {
objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java)
} catch (e: Exception) {
sendProtocolError(session)
return
}
when (request.type) {
UserCreatorChatWebSocketMessageType.JOIN_ROOM -> handleJoinRoom(session, request)
UserCreatorChatWebSocketMessageType.SEND_TEXT -> handleSendText(session, request)
UserCreatorChatWebSocketMessageType.LEAVE_ROOM -> handleLeaveRoom(session, request)
UserCreatorChatWebSocketMessageType.PING -> handlePing(session, request)
else -> sendError(session, request, "common.error.invalid_request")
}
}
private fun handleJoinRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
val memberId = memberId(session)
service.validateParticipant(roomId = request.roomId, memberId = memberId)
clearJoinedRoom(session)
sessionRegistry.register(roomId = request.roomId, memberId = memberId, session = session)
presenceService.markJoined(roomId = request.roomId, memberId = memberId, sessionId = session.id)
session.attributes[JOINED_ROOM_ID_ATTRIBUTE] = request.roomId
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.JOINED,
request.requestId,
request.roomId,
emptyMap<String, Any>()
)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
session.close(CloseStatus.POLICY_VIOLATION)
}
}
private fun handleSendText(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
requireJoinedRoom(session, request.roomId)
val textMessage = request.payload["textMessage"]?.asText().orEmpty()
val message = service.sendTextMessageByWebSocket(
memberId = memberId(session),
roomId = request.roomId,
textMessage = textMessage
)
sendEnvelope(session, UserCreatorChatWebSocketMessageType.SEND_ACK, request.requestId, request.roomId, message)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
}
}
private fun handleLeaveRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
requireJoinedRoom(session, request.roomId)
clearJoinedRoom(session)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
}
}
private fun handlePing(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
requireJoinedRoom(session, request.roomId)
presenceService.refresh(roomId = request.roomId, memberId = memberId(session), sessionId = session.id)
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.PONG,
request.requestId,
request.roomId,
emptyMap<String, Any>()
)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
}
}
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
clearJoinedRoom(session)
}
private fun memberId(session: WebSocketSession): Long {
return session.attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE] as? Long
?: throw SodaException(messageKey = "common.error.bad_credentials")
}
private fun clearJoinedRoom(session: WebSocketSession) {
val roomId = session.attributes.remove(JOINED_ROOM_ID_ATTRIBUTE) as? Long
if (roomId != null) {
presenceService.markLeft(roomId = roomId, memberId = memberId(session), sessionId = session.id)
}
sessionRegistry.remove(session.id)
}
private fun requireJoinedRoom(session: WebSocketSession, roomId: Long) {
if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != roomId) {
throw SodaException(messageKey = "chat.room.join_required")
}
}
private fun sendError(session: WebSocketSession, request: UserCreatorChatWebSocketMessage, messageKey: String) {
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.ERROR,
request.requestId,
request.roomId,
mapOf("messageKey" to messageKey)
)
}
private fun sendProtocolError(session: WebSocketSession) {
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.ERROR,
null,
0L,
mapOf("messageKey" to "common.error.invalid_request")
)
}
private fun sendEnvelope(
session: WebSocketSession,
type: UserCreatorChatWebSocketMessageType,
requestId: String?,
roomId: Long,
payload: Any
) {
session.sendMessage(
TextMessage(
objectMapper.writeValueAsString(
mapOf(
"type" to type,
"requestId" to requestId,
"roomId" to roomId,
"payload" to payload
)
)
)
)
}
companion object {
private const val JOINED_ROOM_ID_ATTRIBUTE = "userCreatorChatJoinedRoomId"
}
}

View File

@@ -0,0 +1,22 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.JsonNode
data class UserCreatorChatWebSocketMessage(
val type: UserCreatorChatWebSocketMessageType,
val requestId: String?,
val roomId: Long,
val payload: JsonNode
)
enum class UserCreatorChatWebSocketMessageType {
JOIN_ROOM,
SEND_TEXT,
LEAVE_ROOM,
PING,
JOINED,
MESSAGE,
SEND_ACK,
ERROR,
PONG
}

View File

@@ -0,0 +1,16 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.UUID
@Configuration
class UserCreatorChatWebSocketServerIdConfig {
@Bean
fun userCreatorChatWebSocketServerId(
@Value("\${user-creator-chat.websocket.server-id:}") configuredServerId: String
): String {
return configuredServerId.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString()
}
}

View File

@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.springframework.stereotype.Component
import org.springframework.web.socket.WebSocketSession
import java.util.concurrent.ConcurrentHashMap
@Component
class UserCreatorChatWebSocketSessionRegistry {
private val sessionsByRoomMember = ConcurrentHashMap<RoomMemberKey, ConcurrentHashMap<String, WebSocketSession>>()
private val sessionIndexes = ConcurrentHashMap<String, RoomMemberKey>()
private val lockStripes = Array(LOCK_STRIPE_COUNT) { Any() }
fun register(roomId: Long, memberId: Long, session: WebSocketSession) {
val sessionId = session.id
synchronized(lockFor(sessionId)) {
removeLocked(sessionId)
val key = RoomMemberKey(roomId, memberId)
sessionsByRoomMember.computeIfAbsent(key) { ConcurrentHashMap() }[sessionId] = session
sessionIndexes[sessionId] = key
}
}
fun findSessions(roomId: Long, memberId: Long): List<WebSocketSession> {
return sessionsByRoomMember[RoomMemberKey(roomId, memberId)]?.values?.toList() ?: emptyList()
}
fun remove(sessionId: String) {
synchronized(lockFor(sessionId)) {
removeLocked(sessionId)
}
}
private fun removeLocked(sessionId: String) {
val key = sessionIndexes.remove(sessionId) ?: return
val sessions = sessionsByRoomMember[key] ?: return
sessions.remove(sessionId)
if (sessions.isEmpty()) {
sessionsByRoomMember.remove(key, sessions)
}
}
private fun lockFor(sessionId: String): Any {
return lockStripes[Math.floorMod(sessionId.hashCode(), lockStripes.size)]
}
private data class RoomMemberKey(
val roomId: Long,
val memberId: Long
)
companion object {
private const val LOCK_STRIPE_COUNT = 64
}
}

View File

@@ -101,6 +101,7 @@ spring:
keepalive-time: ${DB_POOL_KEEPALIVE_TIME_MS:0} keepalive-time: ${DB_POOL_KEEPALIVE_TIME_MS:0}
jpa: jpa:
open-in-view: false
hibernate: hibernate:
ddl-auto: validate ddl-auto: validate
database: mysql database: mysql

View File

@@ -0,0 +1,28 @@
package kr.co.vividnext.sodalive.audition
import kr.co.vividnext.sodalive.audition.role.AuditionRoleRepository
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class AuditionServiceTest {
private val repository = Mockito.mock(AuditionRepository::class.java)
private val authRepository = Mockito.mock(AuthRepository::class.java)
private val service = AuditionService(
repository = repository,
roleRepository = Mockito.mock(AuditionRoleRepository::class.java),
authRepository = authRepository
)
@Test
fun shouldResolveAdultFlagFromAuthRepositoryForAuditionList() {
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
Mockito.`when`(repository.getInProgressAuditionCount(true)).thenReturn(1)
Mockito.`when`(repository.getCompletedAuditionCount(true)).thenReturn(2)
Mockito.`when`(repository.getAuditionList(0L, 20L, true)).thenReturn(emptyList())
service.getAuditionList(offset = 0L, limit = 20L, memberId = 10L)
Mockito.verify(repository).getAuditionList(0L, 20L, true)
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.creator.admin.content.series.genre
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class CreatorAdminContentSeriesGenreServiceTest {
private val repository = Mockito.mock(CreatorAdminContentSeriesGenreRepository::class.java)
private val authRepository = Mockito.mock(AuthRepository::class.java)
private val service = CreatorAdminContentSeriesGenreService(
repository = repository,
authRepository = authRepository
)
@Test
fun shouldResolveAdultFlagFromAuthRepositoryForGenreList() {
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
Mockito.`when`(repository.getGenreList(true)).thenReturn(emptyList())
service.getGenreList(memberId = 10L)
Mockito.verify(repository).getGenreList(true)
}
}

View File

@@ -0,0 +1,39 @@
package kr.co.vividnext.sodalive.event
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class EventServiceTest {
private val repository = Mockito.mock(EventRepository::class.java)
private val authRepository = Mockito.mock(AuthRepository::class.java)
private val service = EventService(
repository = repository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
authRepository = authRepository,
bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
@Test
fun shouldResolveAdultFlagFromAuthRepositoryForMemberEventList() {
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
Mockito.`when`(repository.getEventList(true)).thenReturn(emptyList())
service.getEventList(memberId = 10L, memberRole = MemberRole.USER)
Mockito.verify(repository).getEventList(true)
}
@Test
fun shouldKeepAdminEventListUnfiltered() {
Mockito.`when`(repository.getEventList(null)).thenReturn(emptyList())
service.getEventList(memberId = 1L, memberRole = MemberRole.ADMIN)
Mockito.verify(repository).getEventList(null)
Mockito.verifyNoInteractions(authRepository)
}
}

View File

@@ -0,0 +1,33 @@
package kr.co.vividnext.sodalive.fcm
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class FcmServiceTest {
@Test
@DisplayName("v2 채팅 푸시 data payload는 deep_link만 포함한다")
fun shouldBuildV2ChatPayloadWithOnlyDeepLink() {
val payload = FcmService.buildDataPayload(
roomId = null,
messageId = null,
contentId = null,
creatorId = null,
auditionId = null,
deepLinkValue = null,
deepLinkId = null,
deepLinkCommentPostId = null,
deepLink = "voiceon-test://chat/10",
chatType = null
)
assertEquals(mapOf("deep_link" to "voiceon-test://chat/10"), payload)
}
@Test
@DisplayName("v2 채팅 deep_link는 환경별 scheme과 chat room id로 생성된다")
fun shouldBuildV2ChatDeepLinkWithRoomId() {
assertEquals("voiceon://chat/10", FcmService.buildDeepLink("voiceon", FcmDeepLinkValue.CHAT, 10L))
assertEquals("voiceon-test://chat/10", FcmService.buildDeepLink("local", FcmDeepLinkValue.CHAT, 10L))
}
}

View File

@@ -0,0 +1,26 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat
import kr.co.vividnext.sodalive.v2.usercreatorchat.controller.UserCreatorChatController
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
class UserCreatorChatControllerMappingTest {
private val mockMvc = MockMvcBuilders
.standaloneSetup(UserCreatorChatController(Mockito.mock(UserCreatorChatService::class.java)))
.build()
@Test
fun shouldReturnNotFoundForRemovedSseAndTextMessageEndpoints() {
mockMvc.perform(get("/api/v2/user-creator-chat/rooms/10/events"))
.andExpect(status().isNotFound)
mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/events/disconnect"))
.andExpect(status().isNotFound)
mockMvc.perform(post("/api/v2/user-creator-chat/rooms/10/messages/text"))
.andExpect(status().isNotFound)
}
}

View File

@@ -6,7 +6,6 @@ import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
@@ -58,7 +57,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
} }
@Test @Test
@DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 메시지를 보낼 수 없다") @DisplayName("AI 캐릭터용 Member가 참여한 기존 DM 방에는 WebSocket 텍스트 메시지를 보낼 수 없다")
fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() { fun shouldRejectSendTextMessageWhenOpponentIsAiCharacterMember() {
val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user")) val user = memberRepository.save(Member(email = "dm-message-user@test.com", password = "pw", nickname = "user"))
val creator = memberRepository.save( val creator = memberRepository.save(
@@ -77,7 +76,7 @@ class UserCreatorChatServiceIntegrationTest @Autowired constructor(
entityManager.clear() entityManager.clear()
val exception = assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
service.sendTextMessage(user, room.id!!, SendUserCreatorTextMessageRequest("hello")) service.sendTextMessageByWebSocket(user.id!!, room.id!!, "hello")
} }
assertEquals("message.error.recipient_not_found", exception.messageKey) assertEquals("message.error.recipient_not_found", exception.messageKey)

View File

@@ -1,21 +1,24 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat package kr.co.vividnext.sodalive.v2.usercreatorchat
import com.amazonaws.services.s3.model.ObjectMetadata
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.fcm.FcmDeepLinkValue
import kr.co.vividnext.sodalive.fcm.FcmEvent import kr.co.vividnext.sodalive.fcm.FcmEvent
import kr.co.vividnext.sodalive.fcm.FcmEventType import kr.co.vividnext.sodalive.fcm.FcmEventType
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.SendUserCreatorTextMessageRequest
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatRealtimeService
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -23,7 +26,11 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor import org.mockito.ArgumentCaptor
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DataAccessResourceFailureException
import org.springframework.data.domain.PageRequest import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Optional import java.util.Optional
@@ -33,8 +40,10 @@ class UserCreatorChatServiceTest {
private lateinit var messageRepository: UserCreatorChatMessageRepository private lateinit var messageRepository: UserCreatorChatMessageRepository
private lateinit var memberRepository: MemberRepository private lateinit var memberRepository: MemberRepository
private lateinit var blockMemberRepository: BlockMemberRepository private lateinit var blockMemberRepository: BlockMemberRepository
private lateinit var realtimeService: UserCreatorChatRealtimeService private lateinit var presenceService: UserCreatorChatPresenceService
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
private lateinit var eventPublisher: ApplicationEventPublisher private lateinit var eventPublisher: ApplicationEventPublisher
private lateinit var s3Uploader: S3Uploader
private lateinit var service: UserCreatorChatService private lateinit var service: UserCreatorChatService
@BeforeEach @BeforeEach
@@ -44,8 +53,10 @@ class UserCreatorChatServiceTest {
messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java) messageRepository = Mockito.mock(UserCreatorChatMessageRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java) memberRepository = Mockito.mock(MemberRepository::class.java)
blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java) blockMemberRepository = Mockito.mock(BlockMemberRepository::class.java)
realtimeService = Mockito.mock(UserCreatorChatRealtimeService::class.java) presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
roomMessageBroker = Mockito.mock(UserCreatorChatRoomMessageBroker::class.java)
eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) eventPublisher = Mockito.mock(ApplicationEventPublisher::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
service = UserCreatorChatService( service = UserCreatorChatService(
roomRepository = roomRepository, roomRepository = roomRepository,
@@ -53,10 +64,11 @@ class UserCreatorChatServiceTest {
messageRepository = messageRepository, messageRepository = messageRepository,
memberRepository = memberRepository, memberRepository = memberRepository,
blockMemberRepository = blockMemberRepository, blockMemberRepository = blockMemberRepository,
realtimeService = realtimeService, presenceService = presenceService,
roomMessageBroker = roomMessageBroker,
applicationEventPublisher = eventPublisher, applicationEventPublisher = eventPublisher,
objectMapper = ObjectMapper(), objectMapper = ObjectMapper(),
s3Uploader = Mockito.mock(S3Uploader::class.java), s3Uploader = s3Uploader,
bucket = "test-bucket", bucket = "test-bucket",
cloudFrontHost = "https://cdn.test" cloudFrontHost = "https://cdn.test"
) )
@@ -137,8 +149,8 @@ class UserCreatorChatServiceTest {
} }
@Test @Test
@DisplayName("상대방이 같은 방에 입장 중이면 텍스트 메시지를 실시간 전송하고 푸시를 보내지 않는다") @DisplayName("WebSocket 텍스트 전송은 상대방 presence가 있으면 메시지를 저장하고 broker로 MESSAGE를 발행하며 푸시를 보내지 않는다")
fun shouldSendRealtimeMessageWithoutPushWhenOpponentIsPresent() { fun shouldPublishWebSocketMessageWithoutPushWhenOpponentPresenceExists() {
val user = member(1L, "user") val user = member(1L, "user")
val creator = member(2L, "creator") val creator = member(2L, "creator")
val room = room(10L) val room = room(10L)
@@ -147,25 +159,26 @@ class UserCreatorChatServiceTest {
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())).thenReturn(true)
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 200L } (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 203L }
} }
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
assertEquals(200L, response.message.messageId) assertEquals(203L, response.messageId)
assertEquals("hello", response.message.textMessage) assertEquals("hello", response.textMessage)
assertTrue(response.deliveredRealtime) assertTrue(response.mine)
assertFalse(response.pushSent) val payloadCaptor = ArgumentCaptor.forClass(String::class.java)
Mockito.verify(realtimeService).sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem()) Mockito.verify(roomMessageBroker).publish(Mockito.eq(10L), Mockito.eq(2L), captureString(payloadCaptor))
assertTrue(payloadCaptor.value.contains("\"type\":\"MESSAGE\""))
assertTrue(payloadCaptor.value.contains("\"messageId\":203"))
Mockito.verifyNoInteractions(eventPublisher) Mockito.verifyNoInteractions(eventPublisher)
} }
@Test @Test
@DisplayName("상대방이 같은 방에 없으면 텍스트 메시지를 저장하고 푸시 이벤트를 발행한다") @DisplayName("WebSocket 텍스트 전송은 상대방 presence가 없으면 채팅방 이동용 푸시 이벤트를 발행한다")
fun shouldPublishPushEventWhenOpponentIsNotPresent() { fun shouldPublishPushEventWithChatPayloadWhenOpponentPresenceDoesNotExist() {
val user = member(1L, "user") val user = member(1L, "user")
val creator = member(2L, "creator") val creator = member(2L, "creator")
val room = room(10L) val room = room(10L)
@@ -174,12 +187,99 @@ class UserCreatorChatServiceTest {
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(false) Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(false)
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 201L } (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 204L }
} }
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients)
assertEquals(null, eventCaptor.value.roomId)
assertEquals(null, eventCaptor.value.messageId)
assertEquals(null, eventCaptor.value.chatType)
assertEquals(FcmDeepLinkValue.CHAT, eventCaptor.value.deepLinkValue)
assertEquals(10L, eventCaptor.value.deepLinkId)
Mockito.verifyNoInteractions(roomMessageBroker)
}
@Test
@DisplayName("WebSocket 텍스트 전송은 Redis presence 확인 실패 시 메시지를 저장하고 푸시 이벤트를 발행한다")
fun shouldPublishPushEventWhenPresenceCheckFailsDuringWebSocketTextMessage() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L))
.thenThrow(DataAccessResourceFailureException("redis down"))
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 207L }
}
val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
assertEquals(207L, response.messageId)
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients)
Mockito.verifyNoInteractions(roomMessageBroker)
}
@Test
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다")
fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 205L }
}
givenVoiceUploadReturns("voice/205.m4a")
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
assertEquals(205L, response.message.messageId)
assertTrue(response.deliveredRealtime)
assertFalse(response.pushSent)
val payloadCaptor = ArgumentCaptor.forClass(String::class.java)
Mockito.verify(roomMessageBroker).publish(Mockito.eq(10L), Mockito.eq(2L), captureString(payloadCaptor))
assertTrue(payloadCaptor.value.contains("\"type\":\"MESSAGE\""))
assertTrue(payloadCaptor.value.contains("\"messageType\":\"VOICE\""))
Mockito.verifyNoInteractions(eventPublisher)
}
@Test
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 없으면 푸시 이벤트를 발행한다")
fun shouldPublishPushEventForVoiceMessageWhenOpponentPresenceDoesNotExist() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(false)
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 206L }
}
givenVoiceUploadReturns("voice/206.m4a")
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
assertFalse(response.deliveredRealtime) assertFalse(response.deliveredRealtime)
assertTrue(response.pushSent) assertTrue(response.pushSent)
@@ -187,12 +287,17 @@ class UserCreatorChatServiceTest {
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture()) Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type) assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients) assertEquals(listOf(2L), eventCaptor.value.recipients)
assertEquals(201L, eventCaptor.value.messageId) assertEquals(null, eventCaptor.value.roomId)
assertEquals(null, eventCaptor.value.messageId)
assertEquals(null, eventCaptor.value.chatType)
assertEquals(FcmDeepLinkValue.CHAT, eventCaptor.value.deepLinkValue)
assertEquals(10L, eventCaptor.value.deepLinkId)
Mockito.verifyNoInteractions(roomMessageBroker)
} }
@Test @Test
@DisplayName("상대방이 입장 중이면 실시간 전송 실패 시에도 보완 푸시를 보내지 않는") @DisplayName("음성 메시지 REST 전송은 Redis broker 발행 실패 시 푸시 이벤트를 발행한")
fun shouldNotFallbackToPushWhenRealtimeDeliveryFailsForPresentOpponent() { fun shouldPublishPushEventWhenBrokerPublishFailsDuringVoiceMessage() {
val user = member(1L, "user") val user = member(1L, "user")
val creator = member(2L, "creator") val creator = member(2L, "creator")
val room = room(10L) val room = room(10L)
@@ -201,17 +306,49 @@ class UserCreatorChatServiceTest {
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room) Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant) Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant) Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(realtimeService.isMemberInRoom(10L, 2L)).thenReturn(true) Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.`when`(realtimeService.sendMessage(Mockito.eq(10L), Mockito.eq(2L), anyMessageItem())) Mockito.doThrow(DataAccessResourceFailureException("redis publish down"))
.thenReturn(false) .`when`(roomMessageBroker)
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation -> Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 202L } (invocation.arguments[0] as UserCreatorChatMessage).apply { id = 208L }
}
givenVoiceUploadReturns("voice/208.m4a")
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
assertEquals(208L, response.message.messageId)
assertFalse(response.deliveredRealtime)
assertTrue(response.pushSent)
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients)
} }
val response = service.sendTextMessage(user, 10L, SendUserCreatorTextMessageRequest("hello")) @Test
@DisplayName("음성 메시지 REST 전송은 Redis 계층이 아닌 broker 예외를 푸시로 숨기지 않는다")
fun shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.doThrow(IllegalStateException("programming error"))
.`when`(roomMessageBroker)
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 209L }
}
givenVoiceUploadReturns("voice/209.m4a")
assertFalse(response.deliveredRealtime) assertThrows(IllegalStateException::class.java) {
assertFalse(response.pushSent) service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
}
Mockito.verifyNoInteractions(eventPublisher) Mockito.verifyNoInteractions(eventPublisher)
} }
@@ -261,20 +398,6 @@ class UserCreatorChatServiceTest {
) )
} }
@Test
@DisplayName("실시간 연결 해제는 참여자를 제거하지 않고 presence만 해제한다")
fun shouldDisconnectRealtimeWithoutLeavingRoom() {
val user = member(1L, "user")
val room = room(10L)
val participant = participant(100L, room, user)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(participant)
service.disconnectRealtime(user, 10L)
Mockito.verify(realtimeService).disconnect(10L, 1L)
Mockito.verify(participantRepository, Mockito.never()).save(Mockito.any(UserCreatorChatParticipant::class.java))
}
private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id } private fun member(id: Long, nickname: String) = Member(password = "pw", nickname = nickname).apply { this.id = id }
private fun anyMessageItem(): UserCreatorChatMessageItemDto { private fun anyMessageItem(): UserCreatorChatMessageItemDto {
@@ -291,6 +414,39 @@ class UserCreatorChatServiceTest {
) )
} }
private fun captureString(captor: ArgumentCaptor<String>): String {
return captor.capture() ?: ""
}
private fun voiceFile() = MockMultipartFile("voiceMessageFile", "voice.m4a", "audio/mp4", byteArrayOf(1, 2, 3))
private fun anyInputStream(): InputStream {
return Mockito.any(InputStream::class.java) ?: ByteArrayInputStream(ByteArray(0))
}
private fun anyObjectMetadata(): ObjectMetadata {
return Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
}
private fun anyStringValue(): String {
return Mockito.anyString() ?: ""
}
private fun eqString(value: String): String {
return Mockito.eq(value) ?: value
}
private fun givenVoiceUploadReturns(path: String) {
Mockito.`when`(
s3Uploader.upload(
anyInputStream(),
eqString("test-bucket"),
anyStringValue(),
anyObjectMetadata()
)
).thenReturn(path)
}
private fun room(id: Long) = UserCreatorChatRoom().apply { private fun room(id: Long) = UserCreatorChatRoom().apply {
this.id = id this.id = id
} }

View File

@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.data.redis.core.SetOperations
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.core.ValueOperations
import java.time.Duration
import java.time.Instant
class UserCreatorChatPresenceServiceTest {
private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java)
private val valueOperations = Mockito.mock(ValueOperations::class.java) as ValueOperations<String, String>
private val setOperations = Mockito.mock(SetOperations::class.java) as SetOperations<String, String>
private val objectMapper = ObjectMapper()
.findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
private val service = UserCreatorChatPresenceService(stringRedisTemplate, objectMapper, "test-server")
@Test
@DisplayName("join 시 session presence와 member session index에 TTL을 설정한다")
fun shouldMarkJoinedWithTtl() {
givenRedisOperations()
service.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
Mockito.verify(valueOperations).set(
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
presenceJsonCaptor.capture(),
Mockito.eq(Duration.ofSeconds(90))
)
assertPresenceJson(presenceJsonCaptor.value)
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
Mockito.verify(stringRedisTemplate).expire(
"v2:user-creator-chat:ws:room:10:member:20:sessions",
Duration.ofSeconds(90)
)
Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10", "20")
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
}
@Test
@DisplayName("refresh 시 기존 session presence와 index TTL을 갱신한다")
fun shouldRefreshPresenceTtl() {
givenRedisOperations()
service.refresh(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java)
Mockito.verify(valueOperations).set(
Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"),
presenceJsonCaptor.capture(),
Mockito.eq(Duration.ofSeconds(90))
)
assertPresenceJson(presenceJsonCaptor.value)
Mockito.verify(stringRedisTemplate).expire(
"v2:user-creator-chat:ws:room:10:member:20:sessions",
Duration.ofSeconds(90)
)
Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90))
}
@Test
@DisplayName("leave 시 session presence를 삭제하고 마지막 session이면 member presence를 제거한다")
fun shouldMarkLeftAndRemoveMemberPresenceWhenLastSessionLeaves() {
givenRedisOperations()
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(0L)
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:presence:10:20:session-1")
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1")
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
}
@Test
@DisplayName("leave 후 남은 session id가 stale이면 member presence를 제거한다")
fun shouldRemoveMemberPresenceWhenRemainingSessionIdsAreStale() {
givenRedisOperations()
Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(1L)
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
.thenReturn(setOf("session-2"))
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-2"))
.thenReturn(false)
service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-2")
Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions")
Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20")
}
@Test
@DisplayName("member session index에 live session key가 있으면 room presence가 있다고 판단한다")
fun shouldReturnPresenceFromMemberSessionIndex() {
givenRedisOperations()
Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions"))
.thenReturn(setOf("session-1"))
Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-1"))
.thenReturn(true)
val hasPresence = service.hasPresence(roomId = 10L, memberId = 20L)
org.junit.jupiter.api.Assertions.assertEquals(true, hasPresence, "Expected member presence to be true")
}
private fun givenRedisOperations() {
Mockito.`when`(stringRedisTemplate.opsForValue()).thenReturn(valueOperations)
Mockito.`when`(stringRedisTemplate.opsForSet()).thenReturn(setOperations)
}
private fun assertPresenceJson(json: String) {
val presence = objectMapper.readTree(json)
assertEquals("test-server", presence["serverId"].asText())
assertEquals(20L, presence["memberId"].asLong())
assertEquals(10L, presence["roomId"].asLong())
assertEquals("session-1", presence["sessionId"].asText())
assertNotNull(Instant.parse(presence["lastSeenAt"].asText()))
}
}

View File

@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.TestPropertySource
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import java.time.Instant
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@SpringBootTest
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@TestPropertySource(properties = ["user-creator-chat.websocket.server-id=redis-test-server"])
class UserCreatorChatRedisIntegrationTest {
@Autowired
private lateinit var stringRedisTemplate: StringRedisTemplate
@Autowired
private lateinit var objectMapper: ObjectMapper
@Autowired
private lateinit var presenceService: UserCreatorChatPresenceService
@Autowired
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
@Autowired
private lateinit var roomMessageBroker: UserCreatorChatRoomMessageBroker
@AfterEach
fun tearDown() {
sessionRegistry.remove("redis-integration-session")
sessionRegistry.remove("redis-integration-other-session")
stringRedisTemplate.connectionFactory?.connection?.use { connection ->
connection.flushDb()
}
}
@Test
@DisplayName("embedded Redis에 join presence key와 index를 저장하고 TTL을 설정한다")
fun shouldStorePresenceKeysWithTtlOnJoin() {
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
assertPresenceJson(stringRedisTemplate.opsForValue().get(presenceKey))
assertTrue(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "session-1") == true)
assertTrue(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
assertTrue(stringRedisTemplate.getExpire(presenceKey) > 0)
}
@Test
@DisplayName("embedded Redis에서 마지막 session leave 시 presence key와 index를 정리한다")
fun shouldRemovePresenceKeysWhenLastSessionLeaves() {
presenceService.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1")
presenceService.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1")
val presenceKey = UserCreatorChatPresenceService.presenceKey(10L, 20L, "session-1")
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
val roomKey = UserCreatorChatPresenceService.roomKey(10L)
assertFalse(stringRedisTemplate.hasKey(presenceKey) == true)
assertFalse(stringRedisTemplate.hasKey(memberSessionsKey) == true)
assertFalse(stringRedisTemplate.opsForSet().isMember(roomKey, "20") == true)
}
@Test
@DisplayName("stale session id만 남으면 presence 없음으로 판단하고 stale id를 제거한다")
fun shouldPruneStaleSessionIdWhenCheckingPresence() {
val memberSessionsKey = UserCreatorChatPresenceService.memberSessionsKey(10L, 20L)
stringRedisTemplate.opsForSet().add(memberSessionsKey, "stale-session")
val hasPresence = presenceService.hasPresence(roomId = 10L, memberId = 20L)
assertFalse(hasPresence)
assertFalse(stringRedisTemplate.opsForSet().isMember(memberSessionsKey, "stale-session") == true)
}
@Test
@DisplayName("publish는 embedded Redis pub/sub listener를 거쳐 대상 local session에 payload를 전달한다")
fun shouldPublishThroughRedisAndDeliverToLocalTargetSession() {
val latch = CountDownLatch(1)
val targetSession = session("redis-integration-session", latch)
val otherSession = session("redis-integration-other-session", CountDownLatch(1))
sessionRegistry.register(roomId = 10L, memberId = 20L, session = targetSession)
sessionRegistry.register(roomId = 10L, memberId = 21L, session = otherSession)
roomMessageBroker.publish(roomId = 10L, memberId = 20L, payload = "{\"type\":\"MESSAGE\"}")
assertTrue(latch.await(3, TimeUnit.SECONDS), "Expected Redis pub/sub payload to reach target session")
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
Mockito.verify(targetSession).sendMessage(textCaptor.capture())
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
Mockito.verify(otherSession, Mockito.never()).sendMessage(Mockito.any(TextMessage::class.java))
}
private fun session(id: String, latch: CountDownLatch): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
Mockito.`when`(session.isOpen).thenReturn(true)
Mockito.doAnswer {
latch.countDown()
null
}.`when`(session).sendMessage(Mockito.any(TextMessage::class.java))
return session
}
private fun assertPresenceJson(json: String?) {
assertNotNull(json)
val presence = objectMapper.readTree(json)
assertEquals("redis-test-server", presence["serverId"].asText())
assertEquals(20L, presence["memberId"].asLong())
assertEquals(10L, presence["roomId"].asLong())
assertEquals("session-1", presence["sessionId"].asText())
assertNotNull(Instant.parse(presence["lastSeenAt"].asText()))
}
}

View File

@@ -0,0 +1,113 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.data.redis.connection.Message
import org.springframework.data.redis.connection.MessageListener
import org.springframework.data.redis.core.StringRedisTemplate
import org.springframework.data.redis.listener.PatternTopic
import org.springframework.data.redis.listener.RedisMessageListenerContainer
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import java.io.IOException
import java.nio.charset.StandardCharsets
class UserCreatorChatRoomMessageBrokerTest {
private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java)
private val registry = UserCreatorChatWebSocketSessionRegistry()
private val listenerContainer = Mockito.mock(RedisMessageListenerContainer::class.java)
private val objectMapper = ObjectMapper().findAndRegisterModules()
private val broker = UserCreatorChatRoomMessageBroker(
stringRedisTemplate = stringRedisTemplate,
sessionRegistry = registry,
objectMapper = objectMapper,
listenerContainer = listenerContainer
)
@Test
@DisplayName("room channel로 target member와 payload를 publish한다")
fun shouldPublishMessageToRoomChannel() {
broker.publish(roomId = 10L, memberId = 20L, payload = "{\"type\":\"MESSAGE\"}")
val messageCaptor = ArgumentCaptor.forClass(String::class.java)
Mockito.verify(stringRedisTemplate).convertAndSend(
Mockito.eq("v2:user-creator-chat:ws:room:10"),
messageCaptor.capture()
)
val published = objectMapper.readValue(messageCaptor.value, UserCreatorChatRoomPublishedMessage::class.java)
assertEquals(10L, published.roomId)
assertEquals(20L, published.memberId)
assertEquals("{\"type\":\"MESSAGE\"}", published.payload)
}
@Test
@DisplayName("생성 시 ws room pattern topic을 구독한다")
fun shouldSubscribeRoomPatternOnCreation() {
Mockito.verify(listenerContainer).addMessageListener(
Mockito.any(MessageListener::class.java),
Mockito.eq(PatternTopic("v2:user-creator-chat:ws:room:*"))
)
}
@Test
@DisplayName("subscribe callback은 대상 member의 local session에만 메시지를 전송한다")
fun shouldDeliverSubscribedMessageOnlyToTargetMemberSessions() {
val targetSession = session("target-session")
val otherMemberSession = session("other-session")
registry.register(roomId = 10L, memberId = 20L, session = targetSession)
registry.register(roomId = 10L, memberId = 21L, session = otherMemberSession)
val published = UserCreatorChatRoomPublishedMessage(
roomId = 10L,
memberId = 20L,
payload = "{\"type\":\"MESSAGE\"}"
)
broker.onMessage(redisMessage(objectMapper.writeValueAsString(published)), null)
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
Mockito.verify(targetSession).sendMessage(textCaptor.capture())
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
Mockito.verify(otherMemberSession, Mockito.never()).sendMessage(Mockito.any(TextMessage::class.java))
}
@Test
@DisplayName("일부 local session 전송이 실패해도 같은 member의 다른 session 전송을 계속한다")
fun shouldContinueDeliveryWhenOneTargetSessionFails() {
val brokenSession = session("broken-session")
val healthySession = session("healthy-session")
Mockito.doThrow(IOException("broken socket"))
.`when`(brokenSession)
.sendMessage(Mockito.any(TextMessage::class.java))
registry.register(roomId = 10L, memberId = 20L, session = brokenSession)
registry.register(roomId = 10L, memberId = 20L, session = healthySession)
val published = UserCreatorChatRoomPublishedMessage(
roomId = 10L,
memberId = 20L,
payload = "{\"type\":\"MESSAGE\"}"
)
broker.onMessage(redisMessage(objectMapper.writeValueAsString(published)), null)
val textCaptor = ArgumentCaptor.forClass(TextMessage::class.java)
Mockito.verify(healthySession).sendMessage(textCaptor.capture())
assertEquals("{\"type\":\"MESSAGE\"}", textCaptor.value.payload)
}
private fun redisMessage(body: String): Message {
val message = Mockito.mock(Message::class.java)
Mockito.`when`(message.body).thenReturn(body.toByteArray(StandardCharsets.UTF_8))
return message
}
private fun session(id: String): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
Mockito.`when`(session.isOpen).thenReturn(true)
return session
}
}

View File

@@ -0,0 +1,111 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.http.HttpHeaders
import org.springframework.http.server.ServerHttpRequest
import org.springframework.http.server.ServerHttpResponse
import org.springframework.http.server.ServletServerHttpRequest
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.web.socket.WebSocketHandler
class UserCreatorChatWebSocketAuthInterceptorTest {
private val tokenProvider = Mockito.mock(TokenProvider::class.java)
private val interceptor = UserCreatorChatWebSocketAuthInterceptor(tokenProvider)
private val response = Mockito.mock(ServerHttpResponse::class.java)
private val wsHandler = Mockito.mock(WebSocketHandler::class.java)
@Test
@DisplayName("Authorization Bearer token이 유효하면 handshake attributes에 인증 정보를 저장한다")
fun shouldStoreAuthenticationAttributesWhenBearerTokenIsValid() {
val member = Member(email = "viewer@test.com", password = "password", nickname = "viewer").apply { id = 10L }
val authentication = UsernamePasswordAuthenticationToken(MemberAdapter(member), "valid-token")
Mockito.`when`(tokenProvider.validateToken("valid-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("valid-token")).thenReturn(authentication)
val attributes = mutableMapOf<String, Any>()
val result = interceptor.beforeHandshake(
requestWithAuthorization("Bearer valid-token"),
response,
wsHandler,
attributes
)
assertTrue(result, "Expected valid Bearer token handshake to proceed")
assertEquals(10L, attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE])
assertSame(authentication, attributes[UserCreatorChatWebSocketAuthInterceptor.AUTHENTICATION_ATTRIBUTE])
}
@Test
@DisplayName("Authorization header가 없으면 handshake를 거부한다")
fun shouldRejectHandshakeWithoutAuthorizationHeader() {
val attributes = mutableMapOf<String, Any>()
val result = interceptor.beforeHandshake(
requestWithAuthorization(null),
response,
wsHandler,
attributes
)
assertFalse(result, "Expected missing Authorization header handshake to be rejected")
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
}
@Test
@DisplayName("유효하지 않은 Bearer token이면 handshake를 거부한다")
fun shouldRejectHandshakeWhenBearerTokenIsInvalid() {
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
val attributes = mutableMapOf<String, Any>()
val result = interceptor.beforeHandshake(
requestWithAuthorization("Bearer invalid-token"),
response,
wsHandler,
attributes
)
assertFalse(result, "Expected invalid Bearer token handshake to be rejected")
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
}
@Test
@DisplayName("토큰 검증 후 인증 정보 조회가 실패하면 handshake를 거부한다")
fun shouldRejectHandshakeWhenAuthenticationLookupFails() {
Mockito.`when`(tokenProvider.validateToken("logged-out-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("logged-out-token"))
.thenThrow(IllegalStateException("token not found"))
val attributes = mutableMapOf<String, Any>()
var result = true
assertDoesNotThrow {
result = interceptor.beforeHandshake(
requestWithAuthorization("Bearer logged-out-token"),
response,
wsHandler,
attributes
)
}
assertFalse(result, "Expected authentication lookup failure handshake to be rejected")
assertTrue(attributes.isEmpty(), "Expected rejected handshake to leave attributes empty")
}
private fun requestWithAuthorization(authorization: String?): ServerHttpRequest {
val request = MockHttpServletRequest()
if (authorization != null) {
request.addHeader(HttpHeaders.AUTHORIZATION, authorization)
}
return ServletServerHttpRequest(request)
}
}

View File

@@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
import org.springframework.web.socket.server.support.OriginHandshakeInterceptor
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler
@SpringBootTest(
classes = [
UserCreatorChatWebSocketConfig::class,
UserCreatorChatWebSocketHandler::class,
UserCreatorChatWebSocketAuthInterceptor::class
]
)
class UserCreatorChatWebSocketConfigTest @Autowired constructor(
private val applicationContext: ApplicationContext
) {
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var service: UserCreatorChatService
@MockBean
private lateinit var presenceService: UserCreatorChatPresenceService
@MockBean
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
@MockBean
private lateinit var objectMapper: ObjectMapper
@Test
@DisplayName("유저-크리에이터 채팅 WebSocket handler를 지정 경로와 인증 interceptor, origin 제한으로 등록한다")
fun shouldRegisterUserCreatorChatWebSocketHandler() {
val handlerMappings = applicationContext.getBeansOfType(SimpleUrlHandlerMapping::class.java).values
val urlMap = handlerMappings.flatMap { mapping -> mapping.urlMap.entries }
val handler = urlMap.firstNotNullOfOrNull { (path, handler) ->
if (path == "/ws/v2/user-creator-chat") handler as? WebSocketHttpRequestHandler else null
}
assertNotNull(handler, "Expected /ws/v2/user-creator-chat to be registered")
val interceptors = handler!!.handshakeInterceptors
assertTrue(interceptors.any { it is UserCreatorChatWebSocketAuthInterceptor })
val originInterceptor = interceptors.filterIsInstance<OriginHandshakeInterceptor>().single()
assertEquals(
listOf(
"http://localhost:8888",
"https://creator.sodalive.net",
"https://test-creator.sodalive.net",
"https://test-admin.sodalive.net",
"https://admin.sodalive.net"
),
originInterceptor.allowedOrigins.toList()
)
}
}

View File

@@ -0,0 +1,255 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatMessageItemDto
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
class UserCreatorChatWebSocketHandlerTest {
private val service = Mockito.mock(UserCreatorChatService::class.java)
private val presenceService = Mockito.mock(UserCreatorChatPresenceService::class.java)
private val sessionRegistry = UserCreatorChatWebSocketSessionRegistry()
private val objectMapper = ObjectMapper().findAndRegisterModules()
private val handler = UserCreatorChatWebSocketHandler(
service = service,
presenceService = presenceService,
sessionRegistry = sessionRegistry,
objectMapper = objectMapper
)
@Test
@DisplayName("JOIN_ROOM은 참여자 검증 후 local session과 Redis presence를 등록하고 JOINED를 응답한다")
fun shouldJoinRoomAndRegisterPresenceWhenParticipantIsValid() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
Mockito.verify(service).validateParticipant(roomId = 10L, memberId = 1L)
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
Mockito.verify(presenceService).markJoined(roomId = 10L, memberId = 1L, sessionId = "session-1")
val response = sentJson(session)
assertEquals("JOINED", response["type"].asText())
assertEquals("join-1", response["requestId"].asText())
assertEquals(10L, response["roomId"].asLong())
assertTrue(response["payload"].isObject)
}
@Test
@DisplayName("JOIN_ROOM 요청자가 참여자가 아니면 ERROR 응답 후 WebSocket을 닫는다")
fun shouldSendErrorAndCloseWhenJoinRoomParticipantIsInvalid() {
val session = session("session-1", memberId = 1L)
Mockito.doThrow(SodaException(messageKey = "chat.room.invalid_access"))
.`when`(service)
.validateParticipant(roomId = 10L, memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
val response = sentJson(session)
assertEquals("ERROR", response["type"].asText())
assertEquals("join-1", response["requestId"].asText())
assertEquals(10L, response["roomId"].asLong())
assertEquals("chat.room.invalid_access", response["payload"]["messageKey"].asText())
Mockito.verify(session).close(CloseStatus.POLICY_VIOLATION)
}
@Test
@DisplayName("SEND_TEXT는 메시지 저장 후 sender에게 SEND_ACK를 응답한다")
fun shouldSendAckToSenderWhenTextMessageIsSaved() {
val session = session("session-1", memberId = 1L)
Mockito.`when`(service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello"))
.thenReturn(messageItem())
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
Mockito.clearInvocations(session)
handler.handleMessage(
session,
textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}")
)
val response = sentJson(session)
assertEquals("SEND_ACK", response["type"].asText())
assertEquals("send-1", response["requestId"].asText())
assertEquals(10L, response["roomId"].asLong())
assertEquals(200L, response["payload"]["messageId"].asLong())
assertEquals("hello", response["payload"]["textMessage"].asText())
assertTrue(response["payload"]["mine"].asBoolean())
}
@Test
@DisplayName("SEND_TEXT는 JOIN_ROOM 완료 전이면 저장하지 않고 ERROR를 응답한다")
fun shouldRejectSendTextBeforeJoinRoom() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(
session,
textMessage("SEND_TEXT", "send-1", 10L, "{\"textMessage\":\"hello\"}")
)
val response = sentJson(session)
assertEquals("ERROR", response["type"].asText())
assertEquals("send-1", response["requestId"].asText())
assertEquals(10L, response["roomId"].asLong())
assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText())
Mockito.verify(service, Mockito.never()).sendTextMessageByWebSocket(
Mockito.anyLong(),
Mockito.anyLong(),
Mockito.anyString()
)
}
@Test
@DisplayName("같은 session이 다른 방에 JOIN_ROOM하면 기존 방 presence를 제거하고 새 방만 등록한다")
fun shouldRemovePreviousPresenceWhenSessionJoinsAnotherRoom() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-2", 20L, "{}"))
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
Mockito.verify(presenceService).markJoined(roomId = 20L, memberId = 1L, sessionId = "session-1")
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 20L, memberId = 1L))
}
@Test
@DisplayName("WebSocket close 시 local session과 Redis presence를 제거한다")
fun shouldRemoveLocalSessionAndPresenceWhenConnectionCloses() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
handler.afterConnectionClosed(session, CloseStatus.NORMAL)
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
}
@Test
@DisplayName("LEAVE_ROOM은 local session과 Redis presence를 제거한다")
fun shouldRemoveLocalSessionAndPresenceWhenLeaveRoomReceived() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
Mockito.clearInvocations(session, presenceService)
handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 10L, "{}"))
Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1")
assertEquals(emptyList<WebSocketSession>(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
}
@Test
@DisplayName("LEAVE_ROOM은 joined room과 요청 roomId가 다르면 presence를 제거하지 않고 ERROR를 응답한다")
fun shouldRejectLeaveRoomWhenRequestRoomDoesNotMatchJoinedRoom() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
Mockito.clearInvocations(session, presenceService)
handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 20L, "{}"))
Mockito.verify(presenceService, Mockito.never()).markLeft(
roomId = 10L,
memberId = 1L,
sessionId = "session-1"
)
assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L))
val response = sentJson(session)
assertEquals("ERROR", response["type"].asText())
assertEquals("leave-1", response["requestId"].asText())
assertEquals(20L, response["roomId"].asLong())
assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText())
}
@Test
@DisplayName("PING은 joined room의 presence TTL을 갱신하고 PONG을 응답한다")
fun shouldRefreshPresenceAndSendPongWhenPingReceived() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}"))
Mockito.clearInvocations(session, presenceService)
handler.handleMessage(session, textMessage("PING", "ping-1", 10L, "{}"))
Mockito.verify(presenceService).refresh(roomId = 10L, memberId = 1L, sessionId = "session-1")
val response = sentJson(session)
assertEquals("PONG", response["type"].asText())
assertEquals("ping-1", response["requestId"].asText())
assertEquals(10L, response["roomId"].asLong())
assertTrue(response["payload"].isObject)
}
@Test
@DisplayName("잘못된 JSON 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다")
fun shouldSendErrorWhenMessagePayloadIsMalformedJson() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, TextMessage("{"))
val response = sentJson(session)
assertEquals("ERROR", response["type"].asText())
assertTrue(response["requestId"].isNull)
assertEquals(0L, response["roomId"].asLong())
assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText())
}
@Test
@DisplayName("알 수 없는 type 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다")
fun shouldSendErrorWhenMessageTypeIsUnknown() {
val session = session("session-1", memberId = 1L)
handler.handleMessage(session, textMessage("UNKNOWN", "unknown-1", 10L, "{}"))
val response = sentJson(session)
assertEquals("ERROR", response["type"].asText())
assertTrue(response["requestId"].isNull)
assertEquals(0L, response["roomId"].asLong())
assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText())
}
private fun textMessage(type: String, requestId: String, roomId: Long, payload: String): TextMessage {
return TextMessage(
"""
{
"type": "$type",
"requestId": "$requestId",
"roomId": $roomId,
"payload": $payload
}
""".trimIndent()
)
}
private fun session(id: String, memberId: Long): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
val attributes = mutableMapOf<String, Any>(UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE to memberId)
Mockito.`when`(session.attributes).thenReturn(attributes)
Mockito.`when`(session.isOpen).thenReturn(true)
return session
}
private fun sentJson(session: WebSocketSession): JsonNode {
val captor = ArgumentCaptor.forClass(TextMessage::class.java)
Mockito.verify(session).sendMessage(captor.capture())
return objectMapper.readTree(captor.value.payload)
}
private fun messageItem() = UserCreatorChatMessageItemDto(
messageId = 200L,
messageType = "TEXT",
mine = true,
createdAt = 1781690401000L,
textMessage = "hello",
voiceMessageUrl = null,
senderId = 1L,
senderNickname = "user",
senderProfileImageUrl = "https://cdn.test/profile/user.png"
)
}

View File

@@ -0,0 +1,132 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.ApplicationContext
import org.springframework.http.HttpHeaders
import org.springframework.http.server.ServerHttpResponse
import org.springframework.http.server.ServletServerHttpRequest
import org.springframework.mock.web.MockHttpServletRequest
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
import org.springframework.web.socket.WebSocketHandler
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler
@SpringBootTest(
classes = [
UserCreatorChatWebSocketConfig::class,
UserCreatorChatWebSocketHandler::class,
UserCreatorChatWebSocketAuthInterceptor::class
]
)
class UserCreatorChatWebSocketHandshakeIntegrationTest @Autowired constructor(
private val applicationContext: ApplicationContext
) {
@MockBean
private lateinit var tokenProvider: TokenProvider
@MockBean
private lateinit var service: UserCreatorChatService
@MockBean
private lateinit var presenceService: UserCreatorChatPresenceService
@MockBean
private lateinit var sessionRegistry: UserCreatorChatWebSocketSessionRegistry
@MockBean
private lateinit var objectMapper: ObjectMapper
private val response = Mockito.mock(ServerHttpResponse::class.java)
private val wsHandler = Mockito.mock(WebSocketHandler::class.java)
@Test
@DisplayName("유효한 Bearer token이 있으면 등록된 WebSocket auth interceptor가 handshake를 허용한다")
fun shouldAcceptHandshakeWithValidBearerToken() {
val member = Member(email = "ws-handshake@test.com", password = "pw", nickname = "ws-handshake")
.apply { id = 10L }
val authentication = UsernamePasswordAuthenticationToken(MemberAdapter(member), "valid-token")
Mockito.`when`(tokenProvider.validateToken("valid-token")).thenReturn(true)
Mockito.`when`(tokenProvider.getAuthentication("valid-token")).thenReturn(authentication)
val attributes = mutableMapOf<String, Any>()
val result = authInterceptor().beforeHandshake(
requestWithAuthorization("Bearer valid-token"),
response,
wsHandler,
attributes
)
assertTrue(result)
assertSame(authentication, attributes[UserCreatorChatWebSocketAuthInterceptor.AUTHENTICATION_ATTRIBUTE])
}
@Test
@DisplayName("Authorization header가 없으면 등록된 WebSocket auth interceptor가 handshake를 거부한다")
fun shouldRejectHandshakeWithoutAuthorizationHeader() {
val attributes = mutableMapOf<String, Any>()
val result = authInterceptor().beforeHandshake(
requestWithAuthorization(null),
response,
wsHandler,
attributes
)
assertFalse(result)
assertTrue(attributes.isEmpty())
}
@Test
@DisplayName("유효하지 않은 token이면 등록된 WebSocket auth interceptor가 handshake를 거부한다")
fun shouldRejectHandshakeWithInvalidBearerToken() {
Mockito.`when`(tokenProvider.validateToken("invalid-token")).thenReturn(false)
val attributes = mutableMapOf<String, Any>()
val result = authInterceptor().beforeHandshake(
requestWithAuthorization("Bearer invalid-token"),
response,
wsHandler,
attributes
)
assertFalse(result)
assertTrue(attributes.isEmpty())
}
private fun authInterceptor(): UserCreatorChatWebSocketAuthInterceptor {
val handler = registeredWebSocketHandler()
return handler.handshakeInterceptors.filterIsInstance<UserCreatorChatWebSocketAuthInterceptor>().single()
}
private fun registeredWebSocketHandler(): WebSocketHttpRequestHandler {
val handlerMappings = applicationContext.getBeansOfType(SimpleUrlHandlerMapping::class.java).values
val urlMap = handlerMappings.flatMap { mapping -> mapping.urlMap.entries }
val handler = urlMap.firstNotNullOfOrNull { (path, handler) ->
if (path == UserCreatorChatWebSocketConfig.ENDPOINT) handler as? WebSocketHttpRequestHandler else null
}
assertNotNull(handler, "Expected /ws/v2/user-creator-chat to be registered")
return handler!!
}
private fun requestWithAuthorization(authorization: String?): ServletServerHttpRequest {
val request = MockHttpServletRequest()
if (authorization != null) {
request.addHeader(HttpHeaders.AUTHORIZATION, authorization)
}
return ServletServerHttpRequest(request)
}
}

View File

@@ -0,0 +1,77 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class UserCreatorChatWebSocketMessageTest {
private val objectMapper = jacksonObjectMapper()
@Test
@DisplayName("WebSocket message type enum은 클라이언트 요청과 서버 응답 타입을 모두 가진다")
fun shouldDefineAllWebSocketMessageTypes() {
val names = UserCreatorChatWebSocketMessageType.values().map { it.name }
assertEquals(
listOf(
"JOIN_ROOM",
"SEND_TEXT",
"LEAVE_ROOM",
"PING",
"JOINED",
"MESSAGE",
"SEND_ACK",
"ERROR",
"PONG"
),
names,
"Expected WebSocket message types to match the protocol contract"
)
}
@Test
@DisplayName("SEND_TEXT JSON envelope를 JsonNode payload로 역직렬화한다")
fun shouldDeserializeSendTextEnvelopeWithJsonNodePayload() {
val json = """
{
"type": "SEND_TEXT",
"requestId": "client-request-id",
"roomId": 10,
"payload": {
"textMessage": "hello"
}
}
""".trimIndent()
val message = objectMapper.readValue<UserCreatorChatWebSocketMessage>(json)
assertEquals(UserCreatorChatWebSocketMessageType.SEND_TEXT, message.type)
assertEquals("client-request-id", message.requestId)
assertEquals(10L, message.roomId)
assertNotNull(message.payload, "Expected payload JsonNode to be present")
assertEquals("hello", message.payload["textMessage"].asText())
}
@Test
@DisplayName("payload가 비어 있는 JOIN_ROOM envelope도 역직렬화한다")
fun shouldDeserializeJoinRoomEnvelopeWithEmptyPayload() {
val json = """
{
"type": "JOIN_ROOM",
"requestId": "join-request-id",
"roomId": 10,
"payload": {}
}
""".trimIndent()
val message = objectMapper.readValue<UserCreatorChatWebSocketMessage>(json)
assertEquals(UserCreatorChatWebSocketMessageType.JOIN_ROOM, message.type)
assertEquals("join-request-id", message.requestId)
assertEquals(10L, message.roomId)
assertEquals(0, message.payload.size(), "Expected empty JSON object payload")
}
}

View File

@@ -0,0 +1,129 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.web.socket.WebSocketSession
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class UserCreatorChatWebSocketSessionRegistryTest {
private val registry = UserCreatorChatWebSocketSessionRegistry()
@Test
@DisplayName("roomId/memberId/sessionId 기준으로 local WebSocket session을 등록하고 조회한다")
fun shouldRegisterAndFindSessionsByRoomAndMember() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
assertEquals(1, sessions.size, "Expected one registered local WebSocket session")
assertSame(session, sessions.single())
}
@Test
@DisplayName("sessionId로 등록된 local WebSocket session을 제거한다")
fun shouldRemoveSessionBySessionId() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
registry.remove("session-1")
assertFalse(
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
"Expected removed WebSocket session not to be returned"
)
}
@Test
@DisplayName("같은 session이 다른 room으로 전환되면 기존 room 등록을 제거한다")
fun shouldRemovePreviousRoomWhenSameSessionSwitchesRoom() {
val session = session("session-1")
registry.register(roomId = 10L, memberId = 20L, session = session)
registry.register(roomId = 11L, memberId = 20L, session = session)
assertFalse(
registry.findSessions(roomId = 10L, memberId = 20L).isNotEmpty(),
"Expected previous room mapping to be removed when same session switches rooms"
)
assertEquals(listOf(session), registry.findSessions(roomId = 11L, memberId = 20L))
}
@Test
@DisplayName("room/member에 등록된 여러 local session을 모두 조회한다")
fun shouldFindMultipleSessionsForSameRoomMember() {
val first = session("session-1")
val second = session("session-2")
registry.register(roomId = 10L, memberId = 20L, session = first)
registry.register(roomId = 10L, memberId = 20L, session = second)
val sessions = registry.findSessions(roomId = 10L, memberId = 20L)
assertEquals(setOf(first, second), sessions.toSet())
}
@Test
@DisplayName("같은 session의 동시 room 전환에서도 stale room 등록을 남기지 않는다")
fun shouldNotLeaveStaleRoomMappingWhenSameSessionSwitchesRoomConcurrently() {
val session = sessionWithSynchronizedFirstTwoIdReads("session-1")
val executor = Executors.newFixedThreadPool(2)
try {
val first = executor.submit { registry.register(roomId = 10L, memberId = 20L, session = session) }
val second = executor.submit { registry.register(roomId = 11L, memberId = 20L, session = session) }
first.get(3, TimeUnit.SECONDS)
second.get(3, TimeUnit.SECONDS)
} finally {
executor.shutdownNow()
}
val registeredRooms = listOf(10L, 11L).filter { roomId ->
registry.findSessions(roomId = roomId, memberId = 20L).isNotEmpty()
}
assertEquals(
1,
registeredRooms.size,
"Expected concurrent same-session room switch to leave exactly one active room mapping"
)
}
@Test
@DisplayName("sessionId별 lock map을 유지하지 않는다")
fun shouldNotKeepPerSessionLockMap() {
val hasSessionLockMap = UserCreatorChatWebSocketSessionRegistry::class.java.declaredFields
.any { field -> field.name == "sessionLocks" }
assertFalse(
hasSessionLockMap,
"Expected registry not to keep a per-session lock map that can grow with WebSocket traffic"
)
}
private fun sessionWithSynchronizedFirstTwoIdReads(id: String): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
val readCount = AtomicInteger()
val firstTwoReads = CountDownLatch(2)
Mockito.`when`(session.id).thenAnswer {
if (readCount.incrementAndGet() <= 2) {
firstTwoReads.countDown()
firstTwoReads.await(1, TimeUnit.SECONDS)
}
id
}
return session
}
private fun session(id: String): WebSocketSession {
val session = Mockito.mock(WebSocketSession::class.java)
Mockito.`when`(session.id).thenReturn(id)
return session
}
}

View File

@@ -97,6 +97,7 @@ spring:
password: password:
jpa: jpa:
open-in-view: false
database: h2 database: h2
database-platform: kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect database-platform: kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect
hibernate: hibernate: