14 KiB
14 KiB
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-chatraw WebSocket만 지원하며, STOMP frame(CONNECT/SUBSCRIBE/SEND)을 보내면 안 된다. - WebSocket 연결 완료와 채팅방 입장 완료가 다르므로, UI의 연결 완료 기준을 socket open이 아니라
JOINED수신으로 분리해야 한다.
3. Goals
- 유저-크리에이터 채팅 REST API 4종을 Moya
TargetType에 추가하고, 요청/응답CodableDTO를 정의한다. - 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_ACKtimeout은 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후 WebSocketJOIN_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}/eventsPOST /api/v2/user-creator-chat/rooms/{roomId}/events/disconnectPOST /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 헤더를 포함한다.
-
방 생성/조회
POST /api/v2/user-creator-chat/rooms/create- body:
{"creatorId": 123} - response:
ApiResponse<CreateRoomResponse> - data:
{"roomId": 10}
-
채팅방 열기
GET /api/v2/user-creator-chat/rooms/{roomId}/open?limit=20- data:
roomId,opponentNickname,opponentProfileImageUrl,messages,hasMore,nextCursor
-
과거 메시지 조회
GET /api/v2/user-creator-chat/rooms/{roomId}/messages?cursor={nextCursor}&limit=20- data:
messages,hasMore,nextCursor
-
음성 메시지 전송
POST /api/v2/user-creator-chat/rooms/{roomId}/messages/voicemultipart/form-data- parts:
voiceMessageFile: file datarequest: JSON 문자열{"recipientId": null}
- data:
message,deliveredRealtime,pushSent
7.2 DTO
공통 응답은 기존 ApiResponse<T>를 사용한다.
MessageItem은 아래 필드를 Codable로 정의한다.
{
"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
- REST가
- 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기준 중복 제거 후 appendPONG: 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응답 메시지는 정렬 후 초기 목록으로 표시한다.- 과거 메시지는
messagesAPI로 조회하고,messageId기준 merge한다. SEND_TEXT시UUIDrequestId를 만들고 임시 pending 메시지를 append한다.SEND_ACK수신 시 pending 메시지를 서버MessageItem으로 교체한다.- 15초 안에
SEND_ACK가 없으면 해당 pending 메시지를 failed 상태로 전환한다. MESSAGE수신 시 이미 동일messageId가 있으면 무시한다.- 재연결 후 최신 동기화가 필요하면
openRoom또는messagesAPI 결과를messageId기준으로 merge한다. - ACK 유실 시 동일한 내 텍스트의 서버 메시지와 pending/failed 메시지를 1:1로 재동기화한다.
- 서버/클라이언트 시각 차이로 중복 메시지가 남지 않도록 서버
createdAt이 로컬 pending 생성 시각보다 최대 5분 이전인 경우까지 동일 메시지 후보로 허용한다.
7.7 Loading/Error
- 네트워크 통신 중에는 항상 Loading을 표시한다.
- 최초 진입은
openRoom+ WebSocketJOINED까지 Loading을 유지한다. JOINED전ERROR수신 시 최대 3회 재시도한다.JOIN_ROOM전송 실패 또는 15초JOINEDtimeout도 동일하게 최대 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
- scheme:
- 푸시 터치로 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<T>,DEBUG_LOG,ERROR_LOG,BaseView,.sodaToast,AppState/AppStep의 기존 패턴을 따른다.roomId == 0을 전송하면 서버가ERROR후 close code1008로 닫으므로 모든 outgoing 명령은roomId > 0guard를 둔다.
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 정책과 맞지 않으므로 적용하지 않는다.