docs(dm): WebSocket 전환 계획을 기록한다
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# DM 채팅화면 구현 계획/TASK
|
||||
|
||||
## 1. 목표
|
||||
`docs/20260610_DM_채팅화면/prd.md`를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI `ChatRoomActivity`는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, SSE 클라이언트, 메시지 UI 모델을 최소 범위로 추가한다.
|
||||
`docs/20260610_DM_채팅화면/prd.md`를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI `ChatRoomActivity`는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, WebSocket 클라이언트, 메시지 UI 모델을 최소 범위로 추가한다.
|
||||
|
||||
## 2. 구현 결정 사항
|
||||
- 신규 화면은 `kr.co.vividnext.sodalive.v2.main.chat.dm` 하위에 둔다.
|
||||
@@ -11,39 +11,49 @@
|
||||
- `roomId <= 0 && creatorId > 0`: `CreateOrGetRoom` 호출 후 반환된 `roomId`로 `OpenRoom`을 호출한다.
|
||||
- 둘 다 유효하지 않으면 Activity를 종료한다.
|
||||
- REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + `ApiResponse<T>` 패턴을 사용한다.
|
||||
- SSE는 현재 저장소에 재사용 패턴이 없으므로 별도 라이브러리 추가 없이 기존 `OkHttpClient`의 `newCall()`과 streaming `ResponseBody`를 사용하는 `DmChatEventClient`를 추가한다.
|
||||
- SSE 연결/해제는 Activity foreground 범위에서 처리한다.
|
||||
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 SSE 연결을 시작한다.
|
||||
- `onStop`: SSE call을 cancel하고 `DisconnectRealtime` API를 비동기로 호출한다.
|
||||
- `onDestroy`: listener 참조와 disposable을 정리한다.
|
||||
- `Last-Event-ID` replay는 기대하지 않고, SSE 재연결 후 `GetMessages`로 최신 누락 가능 메시지를 동기화한다.
|
||||
- 네트워크 오류로 SSE가 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우 서버 `reconnectTime=3000`ms 기준으로 재연결을 시도한다.
|
||||
- 재연결 성공 후 `Last-Event-ID` replay는 기대하지 않고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
||||
- Phase 1~8은 기존 SSE 기반 구현 이력으로 보존한다. WebSocket 전환은 Phase 9부터 기존 SSE 구현을 교체하는 후속 범위로 진행한다.
|
||||
- WebSocket은 `OkHttpClient.newWebSocket()` 기반 전용 `DmChatSocketClient`를 추가하고, 기존 `DmChatEventClient`/SSE parser는 WebSocket 전환 완료 후 사용하지 않는다.
|
||||
- WebSocket 연결/해제는 Activity foreground 범위와 로그아웃 흐름에서 처리한다.
|
||||
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 WebSocket 연결을 시작하고 `JOIN_ROOM`을 보낸다.
|
||||
- `JOINED`: 실시간 수신 가능 상태로 판단한다.
|
||||
- `onStop`/화면 이탈/앱 background/로그아웃: `LEAVE_ROOM` 전송 후 socket close를 수행한다.
|
||||
- `onDestroy`/`onCleared`: listener 참조, heartbeat, reconnect 예약, socket을 정리한다.
|
||||
- WebSocket 재연결 후에는 `JOIN_ROOM`을 다시 보내고, 필요하면 `GetMessages`로 최신 누락 가능 메시지를 동기화한다.
|
||||
- 네트워크 오류로 WebSocket이 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우에만 재연결을 시도한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 `GetMessages`로 누락 가능 메시지를 보정한다.
|
||||
- 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
|
||||
- `VOICE` 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
|
||||
- 전송은 낙관적 UI를 적용한다.
|
||||
- 전송 직후 local pending 메시지를 추가한다.
|
||||
- 성공 시 서버 응답 메시지로 교체한다.
|
||||
- 실패 시 실패 상태와 재시도 버튼을 표시한다.
|
||||
- Phase 3 ViewModel 전송 정책은 단일 `isSending` guard로 한 번에 하나의 전송만 허용한다.
|
||||
- 이번 범위에서는 “전송 중 중복 요청 방지” 요구사항을 우선 충족한다.
|
||||
- 서로 다른 메시지의 연속 병렬 전송 허용 여부는 Phase 5 Activity/input UX 연결 시 필요하면 재검토한다.
|
||||
- WebSocket `SEND_ACK` 수신 시 `requestId`로 pending 메시지를 찾아 서버 `messageId`, `createdAt`, 프로필 정보로 확정한다.
|
||||
- `ERROR` 또는 timeout 시 실패 상태와 재시도 버튼을 표시한다.
|
||||
- Phase 9 이후 텍스트 전송 정책은 `requestId` 단위 pending map으로 관리한다.
|
||||
- 서로 다른 텍스트 메시지는 각각의 `requestId`로 독립 pending 상태를 가질 수 있다.
|
||||
- 같은 UI item 재시도는 새 `requestId`를 발급하되 기존 local item을 유지한다.
|
||||
- Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
|
||||
- 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.
|
||||
|
||||
## 3. 파일 구조
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||
- DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/SSE 연결 제어를 담당한다.
|
||||
- DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/WebSocket 연결 제어를 담당한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- 방 생성/열기, pagination, 메시지 전송, SSE 이벤트 반영, disconnect 상태를 관리한다.
|
||||
- 방 생성/열기, pagination, WebSocket 메시지 전송/수신, leave/close 상태를 관리한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
||||
- `user-creator-chat` REST endpoint를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
||||
- REST DTO와 서버 메시지 DTO를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||
- API 호출 래핑, token 전달, SSE 클라이언트 위임을 담당한다.
|
||||
- REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
|
||||
- OkHttp 기반 SSE 연결, `connected`/`message` 이벤트 파싱, cancel 처리를 담당한다.
|
||||
- 기존 SSE 연결 구현이다. Phase 9 이후 WebSocket 전환 완료 시 신규 경로에서 사용하지 않으며 제거 또는 미사용 상태로 둔다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||
- OkHttp WebSocket 연결, handshake header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 수신 callback, close 처리를 담당한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||
- WebSocket envelope, payload, `requestId` 기반 `SEND_ACK`, `ERROR`, `MESSAGE`, `PONG` 모델을 정의한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||
- WebSocket handshake header, endpoint, send/receive envelope, close 동작을 검증한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
||||
- `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`, 알 수 없는 type 파싱을 검증한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
|
||||
- UI 메시지 모델, 전송 상태, 화면 상태를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt`
|
||||
@@ -59,9 +69,15 @@
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
|
||||
- DM item 클릭 시 `DmChatRoomActivity.newIntentByRoomId()`로 이동한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||
- `DmChatApi`, `DmChatRepository`, `DmChatEventClient`, `DmChatRoomViewModel` DI를 추가한다.
|
||||
- `DmChatApi`, `DmChatRepository`, `DmChatSocketClient`, `DmChatRoomViewModel` DI를 등록한다.
|
||||
- Modify: `app/src/main/AndroidManifest.xml`
|
||||
- `DmChatRoomActivity`를 등록한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
|
||||
- 푸시 payload의 `chat_type`, `room_id`를 `DeepLinkActivity`로 전달한다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
|
||||
- `chat_type == "USER_CREATOR"`와 `room_id` 기준으로 DM 채팅방 진입 intent를 만든다.
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
|
||||
- 로그인/메인 진입 후 전달된 DM 푸시 extras를 DM 채팅방 진입으로 연결한다.
|
||||
- Modify: `docs/agent-guides/build-test-style.md`
|
||||
- 신규 DM 채팅 테스트 단일 실행 예시를 추가한다.
|
||||
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt`
|
||||
@@ -75,9 +91,11 @@
|
||||
- OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
|
||||
- 메시지 병합은 `messageId` 기준으로 중복을 제거한다.
|
||||
- 상단 스크롤 시 `hasMore=true`, `nextCursor != null`, `isLoading=false` 조건에서만 과거 메시지를 조회한다.
|
||||
- 텍스트 전송은 blank 입력을 무시하고, 전송 중 중복 요청을 막는다.
|
||||
- 전송 실패 메시지는 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
|
||||
- 화면 stop/destroy 흐름에서 SSE cancel과 disconnect API 호출이 화면 종료를 막지 않는다.
|
||||
- 텍스트 전송은 blank 입력을 무시하고, WebSocket `SEND_TEXT`와 `requestId` pending 매칭을 사용한다.
|
||||
- 전송 실패 메시지는 `ERROR` 또는 timeout 기준으로 실패 상태와 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
|
||||
- 화면 stop/destroy/background/logout 흐름에서 `LEAVE_ROOM` 전송과 socket close가 화면 종료를 막지 않는다.
|
||||
- 제거된 `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`는 신규 텍스트 실시간 송수신 경로에서 호출하지 않는다.
|
||||
- 음성 메시지는 기존 multipart REST API 경로를 유지한다.
|
||||
- DM 화면에는 `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`가 없다.
|
||||
- `ChatRoomActivity` 기존 동작은 변경하지 않는다.
|
||||
|
||||
@@ -440,8 +458,8 @@
|
||||
- 메시지 목록은 header 바로 아래에서 시작한다.
|
||||
- blank 입력은 전송되지 않는다.
|
||||
- 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
|
||||
- 화면 이탈 또는 앱 background 전환 시 disconnect API가 호출된다.
|
||||
- SSE 연결 실패가 앱 crash로 이어지지 않는다.
|
||||
- Phase 9~13 WebSocket 전환 후 화면 이탈 또는 앱 background 전환 시 `LEAVE_ROOM` 전송 후 socket close가 호출된다.
|
||||
- Phase 9~13 WebSocket 전환 후 WebSocket 연결 실패가 앱 crash로 이어지지 않는다.
|
||||
|
||||
### Phase 8: 크리에이터 채널 DM 진입 crash 수정
|
||||
|
||||
@@ -497,6 +515,303 @@
|
||||
- header 상대 정보와 초기 메시지 목록이 표시된다.
|
||||
- 채팅 탭의 기존 `roomId` 기반 DM 진입은 기존처럼 동작한다.
|
||||
|
||||
### Phase 9: WebSocket 계약/클라이언트 기반 추가
|
||||
|
||||
- [ ] **Task 9.1: WebSocket envelope와 payload 모델 정의**
|
||||
- Files:
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt`
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt`
|
||||
- 작업:
|
||||
- WebSocket 공통 envelope는 `type`과 `payload`를 분리해 파싱할 수 있게 정의한다.
|
||||
- Client send type은 `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING`을 지원한다.
|
||||
- Server receive type은 `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`을 지원한다.
|
||||
- `JOIN_ROOM`/`LEAVE_ROOM` payload는 현재 `roomId`를 포함한다.
|
||||
- `SEND_TEXT` payload는 `roomId`, `requestId`, `textMessage`를 포함한다.
|
||||
- `SEND_ACK` payload는 `requestId`와 서버 확정 메시지(`DmChatMessageResponse`)를 포함한다.
|
||||
- `MESSAGE` payload는 서버 메시지(`DmChatMessageResponse`)를 포함한다.
|
||||
- `ERROR` payload는 `requestId`가 있을 수도 있고 없을 수도 있으므로 nullable로 모델링하고, message/code 필드를 보존한다.
|
||||
- 서버의 정확한 JSON field name은 백엔드 계약과 대조해 확정하되, Android 모델은 서버 field name을 `@SerializedName`으로 보존한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1`
|
||||
- Expected: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`, 알 수 없는 type, 잘못된 JSON 파싱 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 9.2: OkHttp WebSocket 클라이언트 추가**
|
||||
- Files:
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||
- 작업:
|
||||
- `DmChatSocketClient`는 `OkHttpClient.newWebSocket()`으로 `${BuildConfig.BASE_URL}`의 scheme을 WebSocket scheme으로 변환해 `/ws/v2/user-creator-chat`에 연결한다.
|
||||
- `https://`는 `wss://`, `http://`는 `ws://`로 변환한다.
|
||||
- handshake request에 `Authorization: Bearer <accessToken>` header를 추가한다.
|
||||
- `connect(token, listener)`는 socket 연결만 수행하고, 방 참여는 ViewModel이 `sendJoinRoom(roomId)`로 명시 호출한다.
|
||||
- `sendJoinRoom(roomId)`, `sendLeaveRoom(roomId)`, `sendText(roomId, requestId, textMessage)`, `sendPing()`를 제공한다.
|
||||
- `close()`는 진행 중 socket을 close하고 listener 참조를 해제한다.
|
||||
- `onMessage`는 `DmChatSocketModels` parser로 envelope를 파싱해 listener callback으로 전달한다.
|
||||
- 알 수 없는 type 또는 파싱 실패는 앱 crash 없이 listener failure 또는 ignored event로 처리한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
||||
- Expected: endpoint URL 변환, Authorization header, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` JSON 송신, 수신 callback, close 정리 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 9.3: Repository와 DI를 WebSocket 클라이언트 기준으로 전환**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
||||
- 작업:
|
||||
- `DmChatRepository`는 `connectRealtime()`/`cancelRealtime()` seam을 WebSocket 클라이언트 위임으로 교체하거나 `connectSocket()`/`closeSocket()`처럼 의미가 분명한 이름으로 변경한다.
|
||||
- REST `createOrGetRoom`, `openRoom`, `getMessages`는 유지한다.
|
||||
- 텍스트 전송용 REST `sendTextMessage()`와 `disconnectRealtime()` repository method는 신규 전송/해제 경로에서 사용하지 않도록 제거하거나 deprecated 없이 삭제한다.
|
||||
- `AppDI.kt`는 `DmChatEventClient` 대신 `DmChatSocketClient`를 등록한다.
|
||||
- `Authorization` header 생성은 기존 `bearer(token)` helper를 재사용해 REST와 WebSocket의 bearer 문자열이 일관되도록 한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
||||
- Expected: REST create/open/messages는 기존처럼 동작하고, WebSocket connect/send/close 위임과 bearer header 생성 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 9.4: 제거 endpoint API 정의 삭제**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
||||
- 작업:
|
||||
- `DmChatApi.sendDmTextMessage()` endpoint 정의를 삭제한다.
|
||||
- `DmChatApi.disconnectRealtime()` endpoint 정의를 삭제한다.
|
||||
- `SendDmTextMessageRequest`, `SendDmChatMessageResponse` 모델은 REST 텍스트 전송 전용이면 삭제한다.
|
||||
- 음성 메시지 multipart REST API가 현재 DM 채팅 화면에 필요하면 별도 `sendDmVoiceMessage()`로 명시하고, 텍스트 전송 경로와 섞지 않는다.
|
||||
- 검증:
|
||||
- Run: `rg "messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\\(" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
||||
- Expected: WebSocket 전환 후 유지가 필요한 과거 검증 로그를 제외하고 main/test 코드의 신규 경로에서 결과 없음.
|
||||
|
||||
### Phase 10: ViewModel WebSocket 세션/수신/전송 전환
|
||||
|
||||
- [ ] **Task 10.1: OpenRoom 성공 후 JOIN_ROOM 흐름으로 연결 기준 변경**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- `OpenRoom` 성공 전에는 WebSocket 연결을 시작하지 않는다.
|
||||
- `roomOpenedEventLiveData` 또는 동등한 단발 이벤트는 Activity가 WebSocket 연결 시작을 트리거하는 용도로 유지한다.
|
||||
- 기존 SSE `connected` callback 기준 상태 갱신을 제거하고, WebSocket `JOINED` 수신 시점에만 실시간 수신 가능 상태로 판단한다.
|
||||
- 같은 `roomId`로 이미 연결 중이면 중복 socket 연결과 중복 `JOIN_ROOM` 전송을 막는다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: OpenRoom 전 미연결, OpenRoom 후 connect + `JOIN_ROOM`, `JOINED` 후 connected 상태, 중복 connect 방지 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 10.2: MESSAGE 수신 반영으로 교체**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- 기존 SSE `onMessage()` callback 연결을 WebSocket `MESSAGE` event callback으로 교체한다.
|
||||
- `MESSAGE` payload의 `DmChatMessageResponse`를 기존 mapper로 UI item에 변환한다.
|
||||
- 현재 채팅방 메시지는 `messageId` 기준 중복 제거 후 append/merge한다.
|
||||
- 현재 room이 아닌 메시지가 payload로 구분 가능하면 현재 목록에 반영하지 않는다.
|
||||
- 잘못된 payload 또는 알 수 없는 type은 앱 crash 없이 무시한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: `MESSAGE` append, 중복 `messageId` 제거, 잘못된 payload 무시 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 10.3: SEND_TEXT/requestId pending 전송으로 교체**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- `sendText(text)`는 trim 후 blank면 종료한다.
|
||||
- 전송 시 `requestId`를 생성하고 local pending item에 보존한다.
|
||||
- REST `repository.sendTextMessage()` 호출을 제거하고 WebSocket `SEND_TEXT`를 전송한다.
|
||||
- `SEND_ACK` 수신 시 `requestId`로 pending item을 찾아 서버 `messageId`, `createdAt`, `senderNickname`, `senderProfileImageUrl` 기준으로 확정한다.
|
||||
- `ERROR` 또는 timeout 시 해당 pending item을 `FAILED`로 전환한다.
|
||||
- `retry(localId)`는 기존 failed item을 유지하고 새 `requestId`를 발급해 `SEND_TEXT`를 다시 전송한다.
|
||||
- 같은 `requestId`의 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: blank 무시, pending 추가, `SEND_TEXT` 송신, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ack 무시 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 10.4: SEND_ACK보다 MESSAGE가 먼저 도착하는 race 처리**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- pending 텍스트와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있는 케이스를 테스트로 고정한다.
|
||||
- `MESSAGE` payload에 `requestId`가 포함되면 requestId 기준으로 pending item을 확정한다.
|
||||
- `MESSAGE` payload에 `requestId`가 없으면 `messageId` 중복 제거를 우선 적용하고, 이후 `SEND_ACK` 도착 시 같은 `messageId`가 중복되지 않게 병합한다.
|
||||
- timeout으로 실패 처리된 뒤 늦은 `SEND_ACK`가 도착하면 같은 local item이 아직 존재하는 경우 정상 메시지로 복구한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: MESSAGE 선도착, ACK 후도착, timeout 후 ACK 복구, 중복 `messageId` 방지 테스트가 PASS.
|
||||
|
||||
### Phase 11: Lifecycle, reconnect, heartbeat, token 갱신 처리
|
||||
|
||||
- [ ] **Task 11.1: LEAVE_ROOM 후 socket close로 lifecycle 해제 전환**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- `onStop`/화면 이탈 시 기존 `disconnectRealtime()` REST 호출 대신 `LEAVE_ROOM` 전송 후 socket close를 수행한다.
|
||||
- 앱 background 진입과 로그아웃 흐름에서 같은 leave/close API를 호출할 수 있도록 ViewModel method를 분리한다.
|
||||
- 이미 leave/close 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다.
|
||||
- close 실패 또는 네트워크 단절은 화면 종료를 막지 않는다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: lifecycle stop에서 `LEAVE_ROOM` + close, 중복 방지, REST disconnect 미호출 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 11.2: WebSocket reconnect 정책 구현**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- 네트워크 오류 또는 비정상 close가 발생하면 현재 채팅방 화면에 남아 있는 동안에만 재연결을 예약한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다.
|
||||
- `JOINED` 수신 후 필요하면 `GetMessages(cursor = null, limit = 20)`로 최신 누락 메시지를 동기화한다.
|
||||
- 화면 이탈/background/logout 이후 예약된 reconnect는 실행하지 않는다.
|
||||
- backoff 간격과 최대 재시도 횟수는 PRD Open Questions에 남긴 상태이므로, 구현 전 서버 권장값이 없으면 기존 3초 반복 재시도 정책을 WebSocket에도 우선 적용한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: 화면 내부 오류 재연결, 재연결 후 `JOIN_ROOM`, 최신 메시지 동기화, 화면 밖 reconnect 취소 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 11.3: PING/PONG heartbeat 구현**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt`
|
||||
- 작업:
|
||||
- `JOINED` 이후 heartbeat timer를 시작한다.
|
||||
- 주기적으로 `PING`을 전송하고 `PONG` 수신 시 마지막 heartbeat 시간을 갱신한다.
|
||||
- `PONG` timeout이면 연결 상태를 disconnected로 바꾸고 WebSocket을 close한 뒤 화면 내부 조건에서 reconnect를 예약한다.
|
||||
- leave/close/onCleared 시 heartbeat timer를 취소한다.
|
||||
- 서버 권장값이 없으면 heartbeat 주기와 timeout 값은 상수로 분리하고 구현 계획 검증 기록에 남긴다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1`
|
||||
- Expected: `PING` 주기 송신, `PONG` 수신 상태 유지, timeout reconnect, leave 시 timer 취소 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 11.4: access token refresh 시 WebSocket 재연결**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- 작업:
|
||||
- token provider가 이전 handshake token과 다른 token을 반환하면 기존 WebSocket을 close한다.
|
||||
- 새 token으로 WebSocket handshake를 다시 수행한다.
|
||||
- 재연결 후 현재 `roomId`로 `JOIN_ROOM`을 다시 보낸다.
|
||||
- token 갱신 중 화면이 종료되면 reconnect를 진행하지 않는다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
|
||||
- Expected: token 변경 감지, 기존 socket close, 새 Authorization header 연결, `JOIN_ROOM` 재전송, 화면 종료 시 중단 테스트가 PASS.
|
||||
|
||||
### Phase 12: 푸시 진입과 제거 endpoint 회귀 검증
|
||||
|
||||
- [ ] **Task 12.1: FCM payload에 chat_type 전달 추가**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingServiceSourceTest.kt`
|
||||
- 작업:
|
||||
- `sendNotification()`에서 `messageData["chat_type"]`이 있으면 `Constants.EXTRA_DATA` bundle에 `chat_type`을 보존한다.
|
||||
- 기존 `room_id`, `message_id`, `deep_link_value` 전달은 유지한다.
|
||||
- deep link URL이 있는 경우에도 `chat_type`/`room_id`가 필요한지 서버 payload 정책을 확인하고, URL 우선 정책을 유지한다면 이 동작을 테스트에 명시한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --max-workers=1`
|
||||
- Expected: `chat_type`과 `room_id`가 notification intent extras에 포함되는 source test가 PASS.
|
||||
|
||||
- [ ] **Task 12.2: USER_CREATOR 푸시를 DM 채팅방으로 라우팅**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt`
|
||||
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
|
||||
- 작업:
|
||||
- `chat_type == "USER_CREATOR"`이고 `room_id`가 Long으로 변환 가능하면 `DmChatRoomActivity.newIntentByRoomId(context, roomId)`로 이동한다.
|
||||
- 푸시로 진입한 채팅방도 일반 진입과 동일하게 `OpenRoom` 후 WebSocket 연결/`JOIN_ROOM`을 수행한다.
|
||||
- `room_id`가 없거나 잘못된 경우 DM 채팅방을 열지 않고 기존 fallback 흐름을 따른다.
|
||||
- 기존 live/content/message deep link 처리는 변경하지 않는다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`
|
||||
- Expected: USER_CREATOR push routing, invalid room fallback, 기존 deep link 유지 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 12.3: 제거 endpoint 호출 회귀 방지 테스트 추가**
|
||||
- Files:
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRemovedEndpointSourceTest.kt`
|
||||
- 작업:
|
||||
- source test로 main DM 채팅 코드에서 아래 문자열이 남아 있지 않은지 확인한다.
|
||||
- `/api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||
- `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||
- `/api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||
- `Accept: text/event-stream`
|
||||
- `EventSource`
|
||||
- ViewModel 테스트에서 텍스트 전송이 REST repository method를 호출하지 않고 WebSocket `SEND_TEXT`를 호출함을 검증한다.
|
||||
- lifecycle close 테스트에서 REST disconnect method가 호출되지 않고 `LEAVE_ROOM` + close만 호출됨을 검증한다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
||||
- Expected: 제거 endpoint 문자열 없음, 텍스트 전송 WebSocket 사용, leave/close WebSocket 사용 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 12.4: 음성 메시지 REST 유지 범위 확인**
|
||||
- Files:
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
|
||||
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
|
||||
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt`
|
||||
- 작업:
|
||||
- 현재 DM 채팅 화면에서 음성 메시지 전송 UI가 없으면 API 추가 없이 DTO의 `voiceMessageUrl` 보존만 유지한다.
|
||||
- DM 음성 전송 UI가 이미 연결되어 있거나 서버 배포 범위에서 필요하면 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart API만 추가한다.
|
||||
- 음성 전송 경로는 WebSocket `SEND_TEXT`/`SEND_ACK` pending 정책과 섞지 않는다.
|
||||
- 검증:
|
||||
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1`
|
||||
- Expected: 음성 API를 추가한 경우 multipart endpoint만 검증되고, 추가하지 않은 경우 기존 DTO 보존 테스트가 PASS.
|
||||
|
||||
### Phase 13: WebSocket 전환 최종 검증과 수동 확인
|
||||
|
||||
- [ ] **Task 13.1: DM 채팅 WebSocket 단위 테스트 실행**
|
||||
- Files:
|
||||
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt`
|
||||
- Run:
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`
|
||||
- Expected:
|
||||
- DM 채팅 WebSocket 전환 관련 단위 테스트가 모두 PASS.
|
||||
|
||||
- [ ] **Task 13.2: 푸시/딥링크 라우팅 테스트 실행**
|
||||
- Files:
|
||||
- Check: `app/src/test/java/kr/co/vividnext/sodalive/fcm/*Test.kt`
|
||||
- Check: `app/src/test/java/kr/co/vividnext/sodalive/main/*Test.kt`
|
||||
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/*Test.kt`
|
||||
- Run:
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1`
|
||||
- Expected:
|
||||
- USER_CREATOR push routing과 기존 deep link 회귀 테스트가 PASS.
|
||||
|
||||
- [ ] **Task 13.3: 빌드/스타일/문자열 회귀 확인**
|
||||
- Files:
|
||||
- Check: Gradle project
|
||||
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
||||
- Run:
|
||||
- `./gradlew :app:compileDebugKotlin --max-workers=1`
|
||||
- `./gradlew :app:ktlintCheck --max-workers=1`
|
||||
- `git diff --check`
|
||||
- `rg "messages/text|events/disconnect|text/event-stream|EventSource" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm`
|
||||
- Expected:
|
||||
- Kotlin compile, ktlint, whitespace check PASS.
|
||||
- 제거 endpoint 문자열은 삭제 검증 테스트나 과거 이력 문서를 제외한 신규 DM main/test 코드에 남지 않는다.
|
||||
|
||||
- [ ] **Task 13.4: WebSocket 전환 수동 확인**
|
||||
- Files:
|
||||
- Check: `DmChatRoomActivity`
|
||||
- Check: `SodaFirebaseMessagingService`
|
||||
- Check: `DeepLinkActivity`
|
||||
- Check: `MainV2Activity`
|
||||
- 확인 항목:
|
||||
- 채팅 탭 DM item 클릭 시 `OpenRoom` 호출 후 WebSocket `/ws/v2/user-creator-chat` 연결과 `JOIN_ROOM` 전송이 발생한다.
|
||||
- `JOINED` 수신 전에는 실시간 수신 상태로 판단하지 않는다.
|
||||
- 상대방 메시지 `MESSAGE` 수신 시 현재 채팅방 메시지 목록에 append된다.
|
||||
- 텍스트 전송 시 REST `/messages/text`가 호출되지 않고 `SEND_TEXT`가 전송된다.
|
||||
- `SEND_ACK` 수신 시 pending 메시지가 서버 `messageId`, `createdAt`, 프로필 정보로 확정된다.
|
||||
- `ERROR` 또는 timeout 시 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
||||
- 화면 이탈, 앱 background, 로그아웃 시 `LEAVE_ROOM` 전송 후 socket close가 발생하고 `/events/disconnect`는 호출되지 않는다.
|
||||
- 네트워크 오류 후 같은 채팅방 화면에 남아 있으면 reconnect 후 `JOIN_ROOM`과 누락 메시지 동기화가 수행된다.
|
||||
- 화면 밖에서는 reconnect가 예약/실행되지 않는다.
|
||||
- heartbeat `PING`/`PONG` timeout 시 연결 상태가 disconnected로 전환된다.
|
||||
- USER_CREATOR push 터치 시 `room_id` 기준 DM 채팅방에 진입하고 일반 진입과 동일하게 `OpenRoom` 후 WebSocket join을 수행한다.
|
||||
|
||||
## 5. 검증 기록
|
||||
- 2026-06-10: `docs/20260610_DM_채팅화면/prd.md`를 확인해 DM 채팅방 진입, UI 제거 대상, REST API, SSE 이벤트, pagination, 전송 실패/재시도, lifecycle disconnect 요구사항을 계획에 반영했다.
|
||||
- 2026-06-10: `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/build-test-style.md`, `docs/agent-guides/code-style.md`를 확인해 신규 계획 문서 위치, phase/task 체크박스 형식, 테스트 명령 작성 방식을 확인했다.
|
||||
@@ -545,3 +860,5 @@
|
||||
- 2026-06-11: Phase 6.7에서 `DmChatEventClientTest`의 `getDeclaredField("okHttpClient")` reflection 검증을 제거하고, OkHttp interceptor의 `chain.readTimeoutMillis()`를 실제 SSE 요청 경로에서 관찰하는 동작 기반 검증으로 대체했다. 비동기 요청 실행을 기다리기 위해 `CountDownLatch`를 사용했고, 공유 client의 60초 read timeout은 유지되며 SSE 요청 경로에서는 0ms read timeout이 적용됨을 확인했다.
|
||||
- 2026-06-11: Phase 6.6~6.7 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 최초 병렬 Gradle 실행에서는 Kotlin incremental cache 동시 접근으로 timeout/daemon fallback이 발생해 순차 실행으로 재검증했다. `ktlintCheck`에서는 기존 `.editorconfig`의 `disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
|
||||
- 2026-06-11: Phase 6.6 리뷰 후속으로 `onCleared()` 테스트를 source 문자열 검증에서 런타임 동작 검증으로 보강했다. 예약된 realtime 재연결이 `onCleared()` 후 실행되지 않는지, background thread에서 예약된 `mainHandler.post { action() }` callback이 `onCleared()` 후 상태를 갱신하지 않는지 확인하도록 수정했다. Phase 6.7 테스트에는 `requestLatch.await(2, TimeUnit.SECONDS)` 반환값 assertion을 추가해 timeout 검증 의도를 명확히 했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1` PASS를 확인했다.
|
||||
- 2026-06-18: `docs/20260610_DM_채팅화면/prd.md`의 WebSocket 전환 요구사항을 기준으로 `plan-task.md`를 보강했다. 기존 Phase 1~8의 SSE 구현 이력은 보존하고, Phase 9~13에 WebSocket 모델/클라이언트, Repository/DI 전환, `JOIN_ROOM`/`JOINED`, `MESSAGE`, `SEND_TEXT`/`SEND_ACK`, `LEAVE_ROOM`/close, reconnect/heartbeat/token refresh, USER_CREATOR push routing, 제거 endpoint 회귀 검증, 최종 수동 확인 작업을 추가했다.
|
||||
- 2026-06-18: 현재 코드 기준으로 `DmChatApi`의 `/messages/text`, `/events/disconnect`, `DmChatEventClient`, `DmChatRoomViewModel.sendText()` REST 전송, `disconnectRealtime()` SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
# PRD: DM 채팅화면
|
||||
|
||||
## 1. Overview
|
||||
`ChatRoomActivity`와 유사한 DM 채팅방 상세 화면을 제공하고, 사용자와 크리에이터 간 DM 메시지를 `user-creator-chat` API와 SSE 기반 실시간 이벤트로 송수신한다.
|
||||
`ChatRoomActivity`와 유사한 DM 채팅방 상세 화면을 제공하고, 사용자와 크리에이터 간 DM 메시지를 `user-creator-chat` REST API와 WebSocket 기반 실시간 이벤트로 송수신한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 기존 `ChatRoomActivity`는 AI 캐릭터 채팅방 기준 화면으로, 캐릭터 타입 배지, CAN 배지, 더보기, 안내 메시지, 쿼터/유료 메시지 흐름이 포함되어 있다.
|
||||
- 채팅 탭의 DM item 클릭 시 이동할 DM 상세 화면이 아직 별도 범위로 구현되어 있지 않다.
|
||||
- DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, SSE 실시간 이벤트 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다.
|
||||
- 화면 이탈 또는 앱 백그라운드 전환 시 실시간 연결 해제 API를 항상 호출해야 하므로 생명주기 요구사항을 명확히 문서화해야 한다.
|
||||
- REST pagination과 SSE 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다.
|
||||
- DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, WebSocket 실시간 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다.
|
||||
- 기존 개발 중 테스트 앱은 채팅방 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` SSE 연결을 사용했고, 화면 이탈/백그라운드/로그아웃 시 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출했다.
|
||||
- 서버 배포와 같은 릴리스 범위에서 위 SSE endpoint와 disconnect endpoint가 제거되므로, 네이티브 앱은 같은 생명주기 위치를 WebSocket `JOIN_ROOM`/`LEAVE_ROOM`/close 흐름으로 전환해야 한다.
|
||||
- REST pagination과 WebSocket 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다.
|
||||
- 텍스트 메시지 전송 성공 판단이 REST 응답의 `deliveredRealtime`/`pushSent`가 아니라 WebSocket `SEND_ACK`/`ERROR`/timeout 기준으로 바뀌므로 pending 메시지 매칭 기준이 필요하다.
|
||||
- 크리에이터 채널에서 `DM 보내기`를 눌러 `creatorId` 기반으로 `DmChatRoomActivity`에 진입하면 `DmChatRoomViewModel.emitContent()`가 background thread에서 `MutableLiveData.setValue()`를 호출해 앱이 crash 된다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 기존 `ChatRoomActivity`의 채팅방 상세 UI 구조를 최대한 재사용하되, DM에 맞지 않는 UI를 제거한 화면을 정의한다.
|
||||
- `CreateOrGetRoom`, `OpenRoom`, `ConnectEvents`, `GetMessages`, `SendTextMessage`, `DisconnectRealtime` API 계약을 Android 프로젝트 네이밍에 맞게 정리한다.
|
||||
- `CreateOrGetRoom`, `OpenRoom`, `GetMessages`, WebSocket 연결/방 참여/텍스트 전송/방 이탈 계약을 Android 프로젝트 네이밍에 맞게 정리한다.
|
||||
- DM 채팅방 진입 시 방 생성/조회 후 생성된 `roomId`로 채팅방을 열고 초기 메시지를 표시한다.
|
||||
- 사용자가 상단으로 스크롤하면 과거 메시지를 커서 기반으로 추가 조회한다.
|
||||
- 텍스트 메시지 전송 후 서버 응답 메시지를 화면에 반영한다.
|
||||
- 채팅방 화면 진입/이탈, 앱 foreground/background 전환에 따른 SSE 연결/해제 정책을 정의한다.
|
||||
- SSE 이벤트 이름/응답 payload, 재연결 가이드, UI thread 비차단, 최신 메시지 동기화 같은 실시간 연결 운영 기준을 정의한다.
|
||||
- 채팅방 화면 진입/이탈, 앱 foreground/background 전환, 로그아웃에 따른 WebSocket 연결/해제 정책을 정의한다.
|
||||
- WebSocket 메시지 타입, `JOINED` 기준 연결 확인, `MESSAGE` 수신, `SEND_TEXT`/`SEND_ACK` pending 확정, `PING`/`PONG` heartbeat, 재연결과 최신 메시지 동기화 기준을 정의한다.
|
||||
- 제거되는 SSE endpoint와 `events/disconnect` endpoint를 더 이상 호출하지 않도록 migration 범위를 명확히 한다.
|
||||
- 크리에이터 채널 `DM 보내기` 진입에서 방 생성/열기 완료 후 모든 `LiveData` 상태 갱신이 main thread에서 수행되어 background thread `setValue()` 예외가 발생하지 않도록 한다.
|
||||
|
||||
---
|
||||
@@ -30,7 +33,7 @@
|
||||
## 4. Non-Goals
|
||||
- AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다.
|
||||
- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 DM 화면에 표시하지 않는다.
|
||||
- 음성 메시지 전송/재생 UI는 이번 범위에서 구현하지 않는다. 단, 서버 DTO의 `voiceMessageUrl` 필드는 모델에 보존한다.
|
||||
- 음성 메시지는 기존 multipart REST API를 유지한다. 단, 음성 메시지 전송/재생 UI 변경은 이번 WebSocket 텍스트 전송 전환 범위에 포함하지 않는다.
|
||||
- 메시지 삭제, 신고, 차단, 알림 설정, 읽음 처리, unread count 실시간 갱신은 이번 범위에 포함하지 않는다.
|
||||
- 백엔드 API 스키마와 필드명을 Android에서 임의 변경하지 않는다. Kotlin class 이름만 프로젝트 가이드에 맞게 조정한다.
|
||||
- 기존 AI `ChatRoomActivity` 동작을 변경하거나 리팩터링하지 않는다.
|
||||
@@ -49,7 +52,9 @@
|
||||
- 사용자는 DM 채팅방에 들어왔을 때 상대 프로필, 상대 이름, 최근 메시지를 바로 보고 싶다.
|
||||
- 사용자는 텍스트를 입력해 크리에이터에게 메시지를 보낼 수 있어야 한다.
|
||||
- 사용자는 채팅 목록 상단으로 스크롤해 이전 대화를 이어서 확인하고 싶다.
|
||||
- 사용자는 화면을 벗어나거나 앱이 백그라운드로 전환될 때 실시간 연결이 안전하게 종료되기를 기대한다.
|
||||
- 사용자는 화면을 벗어나거나 앱이 백그라운드로 전환될 때 WebSocket 실시간 연결이 안전하게 종료되기를 기대한다.
|
||||
- 사용자는 네트워크 오류가 발생해도 현재 채팅방 화면에 머무르는 동안에는 재연결 후 누락 메시지가 보정되기를 기대한다.
|
||||
- 사용자는 푸시 알림으로 DM 채팅방에 진입해도 일반 진입과 동일하게 초기 메시지 조회와 실시간 수신이 시작되기를 기대한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -149,50 +154,45 @@ data class UserCreatorChatRoomOpenResponse(
|
||||
- `CreateOrGetRoom`은 성공했지만 `OpenRoom`이 실패해도 앱 crash 없이 기존 오류 처리 정책을 따른다.
|
||||
- 빠르게 화면을 이탈하거나 background 전환이 발생해도 예약된 realtime callback 정리 정책을 훼손하지 않는다.
|
||||
|
||||
### SSE Realtime Events
|
||||
채팅방이 열려 있는 동안 서버 이벤트를 연결해 새 메시지를 실시간으로 반영한다.
|
||||
### WebSocket Room Session
|
||||
채팅방이 열려 있는 동안 WebSocket으로 방 참여 상태를 만들고, `JOINED` 수신을 실시간 수신 가능 기준으로 삼는다.
|
||||
|
||||
#### Requirements
|
||||
- OpenRoom 성공 후 `GET /api/v2/user-creator-chat/rooms/{roomId}/events`를 호출해 SSE 연결을 시작한다.
|
||||
- SSE 연결은 화면이 foreground에 있고 채팅방이 활성 상태일 때만 유지한다.
|
||||
- 화면을 벗어나거나 앱이 background로 전환되면 `DisconnectRealtime` API를 항상 호출한다.
|
||||
- 재진입 또는 foreground 복귀 시 현재 `roomId`로 다시 SSE 연결을 시도한다.
|
||||
- 재연결 성공 후 필요한 경우 GetMessages API로 누락 가능성이 있는 메시지를 동기화한 뒤 SSE 연결 상태를 갱신한다.
|
||||
- SSE로 수신한 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다.
|
||||
- SSE 연결 객체는 더 이상 사용하지 않을 때 cancel/close 처리하고 listener 참조를 해제한다.
|
||||
- 서버는 SSE `reconnectTime`을 `3000`ms로 내려주므로 클라이언트 재연결 기본 간격은 3초를 따른다.
|
||||
- 서버가 각 이벤트에 `id`를 부여하더라도 현재 백엔드는 재연결 시 `Last-Event-ID`를 해석해 유실 이벤트를 replay하지 않는다.
|
||||
- 네트워크 오류로 인한 재연결은 서버의 3초 가이드를 따르되, 구현 라이브러리가 추가 backoff/jitter를 제공하는 경우 즉시 무한 재시도가 발생하지 않도록 적용한다.
|
||||
- disconnect/cancel 처리는 UI thread를 블로킹하지 않아야 한다.
|
||||
- 현재 저장소에는 SSE/EventSource 구현 패턴이 없으므로 구현 계획에서 OkHttp 기반 EventSource, Retrofit streaming, 별도 라이브러리 사용 여부를 확정한다.
|
||||
- 클라이언트는 채팅방 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 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 연결 직후 현재 `roomId` 기준 `JOIN_ROOM` 메시지를 전송한다.
|
||||
- `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.
|
||||
- 기존 SSE `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다.
|
||||
- access token refresh가 발생하면 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다.
|
||||
|
||||
#### Event Payloads
|
||||
- `connected` 이벤트
|
||||
- 이벤트 이름: `connected`
|
||||
- 데이터 형식: `"connected"` 단순 문자열
|
||||
- 용도: SSE 연결 성공 시 최초 1회 발송되는 handshake 이벤트다.
|
||||
- `message` 이벤트
|
||||
- 이벤트 이름: `message`
|
||||
- 데이터 형식: `UserCreatorChatMessageItemDto` JSON 객체
|
||||
- 용도: 채팅방에 새로운 메시지가 수신되었을 때 실시간으로 전송된다.
|
||||
- 주요 필드: `messageId`, `messageType`, `mine`, `createdAt`, `textMessage`, `voiceMessageUrl`, `senderId`, `senderNickname`, `senderProfileImageUrl`
|
||||
|
||||
#### API Contract
|
||||
- Operation: `ConnectEvents`
|
||||
- Method: `GET`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/events`
|
||||
- Response: `text/event-stream`
|
||||
- Events: `connected`, `message`
|
||||
- Event id: 서버가 이벤트 `id`를 내려주지만 현재 서버는 재연결 시 `Last-Event-ID`를 replay 기준으로 처리하지 않는다.
|
||||
#### WebSocket Message Types
|
||||
- Client to Server: `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING`
|
||||
- Server to Client: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG`
|
||||
- 메시지 envelope의 정확한 JSON 필드명은 서버 계약을 따른다. Android 구현은 type과 payload를 분리해 파싱할 수 있어야 한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 연결 실패 시 앱이 crash 되지 않아야 하며, 기존 네트워크 오류 처리 정책에 맞춰 사용자에게 최소한으로 안내한다.
|
||||
- SSE 종료/실패 콜백 수신 시 연결 상태를 disconnected로 갱신한다.
|
||||
- 이미 연결 중이면 중복 연결을 만들지 않는다.
|
||||
- 연결 해제 API 호출 중 화면 종료가 진행되어도 종료 흐름을 막지 않는다.
|
||||
- `connected` 이벤트는 UI 메시지 목록에 추가하지 않고 연결 상태 갱신에만 사용한다.
|
||||
- `message` 이벤트 수신 시 `UserCreatorChatMessageItemDto`로 파싱하고, `messageType=TEXT`인 메시지만 이번 UI 대상으로 표시한다.
|
||||
- 재연결 후 `Last-Event-ID` 기반 자동 replay를 기대하지 않고, 필요한 경우 GetMessages API로 누락 메시지를 보정한다.
|
||||
- `OpenRoom` 실패 시 WebSocket을 연결하지 않는다.
|
||||
- 이미 같은 `roomId`로 연결 중이면 중복 WebSocket 연결이나 중복 `JOIN_ROOM` 전송을 만들지 않는다.
|
||||
- `JOINED` 수신 전에는 실시간 수신 상태로 표시하지 않는다.
|
||||
- WebSocket 연결 실패 또는 `JOIN_ROOM` 실패 시 앱이 crash 되지 않아야 하며, 현재 채팅방 화면에 남아 있는 동안에만 재연결 정책을 적용한다.
|
||||
|
||||
### WebSocket Message Receive
|
||||
서버가 WebSocket으로 전달하는 `MESSAGE` 이벤트를 현재 채팅방 메시지 목록에 반영한다.
|
||||
|
||||
#### Requirements
|
||||
- 기존 SSE `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다.
|
||||
- `MESSAGE` payload는 `UserCreatorChatMessageItemDto`와 동일한 메시지 모델로 변환한다.
|
||||
- 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다.
|
||||
- 수신 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다.
|
||||
- `messageType=TEXT`인 메시지는 텍스트 말풍선으로 표시한다.
|
||||
- `messageType=VOICE`인 메시지는 DTO에 보존하되, 이번 WebSocket 전환 범위에서 신규 음성 표시 UI를 추가하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 현재 열려 있는 `roomId`와 다른 방의 메시지가 들어오면 현재 목록에 append하지 않는다.
|
||||
- pending 텍스트 메시지와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있으므로 `requestId` 또는 `messageId` 기준 병합 정책을 구현 계획에서 명확히 한다.
|
||||
- 잘못된 JSON이나 알 수 없는 type은 앱 crash 없이 무시하거나 오류 상태로 기록한다.
|
||||
|
||||
### Load Older Messages
|
||||
사용자가 메시지 목록 상단으로 스크롤하면 과거 메시지를 추가 조회한다.
|
||||
@@ -225,53 +225,85 @@ data class UserCreatorChatMessagesPageResponse(
|
||||
|
||||
#### Requirements
|
||||
- 전송 버튼 또는 IME send 액션 시 trim된 입력값이 blank이면 전송하지 않는다.
|
||||
- 전송 요청이 진행 중인 동안 같은 입력에 대한 연타/중복 전송을 방지한다.
|
||||
- 텍스트 메시지는 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text`로 전송한다.
|
||||
- 요청 body는 `textMessage`를 사용한다.
|
||||
- 성공 응답의 `message`를 화면에 반영한다.
|
||||
- `deliveredRealtime`, `pushSent` 값은 응답 모델에 보존하되, 이번 PRD에서는 별도 UI를 추가하지 않는다.
|
||||
- 텍스트 메시지는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다.
|
||||
- 전송 UI는 클라이언트에서 `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK`를 매칭한다.
|
||||
- `SEND_TEXT` payload에는 현재 `roomId`, `requestId`, trim된 `textMessage`를 포함한다.
|
||||
- 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다.
|
||||
- 전송 실패 시 해당 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다.
|
||||
- 재시도 버튼을 누르면 같은 텍스트 메시지를 다시 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다.
|
||||
- `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다.
|
||||
- `ERROR` 또는 timeout을 수신하면 해당 pending 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다.
|
||||
- 재시도 버튼을 누르면 새 `requestId`로 같은 텍스트 메시지를 다시 `SEND_TEXT` 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다.
|
||||
- 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다.
|
||||
- 사용자가 과거 메시지를 보고 있는 상태에서 메시지를 전송하면 최신 메시지 위치로 이동하거나, 최신 페이지 동기화 후 전송 메시지를 반영하는 방식 중 하나를 구현 계획에서 확정한다.
|
||||
|
||||
#### API Contract
|
||||
- Operation: `SendTextMessage`
|
||||
- Method: `POST`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/text`
|
||||
- Request: `SendUserCreatorTextMessageRequest`
|
||||
- Response: `SendUserCreatorChatMessageResponse`
|
||||
#### Edge Cases
|
||||
- `SEND_ACK`가 timeout 이후 도착하면 현재 pending 상태와 중복 여부를 확인해 이미 실패 처리된 메시지를 정상 메시지로 복구할지 구현 계획에서 확정한다.
|
||||
- WebSocket이 연결되지 않았거나 `JOINED` 전이면 텍스트 전송을 막거나 실패 상태로 전환한다.
|
||||
- 같은 `requestId`에 대한 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다.
|
||||
|
||||
```kotlin
|
||||
data class SendUserCreatorTextMessageRequest(
|
||||
val textMessage: String
|
||||
)
|
||||
|
||||
data class SendUserCreatorChatMessageResponse(
|
||||
val message: UserCreatorChatMessageItemDto,
|
||||
val deliveredRealtime: Boolean,
|
||||
val pushSent: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
### Disconnect Realtime
|
||||
채팅방 화면 종료 또는 앱 background 전환 시 실시간 연결을 서버에 명시적으로 해제한다.
|
||||
### Voice Message Send
|
||||
음성 메시지는 WebSocket 전환 범위에서 제외하고 기존 multipart REST API를 유지한다.
|
||||
|
||||
#### Requirements
|
||||
- 화면을 벗어날 때 항상 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`를 호출한다.
|
||||
- 앱이 background로 전환될 때도 같은 API를 호출한다.
|
||||
- 이미 disconnect 호출 중이면 중복 요청을 만들지 않는다.
|
||||
- disconnect 성공 여부와 관계없이 화면 종료 자체는 막지 않는다.
|
||||
- 화면 이탈 시 disconnect는 실시간 연결 해제 범위로 한정하고, 로그아웃처럼 로컬 캐시 삭제가 필요한 흐름과 구분한다.
|
||||
- 음성 메시지 전송은 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출을 유지한다.
|
||||
- 음성 전송 후 상대방 실시간 수신 여부와 push 발송 여부는 서버 정책에 따른다.
|
||||
- 이번 WebSocket 전환 범위에서 음성 메시지를 `SEND_TEXT`와 같은 방식으로 변경하지 않는다.
|
||||
|
||||
#### API Contract
|
||||
- Operation: `DisconnectRealtime`
|
||||
- Operation: `SendVoiceMessage`
|
||||
- Method: `POST`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
|
||||
- Response: `ApiResponse<Boolean>`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice`
|
||||
- Request: `multipart/form-data`
|
||||
|
||||
### Leave Room And Close
|
||||
채팅방 화면 종료 또는 앱 background 전환, 로그아웃 시 WebSocket 방 참여를 해제하고 socket을 닫는다.
|
||||
|
||||
#### Requirements
|
||||
- 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다.
|
||||
- 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다.
|
||||
- 클라이언트는 제거된 `events/disconnect` endpoint를 더 이상 호출하지 않는다.
|
||||
- 이미 leave/close 처리 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다.
|
||||
- close 처리는 UI thread를 블로킹하지 않아야 한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 네트워크 단절로 `LEAVE_ROOM` 전송이 실패해도 화면 종료나 로그아웃 흐름을 막지 않는다.
|
||||
- 로그아웃 흐름에서는 WebSocket close 후 기존 로컬 인증/캐시 정리 정책을 따른다.
|
||||
- 화면 밖에서는 WebSocket 재연결을 예약하지 않는다.
|
||||
|
||||
### Reconnect And Heartbeat
|
||||
WebSocket 연결 상태를 유지하고, 네트워크 오류로 끊긴 경우 현재 채팅방 화면에 남아 있을 때만 복구한다.
|
||||
|
||||
#### Requirements
|
||||
- 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다.
|
||||
- `PONG` timeout 또는 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다.
|
||||
- 재연결 후 필요하면 REST `GET /api/v2/user-creator-chat/rooms/{roomId}/messages` API로 누락 메시지를 동기화한다.
|
||||
- 기존 SSE retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
|
||||
- 재연결 backoff 간격, 최대 재시도 횟수, heartbeat 주기는 구현 계획에서 서버 권장값 또는 앱 공통 네트워크 정책에 맞춰 확정한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 사용자가 화면을 벗어난 뒤 도착한 reconnect timer는 실행하지 않는다.
|
||||
- access token refresh가 필요한 오류는 token 갱신 후 새 WebSocket handshake로 복구한다.
|
||||
- 재연결 중 사용자가 로그아웃하면 즉시 재연결을 중단한다.
|
||||
|
||||
### Push Notification Entry
|
||||
푸시 알림으로 DM 채팅방에 진입하는 흐름을 일반 진입과 동일한 WebSocket lifecycle로 연결한다.
|
||||
|
||||
#### Requirements
|
||||
- 푸시 알림을 터치하면 payload의 `chat_type == "USER_CREATOR"`와 `room_id`를 확인해 해당 채팅방 화면으로 이동한다.
|
||||
- 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `OpenRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM` 전송을 수행한다.
|
||||
- `room_id`가 없거나 유효하지 않으면 DM 채팅방을 열지 않고 기존 푸시 오류 처리 정책을 따른다.
|
||||
|
||||
### Removed SSE Endpoints
|
||||
SSE 제거에 따라 네이티브 앱에서 더 이상 호출하면 안 되는 endpoint를 명시한다.
|
||||
|
||||
#### Requirements
|
||||
- SSE client 또는 `EventSource` wrapper를 제거한다.
|
||||
- `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, SSE event parser, SSE reconnect/retry timer를 삭제한다.
|
||||
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 삭제한다.
|
||||
- 기존 `connected`/`message` SSE event 이름에 의존하는 로직을 WebSocket `JOINED`/`MESSAGE` 기준으로 대체한다.
|
||||
|
||||
### Message DTO
|
||||
서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 화면의 구현 대상은 텍스트 메시지 UI와 텍스트 메시지 전송만으로 한정한다.
|
||||
서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 WebSocket 전환 구현 대상은 텍스트 메시지 실시간 송수신 변경으로 한정한다.
|
||||
|
||||
#### Response Model
|
||||
```kotlin
|
||||
@@ -289,7 +321,8 @@ data class UserCreatorChatMessageItemDto(
|
||||
```
|
||||
|
||||
#### Naming Requirements
|
||||
- Android DTO 이름은 `DmChatMessageResponse`, `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse`, `SendDmTextMessageRequest`, `SendDmChatMessageResponse` 등 기능 중심 이름으로 조정한다.
|
||||
- Android DTO 이름은 `DmChatMessageResponse`, `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse` 등 기능 중심 이름으로 조정한다.
|
||||
- WebSocket envelope와 payload 모델은 `DmChatSocketMessage`, `JoinRoom`, `SendText`, `SendAck` 등 구현 계획에서 확정한 이름을 사용한다.
|
||||
- `messageType`은 서버 문자열을 그대로 보존한다.
|
||||
- 가능한 `messageType` 값은 `TEXT`, `VOICE`다.
|
||||
- `TEXT`는 텍스트 메시지이며 `textMessage`를 사용해 말풍선 UI로 표시한다.
|
||||
@@ -313,11 +346,11 @@ data class UserCreatorChatMessageItemDto(
|
||||
- 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 저장소 규칙에 따라 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다.
|
||||
- DM 화면은 신규 Activity로 생성한다.
|
||||
- 기존 `ChatRoomActivity`는 직접 변형하지 않는다. 필요한 경우 메시지 item/adapter/model 등 공용화 가능한 최소 컴포넌트만 구현 계획에서 검토한다.
|
||||
- REST API 응답은 기존 패턴처럼 `Single<ApiResponse<...>>`를 우선 사용한다. SSE 연결은 `text/event-stream` 수신으로 처리하며, `connected`/`message` event 파싱 방식을 구현 계획에서 확정한다.
|
||||
- token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. Repository 구현 시 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다.
|
||||
- REST API 응답은 기존 패턴처럼 `Single<ApiResponse<...>>`를 우선 사용한다. WebSocket은 `/ws/v2/user-creator-chat` endpoint에 `Authorization: Bearer <accessToken>` handshake header를 전달한다.
|
||||
- token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. REST와 WebSocket handshake의 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다.
|
||||
- 앱 foreground/background 감지는 Activity lifecycle과 앱 전체 `ProcessLifecycleOwner` 중 어떤 기준을 사용할지 구현 계획에서 확정한다.
|
||||
- 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 작성하고, 그 문서에 따라 최소 구현한다.
|
||||
- 테스트는 DTO mapper, 메시지 정렬/중복 제거, pagination state, disconnect lifecycle을 우선 검증한다.
|
||||
- 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 WebSocket 전환 범위에 맞게 갱신하고, 그 문서에 따라 최소 구현한다.
|
||||
- 테스트는 DTO mapper, 메시지 정렬/중복 제거, pagination state, WebSocket envelope 파싱, `requestId` pending 매칭, reconnect/heartbeat, leave/close lifecycle을 우선 검증한다.
|
||||
- 크리에이터 채널 `DM 보내기` crash 수정은 기존 DM 채팅 문서의 후속 범위로 누적하며, 구현 전 `plan-task.md`에 대응 task와 검증 기록을 추가한다.
|
||||
|
||||
---
|
||||
@@ -328,10 +361,14 @@ data class UserCreatorChatMessageItemDto(
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 방 생성 또는 조회 | `POST` | `/api/v2/user-creator-chat/rooms/create` | `CreateUserCreatorChatRoomRequest` | `CreateUserCreatorChatRoomResponse` |
|
||||
| 생성된 채팅방 열기 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` | 없음 | `UserCreatorChatRoomOpenResponse` |
|
||||
| SSE 연결 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/events` | 없음 | `text/event-stream`: `connected`(`String`), `message`(`UserCreatorChatMessageItemDto` JSON) |
|
||||
| WebSocket 연결 | WebSocket | `/ws/v2/user-creator-chat` | Handshake header: `Authorization: Bearer <accessToken>` | WebSocket session |
|
||||
| 방 참여 | WebSocket send | `/ws/v2/user-creator-chat` | `JOIN_ROOM` | `JOINED` |
|
||||
| 실시간 메시지 수신 | WebSocket receive | `/ws/v2/user-creator-chat` | 없음 | `MESSAGE` |
|
||||
| 텍스트 메시지 보내기 | WebSocket send | `/ws/v2/user-creator-chat` | `SEND_TEXT` with `roomId`, `requestId`, `textMessage` | `SEND_ACK` 또는 `ERROR` |
|
||||
| Heartbeat | WebSocket send/receive | `/ws/v2/user-creator-chat` | `PING` | `PONG` |
|
||||
| 방 이탈 | WebSocket send/close | `/ws/v2/user-creator-chat` | `LEAVE_ROOM` | socket close |
|
||||
| 과거 메시지 조회 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={cursor}&limit=20` | 없음 | `UserCreatorChatMessagesPageResponse` |
|
||||
| 텍스트 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/text` | `SendUserCreatorTextMessageRequest` | `SendUserCreatorChatMessageResponse` |
|
||||
| SSE 연결 끊기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` | 없음 | `ApiResponse<Boolean>` |
|
||||
| 음성 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice` | `multipart/form-data` | 기존 서버 계약 유지 |
|
||||
|
||||
---
|
||||
|
||||
@@ -340,13 +377,21 @@ data class UserCreatorChatMessageItemDto(
|
||||
- `roomId` 기반 진입 시 OpenRoom이 `limit=20`으로 호출된다.
|
||||
- OpenRoom 응답 메시지가 시간순으로 `RecyclerView`에 표시된다.
|
||||
- OpenRoom 응답의 `opponentNickname`, `opponentProfileImageUrl`이 header 상대 정보로 표시된다.
|
||||
- OpenRoom 성공 전에는 WebSocket 연결을 시작하지 않는다.
|
||||
- OpenRoom 성공 후 WebSocket `/ws/v2/user-creator-chat`에 access token handshake header로 연결한다.
|
||||
- WebSocket 연결 직후 `JOIN_ROOM`을 보내고 `JOINED` 수신 후에만 실시간 수신 상태로 판단한다.
|
||||
- 상단 스크롤 시 `hasMore=true`와 `nextCursor` 조건에 따라 GetMessages가 호출된다.
|
||||
- 텍스트 메시지 전송 성공 시 응답의 `message`가 화면에 반영된다.
|
||||
- 텍스트 메시지 전송 실패 시 낙관적으로 추가된 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
||||
- 화면 이탈 또는 앱 background 전환 시 DisconnectRealtime 호출이 발생한다.
|
||||
- SSE 재연결 기본 간격은 서버 `reconnectTime=3000`ms를 따른다.
|
||||
- SSE 재연결 후 필요 시 GetMessages API로 누락 메시지를 동기화한다.
|
||||
- disconnect 처리는 UI thread를 블로킹하지 않는다.
|
||||
- `MESSAGE` 수신 시 현재 채팅방 메시지 목록에 상대방 메시지가 append된다.
|
||||
- 텍스트 메시지 전송 시 `requestId`가 생성되고 pending 메시지와 `SEND_ACK`가 매칭된다.
|
||||
- 텍스트 메시지 전송 성공 시 `SEND_ACK`의 `messageId`, `createdAt`, 프로필 정보가 pending 메시지에 반영된다.
|
||||
- 텍스트 메시지 전송 실패 시 `ERROR` 또는 timeout 기준으로 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
|
||||
- 화면 이탈, 앱 background 전환, 로그아웃 시 `LEAVE_ROOM` 전송 후 WebSocket close가 발생한다.
|
||||
- WebSocket 종료 처리는 UI thread를 블로킹하지 않는다.
|
||||
- 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.
|
||||
- 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 필요 시 GetMessages API로 누락 메시지를 동기화한다.
|
||||
- `PING`/`PONG` heartbeat timeout 시 연결 상태가 disconnected로 전환된다.
|
||||
- 푸시 payload의 `chat_type == "USER_CREATOR"`와 `room_id` 기준으로 채팅방에 진입하고, 일반 진입과 동일하게 OpenRoom 및 WebSocket join을 수행한다.
|
||||
- `GET /events`, `POST /events/disconnect`, `POST /messages/text`는 WebSocket 전환된 텍스트 송수신 경로에서 호출되지 않는다.
|
||||
- 제거 대상 UI(`character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`)가 DM 화면에 나타나지 않는다.
|
||||
- 크리에이터 채널 `DM 보내기`로 `creatorId` 기반 진입 시 `Cannot invoke setValue on a background thread` 예외 없이 DM 채팅방 Content 상태가 표시된다.
|
||||
|
||||
@@ -354,6 +399,8 @@ data class UserCreatorChatMessageItemDto(
|
||||
|
||||
## 12. Open Questions
|
||||
- DM 채팅방 진입점별 intent extra 이름은 구현 계획에서 확정한다.
|
||||
- WebSocket envelope의 정확한 JSON 필드명, `JOIN_ROOM`/`SEND_TEXT`/`SEND_ACK` payload 스키마는 서버 계약 문서 또는 백엔드 구현과 대조해 구현 계획에서 확정한다.
|
||||
- `PING` 주기, `PONG` timeout, reconnect backoff와 최대 재시도 횟수는 서버 권장값 또는 앱 공통 정책에 맞춰 구현 계획에서 확정한다.
|
||||
- 음성 메시지 UI/UX와 `VOICE` 메시지 표시 방식은 후속 범위에서 확정한다.
|
||||
|
||||
---
|
||||
@@ -384,3 +431,5 @@ data class UserCreatorChatMessageItemDto(
|
||||
- 2026-06-10: 백그라운드 조사 결과와 대조해 `ConnectEvents` 응답 표기를 `ApiResponse<Unit>`에서 `text/event-stream`으로 보정하고, API Summary와 기술 제약도 SSE stream 수신/파싱 기준으로 갱신했다.
|
||||
- 2026-06-10: 후속 Repository 구현 시 `Authorization` 헤더 오입력 방지를 위해 단일 bearer helper로 헤더 문자열을 생성하도록 기술 제약에 기록했다.
|
||||
- 2026-06-17: 사용자 제보 스택트레이스(`DmChatRoomViewModel.emitContent()`의 `MutableLiveData.setValue()` background thread 예외)와 `DmChatRoomViewModel.kt`의 `createRoomAndOpen()` Rx chain을 확인했다. 크리에이터 채널 `DM 보내기`의 `creatorId` 기반 진입에서 `CreateOrGetRoom` 후 `OpenRoom` 결과 처리 thread를 main thread로 보장해야 하는 요구사항을 기존 DM 채팅화면 PRD에 후속 범위로 누적했다.
|
||||
- 2026-06-18: 사용자 제공 WebSocket 전환 요구사항을 기준으로 기존 SSE 기반 PRD를 갱신했다. Core Features를 `WebSocket Room Session`, `WebSocket Message Receive`, `Send Text Message`, `Voice Message Send`, `Leave Room And Close`, `Reconnect And Heartbeat`, `Push Notification Entry`, `Removed SSE Endpoints`로 분리하고, 제거되는 SSE endpoint와 REST 텍스트 전송 endpoint가 더 이상 텍스트 실시간 송수신 경로에서 호출되지 않도록 성공 기준을 보강했다.
|
||||
- 2026-06-18: 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, plan-task 갱신, 빌드, 테스트는 실행하지 않았다.
|
||||
|
||||
Reference in New Issue
Block a user