# PRD: 유저-크리에이터 1:1 채팅 ## 1. Overview 유저와 크리에이터가 1:1로 대화할 수 있는 DM 채팅방을 iOS 앱에 추가한다. REST API는 기존 `BASE_URL` 기반 Moya `TargetType`로 호출하고, 실시간 텍스트 채팅은 STOMP/SockJS 없이 `URLSessionWebSocketTask` 기반 raw WebSocket으로 처리한다. --- ## 2. Problem - 기존 홈 채팅 탭의 `DM` 아이템은 목록에는 노출되지만 DM 채팅방 진입 화면이 연결되어 있지 않다. - 기존 AI 채팅방(`ChatRoomView`)은 REST 기반 텍스트 전송 API를 사용하므로, raw WebSocket 기반 유저-크리에이터 채팅 계약과 다르다. - 서버는 `/ws/v2/user-creator-chat` raw WebSocket만 지원하며, STOMP frame(`CONNECT`/`SUBSCRIBE`/`SEND`)을 보내면 안 된다. - WebSocket 연결 완료와 채팅방 입장 완료가 다르므로, UI의 연결 완료 기준을 socket open이 아니라 `JOINED` 수신으로 분리해야 한다. --- ## 3. Goals - 유저-크리에이터 채팅 REST API 4종을 Moya `TargetType`에 추가하고, 요청/응답 `Codable` DTO를 정의한다. - WebSocket은 `URLSessionWebSocketTask`로 구현하고, 모든 요청에 `Authorization: Bearer {accessToken}` 헤더를 넣는다. - `openRoom` 성공 후 실제 `roomId > 0`일 때만 WebSocket 연결 및 `JOIN_ROOM`을 수행한다. - WebSocket 상태를 `disconnected`, `connecting`, `socketOpen`, `joining`, `joined` 등으로 분리하고, UI는 `JOINED` 수신 후에만 입력 가능 상태로 본다. - `JOINED` 이후에만 `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 허용한다. - 텍스트 전송은 REST API가 아니라 WebSocket `SEND_TEXT`로만 처리한다. - `SEND_TEXT` 전송 시 pending 메시지를 즉시 표시하고, `SEND_ACK.requestId`로 매칭해 서버 메시지로 확정한다. - `SEND_ACK` timeout은 15초 기준으로 실패 상태를 표시한다. - `MESSAGE` 수신 및 REST 재조회 결과는 `messageId` 기준으로 중복 제거/merge한다. - joined 상태에서 30초 주기로 `PING`을 보내고 `PONG`을 처리한다. - 화면 이탈, 앱 백그라운드, 로그아웃/회원탈퇴 시 `LEAVE_ROOM` 전송 후 WebSocket을 닫는다. - 네트워크 오류 시 사용자가 채팅방 화면에 머무는 동안만 재연결하고, 재연결 후 `JOIN_ROOM`을 다시 보낸다. - `JOINED` 전 `ERROR`가 내려오면 최대 3회 재시도하고, 계속 실패하면 Loading을 숨긴 뒤 토스트를 표시하고 이전 페이지로 이동한다. - 푸시 deep link `voiceon://chat/{roomId}` / `voiceon-test://chat/{roomId}`를 파싱해 일반 진입과 동일하게 `openRoom` 후 WebSocket `JOIN_ROOM`을 수행한다. - UI는 기존 AI 채팅방과 거의 동일하게 유지하되, 헤더에서 `Character / Clone` 표시, 보유 캔 UI, 우측 더보기 버튼을 제거한다. - 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다. - 지난 범위에서 구현하지 않은 `DM 보내기` 버튼을 추가하고, 이 버튼을 터치하면 `creatorId` 기반 DM 생성/조회 후 DM 채팅방으로 이동한다. - 단, 본인 채널의 DM 버튼은 `DM 확인하기`로 표시하고 메인 대화 탭의 DM 필터로 이동한다. - `VOICE`는 REST 통신 계층만 구현하고, 이번 범위에서는 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI를 구현하지 않는다. --- ## 4. Non-Goals - STOMP/SockJS 클라이언트 추가 또는 STOMP frame 전송은 하지 않는다. - 텍스트 메시지 전송에 REST `POST /messages/text`를 사용하지 않는다. - 제거된 event API를 사용하지 않는다. - `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` - 기존 AI 채팅방의 API/동작을 유저-크리에이터 채팅 방식으로 변경하지 않는다. - 크리에이터 채널의 기존 AI 캐릭터 채팅 진입(`handleTalkTap`)을 DM 진입으로 바꾸지 않는다. - 음성 메시지 송신 UI, 수신 음성 메시지 표시 UI, 음성 재생 UI는 이번 범위에서 구현하지 않는다. - `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다. - 서버 API 계약 변경은 포함하지 않는다. --- ## 5. Target Users - 크리에이터와 1:1 DM 대화를 시작하거나 이어서 확인하려는 일반 사용자 - 푸시 알림을 통해 특정 DM 채팅방으로 바로 진입하려는 사용자 --- ## 6. User Stories - 사용자는 크리에이터와의 DM 채팅방에 진입하면 최신 메시지와 과거 메시지를 확인할 수 있다. - 사용자는 채팅방 연결이 완료되기 전까지 Loading 상태를 확인하고, `JOINED` 후 메시지를 입력할 수 있다. - 사용자는 텍스트를 전송하면 즉시 pending 메시지를 보고, 서버 ACK 이후 확정된 메시지 상태를 확인할 수 있다. - 사용자는 상대방이 보낸 실시간 메시지를 중복 없이 목록에서 확인할 수 있다. - 사용자는 과거 메시지를 스크롤로 추가 조회할 수 있다. - 사용자는 푸시 알림을 눌러 `voiceon://chat/{roomId}` 형식의 deep link로 채팅방에 진입할 수 있다. --- ## 7. Core Requirements ### 7.1 REST API 신규 REST API는 Moya `TargetType`로 추가한다. 모든 요청은 기존 REST `BASE_URL`을 사용하고 `Authorization` 헤더를 포함한다. 1. 방 생성/조회 - `POST /api/v2/user-creator-chat/rooms/create` - body: `{"creatorId": 123}` - response: `ApiResponse` - data: `{"roomId": 10}` 2. 채팅방 열기 - `GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20` - data: `roomId`, `opponentNickname`, `opponentProfileImageUrl`, `messages`, `hasMore`, `nextCursor` 3. 과거 메시지 조회 - `GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20` - data: `messages`, `hasMore`, `nextCursor` 4. 음성 메시지 전송 - `POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voice` - `multipart/form-data` - parts: - `voiceMessageFile`: file data - `request`: JSON 문자열 `{"recipientId": null}` - data: `message`, `deliveredRealtime`, `pushSent` ### 7.2 DTO 공통 응답은 기존 `ApiResponse`를 사용한다. `MessageItem`은 아래 필드를 `Codable`로 정의한다. ```json { "messageId": 1, "messageType": "TEXT", "mine": true, "createdAt": 1710000000000, "textMessage": "hello", "voiceMessageUrl": null, "senderId": 1, "senderNickname": "...", "senderProfileImageUrl": "..." } ``` 로컬 pending/failed 상태는 서버 DTO와 분리한 display model 또는 wrapper에서 관리한다. ### 7.3 WebSocket URL/Auth - WebSocket URL은 REST `BASE_URL`의 scheme/host를 기준으로 만든다. - REST가 `http://...`이면 `ws://{host}/ws/v2/user-creator-chat` - REST가 `https://...`이면 `wss://{host}/ws/v2/user-creator-chat` - WebSocket `URLRequest`에 `Authorization: Bearer {accessToken}` 헤더를 넣는다. - STOMP 관련 라이브러리와 STOMP frame은 사용하지 않는다. ### 7.4 WebSocket Protocol `roomId`가 nil 또는 0이면 `JOIN_ROOM`, `SEND_TEXT`, `PING`, `LEAVE_ROOM`을 절대 보내지 않는다. Client -> Server: - `JOIN_ROOM`: `{"type":"JOIN_ROOM","requestId":"uuid","roomId":10,"payload":{}}` - `SEND_TEXT`: `{"type":"SEND_TEXT","requestId":"uuid","roomId":10,"payload":{"textMessage":"hello"}}` - `LEAVE_ROOM`: `{"type":"LEAVE_ROOM","requestId":"uuid","roomId":10,"payload":{}}` - `PING`: `{"type":"PING","requestId":"uuid","roomId":10,"payload":{}}` Server -> Client: - `JOINED`: 연결 완료 기준 - `SEND_ACK`: `requestId`로 pending 메시지 확정 - `MESSAGE`: `messageId` 기준 중복 제거 후 append - `PONG`: ping 응답 처리 - `ERROR`: root code/message가 아니라 `payload.messageKey`를 읽는다. 모든 outgoing JSON과 incoming raw text는 `DEBUG_LOG`로 남긴다. ### 7.5 Connection State - `disconnected`: task 없음 또는 닫힘 - `connecting`: WebSocket task 생성/resume 중 - `socketOpen`: WebSocket task가 열렸지만 room join 전 - `joining`: `JOIN_ROOM` 전송 후 `JOINED` 대기 - `joined`: 채팅 입력/핑/leave 허용 UI 입력 가능, heartbeat 시작, pending send 허용 기준은 `socketOpen`이 아니라 `joined`다. - `JOIN_ROOM` 전송 실패는 즉시 JOIN 재시도 정책으로 전달한다. - `JOIN_ROOM` 전송 후 15초 안에 `JOINED`가 없으면 JOIN 실패로 처리하고 동일한 재시도 정책을 적용한다. ### 7.6 Message Behavior - `openRoom` 응답 메시지는 정렬 후 초기 목록으로 표시한다. - 과거 메시지는 `messages` API로 조회하고, `messageId` 기준 merge한다. - `SEND_TEXT` 시 `UUID` requestId를 만들고 임시 pending 메시지를 append한다. - `SEND_ACK` 수신 시 pending 메시지를 서버 `MessageItem`으로 교체한다. - 15초 안에 `SEND_ACK`가 없으면 해당 pending 메시지를 failed 상태로 전환한다. - `MESSAGE` 수신 시 이미 동일 `messageId`가 있으면 무시한다. - 재연결 후 최신 동기화가 필요하면 `openRoom` 또는 `messages` API 결과를 `messageId` 기준으로 merge한다. - ACK 유실 시 동일한 내 텍스트의 서버 메시지와 pending/failed 메시지를 1:1로 재동기화한다. - 서버/클라이언트 시각 차이로 중복 메시지가 남지 않도록 서버 `createdAt`이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용한다. ### 7.7 Loading/Error - 네트워크 통신 중에는 항상 Loading을 표시한다. - 최초 진입은 `openRoom` + WebSocket `JOINED`까지 Loading을 유지한다. - `JOINED` 전 `ERROR` 수신 시 최대 3회 재시도한다. - `JOIN_ROOM` 전송 실패 또는 15초 `JOINED` timeout도 동일하게 최대 3회 재시도한다. - 3회 재시도 후에도 실패하면 Loading을 숨기고 `대화방에 접속하지 못했습니다.` 토스트를 표시한 뒤 socket을 닫고 이전 페이지로 이동한다. - 일반 REST 실패도 Loading을 숨기고 토스트를 표시한다. ### 7.8 Lifecycle/Reconnection - 화면 이탈 시 `LEAVE_ROOM`을 보내고 WebSocket을 닫는다. - 앱 백그라운드 전환 시 `LEAVE_ROOM` 후 close/cancel한다. - 로그아웃/전체 로그아웃/회원탈퇴 성공 흐름에서는 열린 DM WebSocket이 `LEAVE_ROOM`을 보낸 뒤 닫히도록 알림 또는 공통 세션 종료 훅을 둔다. - 네트워크 오류로 socket이 끊기면 채팅방 화면이 살아 있는 동안만 재연결한다. - 재연결 후에는 동일 `roomId`로 다시 `JOIN_ROOM`을 보낸다. ### 7.9 UI - 신규 DM 채팅방은 AI `ChatRoomView`와 같은 전체 화면 채팅 UX를 따른다. - 헤더에서는 아래 3가지를 제거한다. - `Character / Clone` 표시 - 우측 더보기 버튼 - 더보기 버튼 왼쪽의 보유 캔 표시 UI - 헤더에는 뒤로가기, 상대 프로필 이미지, 상대 닉네임을 표시한다. - 메시지 목록 UI는 이번 범위에서 `TEXT`만 표시한다. - `VOICE` 메시지는 DTO/API/Repository 통신 처리를 추가하되, 채팅방 화면에는 표시하지 않는다. - 입력창은 `joined` 상태에서만 활성화한다. - 기존 AI 채팅방의 quota, locked image, 배경 변경/초기화 설정은 DM 화면에 넣지 않는다. ### 7.10 Deep Link - `voiceon://chat/{roomId}`와 `voiceon-test://chat/{roomId}`를 지원한다. - URL 파싱 기준: - scheme: `voiceon` 또는 `voiceon-test` - host: `chat` - path 첫 segment: `roomId` - 푸시 터치로 roomId를 얻으면 일반 진입과 동일하게 `openRoom(roomId)` 성공 후 WebSocket connect 및 `JOIN_ROOM`을 수행한다. --- ## 8. UX / UI Expectations - 화면 구조, spacing, 색상, 입력창은 기존 `ChatRoomView`의 사용자 경험을 우선 재사용한다. - Loading은 기존 `BaseView(isLoading:)` 또는 동일한 `LoadingView` 패턴을 사용한다. - 토스트는 기존 `.sodaToast` 패턴을 사용한다. - pending 메시지는 사용자가 전송 즉시 인지할 수 있어야 하고, 실패 상태는 재전송 가능 여부를 후속 결정할 수 있도록 명확히 표시한다. --- ## 9. Technical Constraints - 신규 View/ViewModel/Repository/DTO는 새 기능이므로 `SodaLive/Sources/V2/**` 아래에 둔다. - DM 채팅방 전용 컴포넌트는 해당 기능 폴더 하위 `Components`에 둔다. - Moya는 REST API에만 사용한다. - 실시간 채팅에는 `URLSessionWebSocketTask`를 사용한다. 현재 소스에서 별도 raw WebSocket 클라이언트는 확인되지 않았다. - `BASE_URL`, `UserDefaultsKey.token`, `ApiResponse`, `DEBUG_LOG`, `ERROR_LOG`, `BaseView`, `.sodaToast`, `AppState`/`AppStep`의 기존 패턴을 따른다. - `roomId == 0`을 전송하면 서버가 `ERROR` 후 close code `1008`로 닫으므로 모든 outgoing 명령은 `roomId > 0` guard를 둔다. --- ## 10. Assumptions - 문서 작성 기준일은 2026-07-11이며 신규 문서 폴더도 이 날짜를 사용한다. - 기존 대화 목록(`MainChatView`)의 `chatType == "DM"` 아이템은 신규 DM 채팅방으로 라우팅한다. --- ## 11. Confirmed Decisions - 2026-07-11: 크리에이터 채널의 기존 `handleTalkTap`은 계속 AI 캐릭터 채팅 진입으로 유지한다. - 2026-07-11: 크리에이터 채널에 별도 `DM 보내기` 버튼을 추가하고, 해당 버튼 터치 시 `creatorId` 기반 DM 생성/조회 후 신규 DM 채팅방으로 이동한다. - 2026-07-11: 이번 범위에서 `VOICE`는 통신 작업만 구현한다. 음성 메시지 송신 UI와 수신 음성 메시지 표시 UI는 구현하지 않는다. - 2026-07-11: `DM 보내기` 진입은 로그인 여부만 확인한다. AI 캐릭터 채팅 진입에 적용되는 본인인증/성인 콘텐츠 설정 guard는 DM 정책과 맞지 않으므로 적용하지 않는다.