# 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` 패턴을 사용한다. - 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>`를 사용한다. - [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`로 마지막 `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 입력은 전송되지 않는다. - 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다. - 화면 이탈 또는 앱 background 전환 시 disconnect API가 호출된다. - SSE 연결 실패가 앱 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 진입은 기존처럼 동작한다. ## 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>` 반환 타입을 사용하도록 정의했다. - 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` 소비형 이벤트로 바꿔 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를 확인했다.