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

128 KiB

DM 채팅화면 구현 계획/TASK

1. 목표

docs/20260610_DM_채팅화면/prd.md를 기준으로 신규 DM 채팅방 상세 화면을 v2 패키지 하위에 구현한다. 기존 AI ChatRoomActivity는 직접 수정하지 않고, DM 전용 Activity, ViewModel, Repository, DTO, WebSocket 클라이언트, 메시지 UI 모델을 최소 범위로 추가한다.

2. 구현 결정 사항

  • 신규 화면은 kr.co.vividnext.sodalive.v2.main.chat.dm 하위에 둔다.
  • DM 채팅방 Activity 이름은 DmChatRoomActivity로 한다.
  • intent extra는 EXTRA_ROOM_ID, EXTRA_CREATOR_ID를 사용한다.
    • roomId > 0: OpenRoom부터 시작한다.
    • roomId <= 0 && creatorId > 0: CreateOrGetRoom 호출 후 반환된 roomIdOpenRoom을 호출한다.
    • 둘 다 유효하지 않으면 Activity를 종료한다.
  • REST API는 기존 v2 채팅 탭과 동일하게 Retrofit + RxJava3 + ApiResponse<T> 패턴을 사용한다.
  • Phase 1~8은 기존 SSE 기반 구현 이력으로 보존한다. WebSocket 전환은 Phase 9부터 기존 SSE 구현을 교체하는 후속 범위로 진행한다.
  • WebSocket은 OkHttpClient.newWebSocket() 기반 전용 DmChatSocketClient를 추가하고, 기존 DmChatEventClient/SSE parser는 WebSocket 전환 완료 후 사용하지 않는다.
  • WebSocket 연결/해제는 Activity foreground 범위와 로그아웃 흐름에서 처리한다.
    • onStart: OpenRoom 완료 후 연결 가능 상태면 WebSocket 연결을 시작하고 JOIN_ROOM을 보낸다.
    • JOINED: 실시간 수신 가능 상태로 판단한다.
    • onStop/화면 이탈/앱 background/로그아웃: LEAVE_ROOM 전송 후 socket close를 수행한다.
    • onDestroy/onCleared: listener 참조, heartbeat, reconnect 예약, socket을 정리한다.
  • WebSocket 재연결 후에는 JOIN_ROOM을 다시 보내고, 필요하면 GetMessages로 최신 누락 가능 메시지를 동기화한다.
  • 네트워크 오류로 WebSocket이 실패하면 화면이 foreground에 있고 채팅방이 활성 상태인 경우에만 재연결을 시도한다.
    • 재연결 성공 후 JOIN_ROOM을 다시 보내고 GetMessages로 누락 가능 메시지를 보정한다.
    • 화면 이탈 또는 background 전환 시 예약된 재연결 시도는 취소한다.
  • VOICE 메시지는 DTO에 보존하되 이번 UI 목록에는 표시하지 않는다.
  • 전송은 낙관적 UI를 적용한다.
    • 전송 직후 local pending 메시지를 추가한다.
    • WebSocket SEND_ACK 수신 시 requestId로 pending 메시지를 찾아 서버 messageId, createdAt, 프로필 정보로 확정한다.
    • ERROR 또는 timeout 시 실패 상태와 재시도 버튼을 표시한다.
  • Phase 9 이후 텍스트 전송 정책은 requestId 단위 pending map으로 관리한다.
    • 서로 다른 텍스트 메시지는 각각의 requestId로 독립 pending 상태를 가질 수 있다.
    • 같은 UI item 재시도는 새 requestId를 발급하되 기존 local item을 유지한다.
  • Phase 3 ViewModel의 pagination/reconnect 동기화 실패는 화면 종료나 Error 화면 전환 없이 기존 메시지 상태를 유지하고 내부 loading 상태만 복구한다.
    • 사용자 노출 toast/retry UI는 Phase 5 Activity 연결 시 필요하면 별도 처리한다.

3. 파일 구조

  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt
    • DM 채팅방 화면, intent 진입, RecyclerView/input/header/lifecycle/WebSocket 연결 제어를 담당한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
    • 방 생성/열기, pagination, WebSocket 메시지 전송/수신, leave/close 상태를 관리한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt
    • user-creator-chat REST endpoint를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt
    • REST DTO와 서버 메시지 DTO를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt
    • REST API 호출 래핑, token 전달, WebSocket 클라이언트 위임을 담당한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatEventClient.kt
    • 기존 SSE 연결 구현이다. Phase 9 이후 WebSocket 전환 완료 시 신규 경로에서 사용하지 않으며 제거 또는 미사용 상태로 둔다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt
    • OkHttp WebSocket 연결, handshake header, JOIN_ROOM/LEAVE_ROOM/SEND_TEXT/PING 송신, 수신 callback, close 처리를 담당한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt
    • WebSocket envelope, payload, requestId 기반 SEND_ACK, ERROR, MESSAGE, PONG 모델을 정의한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt
    • WebSocket handshake header, endpoint, send/receive envelope, close 동작을 검증한다.
  • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt
    • JOINED, MESSAGE, SEND_ACK, ERROR, PONG, 알 수 없는 type 파싱을 검증한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt
    • UI 메시지 모델, 전송 상태, 화면 상태를 정의한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatMappers.kt
    • 서버 DTO를 UI 모델로 변환하고 정렬/중복 제거 helper를 제공한다.
  • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/ui/DmChatMessageAdapter.kt
    • 내 메시지/상대 메시지 ViewHolder, 실패 재시도 callback을 담당한다.
  • Create: app/src/main/res/layout/activity_dm_chat_room.xml
    • AI 채팅방 layout을 기준으로 DM 전용 header, message list, input 영역을 구성한다.
  • Create: app/src/main/res/layout/item_dm_chat_my_message.xml
    • 내 텍스트 메시지, 전송 중/실패 상태, 재시도 버튼을 표시한다.
  • Create: app/src/main/res/layout/item_dm_chat_opponent_message.xml
    • 상대 텍스트 메시지와 프로필/닉네임을 표시한다.
  • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt
    • DM item 클릭 시 DmChatRoomActivity.newIntentByRoomId()로 이동한다.
  • Modify: app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
    • DmChatApi, DmChatRepository, DmChatSocketClient, DmChatRoomViewModel DI를 등록한다.
  • Modify: app/src/main/AndroidManifest.xml
    • DmChatRoomActivity를 등록한다.
  • Modify: app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
    • 푸시 payload의 deep_linkDeepLinkActivity로 전달한다.
  • Modify: app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
    • ${URISCHEME}://chat/{roomId} deep link 기준으로 DM 채팅방 진입 intent를 만든다.
  • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt
    • 로그인/메인 진입 후 전달된 DM deep link를 DM 채팅방 진입으로 연결한다.
  • Modify: docs/agent-guides/build-test-style.md
    • 신규 DM 채팅 테스트 단일 실행 예시를 추가한다.
  • Test Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatMapperTest.kt
  • Test Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatPaginationStateTest.kt
  • Test Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatEventParserTest.kt
  • Test Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt

4. 성공 기준

  • roomId 기반 진입은 create API 없이 OpenRoom을 호출한다.
  • creatorId 기반 진입은 CreateOrGetRoom 성공 후 반환된 roomIdOpenRoom을 호출한다.
  • OpenRoom 메시지는 오래된 순서에서 최신 순서로 표시된다.
  • 메시지 병합은 messageId 기준으로 중복을 제거한다.
  • 상단 스크롤 시 hasMore=true, nextCursor != null, isLoading=false 조건에서만 과거 메시지를 조회한다.
  • 텍스트 전송은 blank 입력을 무시하고, WebSocket SEND_TEXTrequestId pending 매칭을 사용한다.
  • 전송 실패 메시지는 ERROR 또는 timeout 기준으로 실패 상태와 재시도 버튼을 표시하고, 재시도 성공 시 정상 메시지로 교체된다.
  • 화면 stop/destroy/background/logout 흐름에서 LEAVE_ROOM 전송과 socket close가 화면 종료를 막지 않는다.
  • 제거된 GET /api/v2/user-creator-chat/rooms/{roomId}/events, POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect, 텍스트 전송용 POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text는 신규 텍스트 실시간 송수신 경로에서 호출하지 않는다.
  • 음성 메시지는 기존 multipart REST API 경로를 유지한다.
  • DM 화면에는 character_type_badge, ll_can_badge, iv_more, notice_container가 없다.
  • ChatRoomActivity 기존 동작은 변경하지 않는다.

