43 KiB
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가 아니라 WebSocketSEND_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}/eventsURL 정의가 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_ACKpending 확정,PING/PONGheartbeat, 재연결과 최신 메시지 동기화 기준을 정의한다. - raw WebSocket envelope를 최신 서버 계약 그대로 고정한다. STOMP/SockJS frame은 보내지 않고, 모든 client send JSON은
type,requestId,roomId,payloadroot field를 사용한다. - 제거된
/events,/events/disconnect,/messages/textendpoint와 관련 구현을 active DM 채팅 경로에서 제거한다. - DM 채팅 화면에서는 텍스트 메시지만 전송할 수 있도록 UI 범위를 고정한다.
- 메시지 UI는 현재 구현된 채팅 버블을 유지하고, 상대방 메시지는 왼쪽 정렬, 내 메시지는 오른쪽 정렬로 고정한다.
- 크리에이터 채널
DM 보내기진입에서 방 생성/열기 완료 후 모든LiveData상태 갱신이 main thread에서 수행되어 background threadsetValue()예외가 발생하지 않도록 한다.
4. Non-Goals
- AI 캐릭터 채팅방의 쿼터 구매, 광고 보상, 유료 메시지 구매, 채팅 리셋 기능은 DM 채팅화면에 포함하지 않는다.
- STOMP/SockJS
CONNECT,SUBSCRIBE,SENDframe 전송은 포함하지 않는다. - 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
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_badgell_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을 호출한다. limitquery 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
data class UserCreatorChatRoomOpenResponse(
val roomId: Long,
val opponentNickname: String,
val opponentProfileImageUrl: String,
val messages: List<UserCreatorChatMessageItemDto>,
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}/eventsendpoint를 열지 않는다. - 화면 진입 시 기존
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 <accessToken>헤더로 전달한다. - WebSocket handshake 성공
code=101응답의 body unreadable 표시는 정상으로 본다. - WebSocket 연결 직후라도
roomId가 0/null이면 어떤 메시지도 보내지 않는다.OpenRoom성공 후 실제roomId가 준비된 뒤에만 현재roomId기준JOIN_ROOM메시지를 전송한다. JOINED를 수신하면 해당 채팅방이 실시간 수신 상태라고 판단한다.- UI의 "실시간 연결됨" 상태는 WebSocket
onOpen이 아니라JOINED수신 이후에만 표시한다. - 이전 realtime
connected이벤트 기반 연결 확인 로직은 WebSocketJOINED수신 기준으로 변경한다. - 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만 수행한다.
{"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":{}}
{"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는 rootcode/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전송 자체가 실패하면PONGtimeout을 기다리지 않고 연결 끊김으로 처리해 socket을 닫고 재연결을 예약한다.
WebSocket Message Receive
서버가 WebSocket으로 전달하는 MESSAGE 이벤트를 현재 채팅방 메시지 목록에 반영한다.
Requirements
- 이전 realtime
message이벤트 수신 로직은 WebSocketMESSAGE수신 로직으로 변경한다. MESSAGErootroomId가 현재 채팅방과 같을 때만 반영한다.MESSAGEpayload는 wrapper 없는MessageItem이며UserCreatorChatMessageItemDto와 동일한 메시지 모델로 변환한다.- 상대방 메시지는
MESSAGE이벤트 수신 시 현재 채팅방 메시지 목록에 append한다. - 수신 메시지는 기존 목록에 중복 추가하지 않는다. 중복 판단 기준은
messageId다. messageType=TEXT인 메시지는 텍스트 말풍선으로 표시한다.messageType=VOICE인 메시지는 DTO에 보존하되, 이번 WebSocket 전환 범위에서 신규 음성 표시 UI를 추가하지 않는다.
Edge Cases
- 현재 열려 있는
roomId와 다른 방의 메시지가 들어오면 현재 목록에 append하지 않는다. MESSAGE와SEND_ACK의 rootroomId가 null이면 malformed 이벤트로 보고 현재 방 목록이나 pending 메시지에 반영하지 않는다.SEND_ACKpayload도 wrapper 없는MessageItem이며 rootrequestId로 pending 메시지를 확정한다.- pending 텍스트 메시지와 동일한 서버 메시지가
SEND_ACK보다 먼저MESSAGE로 도착할 수 있으므로 rootrequestId또는messageId기준 병합 정책을 구현 계획에서 명확히 한다. - 잘못된 JSON이나 알 수 없는 type은 앱 crash 없이 무시하거나 오류 상태로 기록한다.
Load Older Messages
사용자가 메시지 목록 상단으로 스크롤하면 과거 메시지를 추가 조회한다.
Requirements
hasMore=true이고isLoading=false인 상태에서 상단에 도달하면 과거 메시지 API를 호출한다.cursor는 OpenRoom 또는 직전 GetMessages 응답의nextCursor를 사용한다.cursor는 현재 화면에 로드된 가장 오래된 메시지보다 이전 페이지를 요청하기 위한 keyset cursor로 취급한다.limitquery 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
data class UserCreatorChatMessagesPageResponse(
val messages: List<UserCreatorChatMessageItemDto>,
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대신 WebSocketSEND_TEXT를 사용한다. - 전송 UI는 클라이언트에서 UUID
requestId를 생성해 pending 메시지와 서버SEND_ACKrootrequestId를 매칭한다. SEND_TEXTroot에는type,requestId,roomId를 포함하고, payload에는 trim된textMessage만 포함한다.- 전송 시 낙관적 UI를 적용해 사용자 메시지를 즉시 목록에 추가하고 전송 중 상태로 표시한다.
SEND_ACK를 수신하면 pending 메시지를 서버가 내려준messageId,createdAt, 프로필 정보 기준으로 확정한다.ERROR또는 15초SEND_ACKtimeout을 수신하면 해당 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/voicemultipart 호출을 구현할 수 있다. - multipart field는 파일 part
voiceMessageFile, string partrequest를 사용한다.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-datavoiceMessageFile: file partrequest: string part, JSON 문자열{"recipientId": null}
Leave Room And Close
채팅방 화면 종료 또는 앱 background 전환, 로그아웃 시 WebSocket 방 참여를 해제하고 socket을 닫는다.
Requirements
- 제거된
POST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnect호출 위치는 WebSocketLEAVE_ROOM전송 후 socket close 처리로 대체한다. - 채팅방 화면 이탈, 앱 백그라운드 진입, 로그아웃 시
JOINED상태이면LEAVE_ROOM을 보낸 뒤 WebSocket을 close한다. JOINED전 또는roomId <= 0상태에서는LEAVE_ROOM을 보내지 않고 normal close만 수행한다.- 클라이언트는 제거된
events/disconnectendpoint를 호출하지 않는다. - 이미 leave/close 처리 중이면 중복
LEAVE_ROOM전송과 중복 close를 만들지 않는다. - close 처리는 UI thread를 블로킹하지 않아야 한다.
Edge Cases
- 네트워크 단절로
LEAVE_ROOM전송이 실패해도 화면 종료나 로그아웃 흐름을 막지 않는다. - 로그아웃 흐름에서는 WebSocket close 후 기존 로컬 인증/캐시 정리 정책을 따른다.
- 화면 밖에서는 WebSocket 재연결을 예약하지 않는다.
Reconnect And Heartbeat
WebSocket 연결 상태를 유지하고, 네트워크 오류로 끊긴 경우 현재 채팅방 화면에 남아 있을 때만 복구한다.
Requirements
- 앱은 heartbeat로
PING을 주기적으로 보내고PONG을 수신해 연결 상태를 판단한다. PONGtimeout 또는 네트워크 오류로 WebSocket이 끊기면 현재 채팅방 화면에 남아 있는 동안에만 재연결한다.- 재연결 성공 후
JOIN_ROOM을 다시 보낸다. - 재연결 후 필요하면 REST
GET /api/v2/user-creator-chat/rooms/{roomId}/messagesAPI로 누락 메시지를 동기화한다. - 이전 realtime retry/backoff 코드는 WebSocket 재연결 정책으로 대체하고, 채팅방 화면 밖에서는 재연결하지 않는다.
- heartbeat
PING주기는 30초로 둔다.PONGtimeout, 재연결 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_typepayload를 사용하지 않는다. - 푸시로 채팅방에 진입한 뒤에도 일반 진입과 동일하게
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/messageevent 이름에 의존하는 로직을 WebSocketJOINED/MESSAGE기준으로 대체한다.
Message DTO
서버 메시지 DTO는 텍스트/음성 필드를 모두 보존하되, 이번 WebSocket 전환 구현 대상은 텍스트 메시지 실시간 송수신 변경으로 한정한다.
Response Model
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<ApiResponse<...>>를 우선 사용한다. WebSocket은/ws/v2/user-creator-chatendpoint에Authorization: Bearer <accessToken>handshake header를 전달한다. - WebSocket 상태는 최소
DISCONNECTED,CONNECTING,SOCKET_OPEN,JOINING,JOINED로 구분한다.onOpen은SOCKET_OPEN일 뿐 실시간 연결 완료가 아니다. - WebSocket envelope parser는 root
requestId, rootroomId, rootpayload를 읽고, 서버 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 파싱,
requestIdpending 매칭, 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 <accessToken> |
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수신 시 rootroomId가 현재 채팅방과 같으면 payloadMessageItem을 현재 채팅방 메시지 목록에 append한다.- 텍스트 메시지 전송 시
requestId가 생성되고 pending 메시지와SEND_ACK가 매칭된다. SEND_ACK는 rootrequestId와 payloadMessageItem으로 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/PONGheartbeat timeout 시 연결 상태가 disconnected로 전환된다.PING은JOINED상태에서 30초 주기로 rootroomId와 UUIDrequestId를 포함해 전송한다.- 푸시 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초로 확정한다.PONGtimeout, 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이벤트는UserCreatorChatMessageItemDtoJSON으로 문서화했다. 서버reconnectTime=3000ms,Last-Event-ID기반 replay 미지원, 재연결 후 GetMessages API를 통한 누락 메시지 보정 요구사항도 반영했다. - 2026-06-10: 백그라운드 조사 결과와 대조해
ConnectEvents응답 표기를ApiResponse<Unit>에서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 미사용은 대체로 맞지만, 기존 SSEDmChatEventClient.kt와/eventsURL 정의는 main source에 남아 있다. 또한 현재 WebSocket envelope는 rootrequestId/roomId가 없고roomId/requestId를 payload에 넣거나 생략한다.MESSAGE/SEND_ACKpayload를{message: ...}wrapper로 기대하고,ERROR를payload.messageKey가 아닌 nullablecode/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 입력창을 비우지 않도록 텍스트 전송 수락 여부 요구사항을 명시했다.