docs(chat): DM 채팅 Phase 5 검증을 기록한다

This commit is contained in:
2026-06-11 11:17:46 +09:00
parent a0b95ea3bd
commit 0263e64f40

View File

@@ -17,6 +17,9 @@
- `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 메시지를 추가한다.
@@ -219,7 +222,7 @@
### Phase 5: Activity 구현과 화면 연결
- [ ] **Task 5.1: DmChatRoomActivity 생성**
- [x] **Task 5.1: DmChatRoomActivity 생성**
- Files:
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt`
- 작업:
@@ -232,7 +235,7 @@
- 검증:
- `ChatRoomActivity`의 쿼터/광고/더보기/notice 관련 로직을 가져오지 않는다.
- [ ] **Task 5.2: SSE lifecycle과 disconnect 연결**
- [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`
@@ -244,7 +247,7 @@
- 검증:
- ViewModel 테스트에서 disconnect 중복 방지와 cancel 호출 여부를 검증한다.
- [ ] **Task 5.3: 채팅 탭 DM item 클릭 연결**
- [x] **Task 5.3: 채팅 탭 DM item 클릭 연결**
- Files:
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- 작업:
@@ -253,6 +256,76 @@
- 검증:
- 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 등록**
@@ -283,6 +356,31 @@
- 검증:
- 문서 변경은 신규 테스트 명령 예시 추가로만 제한한다.
- [ ] **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: 단위 테스트 실행**
@@ -349,3 +447,13 @@
- 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이 출력됐지만 실패는 없었다.