From cf21973276784a3aa128804330f0751ad88e51db Mon Sep 17 00:00:00 2001 From: heygeeji Date: Wed, 24 Dec 2025 08:11:44 +0900 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20=EC=B1=84=ED=8C=85=EB=B0=A9=20descr?= =?UTF-8?q?iption=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chats/controller/ChatRoomController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java index b9aa1fca..1e62ff94 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java @@ -14,7 +14,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -@Tag(name = "Chat Room", description = "공연 채팅방 입장 및 퇴장 API") +@Tag(name = "Chat Room", description = "공연 채팅방 입장 API") @RestController @RequestMapping("/api/v1/chat-room") @RequiredArgsConstructor From 7659cb94b2b2a6a6ca27cbbc40201e4cfdf562a5 Mon Sep 17 00:00:00 2001 From: heygeeji Date: Wed, 24 Dec 2025 09:01:28 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20WebSocket=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chats/controller/ChatStompDocsController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ed625105..8b99bf9c 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 @@ -27,7 +27,7 @@ public class ChatStompDocsController { ``` ws://localhost:8080/ws-chat or - wss://www.naeconcertbutakhae.shop/ws-chat + wss://api.naeconcertbutakhae.shop/ws-chat ``` #### 2️⃣ SEND Destination From 0e030b386f52af6fde5b815e6b27475bfe26d14c Mon Sep 17 00:00:00 2001 From: heygeeji Date: Wed, 24 Dec 2025 15:15:21 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=84=B8=EC=A7=80=20Redis=20Stream=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ChatStreamRepository.java | 43 +++++++++++++++++++ .../chats/service/ChatMessageService.java | 23 +++++----- .../chats/service/ChatPolicyService.java | 24 +++++++++++ .../domain/chats/service/ChatRoomService.java | 14 +++++- 4 files changed, 93 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java 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 new file mode 100644 index 00000000..4917d69a --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/repository/ChatStreamRepository.java @@ -0,0 +1,43 @@ +package com.back.web7_9_codecrete_be.domain.chats.repository; + +import java.time.Duration; + +import org.springframework.data.redis.connection.stream.StreamRecords; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ChatStreamRepository { + + private final RedisTemplate redisTemplate; + + private String streamKey(Long concertId) { + return "chat:stream:" + concertId; + } + + public void save(ChatMessageResponse message) { + String key = streamKey(message.getConcertId()); + + redisTemplate.opsForStream().add( + StreamRecords.newRecord() + .in(key) + .ofObject(message) + ); + } + + public boolean hasTtl(Long concertId) { + Long ttl = redisTemplate.getExpire(streamKey(concertId)); + return ttl != null && ttl >= 0; + } + + public void setTtl(Long concertId, Duration ttl) { + redisTemplate.expire(streamKey(concertId), ttl); + } + + +} 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 77af3a29..3e06207f 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 @@ -8,6 +8,7 @@ import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest; import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse; +import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; @@ -23,8 +24,10 @@ public class ChatMessageService { private final UserRepository userRepository; private final SimpMessagingTemplate messagingTemplate; + private final ChatStreamRepository chatStreamRepository; + private final ChatPolicyService chatPolicyService; - public void sendMessage(ChatMessageRequest message, Principal principal) { + public void sendMessage(ChatMessageRequest request, Principal principal) { String email = principal.getName(); @@ -32,21 +35,21 @@ public void sendMessage(ChatMessageRequest message, Principal principal) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); - Long senderId = user.getId(); - String senderName = user.getNickname(); - ChatMessageResponse response = new ChatMessageResponse( - message.getConcertId(), - senderId, - senderName, - message.getContent(), + request.getConcertId(), + user.getId(), + user.getNickname(), + request.getContent(), LocalDateTime.now() ); - log.info("[SEND MESSAGE] From User ID: {}, Content: {}", senderId, message.getContent()); + log.info("[SEND MESSAGE] From User ID: {}, Content: {}", user.getId(), request.getContent()); + + chatStreamRepository.save(response); + // WebSocket 브로드캐스트 messagingTemplate.convertAndSend( - "/topic/chat/" + message.getConcertId(), + "/topic/chat/" + request.getConcertId(), response ); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java index fee012ca..7f13454f 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java @@ -79,4 +79,28 @@ private Duration calculateTtl(LocalDateTime now, LocalDateTime policyStart, Loca // 정책 기간 종료 후 return Duration.ofMinutes(10); } + + public Duration calculateChatRemainingTtl(Long concertId) { + + Concert concert = concertRepository.getReferenceById(concertId); + + LocalDateTime ticketTime = concert.getTicketTime(); + if (ticketTime == null) { + return Duration.ofMinutes(0); + } + + LocalDateTime now = LocalDateTime.now(); + + LocalDateTime policyEnd = ticketTime.toLocalDate() + .plusDays(3) + .atTime(LocalTime.MAX); + + if (now.isAfter(policyEnd)) { + return Duration.ofMinutes(0); + } + + return Duration.between(now, policyEnd); + } + + } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatRoomService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatRoomService.java index bece20ca..40267ff5 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatRoomService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatRoomService.java @@ -1,11 +1,14 @@ package com.back.web7_9_codecrete_be.domain.chats.service; +import java.time.Duration; + import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.back.web7_9_codecrete_be.domain.chats.entity.ChatRoom; import com.back.web7_9_codecrete_be.domain.chats.repository.ChatRoomRepository; +import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository; import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert; import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository; import com.back.web7_9_codecrete_be.global.error.code.ChatErrorCode; @@ -21,6 +24,7 @@ public class ChatRoomService { private final ChatRoomRepository chatRoomRepository; private final ChatPolicyService chatPolicyService; private final ConcertRepository concertRepository; + private final ChatStreamRepository chatStreamRepository; /** * 채팅방 입장 @@ -38,7 +42,15 @@ public void joinChatRoom(Long concertId) { chatRoomRepository.findByConcert_ConcertId(concertId) .orElseGet(() -> createChatRoomSafely(concertId)); - // TODO: 입장 처리 (WebSocket) + // Redis Stream TTL 설정 + if (!chatStreamRepository.hasTtl(concertId)) { + Duration ttl = chatPolicyService.calculateChatRemainingTtl(concertId); + + if (!ttl.isZero() && !ttl.isNegative()) { + chatStreamRepository.setTtl(concertId, ttl); + } + } + } /** From a7af6edc30b861df8ca2ef5ac166fd37ce77ab9c Mon Sep 17 00:00:00 2001 From: heygeeji Date: Wed, 24 Dec 2025 16:16:36 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20dto=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chats/controller/ChatMessageController.java | 2 +- .../domain/chats/controller/ChatStompDocsController.java | 4 ++-- .../domain/chats/dto/{ => request}/ChatMessageRequest.java | 2 +- .../domain/chats/dto/{ => response}/ChatMessageResponse.java | 2 +- .../domain/chats/service/ChatMessageService.java | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/{ => request}/ChatMessageRequest.java (87%) rename src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/{ => response}/ChatMessageResponse.java (92%) 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 8929b55a..5cbf33f7 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 @@ -5,7 +5,7 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.stereotype.Controller; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest; +import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest; import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageService; import lombok.RequiredArgsConstructor; 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 8b99bf9c..01f7c1b1 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 @@ -4,8 +4,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse; +import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest; +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/request/ChatMessageRequest.java similarity index 87% rename from src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/request/ChatMessageRequest.java index 06bfc514..1954734e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/request/ChatMessageRequest.java @@ -1,4 +1,4 @@ -package com.back.web7_9_codecrete_be.domain.chats.dto; +package com.back.web7_9_codecrete_be.domain.chats.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java similarity index 92% rename from src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java index d7e7d8f4..7c06e591 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatMessageResponse.java @@ -1,4 +1,4 @@ -package com.back.web7_9_codecrete_be.domain.chats.dto; +package com.back.web7_9_codecrete_be.domain.chats.dto.response; import java.time.LocalDateTime; 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 3e06207f..f080d22f 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 @@ -6,8 +6,8 @@ import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse; +import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest; +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse; import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; From 63d8025723213a1db5640226f3880945312fe67a Mon Sep 17 00:00:00 2001 From: heygeeji Date: Mon, 29 Dec 2025 02:47:04 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=EC=B1=84=ED=8C=85=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=20TTL=20=EC=A1=B0=EC=A0=95=20=EB=B0=8F=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=ED=99=94=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chats/controller/ChatRoomController.java | 11 ++++++++--- .../domain/chats/service/ChatPolicyService.java | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java index 1e62ff94..05527821 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatRoomController.java @@ -14,7 +14,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -@Tag(name = "Chat Room", description = "공연 채팅방 입장 API") +@Tag(name = "Chat", description = "공연 채팅방 입장 API") @RestController @RequestMapping("/api/v1/chat-room") @RequiredArgsConstructor @@ -34,8 +34,13 @@ public class ChatRoomController { Redis Stream 기반 과거 채팅과 실시간 채팅을 수신합니다. """ ) @ApiResponse( responseCode = "200", description = "채팅방 입장에 성공하였습니다." ) - @ApiResponse( responseCode = "403", description = "로그인이 필요합니다." ) - @ApiResponse( responseCode = "403", description = "채팅 가능한 기간이 아닙니다." ) + @ApiResponse( + responseCode = "403", + description = """ + - 로그인이 필요합니다. + - 채팅 가능한 기간이 아닙니다. + """ + ) @ApiResponse( responseCode = "404", description = "콘서트가 존재하지 않습니다." ) @PostMapping("/concert/{concertId}/join") public RsData joinChatRoom(@PathVariable Long concertId) { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java index 7f13454f..ed0a14ea 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatPolicyService.java @@ -86,7 +86,7 @@ public Duration calculateChatRemainingTtl(Long concertId) { LocalDateTime ticketTime = concert.getTicketTime(); if (ticketTime == null) { - return Duration.ofMinutes(0); + return Duration.ofMinutes(10); } LocalDateTime now = LocalDateTime.now(); @@ -96,7 +96,7 @@ public Duration calculateChatRemainingTtl(Long concertId) { .atTime(LocalTime.MAX); if (now.isAfter(policyEnd)) { - return Duration.ofMinutes(0); + return Duration.ofMinutes(10); } return Duration.between(now, policyEnd); From 65711fc6aa740bc1f29dd67043a1f013652b5346 Mon Sep 17 00:00:00 2001 From: heygeeji Date: Mon, 29 Dec 2025 02:50:50 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20Redis=20Stream=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=B1=84=ED=8C=85=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ChatMessageReadController.java | 67 ++++++++++++++ .../chats/dto/response/ChatReadResponse.java | 35 ++++++++ .../repository/ChatStreamRepository.java | 89 ++++++++++++++++++- .../chats/service/ChatMessageReadService.java | 39 ++++++++ .../global/security/SecurityConfig.java | 3 +- 5 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageReadController.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageReadService.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageReadController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageReadController.java new file mode 100644 index 00000000..dd7539ad --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageReadController.java @@ -0,0 +1,67 @@ +package com.back.web7_9_codecrete_be.domain.chats.controller; + +import java.util.List; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse; +import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageReadService; +import com.back.web7_9_codecrete_be.global.rsData.RsData; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Chat", description = "채팅 메시지 조회 API") +@RestController +@RequestMapping("/api/v1/chats") +@RequiredArgsConstructor +public class ChatMessageReadController { + + private final ChatMessageReadService chatMessageReadService; + + @Operation( + summary = "채팅 메시지 조회 (cursor 기반 무한 스크롤)", + description = """ + 채팅 메시지를 cursor(before) 기준으로 조회합니다. + + - before 미전달 시: 최신 메시지를 조회합니다. + - before 전달 시: 해당 cursor 이전의 메시지를 조회합니다. + """ + ) + @GetMapping("/{concertId}/messages") + public RsData> getMessages( + @Parameter( + description = "공연 ID", + example = "1", + required = true + ) + @PathVariable Long concertId, + + @Parameter( + description = """ + 조회 기준 cursor (Redis Stream ID). + 해당 값이 주어지면, 그 이전의 채팅 메시지를 조회합니다. + """, + example = "1734940012345-0", + required = false + ) + @RequestParam(required = false) String before, + + @Parameter( + description = "한 번에 조회할 메시지 개수 (기본값: 20)", + example = "20", + required = false + ) + @RequestParam(required = false) Integer size + ) { + return RsData.success( + chatMessageReadService.getMessages(concertId, before, size) + ); + } +} 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 new file mode 100644 index 00000000..1eb9a529 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatReadResponse.java @@ -0,0 +1,35 @@ +package com.back.web7_9_codecrete_be.domain.chats.dto.response; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "채팅 메시지 조회 응답") +public class ChatReadResponse { + + @Schema(description = "Redis Stream 메시지 ID (before로 전달하면, 해당 메시지 이전 메시지를 조회할 수 있습니다)", example = "1700000123456-0") + private String messageId; + + @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 = "전송 시각") + private LocalDateTime sentDate; +} 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 4917d69a..b628f644 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 @@ -1,12 +1,21 @@ package com.back.web7_9_codecrete_be.domain.chats.repository; import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.domain.Range; +import org.springframework.data.redis.connection.Limit; +import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.connection.stream.StreamRecords; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; -import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse; +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse; +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse; import lombok.RequiredArgsConstructor; @@ -20,13 +29,21 @@ private String streamKey(Long concertId) { return "chat:stream:" + concertId; } + /** 저장 */ public void save(ChatMessageResponse message) { String key = streamKey(message.getConcertId()); + Map fields = new HashMap<>(); + fields.put("concertId", message.getConcertId().toString()); + fields.put("senderId", message.getSenderId().toString()); + fields.put("senderName", message.getSenderName()); + fields.put("content", message.getContent()); + fields.put("sentDate", message.getSentDate().toString()); + redisTemplate.opsForStream().add( StreamRecords.newRecord() .in(key) - .ofObject(message) + .ofMap(fields) ); } @@ -39,5 +56,73 @@ public void setTtl(Long concertId, Duration ttl) { redisTemplate.expire(streamKey(concertId), ttl); } + /** 조회 */ + // 채팅방 진입시 가장 최신 메시지 size개 조회 + public List findLatest(Long concertId, int size) { + + List> records = + redisTemplate.opsForStream().reverseRange( + streamKey(concertId), + Range.unbounded(), + Limit.limit().count(size) + ); + + return toResponses(records); + } + + // cursor(before) 기반 과거 메시지 조회 + // beforeMessageId보다 이전에 있던 메시지들 중 최신 size개 조회 + public List findBefore( + Long concertId, + String beforeMessageId, + int size + ) { + String exclusiveBeforeId = exclusiveBefore(beforeMessageId); + + List> records = + redisTemplate.opsForStream().reverseRange( + streamKey(concertId), + Range.leftOpen("0-0", exclusiveBeforeId), + Limit.limit().count(size) + ); + + return toResponses(records); + } + private List toResponses( + List> records + ) { + if (records == null || records.isEmpty()) { + return List.of(); + } + + return records.stream() + .map(record -> { + Map v = record.getValue(); + + return new ChatReadResponse( + record.getId().getValue(), + Long.valueOf(v.get("concertId").toString()), + Long.valueOf(v.get("senderId").toString()), + v.get("senderName").toString(), + v.get("content").toString(), + LocalDateTime.parse(v.get("sentDate").toString()) + ); + }) + .filter(Objects::nonNull) + .toList(); + } + + private String exclusiveBefore(String messageId) { + String[] parts = messageId.split("-"); + long time = Long.parseLong(parts[0]); // 밀리초 타임스탬프 + long seq = Long.parseLong(parts[1]); // 같은 밀리초 내 순번 + + if (seq > 0) { + return time + "-" + (seq - 1); + } + + // seq == 0 인 경우, timestamp를 1 감소 + return (time - 1) + "-0"; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageReadService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageReadService.java new file mode 100644 index 00000000..9742f81a --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageReadService.java @@ -0,0 +1,39 @@ +package com.back.web7_9_codecrete_be.domain.chats.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatReadResponse; +import com.back.web7_9_codecrete_be.domain.chats.repository.ChatStreamRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatMessageReadService { + + private static final int DEFAULT_SIZE = 20; + private static final int MAX_SIZE = 50; + + private final ChatStreamRepository chatStreamRepository; + + public List getMessages( + Long concertId, + String before, + Integer size + ) { + int limit = Math.min( + size != null ? size : DEFAULT_SIZE, + MAX_SIZE + ); + + // 최초 진입 + if (before == null || before.isBlank()) { + return chatStreamRepository.findLatest(concertId, limit); + } + + // 이전 메시지 조회 (무한 스크롤) + return chatStreamRepository.findBefore(concertId, before, limit); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java index 2a0c3b17..25d2170d 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java @@ -59,7 +59,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/location/**", //location 정보 조회 도메인(임시) "/api/v1/concerts/**", // concert 정보 조회 도메인 "/api/v1/artists/**", // artist 정보 저장 도메인(임시) - "/api/v1/users/**" + "/api/v1/users/**", + "/api/v1/chats/**" ).permitAll() // ADMIN 전용 From a1b851efab032c3420ebe7b1b4ed6e55dc670031 Mon Sep 17 00:00:00 2001 From: heygeeji Date: Mon, 29 Dec 2025 03:20:55 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EC=B1=84=ED=8C=85=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=A0=84=EC=86=A1=20=EC=8B=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EB=B3=B4=20Redis=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chats/dto/response/ChatUserCache.java | 14 +++++ .../chats/service/ChatMessageService.java | 18 +++---- .../chats/service/ChatUserCacheService.java | 51 +++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java 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 new file mode 100644 index 00000000..772a78fe --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/response/ChatUserCache.java @@ -0,0 +1,14 @@ +package com.back.web7_9_codecrete_be.domain.chats.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ChatUserCache { + + private Long userId; + private String nickname; +} 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 f080d22f..97cd56f1 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 @@ -8,11 +8,8 @@ import com.back.web7_9_codecrete_be.domain.chats.dto.request.ChatMessageRequest; import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatMessageResponse; +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.domain.users.entity.User; -import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; -import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; -import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -22,28 +19,25 @@ @RequiredArgsConstructor public class ChatMessageService { - private final UserRepository userRepository; private final SimpMessagingTemplate messagingTemplate; private final ChatStreamRepository chatStreamRepository; - private final ChatPolicyService chatPolicyService; + private final ChatUserCacheService chatUserCacheService; public void sendMessage(ChatMessageRequest request, Principal principal) { String email = principal.getName(); - // TODO: 캐싱처리 - User user = userRepository.findByEmail(email) - .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + ChatUserCache chatUser = chatUserCacheService.getChatUser(email); ChatMessageResponse response = new ChatMessageResponse( request.getConcertId(), - user.getId(), - user.getNickname(), + chatUser.getUserId(), + chatUser.getNickname(), request.getContent(), LocalDateTime.now() ); - log.info("[SEND MESSAGE] From User ID: {}, Content: {}", user.getId(), request.getContent()); + log.info("[SEND MESSAGE] From User ID: {}, Content: {}", chatUser.getUserId(), request.getContent()); chatStreamRepository.save(response); 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 new file mode 100644 index 00000000..dd76ed7b --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatUserCacheService.java @@ -0,0 +1,51 @@ +package com.back.web7_9_codecrete_be.domain.chats.service; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import com.back.web7_9_codecrete_be.domain.chats.dto.response.ChatUserCache; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ChatUserCacheService { + + private static final Duration ttl = Duration.ofMinutes(120); + + private final RedisTemplate redisTemplate; + private final UserRepository userRepository; + + private String cacheKey(String email) { + return "chat:user:" + email; + } + + public ChatUserCache getChatUser(String email) { + String key = cacheKey(email); + + ChatUserCache cached = + (ChatUserCache)redisTemplate.opsForValue().get(key); + + if (cached != null) { + return cached; + } + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + + ChatUserCache cache = new ChatUserCache( + user.getId(), + user.getNickname() + ); + + redisTemplate.opsForValue().set(key, cache, ttl); + + return cache; + } +} From 99980b68db5657b9e8e5adce4f225b054c57c361 Mon Sep 17 00:00:00 2001 From: heygeeji Date: Mon, 29 Dec 2025 03:24:28 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=9C=A0=EC=A0=80=20=EC=A0=95=EB=B3=B4=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chats/service/ChatUserCacheService.java | 4 ++++ .../domain/users/service/UserService.java | 22 ++++++++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) 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 dd76ed7b..3ed517c4 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 @@ -48,4 +48,8 @@ public ChatUserCache getChatUser(String email) { return cache; } + + public void removeChatUserCache(String email) { + redisTemplate.delete(cacheKey(email)); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java index 4a35ff55..8cebb3e6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java @@ -1,8 +1,17 @@ package com.back.web7_9_codecrete_be.domain.users.service; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import com.back.web7_9_codecrete_be.domain.auth.dto.request.SignupRequest; import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.chats.service.ChatUserCacheService; import com.back.web7_9_codecrete_be.domain.email.service.EmailService; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserSettingUpdateRequest; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; @@ -18,16 +27,9 @@ import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.storage.FileStorageService; import com.back.web7_9_codecrete_be.global.storage.ImageFileValidator; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.UUID; @Slf4j @Service @@ -40,6 +42,7 @@ public class UserService { private final TokenService tokenService; private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository; private final EmailService emailService; + private final ChatUserCacheService chatUserCacheService; private final ImageFileValidator imageFileValidator; @@ -110,6 +113,9 @@ public UserResponse updateNickname(User user, UserUpdateNicknameRequest req) { user.updateNickname(req.getNickname()); userRepository.save(user); + + chatUserCacheService.removeChatUserCache(user.getEmail()); + return UserResponse.from(user); }