Phase 1: API/모델/매퍼 기반 추가

  • 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<...>>를 사용한다.
  • 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
    • 작업:
      • DmChatMessageUiItemmessageId, localId, mine, textMessage, senderNickname, senderProfileImageUrl, createdAt, status를 둔다.
      • DmChatMessageStatusSENDING, SENT, FAILED로 정의한다.
      • messageType은 서버 계약상 TEXT/VOICE 대문자이나, UI 매핑에서는 오입력 방지를 위해 대소문자를 무시해 TEXT를 판정한다.
      • messageTypeTEXT가 아니거나 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 클라이언트 추가

  • 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를 사용한다.
  • 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 상태와 단위 테스트 추가

  • 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이면 createOrGetRoomopenRoom을 호출한다.
      • 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.
  • 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.
  • 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 구현

  • 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: 결과 없음.
  • 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 구현과 화면 연결

  • 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 관련 로직을 가져오지 않는다.
  • 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 호출 여부를 검증한다.
  • Task 5.3: 채팅 탭 DM item 클릭 연결

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt
    • 작업:
      • ChatRoomType.AI는 기존 ChatRoomActivity로 이동한다.
      • ChatRoomType.DMDmChatRoomActivity.newIntentByRoomId(requireContext(), item.roomId)로 이동한다.
    • 검증:
      • AI item 클릭 동작은 기존과 동일하게 유지한다.
  • 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=3000ms 기준으로 재연결을 예약한다.
      • 재연결 성공 후 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로 이어지지 않는 정책이 확인된다.
  • 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를 직접 변경하지 않는다.
  • 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 중복 방지 동작은 유지된다.
  • 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=3000ms 기준과 충돌하지 않는지 검토한다.
      • 지속 실패 상황의 네트워크 부담을 줄이기 위해 지수 backoff 또는 최대 시도 횟수 제한 중 최소 변경안을 선택한다.
      • backoff/시도 제한을 적용하는 경우, 재연결 성공 또는 수동 재진입 시 재시도 상태가 초기화되도록 한다.
      • PRD 범위와 충돌하거나 정책 결정이 필요하면 구현하지 않고 결정 필요 사항을 문서에 남긴다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest"
      • Expected: 선택한 재연결 정책이 테스트로 고정되고, PRD의 3초 기본 간격 및 foreground 한정 조건을 깨지 않는다.
  • 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마다 완료된 DisposableCompositeDisposable에 계속 누적하는 패턴을 제거한다.
      • 권장 우선순위는 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이 누적되지 않는다.
  • 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
    • 작업:
      • DmChatRoomViewModelonCleared() 오버라이드를 추가한다.
      • 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 정리가 호출된다.
  • 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: 최종 검증과 기록

  • 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 입력은 전송되지 않는다.
      • 텍스트 전송 실패 시 실패 상태와 재시도 버튼이 표시된다.
      • Phase 9~13 WebSocket 전환 후 화면 이탈 또는 앱 background 전환 시 LEAVE_ROOM 전송 후 socket close가 호출된다.
      • Phase 9~13 WebSocket 전환 후 WebSocket 연결 실패가 앱 crash로 이어지지 않는다.

Phase 8: 크리에이터 채널 DM 진입 crash 수정

  • 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: DmChatRoomViewModelTestcreatorId 진입은 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 재전환이 없음을 확인했다.
  • Task 8.2: createRoomAndOpen main thread 전환 보장

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
    • 작업:
      • createRoomAndOpen()CreateOrGetRoomOpenRoom 연속 호출 흐름에서 최종 결과 처리 전에 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를 확인했다.
  • 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에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.
  • Task 8.4: 수동 확인 항목 갱신

    • Files:
      • Check: DmChatRoomActivity
      • Check: CreatorChannelActivity
    • 확인 항목:
      • 크리에이터 채널에서 DM 보내기를 터치하면 DmChatRoomActivity로 이동한다.
      • creatorId 기반 진입 후 방 생성/조회와 OpenRoom 결과 반영 중 앱이 crash 되지 않는다.
      • header 상대 정보와 초기 메시지 목록이 표시된다.
      • 채팅 탭의 기존 roomId 기반 DM 진입은 기존처럼 동작한다.

