# PRD: DM 채팅화면 ## 1. Overview `ChatRoomActivity`와 유사한 DM 채팅방 상세 화면을 제공하고, 사용자와 크리에이터 간 DM 메시지를 `user-creator-chat` REST API와 WebSocket 기반 실시간 이벤트로 송수신한다. --- ## 2. Problem - 기존 `ChatRoomActivity`는 AI 캐릭터 채팅방 기준 화면으로, 캐릭터 타입 배지, CAN 배지, 더보기, 안내 메시지, 쿼터/유료 메시지 흐름이 포함되어 있다. - 채팅 탭의 DM item 클릭 시 이동할 DM 상세 화면이 아직 별도 범위로 구현되어 있지 않다. - DM 채팅은 AI 채팅과 다르게 크리에이터와 사용자 간 메시지 송수신, WebSocket 실시간 연결/해제, 커서 기반 과거 메시지 조회가 핵심이다. - 이전 실시간 구현 흔적과 제거된 `/events` 계열 endpoint 정의가 남아 있으면 서버 계약과 혼동되고, raw WebSocket 동작 검증도 어려워진다. - REST pagination과 WebSocket 실시간 수신 결과가 겹칠 수 있으므로 메시지 병합/중복 제거 기준이 필요하다. - 텍스트 메시지 전송 성공 판단이 REST 응답의 `deliveredRealtime`/`pushSent`가 아니라 WebSocket `SEND_ACK`/`ERROR`/timeout 기준으로 바뀌므로 pending 메시지 매칭 기준이 필요하다. - 크리에이터 채널에서 `DM 보내기`를 눌러 `creatorId` 기반으로 `DmChatRoomActivity`에 진입하면 `DmChatRoomViewModel.emitContent()`가 background thread에서 `MutableLiveData.setValue()`를 호출해 앱이 crash 된다. - 2026-07-10 확인 결과, 현재 WebSocket 문서/구현 일부는 최신 서버 계약과 다르게 `roomId`/`requestId`를 root가 아닌 payload 안에 넣거나, 서버 payload를 `{message: ...}` wrapper로 파싱하는 전제에 기대고 있다. 최신 계약은 raw WebSocket JSON의 root에 `type`, `requestId`, `roomId`, `payload`를 둔다. - WebSocket 전환 후 `AppDI`/Repository 경로는 `DmChatSocketClient`를 사용하지만, 기존 realtime 구현 파일 `DmChatEventClient.kt`와 `/api/v2/user-creator-chat/rooms/{roomId}/events` URL 정의가 main source에 남아 있다. 실제 호출 여부와 별개로 최신 계약 보정 시 제거해야 한다. - 현재 채팅 UI는 말풍선 정렬과 전송 UI 범위가 불명확하다. 상대방 메시지는 왼쪽, 내가 보낸 메시지는 오른쪽으로 고정하고, 현재 구현된 채팅 버블을 그대로 사용해야 한다. --- ## 3. Goals - 기존 `ChatRoomActivity`의 채팅방 상세 UI 구조를 최대한 재사용하되, DM에 맞지 않는 UI를 제거한 화면을 정의한다. - `CreateOrGetRoom`, `OpenRoom`, `GetMessages`, WebSocket 연결/방 참여/텍스트 전송/방 이탈 계약을 Android 프로젝트 네이밍에 맞게 정리한다. - DM 채팅방 진입 시 방 생성/조회 후 생성된 `roomId`로 채팅방을 열고 초기 메시지를 표시한다. - 사용자가 상단으로 스크롤하면 과거 메시지를 커서 기반으로 추가 조회한다. - 텍스트 메시지 전송 후 서버 응답 메시지를 화면에 반영한다. - 채팅방 화면 진입/이탈, 앱 foreground/background 전환, 로그아웃에 따른 WebSocket 연결/해제 정책을 정의한다. - WebSocket 메시지 타입, `JOINED` 기준 연결 확인, `MESSAGE` 수신, `SEND_TEXT`/`SEND_ACK` pending 확정, `PING`/`PONG` heartbeat, 재연결과 최신 메시지 동기화 기준을 정의한다. - raw WebSocket envelope를 최신 서버 계약 그대로 고정한다. STOMP/SockJS frame은 보내지 않고, 모든 client send JSON은 `type`, `requestId`, `roomId`, `payload` root field를 사용한다. - 제거된 `/events`, `/events/disconnect`, `/messages/text` endpoint와 관련 구현을 active DM 채팅 경로에서 제거한다. - DM 채팅 화면에서는 텍스트 메시지만 전송할 수 있도록 UI 범위를 고정한다. - 메시지 UI는 현재 구현된 채팅 버블을 유지하고, 상대방 메시지는 왼쪽 정렬, 내 메시지는 오른쪽 정렬로 고정한다. - 크리에이터 채널 `DM 보내기` 진입에서 방 생성/열기 완료 후 모든 `LiveData` 상태 갱신이 main thread에서 수행되어 background thread `setValue()` 예외가 발생하지 않도록 한다. --- ## 4. Non-Goals - AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다. - STOMP/SockJS `CONNECT`, `SUBSCRIBE`, `SEND` frame 전송은 포함하지 않는다. - SSE/EventSource 기반 실시간 연결은 구현하거나 유지하지 않는다. - `character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`는 DM 화면에 표시하지 않는다. - 음성 메시지 API/DTO는 구현하더라도 이번 화면 UI에서는 사용하지 않는다. - 음성 메시지 녹음, 전송 버튼, 재생 UI는 이번 범위에 포함하지 않는다. - 메시지 삭제, 신고, 차단, 알림 설정, 읽음 처리, unread count 실시간 갱신은 이번 범위에 포함하지 않는다. - 백엔드 API 스키마와 필드명을 Android에서 임의 변경하지 않는다. Kotlin class 이름만 프로젝트 가이드에 맞게 조정한다. - 기존 AI `ChatRoomActivity` 동작을 변경하거나 리팩터링하지 않는다. - `messageType=VOICE` 메시지는 DTO에는 보존하되 이번 구현 대상 UI에서 제외한다. --- ## 5. Target Users - 채팅 탭의 DM 채팅방에서 크리에이터와 1:1 메시지를 주고받으려는 앱 사용자. - `kr.co.vividnext.sodalive.v2.main.chat` 및 신규 DM 채팅 화면을 구현/유지보수하는 Android 개발자. --- ## 6. User Stories - 사용자는 크리에이터 프로필 또는 채팅 탭에서 DM 채팅방을 열고 싶다. - 사용자는 DM 채팅방에 들어왔을 때 상대 프로필, 상대 이름, 최근 메시지를 바로 보고 싶다. - 사용자는 텍스트를 입력해 크리에이터에게 메시지를 보낼 수 있어야 한다. - 사용자는 채팅 목록 상단으로 스크롤해 이전 대화를 이어서 확인하고 싶다. - 사용자는 화면을 벗어나거나 앱이 백그라운드로 전환될 때 WebSocket 실시간 연결이 안전하게 종료되기를 기대한다. - 사용자는 네트워크 오류가 발생해도 현재 채팅방 화면에 머무르는 동안에는 재연결 후 누락 메시지가 보정되기를 기대한다. - 사용자는 푸시 알림으로 DM 채팅방에 진입해도 일반 진입과 동일하게 초기 메시지 조회와 실시간 수신이 시작되기를 기대한다. --- ## 7. Core Features ### DM Chat Room Entry DM 채팅방을 생성하거나 기존 방을 조회한 뒤 상세 화면을 연다. #### Requirements - 크리에이터와의 DM 시작 진입점에서는 `creatorId`를 전달받아 `POST /api/v2/user-creator-chat/rooms/create`를 호출한다. - API 응답의 `roomId`를 기준으로 DM 채팅방 상세 화면을 연다. - 채팅 탭 DM item처럼 이미 `roomId`를 알고 있는 진입점은 방 생성/조회 API를 생략하고 바로 OpenRoom 흐름으로 진입할 수 있다. - `roomId <= 0`인 상태에서는 채팅방을 열지 않고 기존 Activity 종료/오류 처리 정책을 따른다. #### API Contract - Operation: `CreateOrGetRoom` - Method: `POST` - Path: `/api/v2/user-creator-chat/rooms/create` - Request: `CreateUserCreatorChatRoomRequest` - Response: `CreateUserCreatorChatRoomResponse` ```kotlin data class CreateUserCreatorChatRoomRequest( val creatorId: Long ) data class CreateUserCreatorChatRoomResponse( val roomId: Long ) ``` #### Naming Requirements - Android DTO 이름은 기능 도메인과 현재 프로젝트 가이드를 고려해 `CreateDmChatRoomRequest`, `CreateDmChatRoomResponse` 또는 동등하게 명확한 이름으로 변경한다. - 파일/패키지는 신규 v2 화면 원칙에 따라 `kr.co.vividnext.sodalive.v2` 하위에 둔다. ### DM Chat Room UI 기존 `activity_chat_room.xml`과 유사한 전체 구조를 유지하되 DM에 맞지 않는 요소를 제거한다. #### Requirements - 전체 화면은 기존 채팅방처럼 배경 이미지, dim, header, message RecyclerView, input 영역으로 구성한다. - header에는 뒤로가기, 상대 프로필 이미지, 상대 닉네임을 표시한다. - 아래 UI는 표시하지 않는다. - `character_type_badge` - `ll_can_badge` - 더보기 버튼 `iv_more` - 안내 메시지 영역 `notice_container` - `rv_messages`의 top constraint는 `notice_container`가 아닌 `header_container` 하단을 기준으로 조정한다. - 입력 영역은 기존 `et_message`, `iv_send`와 유사하게 텍스트 입력 및 전송 버튼을 제공한다. - 전송 버튼은 입력값이 blank이면 비활성화하고, 입력값이 있으면 활성화한다. - DM 채팅방 입력 UI는 텍스트 전송만 제공한다. - 음성 녹음, 음성 전송, 음성 재생 컨트롤은 이번 화면에 노출하지 않는다. - 상대방 메시지는 왼쪽 정렬로 표시한다. - 내가 보낸 메시지는 오른쪽 정렬로 표시한다. - 채팅 버블 drawable/style은 현재 구현된 DM 채팅 버블을 그대로 사용하고, 신규 버블 디자인을 만들지 않는다. - DM 메시지 item에는 `DmChatMessageUiItem.createdAt`을 기존 메시지 시간 포맷터(`formatMessageTime`)로 변환한 시간을 표시한다. - 채팅 탭 목록의 empty state 문구는 탭별 정책을 따른다. 전체 탭은 `아직 나눈 대화가 없어요.\n크리에이터와 이야기를 시작해 보세요.`, AI 채팅 탭은 `아직 나눈 대화가 없어요.\nAI 크리에이터와 대화를 시작해 보세요.`, DM 탭은 `아직 나눈 대화가 없어요.\n좋아하는 크리에이터에게 메세지를 보내보세요.`를 표시한다. #### Edge Cases - 상대 프로필 이미지 URL이 비어 있거나 로딩 실패하면 기존 placeholder 정책을 따른다. - 상대 닉네임이 비어 있으면 빈 문자열 그대로 표시하고 임의 대체 문구를 추가하지 않는다. - 키보드 표시, IME send, 화면 하단 padding/inset은 기존 `ChatRoomActivity`의 사용자 경험을 우선 참고한다. ### Open Room And Initial Messages 생성되었거나 기존에 존재하는 DM 채팅방을 열고 최신 메시지를 표시한다. #### Requirements - 화면 진입 시 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`을 호출한다. - `limit` query parameter 기본값은 `20`으로 설정한다. - 응답의 `messages`를 오래된 메시지부터 최신 메시지 순으로 정렬해 표시한다. - `hasMore`, `nextCursor`를 저장해 과거 메시지 조회 상태로 사용한다. - 응답의 `opponentNickname`, `opponentProfileImageUrl`을 header 상대 정보로 사용한다. - `mine=true`인 메시지는 오른쪽 정렬 사용자 발신 말풍선으로 표시한다. - `mine=false`인 메시지는 왼쪽 정렬 상대방 발신 말풍선으로 표시한다. - `senderNickname`, `senderProfileImageUrl`은 상대 메시지 item 표시와 메시지별 발신자 정보가 필요한 경우에 활용한다. #### API Contract - Operation: `OpenRoom` - Method: `GET` - Path: `/api/v2/user-creator-chat/rooms/{roomId}/open` - Query: `limit=20` - Response: `UserCreatorChatRoomOpenResponse` ```kotlin data class UserCreatorChatRoomOpenResponse( val roomId: Long, val opponentNickname: String, val opponentProfileImageUrl: String, val messages: List, val hasMore: Boolean, val nextCursor: Long? ) ``` ### Creator Channel DM Entry Crash Fix 크리에이터 채널의 `DM 보내기` 버튼에서 DM 채팅방으로 이동할 때 앱이 종료되지 않도록 한다. #### Requirements - `DmChatRoomActivity.newIntentByCreatorId(context, creatorId)`로 진입한 경우 `CreateOrGetRoom` 성공 후 `OpenRoom` 결과를 안전하게 처리한다. - `DmChatRoomViewModel.emitContent()`에서 `MutableLiveData.setValue()`를 호출하는 시점은 main thread여야 한다. - RxJava chain에서 `flatMap` 이후 upstream/downstream scheduler가 달라져도 `handleOpenRoomResult()`, `handleError()`, `_roomOpenedEventLiveData` 갱신은 main thread에서 실행되어야 한다. - 기존 `roomId` 기반 채팅 탭 DM 진입 동작은 변경하지 않는다. - 수정은 DM 채팅 ViewModel의 thread 전환 문제에 한정하고, 크리에이터 채널 layout이나 다른 UI 동작은 이번 범위에서 변경하지 않는다. #### Edge Cases - `CreateOrGetRoom`은 성공했지만 `OpenRoom`이 실패해도 앱 crash 없이 기존 오류 처리 정책을 따른다. - 빠르게 화면을 이탈하거나 background 전환이 발생해도 예약된 realtime callback 정리 정책을 훼손하지 않는다. ### WebSocket Room Session 채팅방이 열려 있는 동안 WebSocket으로 방 참여 상태를 만들고, `JOINED` 수신을 실시간 수신 가능 기준으로 삼는다. #### Requirements - 클라이언트는 채팅방 화면 진입 시 제거된 `GET /api/v2/user-creator-chat/rooms/{roomId}/events` endpoint를 열지 않는다. - 화면 진입 시 기존 `GET /api/v2/user-creator-chat/rooms/{roomId}/open`으로 초기 메시지와 상대방 정보를 먼저 조회한다. - `OpenRoom` 성공 후 WebSocket `/ws/v2/user-creator-chat`에 연결한다. - WebSocket URL은 기존 REST base URL의 host를 사용해 `wss://{host}/ws/v2/user-creator-chat`로 만든다. local/dev base URL이 `http://`이면 `ws://`, `https://`이면 `wss://`로 변환한다. - WebSocket handshake에는 기존 API와 동일한 access token을 `Authorization: Bearer ` 헤더로 전달한다. - WebSocket handshake 성공 `code=101` 응답의 body unreadable 표시는 정상으로 본다. - WebSocket 연결 직후라도 `roomId`가 0/null이면 어떤 메시지도 보내지 않는다. `OpenRoom` 성공 후 실제 `roomId`가 준비된 뒤에만 현재 `roomId` 기준 `JOIN_ROOM` 메시지를 전송한다. - `JOINED`를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다. - UI의 "실시간 연결됨" 상태는 WebSocket `onOpen`이 아니라 `JOINED` 수신 이후에만 표시한다. - 이전 realtime `connected` 이벤트 기반 연결 확인 로직은 WebSocket `JOINED` 수신 기준으로 변경한다. - access token refresh가 발생하면 기존 WebSocket을 close하고 새 token의 `Authorization` 헤더로 다시 연결한 뒤 `JOIN_ROOM`을 다시 보낸다. - WebSocket send 전 모든 raw JSON을 로그로 남기고, `onMessage`로 받은 raw JSON도 로그로 남긴다. 단, Authorization token은 로그에 남기지 않는다. #### WebSocket Message Types - Client to Server: `JOIN_ROOM`, `LEAVE_ROOM`, `SEND_TEXT`, `PING` - Server to Client: `JOINED`, `MESSAGE`, `SEND_ACK`, `ERROR`, `PONG` - 모든 WebSocket 메시지는 raw JSON object이며 root field는 `type`, `requestId`, `roomId`, `payload`다. - Client request의 `requestId`는 UUID 문자열을 사용한다. - `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`은 `roomId > 0`일 때만 보낸다. - `SEND_TEXT`, `PING`, `LEAVE_ROOM`은 `JOINED` 상태 이후에만 허용한다. `JOINED` 전 화면 이탈은 `LEAVE_ROOM` 없이 normal close만 수행한다. ```json {"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}} {"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}} {"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}} {"type":"PING","requestId":"uuid","roomId":10,"payload":{}} ``` ```json {"type":"JOINED","requestId":"uuid","roomId":10,"payload":{}} {"type":"SEND_ACK","requestId":"send uuid","roomId":10,"payload":{ /* MessageItem */ }} {"type":"MESSAGE","requestId":null,"roomId":10,"payload":{ /* MessageItem */ }} {"type":"PONG","requestId":"ping uuid","roomId":10,"payload":{}} {"type":"ERROR","requestId":null,"roomId":0,"payload":{"messageKey":"chat.room.invalid_access"}} ``` - `ERROR`는 root `code`/`message`가 아니라 `payload.messageKey`를 읽는다. #### Edge Cases - `OpenRoom` 실패 시 WebSocket을 연결하지 않는다. - 이미 같은 `roomId`로 연결 중이면 중복 WebSocket 연결이나 중복 `JOIN_ROOM` 전송을 만들지 않는다. - `JOINED` 수신 전에는 실시간 수신 상태로 표시하지 않는다. - WebSocket 연결 실패 시 앱이 crash 되지 않아야 하며, 현재 채팅방 화면에 남아 있는 동안에만 재연결 정책을 적용한다. - `JOINED` 수신 전 서버가 `ERROR`를 내려주면 `JOIN_ROOM` 실패로 보고 최대 3번 재시도한다. 3번 재시도 후에도 `ERROR` 상태이면 LoadingDialog를 숨기고 `대화방에 접속하지 못했습니다` 토스트를 표시한 뒤 socket을 닫고 이전 페이지로 나간다. - 서버가 WebSocket close frame을 보내거나 socket이 closing/closed 상태로 전환되면 연결 끊김으로 보고, 현재 화면이 foreground인 경우 재연결을 예약한다. - heartbeat `PING` 전송 자체가 실패하면 `PONG` timeout을 기다리지 않고 연결 끊김으로 처리해 socket을 닫고 재연결을 예약한다. ### WebSocket Message Receive 서버가 WebSocket으로 전달하는 `MESSAGE` 이벤트를 현재 채팅방 메시지 목록에 반영한다. #### Requirements - 이전 realtime `message` 이벤트 수신 로직은 WebSocket `MESSAGE` 수신 로직으로 변경한다. - `MESSAGE` root `roomId`가 현재 채팅방과 같을 때만 반영한다. - `MESSAGE` payload는 wrapper 없는 `MessageItem`이며 `UserCreatorChatMessageItemDto`와 동일한 메시지 모델로 변환한다. - 상대방 메시지는 `MESSAGE` 이벤트 수신 시 현재 채팅방 메시지 목록에 append한다. - 수신 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은 `messageId`다. - `messageType=TEXT`인 메시지는 텍스트 말풍선으로 표시한다. - `messageType=VOICE`인 메시지는 DTO에 보존하되, 이번 WebSocket 전환 범위에서 신규 음성 표시 UI를 추가하지 않는다. #### Edge Cases - 현재 열려 있는 `roomId`와 다른 방의 메시지가 들어오면 현재 목록에 append하지 않는다. - `MESSAGE`와 `SEND_ACK`의 root `roomId`가 null이면 malformed 이벤트로 보고 현재 방 목록이나 pending 메시지에 반영하지 않는다. - `SEND_ACK` payload도 wrapper 없는 `MessageItem`이며 root `requestId`로 pending 메시지를 확정한다. - pending 텍스트 메시지와 동일한 서버 메시지가 `SEND_ACK`보다 먼저 `MESSAGE`로 도착할 수 있으므로 root `requestId` 또는 `messageId` 기준 병합 정책을 구현 계획에서 명확히 한다. - 잘못된 JSON이나 알 수 없는 type은 앱 crash 없이 무시하거나 오류 상태로 기록한다. ### Load Older Messages 사용자가 메시지 목록 상단으로 스크롤하면 과거 메시지를 추가 조회한다. #### Requirements - `hasMore=true`이고 `isLoading=false`인 상태에서 상단에 도달하면 과거 메시지 API를 호출한다. - `cursor`는 OpenRoom 또는 직전 GetMessages 응답의 `nextCursor`를 사용한다. - `cursor`는 현재 화면에 로드된 가장 오래된 메시지보다 이전 페이지를 요청하기 위한 keyset cursor로 취급한다. - `limit` query parameter 기본값은 `20`으로 설정한다. - 응답 메시지는 기존 목록 상단에 prepend하고, 스크롤 위치를 유지한다. - 중복 메시지는 `messageId` 기준으로 제거한다. #### API Contract - Operation: `GetMessages` - Method: `GET` - Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages` - Query: `cursor: Long?`, `limit=20` - Response: `UserCreatorChatMessagesPageResponse` ```kotlin data class UserCreatorChatMessagesPageResponse( val messages: List, val hasMore: Boolean, val nextCursor: Long? ) ``` ### Send Text Message 사용자가 입력한 텍스트 메시지를 서버로 전송하고 결과를 UI에 반영한다. #### Requirements - 전송 버튼 또는 IME send 액션 시 trim된 입력값이 blank이면 전송하지 않는다. - 텍스트 메시지는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 대신 WebSocket `SEND_TEXT`를 사용한다. - 전송 UI는 클라이언트에서 UUID `requestId`를 생성해 pending 메시지와 서버 `SEND_ACK` root `requestId`를 매칭한다. - `SEND_TEXT` root에는 `type`, `requestId`, `roomId`를 포함하고, payload에는 trim된 `textMessage`만 포함한다. - 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다. - `SEND_ACK`를 수신하면 pending 메시지를 서버가 내려준 `messageId`, `createdAt`, 프로필 정보 기준으로 확정한다. - `ERROR` 또는 15초 `SEND_ACK` timeout을 수신하면 해당 pending 메시지를 실패 상태로 전환하고 재시도 버튼을 표시한다. - 재시도 버튼을 누르면 새 `requestId`로 같은 텍스트 메시지를 다시 `SEND_TEXT` 전송하고, 성공 시 실패 상태를 정상 메시지 상태로 갱신한다. - 클라이언트는 기존 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/text` 응답의 `deliveredRealtime`/`pushSent`를 텍스트 전송 UI 판단에 사용하지 않는다. - 사용자가 과거 메시지를 보고 있는 상태에서 메시지를 전송하면 최신 메시지 위치로 이동하거나, 최신 페이지 동기화 후 전송 메시지를 반영하는 방식 중 하나를 구현 계획에서 확정한다. #### Edge Cases - `SEND_ACK`가 timeout 이후 도착하면 현재 pending 상태와 중복 여부를 확인해 이미 실패 처리된 메시지를 정상 메시지로 복구할지 구현 계획에서 확정한다. - WebSocket이 연결되지 않았거나 `JOINED` 전이면 텍스트 전송을 수락하지 않고 입력창의 기존 텍스트를 지우지 않는다. - 같은 `requestId`에 대한 `SEND_ACK`가 중복 수신되면 첫 번째 확정 결과만 반영한다. ### Voice Message Contract 음성 메시지는 DTO/API 계약만 보존하고 이번 채팅 UI에서는 사용하지 않는다. #### Requirements - 음성 메시지 전송 API가 필요한 경우 `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` multipart 호출을 구현할 수 있다. - multipart field는 파일 part `voiceMessageFile`, string part `request`를 사용한다. `request` 값은 JSON 문자열 `{"recipientId": null}` 형식이다. - 응답 `data.message`는 `MessageItem`, `data.deliveredRealtime`은 실시간 전달 여부, `data.pushSent`는 push 발송 여부로 처리한다. - 이번 화면에는 음성 녹음/전송/재생 UI를 연결하지 않는다. - 이번 WebSocket 전환 범위에서 음성 메시지를 `SEND_TEXT`와 같은 방식으로 변경하지 않는다. #### API Contract - Operation: `SendVoiceMessage` - Method: `POST` - Path: `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice` - Request: `multipart/form-data` - `voiceMessageFile`: file part - `request`: string part, JSON 문자열 `{"recipientId": null}` ### Leave Room And Close 채팅방 화면 종료 또는 앱 background 전환, 로그아웃 시 WebSocket 방 참여를 해제하고 socket을 닫는다. #### Requirements - 제거된 `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출 위치는 WebSocket `LEAVE_ROOM` 전송 후 socket close 처리로 대체한다. - 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시 `JOINED` 상태이면 `LEAVE_ROOM`을 보낸 뒤 WebSocket을 close한다. - `JOINED` 전 또는 `roomId <= 0` 상태에서는 `LEAVE_ROOM`을 보내지 않고 normal close만 수행한다. - 클라이언트는 제거된 `events/disconnect` endpoint를 호출하지 않는다. - 이미 leave/close 처리 중이면 중복 `LEAVE_ROOM` 전송과 중복 close를 만들지 않는다. - close 처리는 UI thread를 블로킹하지 않아야 한다. #### Edge Cases - 네트워크 단절로 `LEAVE_ROOM` 전송이 실패해도 화면 종료나 로그아웃 흐름을 막지 않는다. - 로그아웃 흐름에서는 WebSocket close 후 기존 로컬 인증/캐시 정리 정책을 따른다. - 화면 밖에서는 WebSocket 재연결을 예약하지 않는다. ### Reconnect And Heartbeat WebSocket 연결 상태를 유지하고, 네트워크 오류로 끊긴 경우 현재 채팅방 화면에 남아 있을 때만 복구한다. #### Requirements - 앱은 heartbeat로 `PING`을 주기적으로 보내고 `PONG`을 수신해 연결 상태를 판단한다. - `PONG` timeout 또는 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다. - 재연결 성공 후 `JOIN_ROOM`을 다시 보낸다. - 재연결 후 필요하면 REST `GET /api/v2/user-creator-chat/rooms/{roomId}/messages` API로 누락 메시지를 동기화한다. - 이전 realtime retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다. - heartbeat `PING` 주기는 30초로 둔다. `PONG` timeout, 재연결 backoff 간격, 최대 재시도 횟수는 구현 계획에서 서버 권장값 또는 앱 공통 네트워크 정책에 맞춰 확정한다. #### Edge Cases - 사용자가 화면을 벗어난 뒤 도착한 reconnect timer는 실행하지 않는다. - access token refresh가 필요한 오류는 token 갱신 후 새 WebSocket handshake로 복구한다. - 재연결 중 사용자가 로그아웃하면 즉시 재연결을 중단한다. ### Push Notification Entry 푸시 알림으로 DM 채팅방에 진입하는 흐름을 일반 진입과 동일한 WebSocket lifecycle로 연결한다. #### Requirements - 푸시 알림을 터치하면 FCM payload의 `deep_link`만 사용해 해당 채팅방 화면으로 이동한다. - DM 채팅방 푸시의 `deep_link` 형식은 `${URISCHEME}://chat/{roomId}`다. - Android는 DM 푸시 진입 판단에 `chat_type` payload를 사용하지 않는다. - 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게 `OpenRoom` 호출 후 WebSocket 연결과 `JOIN_ROOM` 전송을 수행한다. - `deep_link`가 없거나 `/chat/{roomId}`에서 유효한 `roomId`를 파싱할 수 없으면 DM 채팅방을 열지 않고 기존 푸시 오류 처리 정책을 따른다. ### Removed Realtime Endpoints 제거된 realtime endpoint와 관련 구현은 네이티브 앱의 active DM 채팅 경로에서 사용하지 않는다. #### Requirements - `DmChatEventClient.kt`를 active main source에서 제거한다. - `GET /api/v2/user-creator-chat/rooms/{roomId}/events` 호출, `Accept: text/event-stream`, 이전 realtime event parser, 이전 reconnect/retry timer를 삭제한다. - `POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect` 호출을 삭제한다. - 이전 `connected`/`message` event 이름에 의존하는 로직을 WebSocket `JOINED`/`MESSAGE` 기준으로 대체한다. ### Message DTO 서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 WebSocket 전환 구현 대상은 텍스트 메시지 실시간 송수신 변경으로 한정한다. #### Response Model ```kotlin data class UserCreatorChatMessageItemDto( val messageId: Long, val messageType: String, val mine: Boolean, val createdAt: Long, val textMessage: String?, val voiceMessageUrl: String?, val senderId: Long, val senderNickname: String, val senderProfileImageUrl: String ) ``` #### Naming Requirements - Android DTO 이름은 `DmChatMessageResponse`, `DmChatRoomOpenResponse`, `DmChatMessagesPageResponse` 등 기능 중심 이름으로 조정한다. - WebSocket envelope와 payload 모델은 `DmChatSocketMessage`, `JoinRoom`, `SendText`, `SendAck` 등 구현 계획에서 확정한 이름을 사용한다. - `messageType`은 서버 문자열을 그대로 보존한다. - 가능한 `messageType` 값은 `TEXT`, `VOICE`다. - `TEXT`는 텍스트 메시지이며 `textMessage`를 사용해 말풍선 UI로 표시한다. - `VOICE`는 음성 메시지이며 `voiceMessageUrl`을 보존한다. 이번 구현에서는 별도 재생/전송 UI를 만들지 않는다. - `createdAt`은 epoch millis로 보고 기존 시간 표시 유틸 또는 신규 formatter에서 변환한다. --- ## 8. UX / UI Expectations - 기존 AI 채팅방과 동일한 채팅 화면 감각을 유지하되 DM에서 불필요한 캐릭터/쿼터 요소는 제거한다. - header는 뒤로가기, 상대 프로필, 상대 닉네임만으로 간결하게 구성한다. - 메시지 영역은 header 바로 아래부터 시작한다. - 상대방 메시지는 왼쪽 정렬, 내가 보낸 메시지는 오른쪽 정렬로 표시한다. - 채팅 버블은 현재 구현된 DM 채팅 버블 drawable/style을 그대로 사용한다. - 내 메시지와 상대 메시지의 시각적 구분은 기존 DM 채팅 item 스타일을 우선 재사용한다. - 긴 텍스트 메시지는 기존 채팅 말풍선의 줄바꿈/최대 폭 정책을 따른다. - 키보드가 올라와도 입력 영역과 최신 메시지가 가려지지 않아야 한다. --- ## 9. Technical Constraints - Android XML Views, ViewBinding, RecyclerView, RxJava3, Retrofit, Gson, Koin 구조를 따른다. - 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 저장소 규칙에 따라 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. - DM 화면은 신규 Activity로 생성한다. - 기존 `ChatRoomActivity`는 직접 변형하지 않는다. 필요한 경우 메시지 item/adapter/model 등 공용화 가능한 최소 컴포넌트만 구현 계획에서 검토한다. - REST API 응답은 기존 패턴처럼 `Single>`를 우선 사용한다. WebSocket은 `/ws/v2/user-creator-chat` endpoint에 `Authorization: Bearer ` handshake header를 전달한다. - WebSocket 상태는 최소 `DISCONNECTED`, `CONNECTING`, `SOCKET_OPEN`, `JOINING`, `JOINED`로 구분한다. `onOpen`은 `SOCKET_OPEN`일 뿐 실시간 연결 완료가 아니다. - WebSocket envelope parser는 root `requestId`, root `roomId`, root `payload`를 읽고, 서버 payload에 `{message: ...}` wrapper가 있다고 가정하지 않는다. - token 전달은 기존 v2 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다. REST와 WebSocket handshake의 `Authorization` 헤더 문자열은 단일 bearer helper로 생성해 오입력을 방지한다. - 앱 foreground/background 감지는 Activity lifecycle과 앱 전체 `ProcessLifecycleOwner` 중 어떤 기준을 사용할지 구현 계획에서 확정한다. - 구현 전 `docs/20260610_DM_채팅화면/plan-task.md`를 WebSocket 전환 범위에 맞게 갱신하고, 그 문서에 따라 최소 구현한다. - 테스트는 DTO mapper, 메시지 정렬/중복 제거, pagination state, WebSocket envelope 파싱, `requestId` pending 매칭, reconnect/heartbeat, leave/close lifecycle을 우선 검증한다. - 크리에이터 채널 `DM 보내기` crash 수정은 기존 DM 채팅 문서의 후속 범위로 누적하며, 구현 전 `plan-task.md`에 대응 task와 검증 기록을 추가한다. --- ## 10. API Summary | 기능 | Method | Path | Request | Response | | --- | --- | --- | --- | --- | | 방 생성 또는 조회 | `POST` | `/api/v2/user-creator-chat/rooms/create` | `CreateUserCreatorChatRoomRequest` | `CreateUserCreatorChatRoomResponse` | | 생성된 채팅방 열기 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` | 없음 | `UserCreatorChatRoomOpenResponse` | | WebSocket 연결 | WebSocket | `/ws/v2/user-creator-chat` | Handshake header: `Authorization: Bearer ` | WebSocket session | | 방 참여 | WebSocket send | `/ws/v2/user-creator-chat` | root `JOIN_ROOM`, `requestId`, `roomId`, empty `payload` | `JOINED` | | 실시간 메시지 수신 | WebSocket receive | `/ws/v2/user-creator-chat` | 없음 | root `MESSAGE`, payload `MessageItem` | | 텍스트 메시지 보내기 | WebSocket send | `/ws/v2/user-creator-chat` | root `SEND_TEXT`, `requestId`, `roomId`, payload `{textMessage}` | root `SEND_ACK` payload `MessageItem` 또는 `ERROR` | | Heartbeat | WebSocket send/receive | `/ws/v2/user-creator-chat` | root `PING`, `requestId`, `roomId`, empty `payload` | `PONG` | | 방 이탈 | WebSocket send/close | `/ws/v2/user-creator-chat` | root `LEAVE_ROOM`, `requestId`, `roomId`, empty `payload` | socket close | | 과거 메시지 조회 | `GET` | `/api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={cursor}&limit=20` | 없음 | `UserCreatorChatMessagesPageResponse` | | 음성 메시지 보내기 | `POST` | `/api/v2/user-creator-chat/rooms/{roomId}/messages/voice` | `multipart/form-data` | DTO/API 계약 보존, 이번 UI 미연결 | --- ## 11. Metrics - `creatorId` 기반 진입 시 CreateOrGetRoom 성공 후 반환된 `roomId`로 DM 채팅방이 열린다. - `roomId` 기반 진입 시 OpenRoom이 `limit=20`으로 호출된다. - OpenRoom 응답 메시지가 시간순으로 `RecyclerView`에 표시된다. - OpenRoom 응답의 `opponentNickname`, `opponentProfileImageUrl`이 header 상대 정보로 표시된다. - 상대방 메시지는 왼쪽 정렬로 표시된다. - 내가 보낸 메시지는 오른쪽 정렬로 표시된다. - 채팅 버블은 현재 구현된 DM 채팅 버블을 사용한다. - DM 메시지 item은 `createdAt`을 기존 메시지 시간 포맷 기준으로 표시한다. - 입력 UI에서는 텍스트 메시지만 전송할 수 있고 음성 메시지 UI는 노출되지 않는다. - OpenRoom 성공 전에는 WebSocket 연결을 시작하지 않는다. - OpenRoom 성공 후 WebSocket `/ws/v2/user-creator-chat`에 access token handshake header로 연결한다. - WebSocket 연결 직후 `JOIN_ROOM`을 보내고 `JOINED` 수신 후에만 실시간 수신 상태로 판단한다. - client send raw JSON은 root `type`/`requestId`/`roomId`/`payload` 계약을 따른다. - `roomId`가 0/null이면 `JOIN_ROOM`을 보내지 않아 서버 `ERROR` 및 1008 close를 유발하지 않는다. - 상단 스크롤 시 `hasMore=true`와 `nextCursor` 조건에 따라 GetMessages가 호출된다. - `MESSAGE` 수신 시 root `roomId`가 현재 채팅방과 같으면 payload `MessageItem`을 현재 채팅방 메시지 목록에 append한다. - 텍스트 메시지 전송 시 `requestId`가 생성되고 pending 메시지와 `SEND_ACK`가 매칭된다. - `SEND_ACK`는 root `requestId`와 payload `MessageItem`으로 pending 메시지를 확정한다. - `ERROR`는 `payload.messageKey`를 파싱해 pending 실패 또는 세션 오류 처리에 사용한다. - 텍스트 메시지 전송 성공 시 `SEND_ACK`의 `messageId`, `createdAt`, 프로필 정보가 pending 메시지에 반영된다. - 텍스트 메시지 전송 실패 시 `ERROR` 또는 timeout 기준으로 pending 메시지가 실패 상태와 재시도 버튼을 표시한다. - 화면 이탈, 앱 background 전환, 로그아웃 시 `LEAVE_ROOM` 전송 후 WebSocket close가 발생한다. - WebSocket 종료 처리는 UI thread를 블로킹하지 않는다. - 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다. - 재연결 성공 후 `JOIN_ROOM`을 다시 보내고 필요 시 GetMessages API로 누락 메시지를 동기화한다. - `PING`/`PONG` heartbeat timeout 시 연결 상태가 disconnected로 전환된다. - `PING`은 `JOINED` 상태에서 30초 주기로 root `roomId`와 UUID `requestId`를 포함해 전송한다. - 푸시 payload의 `deep_link`가 `${URISCHEME}://chat/{roomId}` 형식이면 `roomId` 기준으로 채팅방에 진입하고, 일반 진입과 동일하게 OpenRoom 및 WebSocket join을 수행한다. - `GET /events`, `POST /events/disconnect`, `POST /messages/text`는 WebSocket 전환된 텍스트 송수신 경로에서 호출되지 않는다. - 제거 대상 UI(`character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`)가 DM 화면에 나타나지 않는다. - 크리에이터 채널 `DM 보내기`로 `creatorId` 기반 진입 시 `Cannot invoke setValue on a background thread` 예외 없이 DM 채팅방 Content 상태가 표시된다. --- ## 12. Open Questions - DM 채팅방 진입점별 intent extra 이름은 구현 계획에서 확정한다. - `PING` 주기는 최신 계약상 30초로 확정한다. `PONG` timeout, reconnect backoff와 최대 재시도 횟수는 서버 권장값 또는 앱 공통 정책에 맞춰 구현 계획에서 확정한다. --- ## 13. References - 기존 AI 채팅방 Activity: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt` - 기존 AI 채팅방 layout: `app/src/main/res/layout/activity_chat_room.xml` - 기존 AI 채팅 API: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/TalkApi.kt` - 기존 AI 채팅 Repository: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRepository.kt` - v2 채팅 탭 API: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomApi.kt` - v2 채팅 탭 모델: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomModels.kt` - 관련 PRD: `docs/20260609_채팅_탭_페이지/prd.md` - PRD 템플릿: `docs/prd/sample-prd.md` - 작업 문서 규칙: `docs/agent-guides/work-plan-docs.md` --- ## 14. Verification Log - 2026-06-10: `docs/prd/sample-prd.md`, `docs/agent-guides/work-plan-docs.md`, `docs/20260609_채팅_탭_페이지/prd.md`를 확인해 PRD 구조, 신규 문서 경로 규칙, 검증 기록 누적 방식을 확인했다. - 2026-06-10: `activity_chat_room.xml`을 확인해 제거 대상 UI인 `tv_character_type_badge`, `ll_can_badge`, `iv_more`, `notice_container`의 실제 위치와 메시지 영역 constraint를 확인했다. - 2026-06-10: `ChatRoomActivity.kt`, `TalkApi.kt`, `ChatRepository.kt`를 확인해 기존 채팅방의 초기 로드, 상단 pagination, 텍스트 전송, 입력 UI, header/notice/쿼터 구조를 분석했다. - 2026-06-10: `ChatRoomApi.kt`, `ChatRoomModels.kt` 및 `docs/20260609_채팅_탭_페이지/prd.md`를 확인해 v2 채팅 탭의 패키지/DTO/Retrofit 패턴과 DM 상세 화면 연결 배경을 확인했다. - 2026-06-10: `rg`로 `EventSource`, `SSE`, `text/event-stream`, `ResponseBody`, `events/disconnect` 등을 검색했으며 현재 저장소에는 재사용 가능한 SSE 구현 패턴이 없는 것으로 확인했다. 따라서 SSE 구현 방식은 후속 `plan-task.md`에서 별도 검증/결정할 항목으로 남겼다. - 2026-06-10: 이번 단계는 PRD 문서 생성만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다. - 2026-06-10: 백그라운드 조사 결과를 반영해 REST pagination과 realtime 수신의 `messageId` 기준 중복 제거, keyset cursor 의미, 전송 중 연타 방지, 과거 페이지를 보고 있을 때 전송 처리 기준, SSE cancel/close, failure 상태 전환, backoff/jitter 재연결, UI thread 비차단, disconnect와 로그아웃 캐시 삭제 범위 구분을 보강했다. - 2026-06-10: 사용자 확정 사항을 반영해 텍스트 전송 실패 UX를 낙관적 UI와 재시도 버튼으로 확정하고, DM 화면은 신규 Activity로 생성하도록 명시했다. OpenRoom 응답에 `opponentNickname`, `opponentProfileImageUrl`을 추가했으며, `messageType` 가능한 값은 `TEXT`, `VOICE`로 확정하고 이번 구현은 텍스트 메시지만 대상으로 제한했다. - 2026-06-10: 사용자 제공 백엔드 계약을 반영해 SSE 이벤트 이름과 payload를 확정했다. `connected` 이벤트는 `"connected"` 문자열 handshake로, `message` 이벤트는 `UserCreatorChatMessageItemDto` JSON으로 문서화했다. 서버 `reconnectTime=3000`ms, `Last-Event-ID` 기반 replay 미지원, 재연결 후 GetMessages API를 통한 누락 메시지 보정 요구사항도 반영했다. - 2026-06-10: 백그라운드 조사 결과와 대조해 `ConnectEvents` 응답 표기를 `ApiResponse`에서 `text/event-stream`으로 보정하고, API Summary와 기술 제약도 SSE stream 수신/파싱 기준으로 갱신했다. - 2026-06-10: 후속 Repository 구현 시 `Authorization` 헤더 오입력 방지를 위해 단일 bearer helper로 헤더 문자열을 생성하도록 기술 제약에 기록했다. - 2026-06-17: 사용자 제보 스택트레이스(`DmChatRoomViewModel.emitContent()`의 `MutableLiveData.setValue()` background thread 예외)와 `DmChatRoomViewModel.kt`의 `createRoomAndOpen()` Rx chain을 확인했다. 크리에이터 채널 `DM 보내기`의 `creatorId` 기반 진입에서 `CreateOrGetRoom` 후 `OpenRoom` 결과 처리 thread를 main thread로 보장해야 하는 요구사항을 기존 DM 채팅화면 PRD에 후속 범위로 누적했다. - 2026-06-18: 사용자 제공 WebSocket 전환 요구사항을 기준으로 기존 SSE 기반 PRD를 갱신했다. Core Features를 `WebSocket Room Session`, `WebSocket Message Receive`, `Send Text Message`, `Voice Message Send`, `Leave Room And Close`, `Reconnect And Heartbeat`, `Push Notification Entry`, `Removed SSE Endpoints`로 분리하고, 제거되는 SSE endpoint와 REST 텍스트 전송 endpoint가 더 이상 텍스트 실시간 송수신 경로에서 호출되지 않도록 성공 기준을 보강했다. - 2026-06-18: 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, plan-task 갱신, 빌드, 테스트는 실행하지 않았다. - 2026-06-19: 사용자 제공 최신 FCM payload 계약을 반영해 Push Notification Entry 요구사항과 성공 기준을 `chat_type`/`room_id` 기준에서 `deep_link=${URISCHEME}://chat/{roomId}` 단독 기준으로 갱신했다. 이번 단계는 PRD 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다. - 2026-07-10: 최신 서버 계약을 기준으로 `DmChatSocketModels.kt`, `DmChatSocketClient.kt`, `DmChatRepository.kt`, `DmChatRoomViewModel.kt`, `DmChatApi.kt`, `DmChatModels.kt`, `DmChatEventClient.kt`, `AppDI.kt`를 확인했다. REST create/open/messages와 제거된 text/disconnect endpoint 미사용은 대체로 맞지만, 기존 SSE `DmChatEventClient.kt`와 `/events` URL 정의는 main source에 남아 있다. 또한 현재 WebSocket envelope는 root `requestId`/`roomId`가 없고 `roomId`/`requestId`를 payload에 넣거나 생략한다. `MESSAGE`/`SEND_ACK` payload를 `{message: ...}` wrapper로 기대하고, `ERROR`를 `payload.messageKey`가 아닌 nullable `code`/`message`로 파싱하며, `PING`에 `roomId`/`requestId`가 없다. 이 차이를 PRD와 계획 문서의 후속 보정 범위로 반영했다. 이번 단계는 문서 수정만 수행했으며 Android 구현, 빌드, 테스트는 실행하지 않았다. - 2026-07-10: 사용자 추가 요청을 반영해 현재 요구사항에서 legacy realtime 관련 내용은 active 구현 대상이 아니라 제거 대상으로만 다루도록 정리했다. 음성 메시지는 DTO/API 계약을 구현하더라도 이번 UI에서는 사용하지 않고, DM 채팅방에서는 텍스트 메시지만 보낼 수 있도록 명시했다. 채팅 UI는 상대방 메시지 왼쪽 정렬, 내 메시지 오른쪽 정렬, 현재 구현된 DM 채팅 버블 유지로 보강했다. 기존 검증 기록은 저장소 규칙에 따라 삭제하지 않았다. - 2026-07-11: DM 메시지 item에서 `DmChatMessageUiItem.createdAt`을 기존 `formatMessageTime` 포맷으로 표시해야 한다는 후속 요구사항을 DM Chat Room UI 요구사항과 Metrics에 반영했다. 이번 단계는 문서 요구사항 갱신이며 구현/빌드/테스트는 후속 계획 문서에 따라 진행한다. - 2026-07-11: DM 메시지 시간 데이터가 서버 DTO `DmChatMessageResponse.createdAt`에서 UI 모델 `DmChatMessageUiItem.createdAt`까지 보존되는 것을 확인했다. 메시지 item UI는 `tv_time`과 기존 `formatMessageTime(createdAt)` 바인딩으로 내 메시지/상대 메시지 모두 시간을 표시하도록 확인했으며, 상세 구현과 검증 기록은 `plan-task.md`의 Phase 16에 누적했다. - 2026-07-11: 리뷰 지적에 따라 WebSocket close frame/closing 상태와 heartbeat `PING` 전송 실패를 연결 끊김으로 처리하는 요구사항을 추가했다. 또한 `JOINED` 전 또는 재연결 중 전송이 수락되지 않은 경우 Activity 입력창을 비우지 않도록 텍스트 전송 수락 여부 요구사항을 명시했다.