diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java index 88b49dce..83d1b86b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java @@ -39,7 +39,7 @@ public void getInitialCount(@Payload Map payload) { Long concertId = Long.valueOf(concertIdObj.toString()); log.info("[CHAT STATUS] 인원수 초기 요청 수신: concertId={}", concertId); - chatMessageService.broadcastUserCount(concertId); + chatMessageService.broadcastUser(concertId); } } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java index e3ef3700..09ad4ace 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; @@ -21,57 +22,59 @@ public class ChatStompDocsController { @Operation( summary = "채팅 메시지 전송 (WebSocket / STOMP 채팅 프로토콜 문서. 문서용 API. 사용X)", description = """ - ### 📡 WebSocket STOMP 채팅 메시지 전송 - - #### 1️⃣ WebSocket Endpoint - ``` - ws://localhost:8080/ws-chat - or - wss://api.naeconcertbutakhae.shop/ws-chat - ``` - - #### 2️⃣ SEND Destination - ``` - /app/chat/send - ``` - - #### 3️⃣ SUBSCRIBE Destination - ``` - /topic/chat/{concertId} - ``` - - #### 4️⃣ SEND Payload - ```json - { - "concertId": 1, - "content": "안녕하세요!" - } - ``` - - #### 5️⃣ SUBSCRIBE Response - ```json - { - "concertId": 1, - "senderId": 10, - "senderName": "테스트 유저", - "content": "안녕하세요!", - "sentAt": "2025-12-23T15:30:00" - } - ``` - """ + ### 📡 WebSocket STOMP 채팅 메시지 전송 + + #### 1️⃣ WebSocket Endpoint + ``` + ws://localhost:8080/ws-chat + or + wss://api.naeconcertbutakhae.shop/ws-chat + ``` + + #### 2️⃣ SEND Destination + ``` + /app/chat/send + ``` + + #### 3️⃣ SUBSCRIBE Destination + ``` + /topic/chat/{concertId} + ``` + + #### 4️⃣ SEND Payload + ```json + { + concertId: 1, + content: "안녕하세요!" + } + ``` + + #### 5️⃣ SUBSCRIBE Response + ```json + { + concertId: 1, + senderId: 2, + senderName: "테스트 유저", + content: "안녕하세요!", + sentDate: "2026-01-02T12:13:18.4422905", + profileImage: "https://example.com/profile.jpg" + } + ``` + """ ) @GetMapping("/stomp") - public void stompChatGuide() {} + public void stompChatGuide() { + } @Operation( summary = "STOMP 채팅 메시지 전송 규격(문서용 API. 사용X)", description = """ - WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다. + WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다. - - 실제 사용되는 HTTP API 아닙니다. - - Swagger 문서용 - """, - requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + - 실제 사용되는 HTTP API 아닙니다. + - Swagger 문서용 + """, + requestBody = @RequestBody( description = "STOMP 메세지 SEND하면 전달되는 요청 데이터", required = true, content = @Content( @@ -93,57 +96,93 @@ public ChatMessageResponse messageSchema() { return null; // 실제 반환 목적 X } - @Operation( summary = "실시간 채팅 접속자 수 집계 (STOMP 전용, 문서용 API. 사용X)", description = """ - ### 👥 실시간 채팅 접속자 수 집계 - - - 동일 유저의 여러 탭은 **1명으로 집계** - - 모든 탭이 닫혔을 때만 접속자 수 감소 - - 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트 - - --- - - ### 1️⃣ CONNECT 시 자동 집계 - - STOMP CONNECT 시 `concertId` 기준으로 접속자 등록 - - 접속 즉시 현재 인원 수가 브로드캐스트됩니다. - - ### 2️⃣ 초기 접속자 수 요청 (SEND) - ``` - /app/chat/status - ``` - - #### SEND Payload - - 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달 - ```json - { - "concertId": 1 - } - ``` - - - 채팅방 최초 진입 시 프론트에서 1회 호출 - - 서버가 Redis 기준 현재 접속자 수 계산 후 브로드캐스트 - - ### 3️⃣ SUBSCRIBE Destination - ``` - /topic/chat/{concertId}/count - ``` - - #### SUBSCRIBE Response - ```json - 5 - ``` - - - 숫자(Number) 형태의 현재 접속자 수 - - 접속 / 퇴장 / 초기 요청 시마다 자동 수신 - - ### 4️⃣ DISCONNECT 처리 - - STOMP DISCONNECT 시 자동 처리 - - 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소 - """ + ### 👥 실시간 채팅 접속자 수 집계 + - 동일 유저의 여러 탭은 **1명으로 집계** + - 모든 탭이 닫혔을 때만 접속자 수 감소 + - 접속 / 퇴장 / 초기 요청 시 자동 브로드캐스트 + + #### 1️⃣ CONNECT 시 자동 집계 + - STOMP CONNECT 시 `concertId` 기준으로 접속자 등록 + - 접속 즉시 현재 인원 수가 브로드캐스트됩니다. + + #### CONNECT Header + - 접속자 수 집계를 위해 `concertId`를 CONNECT 헤더로 전달 + ```text + concertId: 1 + ``` + + #### 2️⃣ 초기 접속자 수 요청 (SEND) + ``` + /app/chat/status + ``` + + #### 3️⃣ SUBSCRIBE Destination + ``` + /topic/chat/{concertId}/count + ``` + + #### SUBSCRIBE Response + ```json + 5 + ``` + + - 숫자(Number) 형태의 현재 접속자 수 + - 접속 / 퇴장 / 초기 요청 시마다 자동 수신 + + #### 4️⃣ DISCONNECT 처리 + - STOMP DISCONNECT 시 자동 처리 + - 동일 유저의 모든 탭이 닫힌 경우에만 접속자 수 감소 + """ ) @GetMapping("/user-count") - public void chatUserCountGuide() {} + public void chatUserCountGuide() { + } + + @Operation( + summary = "실시간 채팅 참여자 목록 조회 (STOMP 전용, 문서용 API. 사용X)", + description = """ + ### 👤 실시간 채팅 참여자 목록 + - 현재 채팅방에 **접속 중인 유저 목록** + - 동일 유저의 여러 탭은 **1명으로 집계** + - 접속 / 퇴장 / 초기 진입 시 자동 브로드캐스트 + + #### 1️⃣ CONNECT + - STOMP CONNECT 시 `concertId` 헤더 전달 + - 서버는 해당 채팅방 Presence에 유저를 등록 + + #### CONNECT Headers + ```text + concertId: number + ``` + + #### 2️⃣ SUBSCRIBE Destination + ``` + /topic/chat/{concertId}/users + ``` + + #### 3️⃣ SUBSCRIBE Response + ```json + [ + { + "userId": 1, + "nickname": "어드민유저", + "profileImage": "https://example.com/profile/1.png" + }, + { + "userId": 2, + "nickname": "테스트유저", + "profileImage": "https://example.com/profile/2.png" + } + ] + ``` + - Array 형태의 참여자 목록 + """ + ) + @GetMapping("/users") + public void chatUserListGuide() { + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java index 7c06e591..70579dca 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java @@ -15,12 +15,19 @@ public class ChatMessageResponse { @Schema(description = "공연 ID", example = "1") private Long concertId; + @Schema(description = "발신자 ID", example = "2") private Long senderId; + @Schema(description = "발신자 닉네임", example = "테스트 유저") private String senderName; + @Schema(description = "메시지 내용", example = "안녕하세요") private String content; + @Schema(description = "전송 시각", example = "2025-12-23T16:28:07.8806432") private LocalDateTime sentDate; + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + private String profileImage; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatParticipantResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatParticipantResponse.java new file mode 100644 index 00000000..27c02c31 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatParticipantResponse.java @@ -0,0 +1,19 @@ +package com.back.web7_9_codecrete_be.domain.chats.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ChatParticipantResponse { + + @Schema(description = "유저 ID", example = "1") + private Long userId; + + @Schema(description = "유저 닉네임", example = "테스트 유저") + private String nickname; + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile/1.png") + private String profileImage; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java index 1eb9a529..5c8a3f33 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java @@ -30,6 +30,9 @@ public class ChatReadResponse { @Schema(description = "메시지 내용", example = "안녕하세요") private String content; - @Schema(description = "전송 시각") + @Schema(description = "전송 시각", example = "2026-01-02T12:13:13.1588173") private LocalDateTime sentDate; + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + private String profileImage; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java index 772a78fe..91ffc763 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java @@ -11,4 +11,5 @@ public class ChatUserCache { private Long userId; private String nickname; + private String profileImage; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java index b628f644..032fe4e8 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java @@ -39,6 +39,7 @@ public void save(ChatMessageResponse message) { fields.put("senderName", message.getSenderName()); fields.put("content", message.getContent()); fields.put("sentDate", message.getSentDate().toString()); + fields.put("profileImage", message.getProfileImage()); redisTemplate.opsForStream().add( StreamRecords.newRecord() @@ -106,7 +107,8 @@ private List toResponses( Long.valueOf(v.get("senderId").toString()), v.get("senderName").toString(), v.get("content").toString(), - LocalDateTime.parse(v.get("sentDate").toString()) + LocalDateTime.parse(v.get("sentDate").toString()), + v.get("profileImage").toString() ); }) .filter(Objects::nonNull) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java index 1a3a35fc..be742710 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java @@ -14,7 +14,6 @@ import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatUserCache; import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository; import com.back.web7_9_codecrete_be.global.security.CustomUserDetail; -import com.back.web7_9_codecrete_be.global.websocket.ChatCountBroadcaster; import com.back.web7_9_codecrete_be.global.websocket.ServerInstanceId; import lombok.RequiredArgsConstructor; @@ -30,7 +29,7 @@ public class ChatMessageService { private final ChatUserCacheService chatUserCacheService; private final RedisTemplate redisTemplate; - private final ChatCountBroadcaster chatCountBroadcaster; + private final ChatPresenceService chatPresenceService; /** * 채팅 메시지 전송 + 세션 TTL 갱신 @@ -63,7 +62,8 @@ private void sendMessage(ChatMessageRequest request, String email) { chatUser.getUserId(), chatUser.getNickname(), request.getContent(), - LocalDateTime.now() + LocalDateTime.now(), + chatUser.getProfileImage() ); log.info("[SEND MESSAGE] From User ID: {}, Content: {}", @@ -96,14 +96,8 @@ private void extendUserSessionTtl(Long userId) { /** * 채팅방 접속자 수 브로드캐스트 */ - public void broadcastUserCount(Long concertId) { + public void broadcastUser(Long concertId) { - String key = "chat:concert:" + concertId + ":users"; - Long count = redisTemplate.opsForSet().size(key); - long userCount = (count != null) ? count : 0; - - chatCountBroadcaster.broadcast(concertId, userCount); - - log.info("[CHAT STATUS] 인원수 응답 완료: concertId={}, count={}", concertId, userCount); + chatPresenceService.broadcast(concertId); } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatParticipantService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatParticipantService.java new file mode 100644 index 00000000..f4c8edc4 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatParticipantService.java @@ -0,0 +1,50 @@ +package com.back.web7_9_codecrete_be.domain.chats.service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatParticipantResponse; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatParticipantService { + + private final RedisTemplate redisTemplate; + private final UserRepository userRepository; + + private String concertUserKey(Long concertId) { + return "chat:concert:" + concertId + ":users"; + } + + public List getParticipants(Long concertId) { + + Set userIds = + redisTemplate.opsForSet().members(concertUserKey(concertId)); + + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + + List ids = userIds.stream() + .map(id -> Long.valueOf(id.toString())) + .toList(); + + List users = userRepository.findAllById(ids); + + return users.stream() + .map(user -> new ChatParticipantResponse( + user.getId(), + user.getNickname(), + user.getProfileImage() + )) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPresenceService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPresenceService.java new file mode 100644 index 00000000..571d4add --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPresenceService.java @@ -0,0 +1,37 @@ +package com.back.web7_9_codecrete_be.domain.chats.service; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.back.web7_9_codecrete_be.global.websocket.ChatStompBroadcaster; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatPresenceService { + + private final RedisTemplate redisTemplate; + private final ChatParticipantService chatParticipantService; + private final ChatStompBroadcaster chatStompBroadcaster; + + private String concertUserKey(Long concertId) { + return "chat:concert:" + concertId + ":users"; + } + + public void broadcast(Long concertId) { + + Long count = + redisTemplate.opsForSet().size(concertUserKey(concertId)); + + chatStompBroadcaster.broadcast( + "/topic/chat/" + concertId + "/count", + count != null ? count : 0 + ); + + chatStompBroadcaster.broadcast( + "/topic/chat/" + concertId + "/users", + chatParticipantService.getParticipants(concertId) + ); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java index 3ed517c4..9f6a3a36 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java @@ -41,7 +41,8 @@ public ChatUserCache getChatUser(String email) { ChatUserCache cache = new ChatUserCache( user.getId(), - user.getNickname() + user.getNickname(), + user.getProfileImage() ); redisTemplate.opsForValue().set(key, cache, ttl); diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatCountBroadcaster.java b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompBroadcaster.java similarity index 76% rename from src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatCountBroadcaster.java rename to src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompBroadcaster.java index 050d64ac..166275ae 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatCountBroadcaster.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompBroadcaster.java @@ -8,11 +8,11 @@ @Component @RequiredArgsConstructor -public class ChatCountBroadcaster { +public class ChatStompBroadcaster { private final ObjectProvider messagingTemplateProvider; - public void broadcast(Long concertId, long count) { + public void broadcast(String destination, Object payload) { SimpMessagingTemplate messagingTemplate = messagingTemplateProvider.getIfAvailable(); @@ -21,10 +21,7 @@ public void broadcast(Long concertId, long count) { return; // WebSocket 브로커 아직 준비 안 됨 } - messagingTemplate.convertAndSend( - "/topic/chat/" + concertId + "/count", - count - ); + messagingTemplate.convertAndSend(destination, payload); } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompHandler.java b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompHandler.java index a166e96e..d7ec3659 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/ChatStompHandler.java @@ -12,6 +12,7 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; +import com.back.web7_9_codecrete_be.domain.chats.service.ChatPresenceService; import com.back.web7_9_codecrete_be.global.security.CustomUserDetail; import com.back.web7_9_codecrete_be.global.security.JwtTokenProvider; @@ -24,8 +25,8 @@ public class ChatStompHandler implements ChannelInterceptor { private final RedisTemplate redisTemplate; - private final ChatCountBroadcaster chatCountBroadcaster; private final JwtTokenProvider jwtTokenProvider; + private final ChatPresenceService chatPresenceService; private String getConcertUserKey(Long concertId) { return "chat:concert:" + concertId + ":users"; @@ -90,7 +91,7 @@ private void handleConnect(StompHeaderAccessor accessor) { // 공연별 접속자 유저 ID 목록(Set)에 추가 (Set -> 동일 유저는 한 번만 기록됨) redisTemplate.opsForSet().add(getConcertUserKey(concertId), userId); - broadcast(concertId); + chatPresenceService.broadcast(concertId); log.info("[CHAT CONNECT] 성공: concertId={}, userId={}, email={}", concertId, userId, userDetail.getUsername()); @@ -119,12 +120,7 @@ private void handleDisconnect(StompHeaderAccessor accessor) { log.info("[CHAT DISCONNECT] 탭 하나 종료 - userId={}, 남은 세션={}", userId, remainingSessions); } - broadcast(concertId); - } - - private void broadcast(Long concertId) { - Long count = redisTemplate.opsForSet().size(getConcertUserKey(concertId)); - chatCountBroadcaster.broadcast(concertId, count != null ? count : 0); + chatPresenceService.broadcast(concertId); } private String extractTokenFromCookie(StompHeaderAccessor accessor) { diff --git a/src/test/java/com/back/web7_9_codecrete_be/Web79CodecreteBeApplicationTests.java b/src/test/java/com/back/web7_9_codecrete_be/Web79CodecreteBeApplicationTests.java index 72c8c491..05bdab39 100644 --- a/src/test/java/com/back/web7_9_codecrete_be/Web79CodecreteBeApplicationTests.java +++ b/src/test/java/com/back/web7_9_codecrete_be/Web79CodecreteBeApplicationTests.java @@ -4,7 +4,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -@ActiveProfiles("test") @SpringBootTest @ActiveProfiles("test") class Web79CodecreteBeApplicationTests {