|
| 1 | +# 채팅방 API 개선안 — 선택지 배지 + 메시지 반응(이모지) + 답글 |
| 2 | + |
| 3 | +> 기반: "채팅_ 투표진행중_채팅 상세_개선" 5-1 ~ 5-6 요구사항 |
| 4 | +> 버전: 1.0 |
| 5 | +> 대상: 회원 (투표 참여자) |
| 6 | +
|
| 7 | +## 1. 요구사항 핵심 요약 |
| 8 | + |
| 9 | +### 5-1 선택지 배지 (본인) |
| 10 | +- 채팅방 진입 시 **본인이 선택한 A/B 배지**가 Chat Input **위 영역**에 **상시 표시** |
| 11 | +- 본인 선택 변경 시 배지 정보 **실시간 반영** |
| 12 | +- A/B 선택지에 따라 시각적으로 다르게 표시 |
| 13 | + |
| 14 | +### 5-2 ~ 5-4 이모지 반응 (👍 👎) |
| 15 | +- **자신의 메시지**에는 Context Menu 호출 불가 (서버도 방어) |
| 16 | +- 말풍선 **0.5초 길게 누르기** → Context Menu (배경 dim) |
| 17 | + - 메뉴 항목: 👍 , 👎 , 답장 |
| 18 | +- 반응 |
| 19 | + - 동일 이모지 복수 사용자 → **누적 카운트** |
| 20 | + - 99개 이상 → `99+` |
| 21 | + - **본인이 누른 이모지 다시 누르면 취소** |
| 22 | +- 메뉴 자동 닫힘: 반응 선택 또는 "답장" 선택 시 |
| 23 | +- "답장" 선택 → 메뉴 닫히고 **텍스트 입력 키패드 즉시 오픈** |
| 24 | + |
| 25 | +### 5-5 ~ 5-6 답글 (Reply) |
| 26 | +- 메시지 길게 누르기 → 키패드 즉시 호출 |
| 27 | +- 답글 작성 중 **원문 인용 박스** 상단 노출 |
| 28 | + - X 버튼 → 답글 작성 취소 |
| 29 | + - 인용 영역 선택 → 원문 메시지 위치로 스크롤 이동 |
| 30 | +- **답글 메시지** 표시 규칙 |
| 31 | + - 메시지 상단에 **원문 내용을 회색 + 말줄임**으로 표시 |
| 32 | + |
| 33 | +--- |
| 34 | + |
| 35 | +## 2. 데이터 모델 변경 |
| 36 | + |
| 37 | +### 2.1 ChatMessage 확장 (답글 지원) |
| 38 | + |
| 39 | +```sql |
| 40 | +ALTER TABLE chat_message |
| 41 | + ADD COLUMN parent_message_id BIGINT NULL |
| 42 | + REFERENCES chat_message(id) ON DELETE SET NULL; |
| 43 | + |
| 44 | +CREATE INDEX idx_chat_message_parent ON chat_message (parent_message_id); |
| 45 | +``` |
| 46 | + |
| 47 | +- `parent_message_id` = 답글 대상 원문 메시지 ID (null = 일반 메시지) |
| 48 | + |
| 49 | +### 2.2 신규 테이블: chat_message_reaction |
| 50 | + |
| 51 | +```sql |
| 52 | +CREATE TABLE chat_message_reaction ( |
| 53 | + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
| 54 | + message_id BIGINT NOT NULL REFERENCES chat_message(id) ON DELETE CASCADE, |
| 55 | + user_id BIGINT NOT NULL REFERENCES users(id), |
| 56 | + emoji VARCHAR(20) NOT NULL, -- THUMBS_UP | THUMBS_DOWN |
| 57 | + created_at TIMESTAMP NOT NULL DEFAULT now(), |
| 58 | + updated_at TIMESTAMP NOT NULL DEFAULT now(), |
| 59 | + |
| 60 | + CONSTRAINT uq_chat_reaction UNIQUE (message_id, user_id) |
| 61 | +); |
| 62 | + |
| 63 | +CREATE INDEX idx_chat_reaction_msg ON chat_message_reaction (message_id); |
| 64 | +CREATE INDEX idx_chat_reaction_user ON chat_message_reaction (user_id); |
| 65 | +``` |
| 66 | + |
| 67 | +**특징** |
| 68 | +- 회원 전용 (anonymous 없음) — 채팅은 투표 참여 회원만 가능 |
| 69 | +- 1인 1반응 (동일 메시지). 다른 이모지 선택 시 row update, null 전송 시 삭제 |
| 70 | + |
| 71 | +### 2.3 신규 Enum (chat/domain) |
| 72 | + |
| 73 | +```java |
| 74 | +package com.ject.vs.chat.domain; |
| 75 | + |
| 76 | +public enum ChatReactionType { |
| 77 | + THUMBS_UP, // 👍 |
| 78 | + THUMBS_DOWN // 👎 |
| 79 | +} |
| 80 | +``` |
| 81 | + |
| 82 | +--- |
| 83 | + |
| 84 | +## 3. 기존 API 변경 사항 |
| 85 | + |
| 86 | +### 3.1 GET `/api/chats/{voteId}` — 채팅방 헤더 (배지 정보 제공) |
| 87 | + |
| 88 | +**기존 응답에 추가** |
| 89 | + |
| 90 | +```json |
| 91 | +{ |
| 92 | + "voteId": 1, |
| 93 | + "title": "...", |
| 94 | + "status": "ONGOING", |
| 95 | + "participantCount": 25, |
| 96 | + "optionA": "...", |
| 97 | + "optionB": "...", |
| 98 | + "endAt": "...", |
| 99 | + "myVoteOption": "A" // ★ 신규: "A" | "B" | null |
| 100 | +} |
| 101 | +``` |
| 102 | + |
| 103 | +**변경 대상** |
| 104 | +- `ChatRoomResult` 에 `VoteOptionCode myVoteOption` 추가 |
| 105 | +- `ChatRoomResponse` 에 동일 필드 + `@Schema` |
| 106 | +- `ChatService.getChatRoom(...)` 시그니처 변경 또는 내부에서 userId 사용 |
| 107 | + - 현재 컨트롤러가 userId를 받지만 getChatRoom에 전달 안 함 → 수정 필요 |
| 108 | +- 구현: `voteQueryUseCase.findSelectedOptionCode(voteId, userId).orElse(null)` |
| 109 | + |
| 110 | +**5-1 배지 대응**: 이 응답으로 진입 시점 배지 렌더링. 선택 변경은 3.4 참고. |
| 111 | + |
| 112 | +### 3.2 POST `/api/chats/{voteId}/messages` — 메시지 전송 (답글 지원) |
| 113 | + |
| 114 | +**Request 변경 (하위 호환)** |
| 115 | + |
| 116 | +```json |
| 117 | +{ |
| 118 | + "content": "이것도 좋은 선택 같아요", |
| 119 | + "replyToMessageId": 1234 // optional. 없으면 일반 메시지 |
| 120 | +} |
| 121 | +``` |
| 122 | + |
| 123 | +**변경 대상** |
| 124 | +- `SendMessageRequest` |
| 125 | +- `SendMessageCommand` |
| 126 | +- `ChatService.sendMessage(...)` |
| 127 | + |
| 128 | +**서버 처리** |
| 129 | +- `replyToMessageId` 가 있으면: |
| 130 | + - 동일 `voteId` 소속 메시지인지 검증 (아니면 404 또는 400) |
| 131 | + - parent 메시지 내용 + 작성자 닉네임 스냅샷 준비 (preview 생성) |
| 132 | +- 저장 시 `ChatMessage.parentMessageId = replyToMessageId` |
| 133 | + |
| 134 | +**응답** (`MessageResponse`) |
| 135 | +- 새 메시지에도 `replyTo` 정보 포함 (아래 3.3 참고) |
| 136 | + |
| 137 | +### 3.3 GET `/api/chats/{voteId}/messages` — 메시지 목록 (reply + reaction 포함) |
| 138 | + |
| 139 | +**각 메시지 응답 확장** |
| 140 | + |
| 141 | +```json |
| 142 | +{ |
| 143 | + "messageId": 1280, |
| 144 | + "content": "답글 내용입니다", |
| 145 | + "sentAt": "...", |
| 146 | + "senderNickname": "승부사", |
| 147 | + "senderProfileIcon": "GREEN", |
| 148 | + "senderVoteOption": "B", |
| 149 | + "isMine": true, |
| 150 | + |
| 151 | + "replyTo": { // ★ 신규 (답글인 경우에만) |
| 152 | + "messageId": 1275, |
| 153 | + "senderNickname": "원작성자", |
| 154 | + "contentPreview": "원문 메시지 앞부분을 잘라서 보여줍니다..." |
| 155 | + }, |
| 156 | + |
| 157 | + "reactions": { // ★ 신규 |
| 158 | + "THUMBS_UP": 14, |
| 159 | + "THUMBS_DOWN": 2 |
| 160 | + }, |
| 161 | + "myReaction": "THUMBS_UP" // ★ 신규 (내 반응, 없으면 null) |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +**변경 대상** |
| 166 | +- `MessageResult` record |
| 167 | +- `MessageResponse` record + from() |
| 168 | +- `MessagePageResponse` |
| 169 | +- `ChatService.getMessages(...)` 내부 enrichment 로직 대폭 수정 |
| 170 | + |
| 171 | +**Enrichment 구현 전략 (성능)** |
| 172 | +- 페이지 메시지 ID 목록 추출 |
| 173 | +- parentMessageId 목록 별도 수집 → `chatMessageRepository.findAllById(...)` + User 정보 |
| 174 | +- reaction: |
| 175 | + ```java |
| 176 | + // ChatMessageReactionRepository |
| 177 | + List<ReactionCount> countByMessageIds(List<Long> messageIds); |
| 178 | + Optional<ChatReactionType> findMyReaction(Long messageId, Long userId); |
| 179 | + ``` |
| 180 | +- Map 으로 매핑 후 MessageResult 생성 |
| 181 | + |
| 182 | +**contentPreview** |
| 183 | +- 부모 메시지의 **전체 내용**을 그대로 내려줍니다. |
| 184 | +- 말줄임(ellipsis) 처리는 프론트엔드에서 담당합니다. (뷰의 관심사) |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## 4. 신규 API |
| 189 | + |
| 190 | +### 4.1 메시지 반응 추가/변경/취소 |
| 191 | + |
| 192 | +**PUT** `/api/chats/{voteId}/messages/{messageId}/reactions` |
| 193 | + |
| 194 | +**Request** |
| 195 | +```json |
| 196 | +{ "emoji": "THUMBS_UP" } // "THUMBS_UP" | "THUMBS_DOWN" | null |
| 197 | +``` |
| 198 | + |
| 199 | +- `emoji` = 값 → 해당 반응 설정 (기존 반응이 있으면 교체) |
| 200 | +- `emoji` = `null` → 반응 취소 |
| 201 | + |
| 202 | +**Response** (204 No Content 대신 200 추천) |
| 203 | +```json |
| 204 | +{ |
| 205 | + "messageId": 1280, |
| 206 | + "reactions": { |
| 207 | + "THUMBS_UP": 15, |
| 208 | + "THUMBS_DOWN": 2 |
| 209 | + }, |
| 210 | + "myReaction": "THUMBS_UP" // null 가능 |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +**에러** |
| 215 | +- `403` : 자신의 메시지에 반응 시도 (`ChatForbiddenException` 재사용 또는 신규) |
| 216 | +- `403` : 투표 참여자가 아님 |
| 217 | +- `404` : 메시지 또는 voteId 불일치 |
| 218 | + |
| 219 | +**구현 위치** |
| 220 | +- 신규: `ChatReactionCommandUseCase` (권장) 또는 `ChatCommandUseCase` 확장 |
| 221 | +- `ChatService.react(...)` |
| 222 | +- `ChatMessageReactionRepository` |
| 223 | + |
| 224 | +**트랜잭션 후** |
| 225 | +- `ChatReactionUpdatedEvent` 발행 → 리스너에서 WS 브로드캐스트 |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## 5. WebSocket (STOMP) 변경 |
| 230 | + |
| 231 | +### 5.1 기존 메시지 브로드캐스트 (`/topic/chat/{voteId}`) |
| 232 | + |
| 233 | +기존 `MessageResult` 페이로드를 **3.3** 확장 형태로 전송 (replyTo + reactions 포함, `myReaction`은 항상 `null`). |
| 234 | + |
| 235 | +### 5.2 반응 업데이트 브로드캐스트 (신규) |
| 236 | + |
| 237 | +같은 토픽으로 다음 형태 전송: |
| 238 | + |
| 239 | +```json |
| 240 | +{ |
| 241 | + "event": "REACTION_UPDATED", |
| 242 | + "messageId": 1280, |
| 243 | + "reactions": { |
| 244 | + "THUMBS_UP": 15, |
| 245 | + "THUMBS_DOWN": 2 |
| 246 | + } |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +클라이언트는 수신 즉시 해당 메시지의 reaction counts 를 갱신. |
| 251 | + |
| 252 | +(개인 `myReaction`은 PUT 응답으로 이미 확정) |
| 253 | + |
| 254 | +### 5.3 배지 실시간 업데이트 (권장) |
| 255 | + |
| 256 | +**선택지 변경은 주로 Vote participate API에서 발생**하므로: |
| 257 | + |
| 258 | +- 클라이언트가 `POST/PUT` participate 성공 시 응답의 `selectedOptionId` → `A/B` 변환 후 **로컬 배지 즉시 갱신** (가장 단순하고 정확) |
| 259 | +- 다중 탭/기기 동기화가 중요해지면 별도 개인 채널 고려: |
| 260 | + - `/user/topic/chat/{voteId}/my-option` |
| 261 | + - `{ "myVoteOption": "B" }` |
| 262 | + |
| 263 | +현재 단계에서는 **헤더 + 클라이언트 상태 동기화**로 5-1 요구사항 충분히 만족 가능. |
| 264 | + |
| 265 | +--- |
| 266 | + |
| 267 | +## 6. 아키텍처 / 코드 변경 포인트 |
| 268 | + |
| 269 | +| 위치 | 변경 내용 | |
| 270 | +|------|----------| |
| 271 | +| `chat/domain/ChatMessage.java` | `parentMessageId` 필드 + getter | |
| 272 | +| `chat/domain/ChatMessageRepository.java` | `findAllById(...)` (기본 제공), parent 조회용 추가 메서드 필요시 | |
| 273 | +| `chat/domain/` 신규 | `ChatMessageReaction.java`, `ChatMessageReactionRepository.java`, `ChatReactionType.java` | |
| 274 | +| `chat/port/in/dto/` | `MessageResult` 대폭 확장, `SendMessageCommand`에 `replyToMessageId` 추가, 신규 `ReactMessageCommand` 등 | |
| 275 | +| `chat/port/ChatService.java` | sendMessage reply 처리 + getMessages 대형 enrichment 로직 추가 + react 메서드 | |
| 276 | +| `chat/adapter/web/ChatController.java` + `ChatDocs.java` | 신규 반응 엔드포인트, 기존에 userId 전달 수정 | |
| 277 | +| `chat/adapter/web/dto/` | `MessageResponse`, `SendMessageRequest` 업데이트 | |
| 278 | +| `chat/adapter/event/` | `ChatMessageEventListener` : replyTo 포함 브로드캐스트 | |
| 279 | +| `vote/port/in/VoteQueryUseCase.java` | 기존 `findSelectedOptionCode` 재사용 (변경 없음) | |
| 280 | +| 신규 이벤트 | `ChatReactionUpdatedEvent` (선택) | |
| 281 | + |
| 282 | +**메서드 네이밍 컨벤션 준수** (프로젝트 규칙) |
| 283 | +- `findAll...`, `findBy...`, `countBy...`, `existsBy...` 등 사용 |
| 284 | + |
| 285 | +--- |
| 286 | + |
| 287 | +## 7. 검증 / 엣지 케이스 |
| 288 | + |
| 289 | +- 자신의 메시지에 반응 → 거부 |
| 290 | +- 삭제된 parent에 답글 → `replyTo.contentPreview = "(삭제된 메시지)"` (id는 유지하여 스크롤 이동 지원) |
| 291 | +- replyTo 메시지가 다른 vote → 400 |
| 292 | +- 반응 99+ → 서버는 정확한 숫자 반환, 클라이언트에서 `count > 99 ? "99+" : count` |
| 293 | +- 시스템 메시지 (senderId=0) → 반응/답글 불가 |
| 294 | +- 투표 종료 후에도 과거 메시지 반응/조회는 허용? (기존 정책 유지. 별도 확인 필요) |
| 295 | +- 동시에 여러 반응 race → UNIQUE 제약 + upsert 로 처리 |
| 296 | + |
| 297 | +--- |
| 298 | + |
| 299 | +## 8. 구현 우선순위 추천 |
| 300 | + |
| 301 | +1. **DB + 도메인** : ChatMessage parent + ChatMessageReaction 테이블/엔티티 |
| 302 | +2. **메시지 전송 + 목록** : replyTo 기능 먼저 (5-5, 5-6) |
| 303 | +3. **채팅방 헤더** : myVoteOption 추가 (5-1) |
| 304 | +4. **반응 기능** : PUT API + 목록 enrichment + WS (5-2~5-4) |
| 305 | +5. **문서화 + 테스트** + 클라이언트 연동 확인 |
| 306 | +6. **(선택)** 다중 기기 배지 동기화를 위한 WS 이벤트 |
| 307 | + |
| 308 | +--- |
| 309 | + |
| 310 | +## 9. OpenAPI / 문서 업데이트 |
| 311 | + |
| 312 | +- `ChatDocs.java` 에 신규 Operation 추가 |
| 313 | +- `swagger.json` 재생성 또는 수동 반영 |
| 314 | +- 기존 `docs/spec/2026-04-26-chat-api-spec.md` 에 "v2 개선" 섹션 또는 별도 이 문서 링크 |
| 315 | + |
| 316 | +--- |
| 317 | + |
| 318 | +## 10. 클라이언트 협업 포인트 |
| 319 | + |
| 320 | +- Context Menu / long-press 는 **클라이언트 전담** |
| 321 | +- 99+ formatting, 말줄임, X 버튼, 인용 박스 클릭 시 scroll-to-id → 클라이언트 |
| 322 | +- 답글 작성 중 원문 quote box는 `replyTo` 데이터로 렌더 |
| 323 | +- 배지 위치는 항상 Input 위 고정 (채팅방 진입 + myVoteOption 기준) |
| 324 | + |
| 325 | +이 개선안을 적용하면 5-1~5-6 요구사항을 모두 만족하면서 기존 채팅 API와 호환성을 유지할 수 있습니다. |
0 commit comments