Phase 9: WebSocket 계약/클라이언트 기반 추가

  • Task 9.1: WebSocket envelope와 payload 모델 정의

    • Files:
      • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketModels.kt
      • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketParserTest.kt
    • 작업:
      • WebSocket 공통 envelope는 typepayload를 분리해 파싱할 수 있게 정의한다.
      • Client send type은 JOIN_ROOM, LEAVE_ROOM, SEND_TEXT, PING을 지원한다.
      • Server receive type은 JOINED, MESSAGE, SEND_ACK, ERROR, PONG을 지원한다.
      • JOIN_ROOM/LEAVE_ROOM payload는 현재 roomId를 포함한다.
      • SEND_TEXT payload는 roomId, requestId, textMessage를 포함한다.
      • SEND_ACK payload는 requestId와 서버 확정 메시지(DmChatMessageResponse)를 포함한다.
      • MESSAGE payload는 서버 메시지(DmChatMessageResponse)를 포함한다.
      • ERROR payload는 requestId가 있을 수도 있고 없을 수도 있으므로 nullable로 모델링하고, message/code 필드를 보존한다.
      • 서버의 정확한 JSON field name은 백엔드 계약과 대조해 확정하되, Android 모델은 서버 field name을 @SerializedName으로 보존한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1
      • Expected: JOINED, MESSAGE, SEND_ACK, ERROR, PONG, 알 수 없는 type, 잘못된 JSON 파싱 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatSocketParserTest를 먼저 추가하고 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1 실행 시 DmChatSocketEvent/DmChatSocketParser 미정의 컴파일 오류로 RED를 확인했다. 이후 DmChatSocketModels.kttype/payload envelope, JOIN_ROOM/LEAVE_ROOM/SEND_TEXT/PING client type, JOINED/MESSAGE/SEND_ACK/ERROR/PONG parser 모델을 추가했다. 재실행 결과 DmChatSocketParserTest PASS를 확인했고, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1도 PASS했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.
      • 2026-06-18: 리뷰에서 ERRORcode/message nullable 보존 테스트가 부족하다는 지적을 받아 code=null, message=null 케이스를 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1 실행 결과 ERROR type은 nullable code와 message를 보존한다 테스트가 RED로 실패함을 확인했고, DmChatSocketErrorPayloadDmChatSocketEvent.Errorcode/message를 nullable로 변경한 뒤 같은 테스트가 PASS했다. 이후 ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1 모두 PASS를 확인했다.
      • 2026-06-18: 코드 리뷰로 DmChatSocketModels.ktDmChatSocketParserTest.kt를 재검토했다. type/payload envelope, JOINED/MESSAGE/SEND_ACK/ERROR/PONG 파싱, nullable ERROR 필드 보존, 알 수 없는 type/잘못된 JSON 무시 동작이 Task 9.1 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1 PASS를 확인했다.
  • Task 9.2: OkHttp WebSocket 클라이언트 추가

    • Files:
      • Create: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt
      • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt
    • 작업:
      • DmChatSocketClientOkHttpClient.newWebSocket()으로 ${BuildConfig.BASE_URL}의 scheme을 WebSocket scheme으로 변환해 /ws/v2/user-creator-chat에 연결한다.
        • https://wss://, http://ws://로 변환한다.
      • handshake request에 Authorization: Bearer <accessToken> header를 추가한다.
      • connect(token, listener)는 socket 연결만 수행하고, 방 참여는 ViewModel이 sendJoinRoom(roomId)로 명시 호출한다.
      • sendJoinRoom(roomId), sendLeaveRoom(roomId), sendText(roomId, requestId, textMessage), sendPing()를 제공한다.
      • close()는 진행 중 socket을 close하고 listener 참조를 해제한다.
      • onMessageDmChatSocketModels parser로 envelope를 파싱해 listener callback으로 전달한다.
      • 알 수 없는 type 또는 파싱 실패는 앱 crash 없이 listener failure 또는 ignored event로 처리한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1
      • Expected: endpoint URL 변환, Authorization header, JOIN_ROOM/LEAVE_ROOM/SEND_TEXT/PING JSON 송신, 수신 callback, close 정리 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatSocketClientTest를 먼저 추가하고 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1 실행 시 DmChatSocketClient 미정의 컴파일 오류로 RED를 확인했다. 이후 DmChatSocketClient.kt를 추가해 https/http base URL의 WebSocket scheme 변환, Authorization: Bearer ... handshake header, 명시적 JOIN_ROOM/LEAVE_ROOM/SEND_TEXT/PING 송신, parser 기반 수신 event callback, 알 수 없는 type/잘못된 JSON 무시, close 정리를 구현했다. 구현 직후 URL scheme 검증 2건이 RED로 남아 OkHttp Request.url 정규화 특성을 반영해 원본 WebSocket URL을 request tag로 보존하도록 조정했고, 재실행 결과 DmChatSocketClientTest PASS를 확인했다. ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1도 PASS했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.
      • 2026-06-18: 코드 리뷰로 DmChatSocketClient.ktDmChatSocketClientTest.kt를 재검토했다. WebSocket endpoint scheme 변환, bearer header, 명시적 JOIN_ROOM/LEAVE_ROOM/SEND_TEXT/PING 송신, 현재 socket guard, close 정리, parser event 전달이 Task 9.2 범위와 일치함을 확인했고 추가 결함은 발견하지 못했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1 PASS를 확인했다.
  • Task 9.3: Repository와 DI를 WebSocket 클라이언트 기준으로 전환

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt
    • 작업:
      • DmChatRepositoryconnectRealtime()/cancelRealtime() seam을 WebSocket 클라이언트 위임으로 교체하거나 connectSocket()/closeSocket()처럼 의미가 분명한 이름으로 변경한다.
      • REST createOrGetRoom, openRoom, getMessages는 유지한다.
      • 텍스트 전송용 REST sendTextMessage()disconnectRealtime() repository method는 신규 전송/해제 경로에서 사용하지 않도록 제거하거나 deprecated 없이 삭제한다.
      • AppDI.ktDmChatEventClient 대신 DmChatSocketClient를 등록한다.
      • Authorization header 생성은 기존 bearer(token) helper를 재사용해 REST와 WebSocket의 bearer 문자열이 일관되도록 한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1
      • Expected: REST create/open/messages는 기존처럼 동작하고, WebSocket connect/send/close 위임과 bearer header 생성 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRepositoryAppDIDmChatSocketClient 기준으로 전환하고 REST createOrGetRoom/openRoom/getMessages 경로는 유지됨을 확인했다. 대상 실행으로 DmChatRepositoryTest, DmChatSocketClientTest, DmChatRoomActivitySourceTest, DmChatRoomViewModelTest가 PASS했고, ./gradlew :app:compileDebugKotlin --max-workers=1 및 style 정리 후 ./gradlew :app:ktlintCheck --max-workers=1도 PASS했다. ktlintCheck에는 기존 .editorconfigdisabled_rules deprecation warning만 남았다.
      • 2026-06-18: Phase 9 코드 리뷰로 DmChatRepository, AppDI, DmChatRoomViewModel, DmChatRoomActivity의 WebSocket 전환 diff를 재검토했다. DmChatRepositoryTest, DmChatSocketClientTest, DmChatRoomActivitySourceTest, DmChatRoomViewModelTest, compileDebugKotlin, ktlintCheck, git diff --check는 PASS했다. 다만 DmChatRoomViewModelJOINED 수신 전 isRealtimeConnected=true로 처리하고, 전송 제어가 아직 단일 isSending 기준이라 Phase 9 이후 정책인 requestId 단위 독립 pending과 완전히 맞지 않는 보완 필요 사항을 발견했다. 해당 보완은 Phase 10의 ViewModel WebSocket 세션/전송 전환 범위에서 처리한다.
  • Task 9.4: 제거 endpoint API 정의 삭제

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatModels.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt
    • 작업:
      • DmChatApi.sendDmTextMessage() endpoint 정의를 삭제한다.
      • DmChatApi.disconnectRealtime() endpoint 정의를 삭제한다.
      • SendDmTextMessageRequest, SendDmChatMessageResponse 모델은 REST 텍스트 전송 전용이면 삭제한다.
      • 음성 메시지 multipart REST API가 현재 DM 채팅 화면에 필요하면 별도 sendDmVoiceMessage()로 명시하고, 텍스트 전송 경로와 섞지 않는다.
    • 검증:
      • Run: rg "messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\\(" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm
      • Expected: WebSocket 전환 후 유지가 필요한 과거 검증 로그를 제외하고 main/test 코드의 신규 경로에서 결과 없음.
    • 검증 기록:
      • 2026-06-18: messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\( 검색 결과 출력이 없음을 확인했다. 대상 실행으로 DmChatRepositoryTest, DmChatSocketClientTest, DmChatRoomActivitySourceTest, DmChatRoomViewModelTest가 PASS했고, ./gradlew :app:compileDebugKotlin --max-workers=1 및 style 정리 후 ./gradlew :app:ktlintCheck --max-workers=1도 PASS했다. ktlintCheck에는 기존 .editorconfigdisabled_rules deprecation warning만 남았다.
      • 2026-06-18: Phase 9 재검증으로 rg "messages/text|events/disconnect|SendDmTextMessageRequest|SendDmChatMessageResponse|disconnectRealtime\\(" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm 결과 없음, git diff --check PASS를 확인했다. 최초 Gradle 실행은 sandbox의 ~/.gradle lock 파일 접근 제한으로 실패했으나, 승인된 Gradle 실행에서 DmChatRepositoryTest, DmChatSocketClientTest, DmChatRoomActivitySourceTest, DmChatRoomViewModelTest, compileDebugKotlin, ktlintCheck가 PASS했다.

Phase 10: ViewModel WebSocket 세션/수신/전송 전환

  • Task 10.1: OpenRoom 성공 후 JOIN_ROOM 흐름으로 연결 기준 변경

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • OpenRoom 성공 전에는 WebSocket 연결을 시작하지 않는다.
      • roomOpenedEventLiveData 또는 동등한 단발 이벤트는 Activity가 WebSocket 연결 시작을 트리거하는 용도로 유지한다.
      • 기존 SSE connected callback 기준 상태 갱신을 제거하고, WebSocket JOINED 수신 시점에만 실시간 수신 가능 상태로 판단한다.
      • Phase 9 코드 리뷰 반영: socket 연결 시도 직후 isRealtimeConnected=true로 확정하지 않고, 연결 시도 상태와 JOINED 완료 상태를 분리한다.
      • 같은 roomId로 이미 연결 중이면 중복 socket 연결과 중복 JOIN_ROOM 전송을 막는다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: OpenRoom 전 미연결, OpenRoom 후 connect + JOIN_ROOM, JOINED 전 connected 미확정, JOINED 후 connected 상태, 중복 connect 방지 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRoomViewModelTestJOINEDisRealtimeConnected=false, JOINEDtrue, 중복 connectRealtime() 시 socket connect와 JOIN_ROOM이 1회만 수행되는 검증을 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 실행 결과 DmChatRoomViewModelTest.kt:326 assertion failure로 RED를 확인했다. 이후 DmChatRoomViewModelisRealtimeJoining/currentRealtimeRoomId를 추가하고 JOINED 수신 시점에만 connected로 전환하도록 변경했다. 재실행 결과 같은 ViewModel 테스트가 PASS했고, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check도 PASS했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다.
  • Task 10.2: MESSAGE 수신 반영으로 교체

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • 기존 SSE onMessage() callback 연결을 WebSocket MESSAGE event callback으로 교체한다.
      • MESSAGE payload의 DmChatMessageResponse를 기존 mapper로 UI item에 변환한다.
      • 현재 채팅방 메시지는 messageId 기준 중복 제거 후 append/merge한다.
      • 현재 room이 아닌 메시지가 payload로 구분 가능하면 현재 목록에 반영하지 않는다.
      • 잘못된 payload 또는 알 수 없는 type은 앱 crash 없이 무시한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: MESSAGE append, 중복 messageId 제거, 잘못된 payload 무시 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: 기존 socketFactory.emitMessage() 기반 WebSocket MESSAGE callback이 handleSocketEvent()에서 onRealtimeMessage()로 연결되고, DmChatMessageResponse.toUiItem()mergeByMessageId()로 UI 목록에 반영되는 흐름을 확인했다. DmChatRoomViewModelTestrealtime message callback은 SSE 메시지를 화면 상태에 병합한다, SSE 메시지는 messageId 중복을 제거하고 최신 메시지를 추가한다 테스트를 유지해 MESSAGE append와 messageId 중복 제거 회귀를 고정했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 PASS를 확인했다.
  • Task 10.3: SEND_TEXT/requestId pending 전송으로 교체

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/model/DmChatUiModels.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • sendText(text)는 trim 후 blank면 종료한다.
      • 전송 시 requestId를 생성하고 local pending item에 보존한다.
      • Phase 9 코드 리뷰 반영: 단일 isSending 전역 제한을 제거하고, 서로 다른 텍스트 메시지는 각각의 requestId로 독립 pending 상태를 가질 수 있게 한다.
      • pending 상태는 requestId 기준 map 또는 동등하게 검증 가능한 구조로 관리한다.
      • REST repository.sendTextMessage() 호출을 제거하고 WebSocket SEND_TEXT를 전송한다.
      • SEND_ACK 수신 시 requestId로 pending item을 찾아 서버 messageId, createdAt, senderNickname, senderProfileImageUrl 기준으로 확정한다.
      • ERROR 또는 timeout 시 해당 pending item을 FAILED로 전환한다.
      • retry(localId)는 기존 failed item을 유지하고 새 requestId를 발급해 SEND_TEXT를 다시 전송한다.
      • 같은 requestIdSEND_ACK가 중복 수신되면 첫 번째 확정 결과만 반영한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: blank 무시, pending 추가, 서로 다른 텍스트의 독립 pending, SEND_TEXT 송신, SEND_ACK requestId 매칭, ERROR 실패, timeout 실패, retry 새 requestId, 중복 ack 무시 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRoomViewModelTest에 requestId 기반 독립 pending, SEND_ACK requestId 매칭, ERROR 실패, timeout 실패, retry 새 requestId, 중복 ACK 무시 테스트를 먼저 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 실행 시 DmChatMessageUiItem.requestId 미정의 컴파일 오류로 RED를 확인했고, timeout 테스트는 구현 전 DmChatRoomViewModelTest.kt:270 assertion failure로 RED를 확인했다.
      • 2026-06-18: DmChatMessageUiItemrequestId를 추가하고, DmChatRoomViewModel의 단일 isSending 제한을 제거해 각 텍스트 전송이 새 requestId와 pending map으로 관리되도록 변경했다. SEND_TEXT는 WebSocket으로 전송하고, SEND_ACK/ERROR/send false/10초 timeout은 해당 requestId의 local item만 확정 또는 실패 처리한다. retry는 기존 localId item을 유지하면서 새 requestId를 발급하도록 변경했다. 이후 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 PASS를 확인했다.
  • Task 10.4: SEND_ACK보다 MESSAGE가 먼저 도착하는 race 처리

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • pending 텍스트와 동일한 서버 메시지가 SEND_ACK보다 먼저 MESSAGE로 도착할 수 있는 케이스를 테스트로 고정한다.
      • MESSAGE payload에 requestId가 포함되면 requestId 기준으로 pending item을 확정한다.
      • MESSAGE payload에 requestId가 없으면 messageId 중복 제거를 우선 적용하고, 이후 SEND_ACK 도착 시 같은 messageId가 중복되지 않게 병합한다.
      • timeout으로 실패 처리된 뒤 늦은 SEND_ACK가 도착하면 같은 local item이 아직 존재하는 경우 정상 메시지로 복구한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: MESSAGE 선도착, ACK 후도착, timeout 후 ACK 복구, 중복 messageId 방지 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatSocketParserTestMESSAGE payload의 nullable requestId 보존 테스트를 추가하고, DmChatRoomViewModelTestMESSAGE(requestId) 선도착 시 pending local item 확정, MESSAGErequestId가 없는 경우 후도착 SEND_ACKmessageId 중복 방지, timeout 실패 후 늦은 SEND_ACK의 같은 local item 복구 테스트를 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1 및 ViewModel 대상 테스트 실행에서 DmChatSocketParserTest.kt:61DmChatSocketEvent.Message.requestId 미정의 컴파일 오류로 RED를 확인했다. 이후 DmChatSocketEvent.Message와 parser가 nullable requestId를 보존하도록 변경하고, ViewModel이 MESSAGE(requestId)SEND_ACK와 동일한 pending 확정 경로로 처리하며 timeout 후 실패 requestId도 늦은 ACK로 복구할 수 있도록 변경했다. 재실행 결과 DmChatSocketParserTestDmChatRoomViewModelTest가 PASS했다. 병렬 Gradle 실행 중 1회 kspDebugUnitTestKotlinStreamCorruptedException이 발생했으나, 같은 ViewModel 테스트 단독 재실행에서 PASS를 확인했다.
      • 2026-06-18: 리뷰에서 timeout된 request-1이 실패 기록에 남은 상태로 같은 local item을 request-2로 retry하고 request-2 ACK 성공 후 늦은 request-1 ACK가 도착하면 최신 성공 메시지를 오래된 ACK로 덮을 수 있다는 blocker를 확인했다. DmChatRoomViewModelTestretry 성공 후 이전 timeout request ACK는 같은 local item을 덮어쓰지 않는다 테스트를 추가했고, 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 실행 결과 DmChatRoomViewModelTest.kt:466 assertion failure로 RED를 확인했다. 이후 retry 시작 및 ACK 성공 확정 시 같은 localId에 묶인 stale failed request 기록을 제거하도록 변경했고, 같은 ViewModel 테스트 재실행 결과 PASS를 확인했다.
  • Task 10.5: Phase 9 코드 리뷰 결과 회귀 테스트 반영

    • Files:
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
      • Modify: docs/20260610_DM_채팅화면/plan-task.md
    • 작업:
      • Task 10.1 검증에 JOINED 전 connected 미확정 테스트를 추가했는지 확인한다.
      • Task 10.3 검증에 서로 다른 텍스트 메시지의 독립 pending 테스트를 추가했는지 확인한다.
      • Phase 9 코드 리뷰에서 지적된 부정확한 leave 중복 테스트는 실제 연결 없는 closeCount == 0 검증으로 유지하지 않는다.
      • LEAVE_ROOM + close의 상세 lifecycle 동작 검증은 기존 범위대로 Task 11.1에서 수행하되, Phase 10 완료 시 해당 테스트 gap이 남아 있음을 문서에 명확히 남긴다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: Phase 9 리뷰 3개 항목 중 ViewModel 세션/전송 항목은 Phase 10 테스트로 고정되고, leave/close 항목은 부정확한 테스트 제거 또는 Task 11.1 이관 기록으로 남는다.
    • 검증 기록:
      • 2026-06-18: Phase 9 리뷰 항목 중 JOINED 전 connected 미확정은 Task 10.1의 roomId가 있으면 realtime 연결 후 connected callback에서 최신 메시지를 동기화한다 테스트에서 JOINEDisRealtimeConnected=false, JOINEDtrue로 고정되어 있음을 확인했다. requestId 단위 독립 pending은 Task 10.3의 서로 다른 텍스트는 각각 requestId로 독립 pending 전송한다 테스트로 고정되어 있음을 확인했다. LEAVE_ROOM + close lifecycle 상세 검증은 Task 11.1 범위로 남기고, 실제 연결 없이 closeCount == 0만 확인하던 realtime leave 중 중복 요청은 close를 반복할 수 있다 테스트는 부정확한 회귀 테스트로 제거했다. Task 10.4까지 포함한 DmChatRoomViewModelTest는 이후 재실행해 PASS를 확인한다.
      • 2026-06-18: Phase 10 전체 단위 코드 리뷰로 DmChatRoomViewModel, DmChatSocketModels, DmChatRoomViewModelTest, DmChatSocketParserTest의 diff를 재검토했다. MESSAGE(requestId) 선도착 시 pending local item 확정, requestId 없는 MESSAGE와 후도착 SEND_ACKmessageId 중복 제거, timeout 후 늦은 SEND_ACK 복구, retry 성공 후 이전 timeout request ACK 무시, Phase 9 리뷰 회귀 테스트 반영 상태를 확인했고 blocking issue는 발견하지 못했다. fresh 검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketParserTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. 최초 샌드박스 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.

Phase 11: Lifecycle, reconnect, heartbeat, token 갱신 처리

  • Task 11.1: LEAVE_ROOM 후 socket close로 lifecycle 해제 전환

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivity.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomActivitySourceTest.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • onStop/화면 이탈 시 기존 disconnectRealtime() REST 호출 대신 LEAVE_ROOM 전송 후 socket close를 수행한다.
      • 앱 background 진입과 로그아웃 흐름에서 같은 leave/close API를 호출할 수 있도록 ViewModel method를 분리한다.
      • 이미 leave/close 중이면 중복 LEAVE_ROOM 전송과 중복 close를 만들지 않는다.
      • close 실패 또는 네트워크 단절은 화면 종료를 막지 않는다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: lifecycle stop에서 LEAVE_ROOM + close, 중복 방지, REST disconnect 미호출 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRoomViewModelTestleave는 LEAVE_ROOM 전송 후 socket을 close하고 중복 호출은 무시한다 테스트를 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 실행에서 중복 leave/close 및 이후 heartbeat/token 관련 RED 실패를 확인했다. 이후 leaveRealtime()에 활성 socket guard와 heartbeat/reconnect 정리를 추가해 LEAVE_ROOM 전송 후 socket close가 1회만 수행되도록 했다.
  • Task 11.2: WebSocket reconnect 정책 구현

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • 네트워크 오류 또는 비정상 close가 발생하면 현재 채팅방 화면에 남아 있는 동안에만 재연결을 예약한다.
      • 재연결 성공 후 JOIN_ROOM을 다시 보낸다.
      • JOINED 수신 후 필요하면 GetMessages(cursor = null, limit = 20)로 최신 누락 메시지를 동기화한다.
      • 화면 이탈/background/logout 이후 예약된 reconnect는 실행하지 않는다.
      • backoff 간격과 최대 재시도 횟수는 PRD Open Questions에 남긴 상태이므로, 구현 전 서버 권장값이 없으면 기존 3초 반복 재시도 정책을 WebSocket에도 우선 적용한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: 화면 내부 오류 재연결, 재연결 후 JOIN_ROOM, 최신 메시지 동기화, 화면 밖 reconnect 취소 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: 기존 WebSocket failure 후 3초 재연결, 재연결 후 JOIN_ROOM, JOINED 후 최신 메시지 동기화, leave 이후 reconnect 취소 테스트를 유지하면서 reconnect 예약 실행 시 최신 token을 다시 읽도록 보강했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1 재실행 결과 PASS를 확인했다.
  • Task 11.3: PING/PONG heartbeat 구현

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatSocketClient.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatSocketClientTest.kt
    • 작업:
      • JOINED 이후 heartbeat timer를 시작한다.
      • 주기적으로 PING을 전송하고 PONG 수신 시 마지막 heartbeat 시간을 갱신한다.
      • PONG timeout이면 연결 상태를 disconnected로 바꾸고 WebSocket을 close한 뒤 화면 내부 조건에서 reconnect를 예약한다.
      • leave/close/onCleared 시 heartbeat timer를 취소한다.
      • 서버 권장값이 없으면 heartbeat 주기와 timeout 값은 상수로 분리하고 구현 계획 검증 기록에 남긴다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1
      • Expected: PING 주기 송신, PONG 수신 상태 유지, timeout reconnect, leave 시 timer 취소 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRoomViewModelTestJOINED 이후 heartbeat는 PING을 보내고 PONG 수신 시 연결을 유지한다, heartbeat PONG timeout은 socket close 후 foreground 조건에서 reconnect를 예약한다, leave는 heartbeat timeout과 reconnect 예약을 취소한다 테스트를 추가했다. RED 단계에서 PING 미전송과 timeout 미처리 assertion 실패를 확인했고, JOINED 이후 30초 주기 PING, 10초 PONG timeout, timeout 시 socket close 후 foreground 조건 재연결 예약, leave/onCleared heartbeat 취소를 구현했다. 서버 권장값이 없어 heartbeat 주기 30초, timeout 10초를 상수로 분리했다.
      • 2026-06-18: 리뷰 게이트에서 PONG 수신 시 timeout을 재예약하면 다음 PING 전에 정상 연결도 닫힐 수 있다는 blocker가 발견됐다. JOINED 이후 heartbeat는 PING을 보내고 PONG 수신 시 연결을 유지한다 테스트를 PONG 수신 후 10초 초과, 다음 30초 PING 전까지 연결 유지 검증으로 강화했고, 수정 전 해당 단일 테스트가 RED로 실패함을 확인했다. 이후 PONG 수신 시 heartbeatTimeoutDisposable을 해제만 하도록 변경했고 같은 테스트가 GREEN으로 전환됨을 확인했다.
  • Task 11.4: access token refresh 시 WebSocket 재연결

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModel.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
    • 작업:
      • token provider가 이전 handshake token과 다른 token을 반환하면 기존 WebSocket을 close한다.
      • 새 token으로 WebSocket handshake를 다시 수행한다.
      • 재연결 후 현재 roomIdJOIN_ROOM을 다시 보낸다.
      • token 갱신 중 화면이 종료되면 reconnect를 진행하지 않는다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1
      • Expected: token 변경 감지, 기존 socket close, 새 Authorization header 연결, JOIN_ROOM 재전송, 화면 종료 시 중단 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRoomViewModelTest에 mutable token provider 기반 token이 변경되면 기존 socket을 close하고 새 token으로 다시 JOIN_ROOM을 보낸다, leave 이후 token이 변경되어도 socket reconnect를 진행하지 않는다 테스트를 추가했다. RED 단계에서 기존 connected guard가 token 변경을 무시하는 assertion 실패를 확인했고, connectRealtime()에서 같은 room의 token 변경을 connected/joining 조기 return보다 먼저 감지해 기존 socket close 후 새 token으로 WebSocket handshake와 JOIN_ROOM을 수행하도록 수정했다. 화면 종료 후에는 leaveRealtime() guard로 reconnect가 진행되지 않음을 확인했다.
      • 2026-06-18: Phase 11 최종 검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. blocker 수정 후에도 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 재확인했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다.
      • 2026-06-18: Phase 11 코드 리뷰 및 재검증을 수행했다. DmChatRoomViewModelLEAVE_ROOM 후 close, 중복 leave 방지, reconnect 예약 시 최신 token 사용, JOINED 이후 heartbeat 시작, PONG 수신 시 timeout 해제, heartbeat timeout 후 close/reconnect, token 변경 시 기존 socket close 후 새 token으로 JOIN_ROOM 재전송 흐름을 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatSocketClientTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. 최초 샌드박스 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.

Phase 12: 푸시 진입과 제거 endpoint 회귀 검증

2026-06-19 추가 계약 변경: DM 채팅 FCM payload는 chat_typeroom_id를 보내지 않고, ${URISCHEME}://chat/{roomId} 형식의 deep_link만 전달한다. Task 12.1~12.2는 이전 payload 계약 기준 완료 이력으로 보존하고, 새 계약 반영은 Task 12.5에서 진행한다.

  • Task 12.1: FCM payload에 chat_type 전달 추가

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingServiceSourceTest.kt
    • 작업:
      • sendNotification()에서 messageData["chat_type"]이 있으면 Constants.EXTRA_DATA bundle에 chat_type을 보존한다.
      • 기존 room_id, message_id, deep_link_value 전달은 유지한다.
      • deep link URL이 있는 경우에도 chat_type/room_id가 필요한지 서버 payload 정책을 확인하고, URL 우선 정책을 유지한다면 이 동작을 테스트에 명시한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --max-workers=1
      • Expected: chat_typeroom_id가 notification intent extras에 포함되는 source test가 PASS.
    • 검증 기록:
      • 2026-06-18: SodaFirebaseMessagingServiceSourceTest를 추가해 chat_type, room_id, message_id, deep_link_valueConstants.EXTRA_DATA bundle에 보존되는지 source test로 고정했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --max-workers=1 실행 결과 SodaFirebaseMessagingServiceSourceTest.kt:15 assertion failure로 RED를 확인했다. 이후 SodaFirebaseMessagingService.sendNotification()의 non-URL extras bundle에 messageData["chat_type"] 복사를 추가했고 같은 테스트 PASS를 확인했다.
      • 2026-06-18: Phase 12 코드 리뷰에서 FCM payload에 deepLink가 함께 있는 경우 기존 구현이 deep_link만 extra bundle에 보존하고 chat_type/room_id를 누락해 USER_CREATOR DM 라우팅이 일반 딥링크 흐름으로 빠질 수 있음을 확인했다. SodaFirebaseMessagingServiceSourceTest에 deepLink payload에서도 chat_type/room_id를 보존하는 회귀 테스트를 추가했고, 수정 전 SodaFirebaseMessagingServiceSourceTest.kt:32 assertion failure RED를 확인했다. 이후 deepLink URL 분기에도 messageData["chat_type"], messageData["room_id"] 복사를 추가했고 같은 테스트 PASS를 확인했다.
  • Task 12.2: USER_CREATOR 푸시를 DM 채팅방으로 라우팅

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt
    • 작업:
      • chat_type == "USER_CREATOR"이고 room_id가 Long으로 변환 가능하면 DmChatRoomActivity.newIntentByRoomId(context, roomId)로 이동한다.
      • 푸시로 진입한 채팅방도 일반 진입과 동일하게 OpenRoom 후 WebSocket 연결/JOIN_ROOM을 수행한다.
      • room_id가 없거나 잘못된 경우 DM 채팅방을 열지 않고 기존 fallback 흐름을 따른다.
      • 기존 live/content/message deep link 처리는 변경하지 않는다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1
      • Expected: USER_CREATOR push routing, invalid room fallback, 기존 deep link 유지 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DeepLinkActivitySourceTestMainV2ActivitySourceTest를 추가해 chat_type == "USER_CREATOR"와 valid room_id 조합이 DmChatRoomActivity.newIntentByRoomId()로 라우팅되는지 source test로 고정했다. 수정 전 실행 결과 두 테스트가 각각 DeepLinkActivitySourceTest.kt:13, MainV2ActivitySourceTest.kt:13 assertion failure로 RED가 됨을 확인했다. 이후 DeepLinkActivityConstants.EXTRA_DATA에서 chat_type을 복사하고 foreground 라우팅에서 USER_CREATOR DM 분기를 기존 live room fallback보다 먼저 처리하도록 변경했다. MainV2Activity도 Splash/Login 이후 전달된 bundle에서 같은 USER_CREATOR DM 분기를 기존 channel/content/message routing보다 먼저 처리하도록 변경했고, 동일 테스트 PASS를 확인했다.
      • 2026-06-18: 리뷰 게이트에서 LiveRoom foreground 상태일 때 ACTION_LIVE_ROOM_DEEPLINK_CONFIRM 브로드캐스트가 USER_CREATOR DM push를 먼저 가로챌 수 있다는 blocker를 확인했다. DeepLinkActivitySourceTestrouteForegroundDeepLink(deepLinkExtras)가 LiveRoom foreground confirm broadcast보다 먼저 실행되는 순서 회귀 테스트를 추가했고, 수정 전 DeepLinkActivitySourceTest.kt:28 assertion failure로 RED를 확인했다. 이후 DeepLinkActivity.onCreate()에서 foreground USER_CREATOR extras는 LiveRoom 브로드캐스트 전에 routeForegroundDeepLink(deepLinkExtras)를 먼저 시도하도록 변경했고, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --max-workers=1 PASS를 확인했다.
      • 2026-06-18: Phase 12 코드 리뷰에서 URL query에 chat_type=USER_CREATOR가 포함된 deepLink 진입은 DeepLinkActivity.buildDeepLinkExtras()room_id는 복사하지만 chat_type은 복사하지 않아 DM 분기를 탈 수 없음을 확인했다. DeepLinkActivitySourceTest에 URL query chat_type 보존 테스트를 추가했고, 수정 전 DeepLinkActivitySourceTest.kt:28 assertion failure RED를 확인했다. 이후 data query 수집 목록에 putQuery("chat_type")을 추가했고 같은 테스트 PASS를 확인했다.
  • Task 12.3: 제거 endpoint 호출 회귀 방지 테스트 추가

    • Files:
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRoomViewModelTest.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt
      • Create: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRemovedEndpointSourceTest.kt
    • 작업:
      • source test로 main DM 채팅 코드에서 아래 문자열이 남아 있지 않은지 확인한다.
        • /api/v2/user-creator-chat/rooms/{roomId}/events
        • /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect
        • /api/v2/user-creator-chat/rooms/{roomId}/messages/text
        • Accept: text/event-stream
        • EventSource
      • ViewModel 테스트에서 텍스트 전송이 REST repository method를 호출하지 않고 WebSocket SEND_TEXT를 호출함을 검증한다.
      • lifecycle close 테스트에서 REST disconnect method가 호출되지 않고 LEAVE_ROOM + close만 호출됨을 검증한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1
      • Expected: 제거 endpoint 문자열 없음, 텍스트 전송 WebSocket 사용, leave/close WebSocket 사용 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: DmChatRemovedEndpointSourceTest를 추가해 active DM 채팅 경로(DmChatRoomActivity, DmChatRoomViewModel, DmChatApi, DmChatRepository, DmChatSocketClient)에 /events, events/disconnect, messages/text, text/event-stream, EventSource 문자열이 남지 않는지 source test로 고정했다. 또한 DmChatRoomViewModel의 텍스트 전송은 repository.sendSocketText(, lifecycle 종료는 repository.sendLeaveRoom(roomId)repository.closeSocket()을 사용하는지 확인했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1 PASS를 확인했다.
  • Task 12.4: 음성 메시지 REST 유지 범위 확인

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatApi.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm/data/DmChatRepository.kt
      • Modify: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/DmChatRepositoryTest.kt
    • 작업:
      • 현재 DM 채팅 화면에서 음성 메시지 전송 UI가 없으면 API 추가 없이 DTO의 voiceMessageUrl 보존만 유지한다.
      • DM 음성 전송 UI가 이미 연결되어 있거나 서버 배포 범위에서 필요하면 POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice multipart API만 추가한다.
      • 음성 전송 경로는 WebSocket SEND_TEXT/SEND_ACK pending 정책과 섞지 않는다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1
      • Expected: 음성 API를 추가한 경우 multipart endpoint만 검증되고, 추가하지 않은 경우 기존 DTO 보존 테스트가 PASS.
    • 검증 기록:
      • 2026-06-18: 현재 DM 채팅 화면에는 음성 전송 UI가 연결되어 있지 않아 신규 multipart API를 추가하지 않았다. DmChatRemovedEndpointSourceTestvoiceMessageUrl DTO 필드는 보존하되 DmChatApimessages/voiceMultipart가 없고 DmChatRepositorysendDmVoiceMessage가 없음을 확인하는 source test를 추가했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1 PASS를 확인했다.
      • 2026-06-18: Phase 12 코드 리뷰 및 통합 검증으로 FCM chat_type 전달, foreground/LiveRoom foreground/Splash/Login 이후 USER_CREATOR DM 라우팅, 제거 endpoint 회귀 방지, 음성 DTO 보존 범위를 재검토했다. deepLink payload/query의 chat_type 누락 blocker 2건은 위 회귀 테스트로 고정 후 수정했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. 최초 샌드박스 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다.
      • 2026-06-19: Phase 12 최신 코드 리뷰로 FCM deepLink/non-deepLink payload의 chat_type/room_id 보존, DeepLinkActivity foreground USER_CREATOR DM 선분기, URL query chat_type 보존, MainV2Activity Splash/Login 이후 USER_CREATOR DM 라우팅, 제거 endpoint 문자열 회귀 방지, 음성 DTO 보존 범위를 재확인했다. 추가 blocking issue는 발견하지 못했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRemovedEndpointSourceTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRepositoryTest" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. 최초 샌드박스 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. Gradle deprecation warning은 출력됐지만 실패는 없었다.
  • Task 12.5: FCM deep_link 단독 payload로 DM 채팅방 라우팅 변경

    • Files:
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingService.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/main/DeepLinkActivity.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt
      • Modify: app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/fcm/SodaFirebaseMessagingServiceSourceTest.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/main/DeepLinkActivitySourceTest.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/main/MainActivitySourceTest.kt
      • Test: app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt
    • 작업:
      • 서버 FCM payload는 chat_typeroom_id를 보내지 않고 deep_link만 보낸다는 최신 계약을 반영한다.
      • DM 채팅방 푸시 deep link 형식은 ${URISCHEME}://chat/{roomId}로 정의한다.
      • SodaFirebaseMessagingServicemessageData["deep_link"]를 notification intent extras 또는 URL deep link 경로로 보존한다.
      • DeepLinkActivitychat_type/room_id 없이 deep link path /chat/{roomId}에서 roomId를 파싱해 DmChatRoomActivity.newIntentByRoomId(context, roomId)로 이동한다.
      • LiveRoom foreground confirm 이후 legacy MainActivity로 전달되는 deep link도 같은 /chat/{roomId} 규칙으로 DM 채팅방에 연결한다.
      • Splash/Login 이후 MainV2Activity로 전달되는 deep link도 같은 /chat/{roomId} 규칙으로 DM 채팅방에 연결한다.
      • roomId가 없거나 Long으로 변환할 수 없으면 DM 채팅방을 열지 않고 기존 fallback 흐름을 따른다.
      • chat_type 기반 DM 라우팅은 배포 전 폐기된 계약이므로 호환 경로로 남기지 않고 제거한다.
    • 검증:
      • Run: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1
      • Expected: deep_link=${URISCHEME}://chat/{roomId} 단독 payload가 chat_type/room_id 없이 DM 채팅방으로 라우팅되고, invalid room fallback과 기존 deep link 흐름 유지 테스트가 PASS.
    • 검증 기록:
      • 2026-06-19: SodaFirebaseMessagingServiceSourceTestdeep_link 단독 payload가 ACTION_VIEW data와 deep_link extra로 보존되는지 확인하는 source test를 추가했다. DeepLinkActivitySourceTestMainV2ActivitySourceTest에는 ${URISCHEME}://chat/{roomId} path가 chat_type/room_id 없이 room_id, deep_link_value=chat, deep_link_sub5로 정규화되고 기존 live room fallback보다 먼저 DmChatRoomActivity.newIntentByRoomId()로 라우팅되는지 확인하는 source test를 추가했다. 수정 전 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1 실행 결과 DeepLinkActivitySourceTest.kt:52, MainV2ActivitySourceTest.kt:29 assertion failure로 RED를 확인했다.
      • 2026-06-19: DeepLinkActivity.applyPathDeepLink()MainV2Activity.applyPathDeepLink()chat path 매핑을 추가해 room_id, deep_link_value=chat, deep_link_sub5를 채우도록 변경했다. 기존 chat_type=USER_CREATOR 호환 경로는 유지하면서 deep_link_value=chat도 DM 채팅방으로 라우팅하도록 isDmChatDeepLink()를 추가했다. 이후 같은 source test 명령 재실행 결과 PASS를 확인했다.
      • 2026-06-19: Task 12.5 최종 검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다. adb devices 결과 연결된 Android 기기가 없어 실제 앱에서 푸시 터치 또는 deep link launch 수동 확인은 수행하지 못했다.
      • 2026-06-19: 리뷰 게이트에서 LiveRoom foreground 상태의 DeepLinkActivity/chat/{roomId}ACTION_LIVE_ROOM_DEEPLINK_CONFIRM broadcast로 먼저 넘길 수 있고, 해당 confirm 후 진입하는 legacy MainActivity에도 chat path DM 라우팅이 누락됐다는 blocker를 확인했다. DeepLinkActivitySourceTest와 신규 MainActivitySourceTest에 재현 테스트를 추가했고, 수정 전 DeepLinkActivitySourceTest.kt:49, MainActivitySourceTest.kt:17 assertion failure로 RED를 확인했다. 이후 DeepLinkActivity foreground 선분기 조건을 isDmChatDeepLink(deepLinkExtras)로 변경하고, MainActivitychat path 정규화와 DmChatRoomActivity.newIntentByRoomId() DM 라우팅을 추가했다. 재실행 결과 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다.
      • 2026-06-19: 코드 품질 재리뷰에서 deep_link와 기존 chat_type=USER_CREATOR/room_id sidecar가 함께 전달되는 호환 payload의 경우 MainV2ActivityMainActivity가 deep link URL 파싱 결과를 원본 bundle 대신 사용해 sidecar를 잃을 수 있다는 blocker를 확인했다. MainV2ActivitySourceTestMainActivitySourceTest에 sidecar 보존 테스트를 추가했고, 수정 전 MainV2ActivitySourceTest.kt:26, MainActivitySourceTest.kt:13 assertion failure로 RED를 확인했다. 이후 deep link URL 파싱 결과를 Bundle(bundle) 위에 putAll()로 merge하고, query room_id/chat_type 보존 및 MainActivityisUserCreatorChat() 호환 판정을 추가했다. 재실행 결과 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1 PASS를 확인했다.
      • 2026-06-19: Task 12.5 코드 리뷰 및 검증으로 SodaFirebaseMessagingService, DeepLinkActivity, MainActivity, MainV2Activity의 deep link 수집/정규화/라우팅 분기와 source test를 재검토했다. 리뷰 중 deep_link만 있는 data payload에서 sendNotification() 호출 조건을 통과하지 못해 ACTION_VIEW data와 deep_link extra 보존 코드가 실행되지 않을 수 있는 blocker를 확인했다. SodaFirebaseMessagingServiceSourceTesthasDeepLink(remoteMessage.data) dispatch 조건 검증을 추가하고, SodaFirebaseMessagingServicedeepLink/deep_link 존재 시 기존 notification 생성 경로로 진입하는 최소 수정만 적용했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. 최초 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했다. ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐고, 승인된 adb devices 결과 연결 기기가 없어 실제 푸시 터치/deep link launch 수동 확인은 수행하지 못했다.
      • 2026-06-19: 계약이 배포 전 deep_link=${URISCHEME}://chat/{roomId} 단독 payload로 확정되어 chat_type 호환 경로를 제거했다. SodaFirebaseMessagingServicechat_type extra 복사, DeepLinkActivity/MainActivity/MainV2Activitychat_type query 수집과 isUserCreatorChat() 판정을 제거하고, DM 라우팅은 deep_link_value == "chat"만 사용하도록 정리했다. source test는 active source에 chat_type/isUserCreatorChat이 남지 않는지 검증하도록 변경했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.SodaFirebaseMessagingServiceSourceTest" --tests "kr.co.vividnext.sodalive.main.DeepLinkActivitySourceTest" --tests "kr.co.vividnext.sodalive.main.MainActivitySourceTest" --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. active main 코드 대상 rg "chat_type|isUserCreatorChat|USER_CREATOR" ... 결과는 없음이며, test 코드에는 부재 검증 assertion만 남는다. 최초 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, ktlintCheck에서는 기존 .editorconfigdisabled_rules deprecation warning만 출력됐다.

Phase 13: WebSocket 전환 최종 검증과 수동 확인

  • Task 13.1: DM 채팅 WebSocket 단위 테스트 실행

    • Files:
      • Check: app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm/*Test.kt
    • Run:
      • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1
    • Expected:
      • DM 채팅 WebSocket 전환 관련 단위 테스트가 모두 PASS.
    • 검증 기록:
      • 2026-06-19: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1 실행 결과 BUILD SUCCESSFUL을 확인했다.
  • Task 13.2: 푸시/딥링크 라우팅 테스트 실행

    • Files:
      • Check: app/src/test/java/kr/co/vividnext/sodalive/fcm/*Test.kt
      • Check: app/src/test/java/kr/co/vividnext/sodalive/main/*Test.kt
      • Check: app/src/test/java/kr/co/vividnext/sodalive/v2/main/*Test.kt
    • Run:
      • ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1
    • Expected:
      • deep_link=${URISCHEME}://chat/{roomId} push routing과 기존 deep link 회귀 테스트가 PASS.
    • 검증 기록:
      • 2026-06-19: ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1 실행 결과 BUILD SUCCESSFUL을 확인했다.
  • Task 13.3: 빌드/스타일/문자열 회귀 확인

    • Files:
      • Check: Gradle project
      • Check: app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm
    • Run:
      • ./gradlew :app:compileDebugKotlin --max-workers=1
      • ./gradlew :app:ktlintCheck --max-workers=1
      • git diff --check
      • rg "messages/text|events/disconnect|text/event-stream|EventSource" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/dm
    • Expected:
      • Kotlin compile, ktlint, whitespace check PASS.
      • 제거 endpoint 문자열은 삭제 검증 테스트나 과거 이력 문서를 제외한 신규 DM main/test 코드에 남지 않는다.
    • 검증 기록:
      • 2026-06-19: ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check를 실행해 모두 PASS를 확인했다. Gradle 실행에서는 기존 deprecation warning이 출력됐지만 실패는 없었다.
      • 2026-06-19: rg "messages/text|events/disconnect|text/event-stream|EventSource" app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/dm 결과 없음으로 active DM main 코드에 제거 endpoint 문자열이 남지 않음을 확인했다. 동일 패턴을 DM test 경로까지 넓히면 DmChatRemovedEndpointSourceTest의 금지 문자열 assertion과 과거 SSE 이력 테스트인 DmChatEventClientTesttext/event-stream fixture만 매칭됨을 확인했다.
  • Task 13.4: WebSocket 전환 수동 확인

    • Files:
      • Check: DmChatRoomActivity
      • Check: SodaFirebaseMessagingService
      • Check: DeepLinkActivity
      • Check: MainV2Activity
    • 확인 항목:
      • 채팅 탭 DM item 클릭 시 OpenRoom 호출 후 WebSocket /ws/v2/user-creator-chat 연결과 JOIN_ROOM 전송이 발생한다.
      • JOINED 수신 전에는 실시간 수신 상태로 판단하지 않는다.
      • 상대방 메시지 MESSAGE 수신 시 현재 채팅방 메시지 목록에 append된다.
      • 텍스트 전송 시 REST /messages/text가 호출되지 않고 SEND_TEXT가 전송된다.
      • SEND_ACK 수신 시 pending 메시지가 서버 messageId, createdAt, 프로필 정보로 확정된다.
      • ERROR 또는 timeout 시 pending 메시지가 실패 상태와 재시도 버튼을 표시한다.
      • 화면 이탈, 앱 background, 로그아웃 시 LEAVE_ROOM 전송 후 socket close가 발생하고 /events/disconnect는 호출되지 않는다.
      • 네트워크 오류 후 같은 채팅방 화면에 남아 있으면 reconnect 후 JOIN_ROOM과 누락 메시지 동기화가 수행된다.
      • 화면 밖에서는 reconnect가 예약/실행되지 않는다.
      • heartbeat PING/PONG timeout 시 연결 상태가 disconnected로 전환된다.
      • DM push 터치 시 deep_link=${URISCHEME}://chat/{roomId} 기준 DM 채팅방에 진입하고 일반 진입과 동일하게 OpenRoom 후 WebSocket join을 수행한다.
    • 검증 기록:
      • 2026-06-19: DmChatRoomViewModelTest, DmChatSocketClientTest, DmChatRemovedEndpointSourceTest, SodaFirebaseMessagingServiceSourceTest, DeepLinkActivitySourceTest, MainV2ActivitySourceTest로 WebSocket join/send/ack/error/timeout/retry/reconnect/heartbeat, 제거 endpoint 미사용, USER_CREATOR push/deep link 라우팅의 자동 검증 PASS를 확인했다. 단, adb devices 결과 연결된 Android 기기가 없어 실제 앱 화면과 실제 서버/WebSocket을 통한 수동 확인은 수행하지 못했으므로 Task 13.4는 미완료로 유지한다.
      • 2026-06-19: Phase 13 코드 리뷰로 DmChatRoomActivity, DmChatRoomViewModel, DmChatSocketClient, DmChatSocketModels, DmChatRepository, DmChatApi의 WebSocket 연결/해제, JOINED 기준 연결 확정, SEND_TEXT/SEND_ACK pending 처리, timeout/retry/reconnect/heartbeat, 제거 REST endpoint 미사용 경로를 재검토했고 blocking issue는 발견하지 못했다. 재검증으로 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.fcm.*" --tests "kr.co.vividnext.sodalive.main.*" --tests "kr.co.vividnext.sodalive.v2.main.*" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. active DM main 코드의 제거 endpoint 문자열 검색 결과는 없음이며, DM test 경로까지 확장하면 삭제 검증 테스트와 과거 SSE 이력 테스트 fixture만 매칭된다. 최초 Gradle 실행은 ~/.gradle wrapper lock 파일 접근 제한으로 실패해 승인된 Gradle 실행으로 재검증했고, adb devices는 승인된 실행에서도 연결된 기기가 없어 실제 앱/서버 WebSocket 수동 확인은 미완료로 유지한다.

5. 검증 기록

  • 2026-06-10: docs/20260610_DM_채팅화면/prd.md를 확인해 DM 채팅방 진입, UI 제거 대상, REST API, SSE 이벤트, pagination, 전송 실패/재시도, lifecycle disconnect 요구사항을 계획에 반영했다.

  • 2026-06-10: docs/agent-guides/work-plan-docs.md, docs/agent-guides/build-test-style.md, docs/agent-guides/code-style.md를 확인해 신규 계획 문서 위치, phase/task 체크박스 형식, 테스트 명령 작성 방식을 확인했다.

  • 2026-06-10: ChatRoomActivity.kt, activity_chat_room.xml, ChatRoomApi.kt, ChatRoomModels.kt, ChatRoomRepository.kt, ChatMainFragment.kt, AppDI.kt, AndroidManifest.xml을 확인해 기존 채팅 화면 구조, v2 API/Repository 패턴, DI/Manifest 등록 위치, DM item 클릭 연결 지점을 확인했다.

  • 2026-06-10: rgEventSource, 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"를 실행해 DmChatMessageResponsedm.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에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.

  • 2026-06-10: DmChatMappers.ktDmChatMapperTest.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에서는 기존 .editorconfigdisabled_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"를 실행해 DmChatRoomViewModelDmChatRoomUiState 심볼 부재로 실패하는 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에서는 기존 .editorconfigdisabled_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_messagesheader_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에서는 기존 .editorconfigdisabled_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.xmlguideline_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에서는 기존 .editorconfigdisabled_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=3000ms 기준 재연결 예약, 재연결 성공 후 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.8roomOpenedEventLiveData 스티키 재전달 방지를 다룬다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.

  • 2026-06-11: Phase 5.5~5.8 범위로 예약 재연결 실행 시 connectRealtime()를 scheduler thread에서 직접 호출하지 않고 main callback 경로로 전달하도록 수정했다. disconnect 이후 예약 재연결이 실행되어도 shouldReconnectRealtime 재확인으로 새 SSE 연결이 남지 않도록 했고, roomOpenedEventLiveDataDmChatEvent<Boolean> 소비형 이벤트로 바꿔 observer 재등록만으로 realtime connect가 반복 트리거되지 않도록 했다. SSE 재연결 정책은 PRD의 서버 reconnectTime=3000ms 및 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에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.

  • 2026-06-11: Phase 6.1~6.5 범위로 AppDI.ktDmChatApi, DmChatRealtimeClient/DmChatEventClient, DmChatRepository, DmChatRoomViewModel DI 등록을 추가하고, AndroidManifest.xml.v2.main.chat.dm.DmChatRoomActivitystateAlwaysHidden|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에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다. Phase 7.4 수동 화면 확인은 현재 연결된 기기/에뮬레이터 실기 실행 증거가 없어 완료 처리하지 않았다.

  • 2026-06-11: Phase 6, 7 코드리뷰 개선사항을 후속 Task로 추가했다. Task 6.6DmChatRoomViewModel.onCleared()에서 main handler callback, disposable, realtime client 정리를 보장하는 작업이고, Task 6.7DmChatEventClientTestgetDeclaredField("okHttpClient") reflection 의존을 동작 기반 검증으로 대체하거나 유지 사유를 명확히 남기는 작업이다. 이번 변경은 문서 갱신만 수행했으며 Android 빌드/테스트는 실행하지 않았다.

  • 2026-06-11: Phase 6.6 RED 단계에서 DmChatRoomViewModelTestonCleared() 정리 테스트를 추가했고, 기존 구현에서 onCleared() 오버라이드 및 mainHandler.removeCallbacksAndMessages(null) 부재로 실패를 확인했다. 이후 DmChatRoomViewModel.onCleared()에서 예약된 main handler callback 제거, reconnectDisposable dispose, realtime client cancel을 수행하고 super.onCleared()로 기존 compositeDisposable.dispose() 정리를 유지하도록 수정했다.

  • 2026-06-11: Phase 6.7에서 DmChatEventClientTestgetDeclaredField("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에서는 기존 .editorconfigdisabled_rules deprecation warning이 출력됐지만 실패는 없었다.

  • 2026-06-11: Phase 6.6 리뷰 후속으로 onCleared() 테스트를 source 문자열 검증에서 런타임 동작 검증으로 보강했다. 예약된 realtime 재연결이 onCleared() 후 실행되지 않는지, background thread에서 예약된 mainHandler.post { action() } callback이 onCleared() 후 상태를 갱신하지 않는지 확인하도록 수정했다. Phase 6.7 테스트에는 requestLatch.await(2, TimeUnit.SECONDS) 반환값 assertion을 추가해 timeout 검증 의도를 명확히 했다. 이후 ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatEventClientTest" --max-workers=1, ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.*" --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1 PASS를 확인했다.

  • 2026-06-18: docs/20260610_DM_채팅화면/prd.md의 WebSocket 전환 요구사항을 기준으로 plan-task.md를 보강했다. 기존 Phase 18의 SSE 구현 이력은 보존하고, Phase 913에 WebSocket 모델/클라이언트, Repository/DI 전환, JOIN_ROOM/JOINED, MESSAGE, SEND_TEXT/SEND_ACK, LEAVE_ROOM/close, reconnect/heartbeat/token refresh, USER_CREATOR push routing, 제거 endpoint 회귀 검증, 최종 수동 확인 작업을 추가했다.

  • 2026-06-18: 현재 코드 기준으로 DmChatApi/messages/text, /events/disconnect, DmChatEventClient, DmChatRoomViewModel.sendText() REST 전송, disconnectRealtime() SSE 해제 경로가 남아 있음을 확인했고, 이를 Phase 9~13에서 제거/교체할 대상으로 문서화했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.

  • 2026-06-18: Phase 10의 Task 10.1~10.3 코드 리뷰 및 검증을 수행했다. DmChatRoomViewModelJOINED 기준 연결 확정, WebSocket MESSAGE 병합, requestId 단위 pending/SEND_ACK/ERROR/timeout/retry 처리를 확인했고 blocking issue는 발견하지 못했다. ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest" --max-workers=1, ./gradlew :app:compileDebugKotlin --max-workers=1, ./gradlew :app:ktlintCheck --max-workers=1, git diff --check PASS를 확인했다. ktlintCheck와 Gradle 실행에서는 기존 Gradle deprecation warning만 출력됐고 실패는 없었다. Task 10.4의 MESSAGE/SEND_ACK race와 Task 10.5 회귀 테스트 정리는 후속 미완료 범위로 유지한다.

  • 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 파일 구조와 Phase 13 수동 확인 항목의 푸시 진입 기준을 deep_link=${URISCHEME}://chat/{roomId}로 갱신했다. Phase 12에는 기존 chat_type/room_id 기준 완료 이력을 보존한다는 주석을 추가하고, 새 계약 반영 범위를 미완료 Task 12.5로 추가했다. 이번 단계는 계획 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다.