Files
sodalive-android/docs/20260610_DM_채팅화면/plan-task.md

976 lines
128 KiB
Markdown

# DM 채팅화면 구현 계획/TASK
## 1. 목표
`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` 하위에 둔다.
- DM 채팅방 Activity 이름은 `DmChatRoomActivity`로 한다.
- intent extra는 `EXTRA_ROOM_ID`, `EXTRA_CREATOR_ID`를 사용한다.
- `roomId > 0`: `OpenRoom`부터 시작한다.
- `roomId <= 0 && creatorId > 0`: `CreateOrGetRoom` 호출 후 반환된 `roomId``OpenRoom`을 호출한다.
- 둘 다 유효하지 않으면 Activity를 종료한다.
- REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + `ApiResponse<T>` 패턴을 사용한다.
- 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 메시지를 추가한다.
- 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/WebSocket 연결 제어를 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- 방 생성/열기, 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`
- REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
- 기존 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`
- 서버 DTO를 UI 모델로 변환하고 정렬/중복 제거 helper를 제공한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
- 내 메시지/상대 메시지 ViewHolder, 실패 재시도 callback을 담당한다.
- Create: `app/src/main/res/layout/activity_dm_chat_room.xml`
- AI 채팅방 layout을 기준으로 DM 전용 header, message list, input 영역을 구성한다.
- Create: `app/src/main/res/layout/item_dm_chat_my_message.xml`
- 내 텍스트 메시지, 전송 중/실패 상태, 재시도 버튼을 표시한다.
- Create: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
- 상대 텍스트 메시지와 프로필/닉네임을 표시한다.
- 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`, `DmChatSocketClient`, `DmChatRoomViewModel` DI를 등록한다.
- Modify: `app/src/main/AndroidManifest.xml`
- `DmChatRoomActivity`를 등록한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
- 푸시 payload의 `deep_link``DeepLinkActivity`로 전달한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
- `${URISCHEME}://chat/{roomId}` deep link 기준으로 DM 채팅방 진입 intent를 만든다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 로그인/메인 진입 후 전달된 DM deep link를 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`
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt`
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventParserTest.kt`
- Test Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
## 4. 성공 기준
- `roomId` 기반 진입은 create API 없이 `OpenRoom`을 호출한다.
- `creatorId` 기반 진입은 `CreateOrGetRoom` 성공 후 반환된 `roomId``OpenRoom`을 호출한다.
- OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
- 메시지 병합은 `messageId` 기준으로 중복을 제거한다.
- 상단 스크롤 시 `hasMore=true`, `nextCursor != null`, `isLoading=false` 조건에서만 과거 메시지를 조회한다.
- 텍스트 전송은 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` 기존 동작은 변경하지 않는다.
---
### Phase 1: API/모델/매퍼 기반 추가
- [x] **Task 1.1: REST DTO와 API 정의**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
- 작업:
- `CreateDmChatRoomRequest`, `CreateDmChatRoomResponse`를 추가한다.
- `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse`, `DmChatMessageResponse`, `SendDmTextMessageRequest`, `SendDmChatMessageResponse`를 추가한다.
- Retrofit endpoint를 아래 계약으로 추가한다.
- `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/text`
- `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect`
- 검증:
- DTO 필드명은 PRD의 서버 필드명을 `@SerializedName`으로 그대로 보존한다.
- REST 반환 타입은 `Single<ApiResponse<...>>`를 사용한다.
- [x] **Task 1.2: UI 모델과 메시지 병합 helper 추가**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt`
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt`
- 작업:
- `DmChatMessageUiItem``messageId`, `localId`, `mine`, `textMessage`, `senderNickname`, `senderProfileImageUrl`, `createdAt`, `status`를 둔다.
- `DmChatMessageStatus``SENDING`, `SENT`, `FAILED`로 정의한다.
- `messageType`은 서버 계약상 `TEXT`/`VOICE` 대문자이나, UI 매핑에서는 오입력 방지를 위해 대소문자를 무시해 `TEXT`를 판정한다.
- `messageType``TEXT`가 아니거나 `textMessage == null`이면 UI item으로 매핑하지 않는다.
- `sortByCreatedAtAndMessageId()``mergeByMessageId()` helper를 추가한다.
- 정렬은 `createdAt` 오름차순 후 같은 시각에서는 `messageId` 오름차순을 따른다.
- 중복 `messageId` 병합은 선도착한 기존 item을 유지하고 후도착 중복 item을 버린다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`
- Expected: `TEXT` 대소문자 무시 매핑, `TEXT` 외 타입과 `textMessage == null` 제외, `createdAt`/`messageId` 오름차순 정렬, 중복 `messageId` 선도착 우선 테스트가 PASS.
### Phase 2: Repository와 SSE 클라이언트 추가
- [x] **Task 2.1: Repository 추가**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
- 작업:
- `createOrGetRoom(token, creatorId)`, `openRoom(token, roomId, limit)`, `getMessages(token, roomId, cursor, limit)`, `sendTextMessage(token, roomId, textMessage)`, `disconnectRealtime(token, roomId)`를 추가한다.
- `limit` 기본값은 `20`으로 둔다.
- `Authorization` 헤더 문자열은 Repository 내부의 단일 helper로 만든다. 예: `private fun bearer(token: String) = "Bearer $token"`.
- 검증:
- Repository는 API 호출을 얇게 위임하고 별도 비즈니스 로직을 넣지 않는다.
- 모든 REST API 호출은 동일한 bearer helper를 통해 생성된 auth header를 사용한다.
- [x] **Task 2.2: OkHttp 기반 SSE 클라이언트 추가**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventParserTest.kt`
- 작업:
- `connect(token, roomId, listener)``GET /api/v2/user-creator-chat/rooms/{roomId}/events` 요청을 만든다.
- header는 REST와 동일하게 `Authorization: Bearer ...`를 전달한다.
- `connected` 이벤트는 메시지 목록에 전달하지 않고 연결 상태 callback만 호출한다.
- `message` 이벤트 data는 Gson으로 `DmChatMessageResponse`로 파싱한다.
- `cancel()`은 진행 중인 `Call`을 cancel하고 listener 참조를 해제한다.
- stream read와 cancel은 UI thread를 블로킹하지 않도록 OkHttp callback thread에서 처리한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`
- Expected: `connected` 이벤트, 단일 `message` 이벤트, 여러 줄 SSE frame 파싱, 잘못된 JSON 무시 또는 failure callback 테스트가 PASS.
### Phase 3: ViewModel 상태와 단위 테스트 추가
- [x] **Task 3.1: ViewModel 초기 진입 흐름 구현**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
- 작업:
- `enter(roomId, creatorId)`를 추가한다.
- `roomId > 0`이면 `openRoom`을 호출한다.
- `roomId <= 0 && creatorId > 0`이면 `createOrGetRoom``openRoom`을 호출한다.
- `roomId <= 0 && creatorId <= 0`이면 종료 이벤트를 발행한다.
- OpenRoom 성공 시 header 정보와 메시지 목록, `hasMore`, `nextCursor`를 상태에 반영한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: roomId 진입, creatorId 진입, invalid 진입, OpenRoom 정렬 반영 테스트가 PASS.
- [x] **Task 3.2: pagination 상태 구현**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt`
- 작업:
- `loadOlderMessages()`를 추가한다.
- `hasMore=false`, `isLoadingOlder=true`, `nextCursor=null`이면 요청하지 않는다.
- 성공 응답 메시지는 기존 목록 상단에 prepend하고 `messageId` 기준 중복을 제거한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"`
- Expected: 요청 조건, cursor 전달, prepend, 스크롤 보정용 추가 개수 반환 테스트가 PASS.
- [x] **Task 3.3: 전송/재시도/SSE 반영 상태 구현**
- 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`
- 작업:
- `sendText(text)`는 trim 후 blank면 종료한다.
- 전송 중 같은 local message에 대한 중복 요청을 막는다.
- 전송 직후 `SENDING` local item을 추가한다.
- 성공 시 local item을 서버 메시지로 교체한다.
- 실패 시 local item status를 `FAILED`로 변경한다.
- `retry(localId)`는 실패 item의 text를 다시 전송한다.
- `onRealtimeMessage(message)``messageId` 중복을 제거하고 최신 메시지로 append한다.
- 재연결 후 동기화는 `syncLatestMessagesAfterReconnect()`로 분리해 `GetMessages` 호출과 병합을 수행한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: blank 무시, pending 추가, 성공 교체, 실패 상태, retry 성공, SSE 중복 제거 테스트가 PASS.
### Phase 4: UI 레이아웃과 Adapter 구현
- [x] **Task 4.1: DM 채팅방 layout 생성**
- Files:
- Create: `app/src/main/res/layout/activity_dm_chat_room.xml`
- 작업:
- `activity_chat_room.xml` 구조를 참고해 배경 이미지, dim, header, `rv_messages`, `input_container`를 구성한다.
- header에는 `iv_back`, `iv_profile`, `tv_name`만 둔다.
- `rv_messages` top constraint는 `header_container` 하단으로 연결한다.
- `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 추가하지 않는다.
- 검증:
- XML에서 제거 대상 id가 검색되지 않아야 한다.
- Run: `rg "character_type_badge|ll_can_badge|iv_more|notice_container" app/src/main/res/layout/activity_dm_chat_room.xml`
- Expected: 결과 없음.
- [x] **Task 4.2: 메시지 item layout과 Adapter 구현**
- Files:
- Create: `app/src/main/res/layout/item_dm_chat_my_message.xml`
- Create: `app/src/main/res/layout/item_dm_chat_opponent_message.xml`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt`
- 작업:
- 내 메시지는 오른쪽 정렬, 상대 메시지는 왼쪽 정렬로 구성한다.
- 내 실패 메시지에는 재시도 버튼 또는 클릭 가능한 실패 상태 view를 둔다.
- `DiffUtil`과 stable id는 `messageId` 우선, local pending 메시지는 `localId` 기준으로 처리한다.
- 상대 프로필 이미지는 기존 placeholder 정책에 맞춰 `ic_placeholder_profile`을 사용한다.
- 검증:
- 긴 텍스트가 item 너비 안에서 줄바꿈되는지 XML `maxWidth` 또는 constraint를 확인한다.
### Phase 5: Activity 구현과 화면 연결
- [x] **Task 5.1: DmChatRoomActivity 생성**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
- 작업:
- `newIntentByRoomId(context, roomId)``newIntentByCreatorId(context, creatorId)`를 제공한다.
- ViewBinding으로 `activity_dm_chat_room.xml`을 연결한다.
- header, RecyclerView, input, IME send, send button enable/disable을 설정한다.
- 상단 도달 시 `viewModel.loadOlderMessages()`를 호출한다.
- prepend 후 기존 스크롤 위치를 유지한다.
- 사용자가 하단 근처에 있을 때만 새 메시지 수신 후 하단으로 스크롤한다.
- 검증:
- `ChatRoomActivity`의 쿼터/광고/더보기/notice 관련 로직을 가져오지 않는다.
- [x] **Task 5.2: SSE lifecycle과 disconnect 연결**
- 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`
- 작업:
- `onStart`에서 OpenRoom 완료 상태이면 `viewModel.connectRealtime()`를 호출한다.
- `onStop`에서 `viewModel.disconnectRealtime()`를 호출한다.
- disconnect 요청 중복을 막는 `isDisconnecting` 상태를 둔다.
- disconnect 실패는 화면 종료를 막지 않고 toast를 과하게 노출하지 않는다.
- 검증:
- ViewModel 테스트에서 disconnect 중복 방지와 cancel 호출 여부를 검증한다.
- [x] **Task 5.3: 채팅 탭 DM item 클릭 연결**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- 작업:
- `ChatRoomType.AI`는 기존 `ChatRoomActivity`로 이동한다.
- `ChatRoomType.DM``DmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)`로 이동한다.
- 검증:
- AI item 클릭 동작은 기존과 동일하게 유지한다.
- [x] **Task 5.4: Phase 5 리뷰 관찰 항목 정리**
- 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`
- 작업:
- `bindContent`의 모든 Content emit마다 `connectRealtime()`를 호출하는 흐름을 점검한다.
- 기능 변경이 과도하지 않으면 OpenRoom 완료 후 연결 가능 상태 진입 시점에만 realtime connect를 트리거하는 별도 신호로 분리한다.
- SSE 실패 후 자동 재연결은 PRD 범위에 포함되므로, `onFailure` 이후 foreground/활성 채팅방 상태일 때 서버 `reconnectTime=3000`ms 기준으로 재연결을 예약한다.
- 재연결 성공 후 `Last-Event-ID` 기반 replay는 기대하지 않고 `GetMessages`로 누락 가능 메시지를 보정한다.
- 화면 이탈 또는 background 전환 시 예약된 재연결을 취소해 종료 후 재연결이 발생하지 않도록 한다.
- `disconnectRealtime()` 진행 중 빠른 `onStart` 재진입 시 crash 위험이 없음을 guard 조합과 테스트로 확인한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: realtime connect 호출 의도가 테스트 또는 source test로 확인되고, SSE 실패 후 3초 지연 재연결, 재연결 후 최신 메시지 동기화, 화면 이탈 시 예약 재연결 취소, disconnect 중 재진입이 crash로 이어지지 않는 정책이 확인된다.
- [x] **Task 5.5: 자동 재연결 실행 스레드 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`
- 작업:
- `reconnectScheduler.scheduleDirect { connectRealtime(token) }`처럼 io scheduler에서 직접 `connectRealtime()`를 실행하는 흐름을 제거한다.
- 지연 예약은 기존 scheduler를 사용하되, 실제 `connectRealtime(token)` 호출과 realtime mutable flag 변경은 main thread에서 수행되도록 `scheduleRealtimeCallback { connectRealtime(token) }` 또는 main scheduler 관찰로 옮긴다.
- `isRealtimeConnected`, `shouldReconnectRealtime`, `reconnectDisposable`, `currentRealtimeToken` 변경 스레드가 main thread 기준으로 일관되는지 확인한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: SSE failure 후 예약된 재연결이 main thread에서 `connectRealtime()`를 실행하고, background/io thread에서 realtime mutable flag를 직접 변경하지 않는다.
- [x] **Task 5.6: disconnect와 예약 재연결 경합 방지**
- 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`
- 작업:
- 예약된 재연결 람다가 실행을 시작한 직후 `disconnectRealtime()`가 호출되는 경우를 점검한다.
- `connectRealtime()` 진입부에서 `shouldReconnectRealtime` 또는 foreground/활성 채팅방 상태를 재확인해 disconnect 이후 재연결이 살아남지 않도록 한다.
- `disconnectRealtime()`의 예약 취소와 local realtime 정리 순서가 기존 중복 disconnect API guard와 충돌하지 않는지 확인한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: 예약 재연결 실행 직전/직후 disconnect가 호출되어도 새 SSE 연결이 남지 않고, disconnect API 중복 방지 동작은 유지된다.
- [x] **Task 5.7: SSE 재연결 backoff 또는 시도 제한 검토**
- 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`
- Modify: `docs/20260610_DM_채팅화면/plan-task.md`
- 작업:
- foreground 한정 3초 무한 재시도가 PRD의 서버 `reconnectTime=3000`ms 기준과 충돌하지 않는지 검토한다.
- 지속 실패 상황의 네트워크 부담을 줄이기 위해 지수 backoff 또는 최대 시도 횟수 제한 중 최소 변경안을 선택한다.
- backoff/시도 제한을 적용하는 경우, 재연결 성공 또는 수동 재진입 시 재시도 상태가 초기화되도록 한다.
- PRD 범위와 충돌하거나 정책 결정이 필요하면 구현하지 않고 결정 필요 사항을 문서에 남긴다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: 선택한 재연결 정책이 테스트로 고정되고, PRD의 3초 기본 간격 및 foreground 한정 조건을 깨지 않는다.
- [x] **Task 5.8: roomOpenedEventLiveData 스티키 재전달 방지**
- 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`
- 작업:
- `roomOpenedEventLiveData`가 일반 `MutableLiveData<Boolean>`로 마지막 `true`를 재구독자에게 재전달하는지 확인한다.
- 단발성 이벤트에는 기존 프로젝트 패턴에 맞는 SingleLiveEvent, Event wrapper, consume flag 중 최소 변경 방식을 적용한다.
- 화면 회전 또는 observer 재등록 시 `connectRealtimeIfStarted()`가 이벤트 재전달만으로 다시 호출되지 않도록 한다.
- 기존 `connectRealtime()` idempotent guard는 유지하되, 단발성 이벤트 자체의 의미를 명확히 한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: OpenRoom 완료 이벤트는 한 번만 소비되고, observer 재등록만으로 realtime connect 트리거가 반복되지 않는다.
### Phase 6: DI, Manifest, 문서 갱신
- [x] **Task 6.1: Koin DI 등록**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 작업:
- `DmChatApi` API builder 등록을 추가한다.
- `DmChatEventClient`는 기존 `OkHttpClient`, `Gson`, `BuildConfig.BASE_URL` 기반으로 생성되도록 등록한다.
- `DmChatRepository`, `DmChatRoomViewModel` 등록을 추가한다.
- 검증:
- import 추가 외 기존 DI 등록 순서를 불필요하게 재정렬하지 않는다.
- [x] **Task 6.2: Manifest 등록**
- Files:
- Modify: `app/src/main/AndroidManifest.xml`
- 작업:
- `.v2.main.chat.dm.DmChatRoomActivity`를 application 하위 activity 목록에 추가한다.
- 키보드 UX는 기존 채팅방과 유사하게 `android:windowSoftInputMode="stateAlwaysHidden|adjustResize"`를 우선 적용한다.
- 검증:
- activity는 exported를 명시하지 않는 기존 내부 Activity 패턴을 따른다.
- [x] **Task 6.3: 테스트 실행 가이드 갱신**
- Files:
- Modify: `docs/agent-guides/build-test-style.md`
- 작업:
- DM 채팅 테스트 단일 실행 예시를 추가한다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`
- 검증:
- 문서 변경은 신규 테스트 명령 예시 추가로만 제한한다.
- [x] **Task 6.4: SSE 전용 read timeout 제거**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventClientTest.kt`
- 작업:
- Phase 6 DI 등록 시 `DmChatEventClient`에 주입되는 `OkHttpClient` 인스턴스를 확인한다.
- 공유 `OkHttpClient`의 일반 `readTimeout`이 idle SSE stream을 조기 종료하지 않도록 SSE 전용 client를 `okHttpClient.newBuilder().readTimeout(0, TimeUnit.MILLISECONDS).build()`로 생성한다.
- REST API용 공유 client timeout 정책은 변경하지 않는다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`
- Expected: SSE 전용 client의 read timeout이 0으로 설정되고, 기존 SSE parsing/cancel/failure 동작은 유지된다.
- [x] **Task 6.5: realtime callback scheduling Disposable 누적 방지**
- 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`
- 작업:
- `scheduleRealtimeCallback()`이 SSE message마다 완료된 `Disposable``CompositeDisposable`에 계속 누적하는 패턴을 제거한다.
- 권장 우선순위는 `Handler(Looper.getMainLooper()).post { }`로 main thread에 전달하는 방식이다.
- Rx scheduler를 유지해야 한다면 완료 후 자기 자신을 `CompositeDisposable`에서 제거하는 방식으로 누적을 방지한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: SSE callback은 main thread에서 처리되고, 메시지 수신 횟수만큼 완료된 `Disposable`이 누적되지 않는다.
- [x] **Task 6.6: ViewModel onCleared 정리 보장**
- 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`
- 작업:
- `DmChatRoomViewModel``onCleared()` 오버라이드를 추가한다.
- ViewModel 소멸 후 `mainHandler.post { action() }`로 예약된 realtime callback이 실행되지 않도록 `mainHandler.removeCallbacksAndMessages(null)`를 호출한다.
- `compositeDisposable.clear()` 또는 `dispose()`, `reconnectDisposable.dispose()`, `realtimeClient.cancel()` 정리가 `onCleared()`에서도 보장되는지 확인하고 누락된 정리를 추가한다.
- Activity `onStop`/`onDestroy` 경로에만 의존하지 않고 ViewModel lifecycle 종료만으로 realtime 연결과 예약 작업이 정리되도록 한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`
- Expected: `onCleared()` 이후 예약된 main callback이 상태를 갱신하지 않고, composite/reconnect disposable 및 realtime client cancel 정리가 호출된다.
- [x] **Task 6.7: DmChatEventClientTest reflection 의존 제거 검토**
- Files:
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventClientTest.kt`
- 작업:
- `getDeclaredField("okHttpClient")`로 내부 필드를 읽는 검증을 동작 기반 검증으로 대체할 수 있는지 확인한다.
- 최소 변경으로 가능하면 `MockWebServer` 또는 실제 request/stream 동작을 통해 SSE 전용 read timeout 정책과 기존 parsing/cancel/failure 동작을 검증한다.
- reflection 제거가 과도한 테스트 구조 변경을 요구하면 현재 검증은 유지하되, 필드명 변경에 취약한 낮은 우선순위 테스트 부채로 문서에 남긴다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`
- Expected: 내부 필드명에 직접 의존하지 않거나, 유지 사유가 명확히 기록되고 기존 SSE 전용 client 동작 검증은 유지된다.
### Phase 7: 최종 검증과 기록
- [x] **Task 7.1: 단위 테스트 실행**
- 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.*"`
- Expected:
- 신규 DM 채팅 단위 테스트가 모두 PASS.
- [x] **Task 7.2: 앱 빌드 확인**
- Files:
- Check: Gradle project
- Run:
- `./gradlew :app:assembleDebug`
- Expected:
- Debug APK 빌드 PASS.
- [x] **Task 7.3: 린트/스타일 확인**
- Files:
- Check: Kotlin/XML 변경 파일
- Run:
- `./gradlew :app:ktlintCheck`
- Expected:
- ktlint PASS.
- [ ] **Task 7.4: 수동 확인**
- Files:
- Check: `DmChatRoomActivity`
- 확인 항목:
- DM item 클릭 시 DM 채팅방 화면으로 이동한다.
- header에 뒤로가기, 상대 프로필, 상대 닉네임만 표시된다.
- 메시지 목록은 header 바로 아래에서 시작한다.
- blank 입력은 전송되지 않는다.
- 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
- Phase 9~13 WebSocket 전환 후 화면 이탈 또는 앱 background 전환 시 `LEAVE_ROOM` 전송 후 socket close가 호출된다.
- Phase 9~13 WebSocket 전환 후 WebSocket 연결 실패가 앱 crash로 이어지지 않는다.
### Phase 8: 크리에이터 채널 DM 진입 crash 수정
- [x] **Task 8.1: creatorId 기반 진입 thread crash 재현 테스트 추가**
- Files:
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
- 작업:
- `DmChatRoomActivity.newIntentByCreatorId()`로 들어오는 흐름에 대응해 `enter(roomId = 0L, creatorId > 0L)` 테스트를 보강한다.
- `CreateOrGetRoom` 이후 `OpenRoom` 결과가 background scheduler에서 전달되어도 `LiveData` 상태 갱신이 main thread에서 처리되어야 함을 고정한다.
- 기존 roomId 기반 진입 테스트는 유지하고, creatorId 기반 진입만의 Rx chain thread 전환 문제를 분리해 검증한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
- Expected: 수정 전에는 background thread `MutableLiveData.setValue()` 예외 또는 main thread 보장 assertion으로 RED를 확인하고, 수정 후 PASS한다.
- 검증 기록:
- 2026-06-17: `DmChatRoomViewModelTest``creatorId 진입은 openRoom 결과 처리 전에 main thread로 다시 전환한다` 테스트를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 결과 해당 테스트가 `DmChatRoomViewModelTest.kt:126` assertion failure로 RED가 되었고, 현재 `createRoomAndOpen()`에는 `flatMap` 이후 `OpenRoom` 결과 처리 전 main thread 재전환이 없음을 확인했다.
- [x] **Task 8.2: createRoomAndOpen main thread 전환 보장**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- 작업:
- `createRoomAndOpen()``CreateOrGetRoom``OpenRoom` 연속 호출 흐름에서 최종 결과 처리 전에 main thread 전환이 보장되는지 점검한다.
- `handleOpenRoomResult()`, `handleError()`, `_roomOpenedEventLiveData` 갱신, `emitContent()` 호출이 main thread에서 실행되도록 최소 변경을 적용한다.
- `openRoom(roomId)` 단독 진입, pagination, send/retry, SSE callback scheduling, reconnect/disconnect 정리 정책은 변경하지 않는다.
- `postValue()`로 증상을 숨기기보다 기존 ViewModel의 `setValue` 기반 동기 상태 갱신 의미를 유지할 수 있는 scheduler 위치를 우선 검토한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
- Expected: creatorId 기반 진입에서 `Cannot invoke setValue on a background thread` 예외 없이 Content 상태와 room opened event가 발행된다.
- 검증 기록:
- 2026-06-17: `DmChatRoomViewModel.createRoomAndOpen()``flatMap` 뒤에 `observeOn(AndroidSchedulers.mainThread())`를 추가해 `OpenRoom` 결과 처리 전 main thread 전환을 보장했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`을 실행해 Task 8.1에서 RED였던 테스트를 포함한 `DmChatRoomViewModelTest` 전체 PASS를 확인했다.
- [x] **Task 8.3: DM 채팅 회귀 테스트와 빌드 확인**
- Files:
- Check: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt`
- Check: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- 작업:
- DM 채팅 ViewModel 변경이 mapper, repository, SSE parser/client, pagination 상태 테스트를 깨지 않는지 확인한다.
- 가능하면 debug 빌드와 ktlint를 순차 실행해 Gradle cache 경합을 피한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`
- Run: `./gradlew :app:compileDebugKotlin --max-workers=1`
- Run: `./gradlew :app:ktlintCheck --max-workers=1`
- Expected: 모두 PASS. 기존 `.editorconfig` `disabled_rules` deprecation warning은 실패로 보지 않는다.
- 검증 기록:
- 2026-06-17: `./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`를 순차 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- [x] **Task 8.4: 수동 확인 항목 갱신**
- Files:
- Check: `DmChatRoomActivity`
- Check: `CreatorChannelActivity`
- 확인 항목:
- 크리에이터 채널에서 `DM 보내기`를 터치하면 `DmChatRoomActivity`로 이동한다.
- `creatorId` 기반 진입 후 방 생성/조회와 OpenRoom 결과 반영 중 앱이 crash 되지 않는다.
- header 상대 정보와 초기 메시지 목록이 표시된다.
- 채팅 탭의 기존 `roomId` 기반 DM 진입은 기존처럼 동작한다.
### Phase 9: WebSocket 계약/클라이언트 기반 추가
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatSocketParserTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` 실행 시 `DmChatSocketEvent`/`DmChatSocketParser` 미정의 컴파일 오류로 RED를 확인했다. 이후 `DmChatSocketModels.kt``type`/`payload` envelope, `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` client type, `JOINED`/`MESSAGE`/`SEND_ACK`/`ERROR`/`PONG` parser 모델을 추가했다. 재실행 결과 `DmChatSocketParserTest` PASS를 확인했고, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-18: 리뷰에서 `ERROR``code`/`message` nullable 보존 테스트가 부족하다는 지적을 받아 `code=null`, `message=null` 케이스를 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` 실행 결과 `ERROR type은 nullable code와 message를 보존한다` 테스트가 RED로 실패함을 확인했고, `DmChatSocketErrorPayload``DmChatSocketEvent.Error``code`/`message`를 nullable로 변경한 뒤 같은 테스트가 PASS했다. 이후 `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1` 모두 PASS를 확인했다.
- 2026-06-18: 코드 리뷰로 `DmChatSocketModels.kt``DmChatSocketParserTest.kt`를 재검토했다. `type`/`payload` envelope, `JOINED`/`MESSAGE`/`SEND_ACK`/`ERROR`/`PONG` 파싱, nullable `ERROR` 필드 보존, 알 수 없는 type/잘못된 JSON 무시 동작이 Task 9.1 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatSocketClientTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1` 실행 시 `DmChatSocketClient` 미정의 컴파일 오류로 RED를 확인했다. 이후 `DmChatSocketClient.kt`를 추가해 `https`/`http` base URL의 WebSocket scheme 변환, `Authorization: Bearer ...` handshake header, 명시적 `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, parser 기반 수신 event callback, 알 수 없는 type/잘못된 JSON 무시, close 정리를 구현했다. 구현 직후 URL scheme 검증 2건이 RED로 남아 OkHttp `Request.url` 정규화 특성을 반영해 원본 WebSocket URL을 request tag로 보존하도록 조정했고, 재실행 결과 `DmChatSocketClientTest` PASS를 확인했다. `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-18: 코드 리뷰로 `DmChatSocketClient.kt``DmChatSocketClientTest.kt`를 재검토했다. WebSocket endpoint scheme 변환, bearer header, 명시적 `JOIN_ROOM`/`LEAVE_ROOM`/`SEND_TEXT`/`PING` 송신, 현재 socket guard, close 정리, parser event 전달이 Task 9.2 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --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` PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatRepository``AppDI``DmChatSocketClient` 기준으로 전환하고 REST `createOrGetRoom`/`openRoom`/`getMessages` 경로는 유지됨을 확인했다. 대상 실행으로 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1` 및 style 정리 후 `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에는 기존 `.editorconfig``disabled_rules` deprecation warning만 남았다.
- 2026-06-18: Phase 9 코드 리뷰로 `DmChatRepository`, `AppDI`, `DmChatRoomViewModel`, `DmChatRoomActivity`의 WebSocket 전환 diff를 재검토했다. `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`는 PASS했다. 다만 `DmChatRoomViewModel``JOINED` 수신 전 `isRealtimeConnected=true`로 처리하고, 전송 제어가 아직 단일 `isSending` 기준이라 Phase 9 이후 정책인 `requestId` 단위 독립 pending과 완전히 맞지 않는 보완 필요 사항을 발견했다. 해당 보완은 Phase 10의 ViewModel WebSocket 세션/전송 전환 범위에서 처리한다.
- [x] **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 코드의 신규 경로에서 결과 없음.
- 검증 기록:
- 2026-06-18: `messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\(` 검색 결과 출력이 없음을 확인했다. 대상 실행으로 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1` 및 style 정리 후 `./gradlew :app:ktlintCheck --max-workers=1`도 PASS했다. `ktlintCheck`에는 기존 `.editorconfig``disabled_rules` deprecation warning만 남았다.
- 2026-06-18: Phase 9 재검증으로 `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` 결과 없음, `git diff --check` PASS를 확인했다. 최초 Gradle 실행은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 실패했으나, 승인된 Gradle 실행에서 `DmChatRepositoryTest`, `DmChatSocketClientTest`, `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `compileDebugKotlin`, `ktlintCheck`가 PASS했다.
### Phase 10: ViewModel WebSocket 세션/수신/전송 전환
- [x] **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` 수신 시점에만 실시간 수신 가능 상태로 판단한다.
- Phase 9 코드 리뷰 반영: socket 연결 시도 직후 `isRealtimeConnected=true`로 확정하지 않고, 연결 시도 상태와 `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 미확정, `JOINED` 후 connected 상태, 중복 connect 방지 테스트가 PASS.
- 검증 기록:
- 2026-06-18: `DmChatRoomViewModelTest``JOINED``isRealtimeConnected=false`, `JOINED``true`, 중복 `connectRealtime()` 시 socket connect와 `JOIN_ROOM`이 1회만 수행되는 검증을 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 결과 `DmChatRoomViewModelTest.kt:326` assertion failure로 RED를 확인했다. 이후 `DmChatRoomViewModel``isRealtimeJoining`/`currentRealtimeRoomId`를 추가하고 `JOINED` 수신 시점에만 connected로 전환하도록 변경했다. 재실행 결과 같은 ViewModel 테스트가 PASS했고, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check`도 PASS했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다.
- [x] **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.
- 검증 기록:
- 2026-06-18: 기존 `socketFactory.emitMessage()` 기반 WebSocket `MESSAGE` callback이 `handleSocketEvent()`에서 `onRealtimeMessage()`로 연결되고, `DmChatMessageResponse.toUiItem()``mergeByMessageId()`로 UI 목록에 반영되는 흐름을 확인했다. `DmChatRoomViewModelTest``realtime message callback은 SSE 메시지를 화면 상태에 병합한다`, `SSE 메시지는 messageId 중복을 제거하고 최신 메시지를 추가한다` 테스트를 유지해 `MESSAGE` append와 `messageId` 중복 제거 회귀를 고정했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` PASS를 확인했다.
- [x] **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에 보존한다.
- Phase 9 코드 리뷰 반영: 단일 `isSending` 전역 제한을 제거하고, 서로 다른 텍스트 메시지는 각각의 `requestId`로 독립 pending 상태를 가질 수 있게 한다.
- pending 상태는 `requestId` 기준 map 또는 동등하게 검증 가능한 구조로 관리한다.
- 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 추가, 서로 다른 텍스트의 독립 pending, `SEND_TEXT` 송신, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ack 무시 테스트가 PASS.
- 검증 기록:
- 2026-06-18: `DmChatRoomViewModelTest`에 requestId 기반 독립 pending, `SEND_ACK` requestId 매칭, `ERROR` 실패, timeout 실패, retry 새 requestId, 중복 ACK 무시 테스트를 먼저 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 시 `DmChatMessageUiItem.requestId` 미정의 컴파일 오류로 RED를 확인했고, timeout 테스트는 구현 전 `DmChatRoomViewModelTest.kt:270` assertion failure로 RED를 확인했다.
- 2026-06-18: `DmChatMessageUiItem``requestId`를 추가하고, `DmChatRoomViewModel`의 단일 `isSending` 제한을 제거해 각 텍스트 전송이 새 `requestId`와 pending map으로 관리되도록 변경했다. `SEND_TEXT`는 WebSocket으로 전송하고, `SEND_ACK`/`ERROR`/send false/10초 timeout은 해당 requestId의 local item만 확정 또는 실패 처리한다. retry는 기존 `localId` item을 유지하면서 새 `requestId`를 발급하도록 변경했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatSocketParserTest``MESSAGE` payload의 nullable `requestId` 보존 테스트를 추가하고, `DmChatRoomViewModelTest``MESSAGE(requestId)` 선도착 시 pending local item 확정, `MESSAGE``requestId`가 없는 경우 후도착 `SEND_ACK``messageId` 중복 방지, timeout 실패 후 늦은 `SEND_ACK`의 같은 local item 복구 테스트를 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1` 및 ViewModel 대상 테스트 실행에서 `DmChatSocketParserTest.kt:61``DmChatSocketEvent.Message.requestId` 미정의 컴파일 오류로 RED를 확인했다. 이후 `DmChatSocketEvent.Message`와 parser가 nullable `requestId`를 보존하도록 변경하고, ViewModel이 `MESSAGE(requestId)``SEND_ACK`와 동일한 pending 확정 경로로 처리하며 timeout 후 실패 requestId도 늦은 ACK로 복구할 수 있도록 변경했다. 재실행 결과 `DmChatSocketParserTest``DmChatRoomViewModelTest`가 PASS했다. 병렬 Gradle 실행 중 1회 `kspDebugUnitTestKotlin``StreamCorruptedException`이 발생했으나, 같은 ViewModel 테스트 단독 재실행에서 PASS를 확인했다.
- 2026-06-18: 리뷰에서 timeout된 `request-1`이 실패 기록에 남은 상태로 같은 local item을 `request-2`로 retry하고 `request-2` ACK 성공 후 늦은 `request-1` ACK가 도착하면 최신 성공 메시지를 오래된 ACK로 덮을 수 있다는 blocker를 확인했다. `DmChatRoomViewModelTest``retry 성공 후 이전 timeout request ACK는 같은 local item을 덮어쓰지 않는다` 테스트를 추가했고, 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행 결과 `DmChatRoomViewModelTest.kt:466` assertion failure로 RED를 확인했다. 이후 retry 시작 및 ACK 성공 확정 시 같은 `localId`에 묶인 stale failed request 기록을 제거하도록 변경했고, 같은 ViewModel 테스트 재실행 결과 PASS를 확인했다.
- [x] **Task 10.5: Phase 9 코드 리뷰 결과 회귀 테스트 반영**
- Files:
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt`
- Modify: `docs/20260610_DM_채팅화면/plan-task.md`
- 작업:
- Task 10.1 검증에 `JOINED` 전 connected 미확정 테스트를 추가했는지 확인한다.
- Task 10.3 검증에 서로 다른 텍스트 메시지의 독립 pending 테스트를 추가했는지 확인한다.
- Phase 9 코드 리뷰에서 지적된 부정확한 leave 중복 테스트는 실제 연결 없는 `closeCount == 0` 검증으로 유지하지 않는다.
- `LEAVE_ROOM + close`의 상세 lifecycle 동작 검증은 기존 범위대로 Task 11.1에서 수행하되, Phase 10 완료 시 해당 테스트 gap이 남아 있음을 문서에 명확히 남긴다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`
- Expected: Phase 9 리뷰 3개 항목 중 ViewModel 세션/전송 항목은 Phase 10 테스트로 고정되고, leave/close 항목은 부정확한 테스트 제거 또는 Task 11.1 이관 기록으로 남는다.
- 검증 기록:
- 2026-06-18: Phase 9 리뷰 항목 중 `JOINED` 전 connected 미확정은 Task 10.1의 `roomId가 있으면 realtime 연결 후 connected callback에서 최신 메시지를 동기화한다` 테스트에서 `JOINED``isRealtimeConnected=false`, `JOINED``true`로 고정되어 있음을 확인했다. requestId 단위 독립 pending은 Task 10.3의 `서로 다른 텍스트는 각각 requestId로 독립 pending 전송한다` 테스트로 고정되어 있음을 확인했다. `LEAVE_ROOM + close` lifecycle 상세 검증은 Task 11.1 범위로 남기고, 실제 연결 없이 `closeCount == 0`만 확인하던 `realtime leave 중 중복 요청은 close를 반복할 수 있다` 테스트는 부정확한 회귀 테스트로 제거했다. Task 10.4까지 포함한 `DmChatRoomViewModelTest`는 이후 재실행해 PASS를 확인한다.
- 2026-06-18: Phase 10 전체 단위 코드 리뷰로 `DmChatRoomViewModel`, `DmChatSocketModels`, `DmChatRoomViewModelTest`, `DmChatSocketParserTest`의 diff를 재검토했다. `MESSAGE(requestId)` 선도착 시 pending local item 확정, requestId 없는 `MESSAGE`와 후도착 `SEND_ACK``messageId` 중복 제거, timeout 후 늦은 `SEND_ACK` 복구, retry 성공 후 이전 timeout request ACK 무시, Phase 9 리뷰 회귀 테스트 반영 상태를 확인했고 blocking issue는 발견하지 못했다. fresh 검증으로 `./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.DmChatSocketParserTest" --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 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.
### Phase 11: Lifecycle, reconnect, heartbeat, token 갱신 처리
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatRoomViewModelTest``leave는 LEAVE_ROOM 전송 후 socket을 close하고 중복 호출은 무시한다` 테스트를 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 실행에서 중복 leave/close 및 이후 heartbeat/token 관련 RED 실패를 확인했다. 이후 `leaveRealtime()`에 활성 socket guard와 heartbeat/reconnect 정리를 추가해 `LEAVE_ROOM` 전송 후 socket close가 1회만 수행되도록 했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: 기존 WebSocket failure 후 3초 재연결, 재연결 후 `JOIN_ROOM`, `JOINED` 후 최신 메시지 동기화, leave 이후 reconnect 취소 테스트를 유지하면서 reconnect 예약 실행 시 최신 token을 다시 읽도록 보강했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1` 재실행 결과 PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatRoomViewModelTest``JOINED 이후 heartbeat는 PING을 보내고 PONG 수신 시 연결을 유지한다`, `heartbeat PONG timeout은 socket close 후 foreground 조건에서 reconnect를 예약한다`, `leave는 heartbeat timeout과 reconnect 예약을 취소한다` 테스트를 추가했다. RED 단계에서 `PING` 미전송과 timeout 미처리 assertion 실패를 확인했고, `JOINED` 이후 30초 주기 `PING`, 10초 `PONG` timeout, timeout 시 socket close 후 foreground 조건 재연결 예약, leave/onCleared heartbeat 취소를 구현했다. 서버 권장값이 없어 heartbeat 주기 30초, timeout 10초를 상수로 분리했다.
- 2026-06-18: 리뷰 게이트에서 `PONG` 수신 시 timeout을 재예약하면 다음 `PING` 전에 정상 연결도 닫힐 수 있다는 blocker가 발견됐다. `JOINED 이후 heartbeat는 PING을 보내고 PONG 수신 시 연결을 유지한다` 테스트를 `PONG` 수신 후 10초 초과, 다음 30초 `PING` 전까지 연결 유지 검증으로 강화했고, 수정 전 해당 단일 테스트가 RED로 실패함을 확인했다. 이후 `PONG` 수신 시 `heartbeatTimeoutDisposable`을 해제만 하도록 변경했고 같은 테스트가 GREEN으로 전환됨을 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatRoomViewModelTest`에 mutable token provider 기반 `token이 변경되면 기존 socket을 close하고 새 token으로 다시 JOIN_ROOM을 보낸다`, `leave 이후 token이 변경되어도 socket reconnect를 진행하지 않는다` 테스트를 추가했다. RED 단계에서 기존 connected guard가 token 변경을 무시하는 assertion 실패를 확인했고, `connectRealtime()`에서 같은 room의 token 변경을 connected/joining 조기 return보다 먼저 감지해 기존 socket close 후 새 token으로 WebSocket handshake와 `JOIN_ROOM`을 수행하도록 수정했다. 화면 종료 후에는 `leaveRealtime()` guard로 reconnect가 진행되지 않음을 확인했다.
- 2026-06-18: Phase 11 최종 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --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를 확인했다. blocker 수정 후에도 `./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를 재확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다.
- 2026-06-18: Phase 11 코드 리뷰 및 재검증을 수행했다. `DmChatRoomViewModel``LEAVE_ROOM` 후 close, 중복 leave 방지, reconnect 예약 시 최신 token 사용, `JOINED` 이후 heartbeat 시작, `PONG` 수신 시 timeout 해제, heartbeat timeout 후 close/reconnect, token 변경 시 기존 socket close 후 새 token으로 `JOIN_ROOM` 재전송 흐름을 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --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 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.
### Phase 12: 푸시 진입과 제거 endpoint 회귀 검증
> 2026-06-19 추가 계약 변경: DM 채팅 FCM payload는 `chat_type`과 `room_id`를 보내지 않고, `${URISCHEME}://chat/{roomId}` 형식의 `deep_link`만 전달한다. Task 12.1~12.2는 이전 payload 계약 기준 완료 이력으로 보존하고, 새 계약 반영은 Task 12.5에서 진행한다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `SodaFirebaseMessagingServiceSourceTest`를 추가해 `chat_type`, `room_id`, `message_id`, `deep_link_value``Constants.EXTRA_DATA` bundle에 보존되는지 source test로 고정했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --max-workers=1` 실행 결과 `SodaFirebaseMessagingServiceSourceTest.kt:15` assertion failure로 RED를 확인했다. 이후 `SodaFirebaseMessagingService.sendNotification()`의 non-URL extras bundle에 `messageData["chat_type"]` 복사를 추가했고 같은 테스트 PASS를 확인했다.
- 2026-06-18: Phase 12 코드 리뷰에서 FCM payload에 `deepLink`가 함께 있는 경우 기존 구현이 `deep_link`만 extra bundle에 보존하고 `chat_type`/`room_id`를 누락해 USER_CREATOR DM 라우팅이 일반 딥링크 흐름으로 빠질 수 있음을 확인했다. `SodaFirebaseMessagingServiceSourceTest`에 deepLink payload에서도 `chat_type`/`room_id`를 보존하는 회귀 테스트를 추가했고, 수정 전 `SodaFirebaseMessagingServiceSourceTest.kt:32` assertion failure RED를 확인했다. 이후 deepLink URL 분기에도 `messageData["chat_type"]`, `messageData["room_id"]` 복사를 추가했고 같은 테스트 PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DeepLinkActivitySourceTest``MainV2ActivitySourceTest`를 추가해 `chat_type == "USER_CREATOR"`와 valid `room_id` 조합이 `DmChatRoomActivity.newIntentByRoomId()`로 라우팅되는지 source test로 고정했다. 수정 전 실행 결과 두 테스트가 각각 `DeepLinkActivitySourceTest.kt:13`, `MainV2ActivitySourceTest.kt:13` assertion failure로 RED가 됨을 확인했다. 이후 `DeepLinkActivity``Constants.EXTRA_DATA`에서 `chat_type`을 복사하고 foreground 라우팅에서 USER_CREATOR DM 분기를 기존 live room fallback보다 먼저 처리하도록 변경했다. `MainV2Activity`도 Splash/Login 이후 전달된 bundle에서 같은 USER_CREATOR DM 분기를 기존 channel/content/message routing보다 먼저 처리하도록 변경했고, 동일 테스트 PASS를 확인했다.
- 2026-06-18: 리뷰 게이트에서 LiveRoom foreground 상태일 때 `ACTION_LIVE_ROOM_DEEPLINK_CONFIRM` 브로드캐스트가 USER_CREATOR DM push를 먼저 가로챌 수 있다는 blocker를 확인했다. `DeepLinkActivitySourceTest``routeForegroundDeepLink(deepLinkExtras)`가 LiveRoom foreground confirm broadcast보다 먼저 실행되는 순서 회귀 테스트를 추가했고, 수정 전 `DeepLinkActivitySourceTest.kt:28` assertion failure로 RED를 확인했다. 이후 `DeepLinkActivity.onCreate()`에서 foreground USER_CREATOR extras는 LiveRoom 브로드캐스트 전에 `routeForegroundDeepLink(deepLinkExtras)`를 먼저 시도하도록 변경했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --max-workers=1` PASS를 확인했다.
- 2026-06-18: Phase 12 코드 리뷰에서 URL query에 `chat_type=USER_CREATOR`가 포함된 deepLink 진입은 `DeepLinkActivity.buildDeepLinkExtras()``room_id`는 복사하지만 `chat_type`은 복사하지 않아 DM 분기를 탈 수 없음을 확인했다. `DeepLinkActivitySourceTest`에 URL query `chat_type` 보존 테스트를 추가했고, 수정 전 `DeepLinkActivitySourceTest.kt:28` assertion failure RED를 확인했다. 이후 data query 수집 목록에 `putQuery("chat_type")`을 추가했고 같은 테스트 PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: `DmChatRemovedEndpointSourceTest`를 추가해 active DM 채팅 경로(`DmChatRoomActivity`, `DmChatRoomViewModel`, `DmChatApi`, `DmChatRepository`, `DmChatSocketClient`)에 `/events`, `events/disconnect`, `messages/text`, `text/event-stream`, `EventSource` 문자열이 남지 않는지 source test로 고정했다. 또한 `DmChatRoomViewModel`의 텍스트 전송은 `repository.sendSocketText(`, lifecycle 종료는 `repository.sendLeaveRoom(roomId)``repository.closeSocket()`을 사용하는지 확인했다. `./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` PASS를 확인했다.
- [x] **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.
- 검증 기록:
- 2026-06-18: 현재 DM 채팅 화면에는 음성 전송 UI가 연결되어 있지 않아 신규 multipart API를 추가하지 않았다. `DmChatRemovedEndpointSourceTest``voiceMessageUrl` DTO 필드는 보존하되 `DmChatApi``messages/voice``Multipart`가 없고 `DmChatRepository``sendDmVoiceMessage`가 없음을 확인하는 source test를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1` PASS를 확인했다.
- 2026-06-18: Phase 12 코드 리뷰 및 통합 검증으로 FCM `chat_type` 전달, foreground/LiveRoom foreground/Splash/Login 이후 USER_CREATOR DM 라우팅, 제거 endpoint 회귀 방지, 음성 DTO 보존 범위를 재검토했다. deepLink payload/query의 `chat_type` 누락 blocker 2건은 위 회귀 테스트로 고정 후 수정했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 최초 샌드박스 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다.
- 2026-06-19: Phase 12 최신 코드 리뷰로 FCM deepLink/non-deepLink payload의 `chat_type`/`room_id` 보존, `DeepLinkActivity` foreground USER_CREATOR DM 선분기, URL query `chat_type` 보존, `MainV2Activity` Splash/Login 이후 USER_CREATOR DM 라우팅, 제거 endpoint 문자열 회귀 방지, 음성 DTO 보존 범위를 재확인했다. 추가 blocking issue는 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 최초 샌드박스 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.
- [x] **Task 12.5: FCM deep_link 단독 payload로 DM 채팅방 라우팅 변경**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingServiceSourceTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/main/MainActivitySourceTest.kt`
- Test: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt`
- 작업:
- 서버 FCM payload는 `chat_type``room_id`를 보내지 않고 `deep_link`만 보낸다는 최신 계약을 반영한다.
- DM 채팅방 푸시 deep link 형식은 `${URISCHEME}://chat/{roomId}`로 정의한다.
- `SodaFirebaseMessagingService``messageData["deep_link"]`를 notification intent extras 또는 URL deep link 경로로 보존한다.
- `DeepLinkActivity``chat_type`/`room_id` 없이 deep link path `/chat/{roomId}`에서 `roomId`를 파싱해 `DmChatRoomActivity.newIntentByRoomId(context, roomId)`로 이동한다.
- LiveRoom foreground confirm 이후 legacy `MainActivity`로 전달되는 deep link도 같은 `/chat/{roomId}` 규칙으로 DM 채팅방에 연결한다.
- Splash/Login 이후 `MainV2Activity`로 전달되는 deep link도 같은 `/chat/{roomId}` 규칙으로 DM 채팅방에 연결한다.
- `roomId`가 없거나 Long으로 변환할 수 없으면 DM 채팅방을 열지 않고 기존 fallback 흐름을 따른다.
- `chat_type` 기반 DM 라우팅은 배포 전 폐기된 계약이므로 호환 경로로 남기지 않고 제거한다.
- 검증:
- Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`
- Expected: `deep_link=${URISCHEME}://chat/{roomId}` 단독 payload가 `chat_type`/`room_id` 없이 DM 채팅방으로 라우팅되고, invalid room fallback과 기존 deep link 흐름 유지 테스트가 PASS.
- 검증 기록:
- 2026-06-19: `SodaFirebaseMessagingServiceSourceTest``deep_link` 단독 payload가 `ACTION_VIEW` data와 `deep_link` extra로 보존되는지 확인하는 source test를 추가했다. `DeepLinkActivitySourceTest``MainV2ActivitySourceTest`에는 `${URISCHEME}://chat/{roomId}` path가 `chat_type`/`room_id` 없이 `room_id`, `deep_link_value=chat`, `deep_link_sub5`로 정규화되고 기존 live room fallback보다 먼저 `DmChatRoomActivity.newIntentByRoomId()`로 라우팅되는지 확인하는 source test를 추가했다. 수정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1` 실행 결과 `DeepLinkActivitySourceTest.kt:52`, `MainV2ActivitySourceTest.kt:29` assertion failure로 RED를 확인했다.
- 2026-06-19: `DeepLinkActivity.applyPathDeepLink()``MainV2Activity.applyPathDeepLink()``chat` path 매핑을 추가해 `room_id`, `deep_link_value=chat`, `deep_link_sub5`를 채우도록 변경했다. 기존 `chat_type=USER_CREATOR` 호환 경로는 유지하면서 `deep_link_value=chat`도 DM 채팅방으로 라우팅하도록 `isDmChatDeepLink()`를 추가했다. 이후 같은 source test 명령 재실행 결과 PASS를 확인했다.
- 2026-06-19: Task 12.5 최종 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`, `./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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다. `adb devices` 결과 연결된 Android 기기가 없어 실제 앱에서 푸시 터치 또는 deep link launch 수동 확인은 수행하지 못했다.
- 2026-06-19: 리뷰 게이트에서 LiveRoom foreground 상태의 `DeepLinkActivity``/chat/{roomId}``ACTION_LIVE_ROOM_DEEPLINK_CONFIRM` broadcast로 먼저 넘길 수 있고, 해당 confirm 후 진입하는 legacy `MainActivity`에도 `chat` path DM 라우팅이 누락됐다는 blocker를 확인했다. `DeepLinkActivitySourceTest`와 신규 `MainActivitySourceTest`에 재현 테스트를 추가했고, 수정 전 `DeepLinkActivitySourceTest.kt:49`, `MainActivitySourceTest.kt:17` assertion failure로 RED를 확인했다. 이후 `DeepLinkActivity` foreground 선분기 조건을 `isDmChatDeepLink(deepLinkExtras)`로 변경하고, `MainActivity``chat` path 정규화와 `DmChatRoomActivity.newIntentByRoomId()` DM 라우팅을 추가했다. 재실행 결과 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --max-workers=1`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`, `./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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다.
- 2026-06-19: 코드 품질 재리뷰에서 `deep_link`와 기존 `chat_type=USER_CREATOR`/`room_id` sidecar가 함께 전달되는 호환 payload의 경우 `MainV2Activity``MainActivity`가 deep link URL 파싱 결과를 원본 bundle 대신 사용해 sidecar를 잃을 수 있다는 blocker를 확인했다. `MainV2ActivitySourceTest``MainActivitySourceTest`에 sidecar 보존 테스트를 추가했고, 수정 전 `MainV2ActivitySourceTest.kt:26`, `MainActivitySourceTest.kt:13` assertion failure로 RED를 확인했다. 이후 deep link URL 파싱 결과를 `Bundle(bundle)` 위에 `putAll()`로 merge하고, query `room_id`/`chat_type` 보존 및 `MainActivity``isUserCreatorChat()` 호환 판정을 추가했다. 재실행 결과 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1` PASS를 확인했다.
- 2026-06-19: Task 12.5 코드 리뷰 및 검증으로 `SodaFirebaseMessagingService`, `DeepLinkActivity`, `MainActivity`, `MainV2Activity`의 deep link 수집/정규화/라우팅 분기와 source test를 재검토했다. 리뷰 중 `deep_link`만 있는 data payload에서 `sendNotification()` 호출 조건을 통과하지 못해 `ACTION_VIEW` data와 `deep_link` extra 보존 코드가 실행되지 않을 수 있는 blocker를 확인했다. `SodaFirebaseMessagingServiceSourceTest``hasDeepLink(remoteMessage.data)` dispatch 조건 검증을 추가하고, `SodaFirebaseMessagingService``deepLink`/`deep_link` 존재 시 기존 notification 생성 경로로 진입하는 최소 수정만 적용했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`, `./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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. 최초 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐고, 승인된 `adb devices` 결과 연결 기기가 없어 실제 푸시 터치/deep link launch 수동 확인은 수행하지 못했다.
- 2026-06-19: 계약이 배포 전 `deep_link=${URISCHEME}://chat/{roomId}` 단독 payload로 확정되어 `chat_type` 호환 경로를 제거했다. `SodaFirebaseMessagingService``chat_type` extra 복사, `DeepLinkActivity`/`MainActivity`/`MainV2Activity``chat_type` query 수집과 `isUserCreatorChat()` 판정을 제거하고, DM 라우팅은 `deep_link_value == "chat"`만 사용하도록 정리했다. source test는 active source에 `chat_type`/`isUserCreatorChat`이 남지 않는지 검증하도록 변경했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1`, `./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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. active main 코드 대상 `rg "chat_type|isUserCreatorChat|USER_CREATOR" ...` 결과는 없음이며, test 코드에는 부재 검증 assertion만 남는다. 최초 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning만 출력됐다.
### Phase 13: WebSocket 전환 최종 검증과 수동 확인
- [x] **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.
- 검증 기록:
- 2026-06-19: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **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:
- `deep_link=${URISCHEME}://chat/{roomId}` push routing과 기존 deep link 회귀 테스트가 PASS.
- 검증 기록:
- 2026-06-19: `./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` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- [x] **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 코드에 남지 않는다.
- 검증 기록:
- 2026-06-19: `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check`를 실행해 모두 PASS를 확인했다. Gradle 실행에서는 기존 deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-19: `rg "messages/text|events/disconnect|text/event-stream|EventSource" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm` 결과 없음으로 active DM main 코드에 제거 endpoint 문자열이 남지 않음을 확인했다. 동일 패턴을 DM test 경로까지 넓히면 `DmChatRemovedEndpointSourceTest`의 금지 문자열 assertion과 과거 SSE 이력 테스트인 `DmChatEventClientTest``text/event-stream` fixture만 매칭됨을 확인했다.
- [ ] **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로 전환된다.
- DM push 터치 시 `deep_link=${URISCHEME}://chat/{roomId}` 기준 DM 채팅방에 진입하고 일반 진입과 동일하게 `OpenRoom` 후 WebSocket join을 수행한다.
- 검증 기록:
- 2026-06-19: `DmChatRoomViewModelTest`, `DmChatSocketClientTest`, `DmChatRemovedEndpointSourceTest`, `SodaFirebaseMessagingServiceSourceTest`, `DeepLinkActivitySourceTest`, `MainV2ActivitySourceTest`로 WebSocket join/send/ack/error/timeout/retry/reconnect/heartbeat, 제거 endpoint 미사용, USER_CREATOR push/deep link 라우팅의 자동 검증 PASS를 확인했다. 단, `adb devices` 결과 연결된 Android 기기가 없어 실제 앱 화면과 실제 서버/WebSocket을 통한 수동 확인은 수행하지 못했으므로 Task 13.4는 미완료로 유지한다.
- 2026-06-19: Phase 13 코드 리뷰로 `DmChatRoomActivity`, `DmChatRoomViewModel`, `DmChatSocketClient`, `DmChatSocketModels`, `DmChatRepository`, `DmChatApi`의 WebSocket 연결/해제, `JOINED` 기준 연결 확정, `SEND_TEXT`/`SEND_ACK` pending 처리, timeout/retry/reconnect/heartbeat, 제거 REST endpoint 미사용 경로를 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1`, `./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`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. active DM main 코드의 제거 endpoint 문자열 검색 결과는 없음이며, DM test 경로까지 확장하면 삭제 검증 테스트와 과거 SSE 이력 테스트 fixture만 매칭된다. 최초 Gradle 실행은 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, `adb devices`는 승인된 실행에서도 연결된 기기가 없어 실제 앱/서버 WebSocket 수동 확인은 미완료로 유지한다.
## 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 체크박스 형식, 테스트 명령 작성 방식을 확인했다.
- 2026-06-10: `ChatRoomActivity.kt`, `activity_chat_room.xml`, `ChatRoomApi.kt`, `ChatRoomModels.kt`, `ChatRoomRepository.kt`, `ChatMainFragment.kt`, `AppDI.kt`, `AndroidManifest.xml`을 확인해 기존 채팅 화면 구조, v2 API/Repository 패턴, DI/Manifest 등록 위치, DM item 클릭 연결 지점을 확인했다.
- 2026-06-10: `rg``EventSource`, `text/event-stream`, `ResponseBody`, `OkHttpClient` 사용 현황을 확인했으며 저장소에 기존 SSE 구현 패턴은 없고 OkHttp 의존성은 이미 존재함을 확인했다. 이에 따라 별도 라이브러리 추가 없이 OkHttp streaming 기반 `DmChatEventClient`를 계획에 반영했다.
- 2026-06-10: 이번 단계는 계획 문서 생성만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.
- 2026-06-10: Phase 1 구현 전 `DmChatMapperTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`를 실행해 `DmChatMessageResponse``dm.model` 심볼 부재로 실패하는 RED 상태를 확인했다.
- 2026-06-10: Phase 1 범위로 `DmChatApi.kt`, `DmChatModels.kt`, `DmChatUiModels.kt`, `DmChatMappers.kt`, `DmChatMapperTest.kt`를 추가했다. DTO/API는 PRD 서버 필드를 `@SerializedName`으로 보존하고 `Single<ApiResponse<...>>` 반환 타입을 사용하도록 정의했다.
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-10: `DmChatMappers.kt``DmChatMapperTest.kt`를 보강해 `messageType` 대소문자 무시 `TEXT` 매핑, `TEXT` 외 타입 및 `textMessage == null` 미매핑, `createdAt` 동일 시 `messageId` 오름차순, 중복 `messageId` 선도착 우선 정책을 명시했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatMapperTest"``./gradlew :app:ktlintCheck` PASS를 확인했다.
- 2026-06-10: Phase 2 구현 전 `DmChatEventParserTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`를 실행해 `DmChatEventParser` 심볼 부재로 실패하는 RED 상태를 확인했다.
- 2026-06-10: Phase 2 범위로 `DmChatRepository.kt`, `DmChatEventClient.kt`, `DmChatEventParserTest.kt`, `DmChatRepositoryTest.kt`를 추가했다. Repository는 `bearer(token)` helper를 통해 모든 REST API auth header를 생성하고 API 호출만 얇게 위임하도록 구현했다. SSE 클라이언트는 OkHttp `Call.enqueue()`와 streaming `ResponseBody`를 사용하고, `connected`/`message` event frame parser를 분리했다.
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventParserTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`를 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-10: Phase 2 리뷰에서 SSE stream read 중 `IOException`이 failure callback으로 전달되지 않는 문제가 발견되어, 취소되지 않은 call의 read 실패만 `listener.onFailure(e)`로 전달하도록 `DmChatEventClient`를 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck` PASS를 재확인했다.
- 2026-06-10: SSE 클라이언트 보강으로 비정상 HTTP 응답 failure callback 전달, trailing blank line 없는 마지막 frame dispatch, `data:` 뒤 단일 공백 제거 정책, listener `@Volatile` 가시성 보완을 반영했다. 보강 전 `DmChatEventClientTest`에서 HTTP 500 failure 누락과 마지막 frame 누락 RED를 확인했고, 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS를 확인했다.
- 2026-06-10: Phase 3 구현 전 `DmChatRoomViewModelTest`, `DmChatPaginationStateTest`를 먼저 추가하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`를 실행해 `DmChatRoomViewModel``DmChatRoomUiState` 심볼 부재로 실패하는 RED 상태를 확인했다.
- 2026-06-10: Phase 3 범위로 `DmChatRoomViewModel.kt`, `DmChatUiModels.kt`, `DmChatRoomViewModelTest.kt`, `DmChatPaginationStateTest.kt`를 추가/수정했다. roomId/creatorId 진입, invalid 종료 이벤트, OpenRoom 정렬, pagination guard/prepend/중복 제거, blank 전송 무시, pending 추가, 성공 교체, 실패 상태, retry 성공, SSE 중복 제거, 재연결 후 최신 메시지 동기화를 ViewModel 상태로 구현했다.
- 2026-06-10: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"` PASS를 확인했다.
- 2026-06-10: Phase 3 최종 확인으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-10: Phase 3 리뷰에서 전송 성공 전 SSE echo가 먼저 도착하면 같은 `messageId`가 중복될 수 있는 문제가 발견되어 `DmChatRoomViewModelTest`에 재현 테스트를 추가했다. 보강 전 해당 테스트는 중복 메시지 assertion으로 RED를 확인했고, 전송 성공 local 교체 후 동일 `messageId`를 한 개로 정리하도록 `DmChatRoomViewModel`을 수정했다. 이후 해당 단일 테스트와 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"` PASS를 확인했다.
- 2026-06-10: Phase 3 추가 리뷰 보강으로 retry 중 SSE echo가 먼저 도착해도 성공 교체 후 동일 `messageId`가 한 개만 남는 테스트, 과거 메시지 요청 실패 시 `isLoadingOlder=false`로 복구하고 기존 목록을 유지하는 테스트, 재연결 최신 메시지 동기화 실패 시 기존 메시지를 유지하는 테스트를 추가했다. `isSending` 단일 전송 guard와 pagination/reconnect 실패의 silent 유지 정책은 Phase 3 ViewModel 범위의 의도된 제약으로 문서화했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatPaginationStateTest"` PASS를 확인했다.
- 2026-06-10: Phase 4 범위로 `activity_dm_chat_room.xml`, `item_dm_chat_my_message.xml`, `item_dm_chat_opponent_message.xml`, `DmChatMessageAdapter.kt`를 추가했다. DM 레이아웃은 기존 채팅방의 배경/딤/header/메시지 목록/input 구조를 따르되 header에는 `iv_back`, `iv_profile`, `tv_name`만 두고 `rv_messages``header_container` 하단에 연결했다. 메시지 Adapter는 `DiffUtil`, stable id(`messageId` 우선, `localId` fallback), 내 메시지 실패 재시도 callback, 상대 프로필 `ic_placeholder_profile` 로딩을 구현했다.
- 2026-06-10: Phase 4 검증으로 `rg "character_type_badge|ll_can_badge|iv_more|notice_container" app/src/main/res/layout/activity_dm_chat_room.xml` 결과 없음, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:assembleDebug`, `git diff --check` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다. Phase 4 변경 파일 대상 리뷰어 검토에서도 blocking issue 없음 PASS를 받았다.
- 2026-06-10: Phase 4 리뷰 개선 권장 사항 반영으로 DM 메시지 item XML의 고정 `layout_constraintWidth_max`를 제거해 말풍선 폭 제어를 Adapter 비율 기준으로 통일했다. 내 메시지는 기존 사용자 메시지 관례처럼 65%, 상대 메시지는 기존 AI/상대 메시지 관례와 `guideline_90`에 맞춰 90%를 적용했다. 또한 `DmChatMessageAdapter`의 local/fallback stable id를 64-bit 문자열 해시 기반 음수 namespace로 분리해 서버 `messageId`와의 충돌 가능성을 낮췄다.
- 2026-06-10: Phase 4 리뷰 개선 반영 후 `rg "layout_constraintWidth_max" app/src/main/res/layout/item_dm_chat_my_message.xml app/src/main/res/layout/item_dm_chat_opponent_message.xml` 결과 없음, `./gradlew :app:ktlintCheck`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"` PASS를 확인했다. 최초 병렬 Gradle 실행에서는 `kspCaches/debug` 증분 캐시 동시 접근으로 `:app:kspDebugKotlin`이 실패했으나, 동일 명령을 순차 재실행해 PASS를 확인했다.
- 2026-06-10: Phase 4 재리뷰 후속 보강으로 남은 개선 권장 사항을 반영했다. (4) 재시도 아이콘을 시스템 리소스 `@android:drawable/ic_popup_sync`에서 프로젝트 전용 vector `ic_dm_retry`로 교체했다. (5) 내/상대 말풍선 폭 기준 불일치(65% vs 90%)를 단일 상수 `MESSAGE_MAX_WIDTH_RATIO=0.68f`로 통일하고, `item_dm_chat_opponent_message.xml``guideline_90` 의존을 제거해 폭 제어를 Adapter 비율 단일 소스로 일원화했다. 권장 1(폭 이중 제어)·2(stableId namespace)는 직전 보강에서 이미 반영된 상태를 확인했다.
- 2026-06-10: 위 보강 검증으로 `./gradlew :app:ktlintCheck`(ktlintMainSourceSetCheck) PASS를 확인했다. 단, 본 작업 환경에는 JDK 17이 없고 Android Studio JBR 21만 존재해 `jvmToolchain(17)`을 요구하는 `:app:assembleDebug`/`testDebugUnitTest`는 toolchain 미탐지로 실행하지 못했다. 변경은 XML/소량 Kotlin 수정으로 surgical하며 JDK 17 환경에서 빌드/테스트 재확인이 필요하다.
- 2026-06-10: Phase 5 구현 전 `DmChatRoomActivitySourceTest`, `DmChatRoomViewModelTest`, `ChatMainFragmentLayoutTest`를 추가/수정하고 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`를 실행해 `DmChatRealtimeClient`, `connectRealtime()`, `disconnectRealtime()`, `DmChatRoomActivity`/DM routing 부재로 RED 상태를 확인했다.
- 2026-06-10: Phase 5 범위로 `DmChatRoomActivity.kt`를 추가하고 Activity intent helper, ViewBinding, header, RecyclerView, input/IME send, 상단 pagination, prepend scroll 보정, 하단 근처 auto-scroll을 연결했다. `DmChatRoomViewModel`에는 SSE connect/disconnect lifecycle, connected callback 최신 동기화, message callback 병합, duplicate connect/disconnect guard, disconnect 실패 silent 처리 상태를 추가했다. `DmChatRepository`에는 `DmChatRealtimeClient` seam을 추가하고 `DmChatEventClient`가 이를 구현하도록 정리했다. `ChatMainFragment`는 AI item은 기존 `ChatRoomActivity`, DM item은 `DmChatRoomActivity.newIntentByRoomId()`로 이동하도록 분기했다.
- 2026-06-10: Phase 5 검증으로 targeted 테스트 3종, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-10: Phase 5 리뷰 게이트에서 SSE listener callback이 OkHttp background thread에서 호출되어 `LiveData.setValue()` crash 가능성이 있다는 blocking issue가 발견됐다. `DmChatRoomViewModelTest`에 main thread scheduler 사용을 고정하는 RED 테스트를 추가해 실패를 확인한 뒤, `connectRealtime()``onConnected`/`onMessage`/`onFailure` 상태 갱신을 `AndroidSchedulers.mainThread().scheduleDirect`로 marshal하도록 수정했다. 이후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 재확인했다.
- 2026-06-11: Phase 5 코드리뷰 권장/관찰 항목을 계획 문서에 후속 Task로 반영했다. Phase 5에는 `bindContent`의 반복 `connectRealtime()` 호출 정리, SSE 자동 재연결 미구현 정책 인지, disconnect 중 빠른 재진입 확인을 `Task 5.4`로 추가했다. Phase 6에는 SSE 전용 read timeout 제거를 `Task 6.4`, realtime callback scheduling `Disposable` 누적 방지를 `Task 6.5`로 추가했다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
- 2026-06-11: `prd.md`의 SSE Realtime Events 요구사항과 성공 기준을 재확인한 결과, 네트워크 오류 후 SSE 자동 재연결은 PRD 범위에 포함되는 것으로 판단했다. 이에 따라 `Task 5.4`의 “자동 재연결 미구현” 문구를 정정하고, foreground/활성 채팅방 상태에서 서버 `reconnectTime=3000`ms 기준 재연결 예약, 재연결 성공 후 `GetMessages` 누락 메시지 보정, 화면 이탈/background 전환 시 예약 재연결 취소를 후속 작업으로 명시했다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
- 2026-06-11: Task 5.4 리뷰 게이트 후속 보강으로 `disconnectRealtime()`의 local realtime 정리 순서를 `isDisconnecting` API 중복 guard보다 앞에 두어 disconnect API 진행 중 다시 background로 가는 경우에도 새 SSE 연결과 예약 재연결이 cancel되도록 수정했다. 또한 `DmChatEventClient`가 취소되지 않은 SSE stream EOF 종료를 `SSE stream closed` failure callback으로 전달하도록 보강해 조용한 stream 종료도 ViewModel의 3초 재연결 경로로 들어가게 했다. 기존 403번째 stale 완료 기록은 제거 상태를 유지했다. 회귀 테스트로 `disconnect API 진행 중 다시 background로 가면 새 SSE 연결도 cancel하고 API 중복 호출은 하지 않는다`, `취소되지 않은 SSE stream이 EOF로 종료되면 failure callback으로 전달된다`를 추가했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다.
- 2026-06-11: Phase 5 코드리뷰 권장 변경사항 A-D를 각각 후속 Task로 추가했다. `Task 5.5`는 자동 재연결 실행 스레드 race 제거, `Task 5.6`은 disconnect와 예약 재연결 경합 방지, `Task 5.7`은 SSE 재연결 backoff 또는 시도 제한 검토, `Task 5.8``roomOpenedEventLiveData` 스티키 재전달 방지를 다룬다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
- 2026-06-11: Phase 5.5~5.8 범위로 예약 재연결 실행 시 `connectRealtime()`를 scheduler thread에서 직접 호출하지 않고 main callback 경로로 전달하도록 수정했다. disconnect 이후 예약 재연결이 실행되어도 `shouldReconnectRealtime` 재확인으로 새 SSE 연결이 남지 않도록 했고, `roomOpenedEventLiveData``DmChatEvent<Boolean>` 소비형 이벤트로 바꿔 observer 재등록만으로 realtime connect가 반복 트리거되지 않도록 했다. SSE 재연결 정책은 PRD의 서버 `reconnectTime=3000`ms 및 foreground 한정 조건을 우선해 backoff/최대 횟수 제한을 추가하지 않고 3초 반복 재시도 유지로 테스트 고정했다.
- 2026-06-11: Phase 5.5~5.8 검증으로 RED 단계에서 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"``consume` API 부재로 실패함을 확인했고, 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check`, `./gradlew :app:assembleDebug` PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다.
- 2026-06-11: Phase 6.1~6.5 범위로 `AppDI.kt``DmChatApi`, `DmChatRealtimeClient`/`DmChatEventClient`, `DmChatRepository`, `DmChatRoomViewModel` DI 등록을 추가하고, `AndroidManifest.xml``.v2.main.chat.dm.DmChatRoomActivity``stateAlwaysHidden|adjustResize`로 등록했다. `docs/agent-guides/build-test-style.md`에는 DM 채팅 단위 테스트 wildcard 실행 예시를 추가했다.
- 2026-06-11: Phase 6.4 RED 단계에서 `DmChatEventClientTest`에 공유 `OkHttpClient`의 60초 read timeout이 SSE 전용 client에서 0으로 제거되어야 한다는 테스트를 추가했고, 기존 구현에서 `assertEquals(0, sseClient.readTimeoutMillis)` 실패를 확인했다. 이후 `DmChatEventClient`가 전달받은 client에서 `readTimeout(0, TimeUnit.MILLISECONDS)`를 적용한 전용 client를 생성하도록 수정했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest"` PASS를 확인했다.
- 2026-06-11: Phase 6.5 RED 단계에서 `DmChatRoomViewModelTest`에 반복 realtime message callback 후 `compositeDisposable.size()`가 증가하지 않아야 한다는 테스트를 추가했고, 기존 `AndroidSchedulers.mainThread().scheduleDirect` disposable 누적으로 실패를 확인했다. 이후 realtime callback 전달을 main thread에서는 즉시 실행, background thread에서는 `Handler(Looper.getMainLooper()).post { ... }`로 전달하도록 수정해 완료된 callback `Disposable` 누적을 제거했고, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"` PASS를 확인했다.
- 2026-06-11: Phase 7.1~7.3 자동 검증으로 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck`를 순차 실행해 모두 PASS를 확인했다. `ktlintCheck`에서는 기존 `.editorconfig``disabled_rules` deprecation warning이 출력됐지만 실패는 없었다. Phase 7.4 수동 화면 확인은 현재 연결된 기기/에뮬레이터 실기 실행 증거가 없어 완료 처리하지 않았다.
- 2026-06-11: Phase 6, 7 코드리뷰 개선사항을 후속 Task로 추가했다. `Task 6.6``DmChatRoomViewModel.onCleared()`에서 main handler callback, disposable, realtime client 정리를 보장하는 작업이고, `Task 6.7``DmChatEventClientTest``getDeclaredField("okHttpClient")` reflection 의존을 동작 기반 검증으로 대체하거나 유지 사유를 명확히 남기는 작업이다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.
- 2026-06-11: Phase 6.6 RED 단계에서 `DmChatRoomViewModelTest``onCleared()` 정리 테스트를 추가했고, 기존 구현에서 `onCleared()` 오버라이드 및 `mainHandler.removeCallbacksAndMessages(null)` 부재로 실패를 확인했다. 이후 `DmChatRoomViewModel.onCleared()`에서 예약된 main handler callback 제거, `reconnectDisposable` dispose, realtime client cancel을 수행하고 `super.onCleared()`로 기존 `compositeDisposable.dispose()` 정리를 유지하도록 수정했다.
- 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 구현, 빌드, 테스트는 실행하지 않았다.
- 2026-06-18: Phase 10의 Task 10.1~10.3 코드 리뷰 및 검증을 수행했다. `DmChatRoomViewModel``JOINED` 기준 연결 확정, WebSocket `MESSAGE` 병합, `requestId` 단위 pending/SEND_ACK/ERROR/timeout/retry 처리를 확인했고 blocking issue는 발견하지 못했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1`, `./gradlew :app:compileDebugKotlin --max-workers=1`, `./gradlew :app:ktlintCheck --max-workers=1`, `git diff --check` PASS를 확인했다. `ktlintCheck`와 Gradle 실행에서는 기존 Gradle deprecation warning만 출력됐고 실패는 없었다. Task 10.4의 MESSAGE/SEND_ACK race와 Task 10.5 회귀 테스트 정리는 후속 미완료 범위로 유지한다.
- 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 파일 구조와 Phase 13 수동 확인 항목의 푸시 진입 기준을 `deep_link=${URISCHEME}://chat/{roomId}`로 갱신했다. Phase 12에는 기존 `chat_type`/`room_id` 기준 완료 이력을 보존한다는 주석을 추가하고, 새 계약 반영 범위를 미완료 `Task 12.5`로 추가했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.