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

460 lines
47 KiB
Markdown

# DM 채팅화면 구현 계획/TASK
## 1. 목표
`docs/20260610_DM_채팅화면/prd.md`를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI `ChatRoomActivity`는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, SSE 클라이언트, 메시지 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>` 패턴을 사용한다.
- SSE는 현재 저장소에 재사용 패턴이 없으므로 별도 라이브러리 추가 없이 기존 `OkHttpClient``newCall()`과 streaming `ResponseBody`를 사용하는 `DmChatEventClient`를 추가한다.
- SSE 연결/해제는 Activity foreground 범위에서 처리한다.
- `onStart`: `OpenRoom` 완료 후 연결 가능 상태면 SSE 연결을 시작한다.
- `onStop`: SSE call을 cancel하고 `DisconnectRealtime` API를 비동기로 호출한다.
- `onDestroy`: listener 참조와 disposable을 정리한다.
- `Last-Event-ID` replay는 기대하지 않고, SSE 재연결 후 `GetMessages`로 최신 누락 가능 메시지를 동기화한다.
- 네트워크 오류로 SSE가 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우 서버 `reconnectTime=3000`ms 기준으로 재연결을 시도한다.
- 재연결 성공 후 `Last-Event-ID` replay는 기대하지 않고 `GetMessages`로 누락 가능 메시지를 보정한다.
- 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
- `VOICE` 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
- 전송은 낙관적 UI를 적용한다.
- 전송 직후 local pending 메시지를 추가한다.
- 성공 시 서버 응답 메시지로 교체한다.
- 실패 시 실패 상태와 재시도 버튼을 표시한다.
- Phase 3 ViewModel 전송 정책은 단일 `isSending` guard로 한 번에 하나의 전송만 허용한다.
- 이번 범위에서는 “전송 중 중복 요청 방지” 요구사항을 우선 충족한다.
- 서로 다른 메시지의 연속 병렬 전송 허용 여부는 Phase 5 Activity/input UX 연결 시 필요하면 재검토한다.
- Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
- 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.
## 3. 파일 구조
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
- DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/SSE 연결 제어를 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt`
- 방 생성/열기, pagination, 메시지 전송, SSE 이벤트 반영, disconnect 상태를 관리한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt`
- `user-creator-chat` REST endpoint를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt`
- REST DTO와 서버 메시지 DTO를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt`
- API 호출 래핑, token 전달, SSE 클라이언트 위임을 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt`
- OkHttp 기반 SSE 연결, `connected`/`message` 이벤트 파싱, cancel 처리를 담당한다.
- 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`, `DmChatEventClient`, `DmChatRoomViewModel` DI를 추가한다.
- Modify: `app/src/main/AndroidManifest.xml`
- `DmChatRoomActivity`를 등록한다.
- 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 입력을 무시하고, 전송 중 중복 요청을 막는다.
- 전송 실패 메시지는 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
- 화면 stop/destroy 흐름에서 SSE cancel과 disconnect 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, 문서 갱신
- [ ] **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 등록 순서를 불필요하게 재정렬하지 않는다.
- [ ] **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 패턴을 따른다.
- [ ] **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.*"`
- 검증:
- 문서 변경은 신규 테스트 명령 예시 추가로만 제한한다.
- [ ] **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 동작은 유지된다.
- [ ] **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`이 누적되지 않는다.
### Phase 7: 최종 검증과 기록
- [ ] **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.
- [ ] **Task 7.2: 앱 빌드 확인**
- Files:
- Check: Gradle project
- Run:
- `./gradlew :app:assembleDebug`
- Expected:
- Debug APK 빌드 PASS.
- [ ] **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 입력은 전송되지 않는다.
- 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
- 화면 이탈 또는 앱 background 전환 시 disconnect API가 호출된다.
- SSE 연결 실패가 앱 crash로 이어지지 않는다.
## 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이 출력됐지만 실패는 없었다.