From e0a7f057d33a9aaa680c6dda5e51c42721c6dbfd Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:36:00 +0900 Subject: [PATCH 01/14] =?UTF-8?q?:package:=20(#304)=20=EC=9B=B9=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/build.gradle b/backend/build.gradle index 4e5168a0..175194ec 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -46,6 +46,7 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.12.600' implementation 'com.amazonaws:aws-java-sdk-core:1.12.681' implementation 'org.apache.commons:commons-lang3:3.12.0' + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { From 07bd2bacfe725d2a8f4b6c389c3937742c62f7fe Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:36:13 +0900 Subject: [PATCH 02/14] =?UTF-8?q?:package:=20(#304)=20config=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/global/redis/RedisConfig.java | 9 ++++++++ .../global/websocket/WebSocketConfig.java | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java index e14bff99..b03204c7 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java @@ -6,6 +6,7 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -30,4 +31,12 @@ public RedisTemplate redisTemplate() { redisTemplate.setValueSerializer(new StringRedisSerializer()); // value를 문자열로 직렬화 return redisTemplate; } + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer() { + // redis pub/sub 메시지 처리 listener + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory()); + return container; + } } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java new file mode 100644 index 00000000..d1024237 --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java @@ -0,0 +1,23 @@ +package org.example.backend.global.websocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 핸드쉐이크 + registry.addEndpoint("/ws-connect").setAllowedOrigins("*").withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); // 메시지 받을 경로 + registry.setApplicationDestinationPrefixes("/pub"); // 메시지 보낼 경로 + } +} From de40c20ef31275b278bcfbd2bd31d4e13930db30 Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:36:38 +0900 Subject: [PATCH 03/14] =?UTF-8?q?:sparkles:=20(#304)=20redis=20listener=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/redis/RedisMessageSubscriber.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java new file mode 100644 index 00000000..54873654 --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java @@ -0,0 +1,45 @@ +package org.example.backend.global.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.backend.domain.question.dto.request.MessageRequestDTO; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +/** + * redis subscriber 역할을 수행하는 클래스 + * redis pub/sub 채널로 메시지를 수신하면, 해당 메시지를 STOMP를 통해 web socket 구독자에게 브로드캐스트 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class RedisMessageSubscriber implements MessageListener { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + private final ObjectMapper objectMapper; + + /** + * redis로부터 메시지를 수신했을 때 호출되는 메소드 + * + * @param message redis에서 전달된 메시지(json 형태) + * @param pattern 구독중인 채널 패턴 + */ + @Override + public void onMessage(Message message, byte[] pattern) { + try{ + String channel = new String(pattern); + String body = new String(message.getBody()); + + // JSON -> DTO 역직렬화 + MessageRequestDTO.MessageDTO chatMessage = objectMapper.readValue(body, MessageRequestDTO.MessageDTO.class); + + // subscriber에게 STOMP 메시지 전송 + simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ chatMessage.getLectureId(), chatMessage); + } catch (Exception e) { + log.error("Redis 메시지 수신 실패", e); + } + } +} From 65b0480f6a1cb1ee8d299c9e81d857be0cfb4ff6 Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:36:50 +0900 Subject: [PATCH 04/14] =?UTF-8?q?:sparkles:=20(#304)=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20DTO=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/dto/request/MessageRequestDTO.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java diff --git a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java new file mode 100644 index 00000000..a40e1f9a --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -0,0 +1,17 @@ +package org.example.backend.domain.question.dto.request; + +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class MessageRequestDTO { + @Getter + @Setter + public static class MessageDTO { + private UUID senderId; + private UUID lectureId; + private String content; + private LocalDateTime timestamp; + } +} From dff2b693e3af1df760cc99f30d7df5e97501c2d7 Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:37:22 +0900 Subject: [PATCH 05/14] =?UTF-8?q?:sparkles:=20(#304)=20=EC=B1=84=ED=8C=85?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/controller/ChatController.java | 41 ++++++++++++++ .../question/exception/QuestionErrorCode.java | 3 +- .../domain/question/service/ChatService.java | 7 +++ .../question/service/ChatServiceImpl.java | 53 +++++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java create mode 100644 backend/src/main/java/org/example/backend/domain/question/service/ChatService.java create mode 100644 backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java diff --git a/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java new file mode 100644 index 00000000..552659b9 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java @@ -0,0 +1,41 @@ +package org.example.backend.domain.question.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.backend.domain.question.dto.request.MessageRequestDTO; +import org.example.backend.domain.question.service.ChatService; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * STOMP 메시지 수신 & Redis Pub/Sub으로 전달 + */ +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + /** + * 클라이언트기 "/pub/lecture/{lectureId}"로 메시지를 전송하면 해당 메시지를 redis pub/sub 채널로 publish + * @param lectureId + * @param messageDTO + */ + @MessageMapping("/lecture/{lectureId}") + public void sendMessage(@DestinationVariable UUID lectureId, MessageRequestDTO.MessageDTO messageDTO) { + log.info("메시지 수신: {}", messageDTO); + + messageDTO.setLectureId(lectureId); + + if (messageDTO.getTimestamp() == null) { + messageDTO.setTimestamp(LocalDateTime.now()); + } + + chatService.sendMessage(messageDTO); + } +} diff --git a/backend/src/main/java/org/example/backend/domain/question/exception/QuestionErrorCode.java b/backend/src/main/java/org/example/backend/domain/question/exception/QuestionErrorCode.java index 28232cab..2ce2f6c7 100644 --- a/backend/src/main/java/org/example/backend/domain/question/exception/QuestionErrorCode.java +++ b/backend/src/main/java/org/example/backend/domain/question/exception/QuestionErrorCode.java @@ -9,7 +9,8 @@ @Getter @AllArgsConstructor public enum QuestionErrorCode implements BaseErrorCode { - _FORBIDDEN_LECTURE_ACCESS(HttpStatus.FORBIDDEN,"QUESTION403_1","해당 강의를 수강중인 학생만 조회가능합니다."); + _FORBIDDEN_LECTURE_ACCESS(HttpStatus.FORBIDDEN,"QUESTION403_1","해당 강의를 수강중인 학생만 조회가능합니다."), + _CHAT_MESSAGE_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "QUESTION500_1", "채팅 메시지 전송에 실패했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java b/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java new file mode 100644 index 00000000..d4fe7f5a --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java @@ -0,0 +1,7 @@ +package org.example.backend.domain.question.service; + +import org.example.backend.domain.question.dto.request.MessageRequestDTO; + +public interface ChatService { + void sendMessage(MessageRequestDTO.MessageDTO messageDTO); +} diff --git a/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java new file mode 100644 index 00000000..28abb979 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java @@ -0,0 +1,53 @@ +package org.example.backend.domain.question.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.backend.domain.question.dto.request.MessageRequestDTO; +import org.example.backend.domain.question.exception.QuestionErrorCode; +import org.example.backend.domain.question.exception.QuestionException; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + @Override + public void sendMessage(MessageRequestDTO.MessageDTO messageDTO) { + /** + * STOMP로부터 수신한 메시지를 Redis Pub/Sub으로 전파하고, + * Redis List에 저장하는 메서드 + */ + + try{ + UUID lectureId = messageDTO.getLectureId(); + String messageJson = objectMapper.writeValueAsString(messageDTO); // DTO -> JSON 직렬화 + + // Redis 채널 이름 지정: lecture:{lectureId} + String redisChannel = "lecture:"+ lectureId; + // Redis List 키 이름 지정(채팅 내용 저장용): chat:lecture:{lectureId} + String redisListKey = "chat:lecture:"+ lectureId; + + // Redis pub/sub - 메시지 전파 + redisTemplate.convertAndSend(redisChannel, messageJson); + + // Redis list - 메시지 저장 + redisTemplate.opsForList().rightPush(redisListKey, messageJson); + + // 추후 제거 + log.info("채팅 메시지 전송 성공 - lectureId={}, senderId={}, content={}", + lectureId, messageDTO.getSenderId(), messageDTO.getContent()); + + } catch (Exception e){ + log.error("채팅 메시지 전송 실패",e); + throw new QuestionException(QuestionErrorCode._CHAT_MESSAGE_SEND_FAIL); + } + } +} From 4108f0a6e000270aa6a801e55bf4543cde970f3a Mon Sep 17 00:00:00 2001 From: sunninz Date: Wed, 3 Sep 2025 17:37:48 +0900 Subject: [PATCH 06/14] =?UTF-8?q?:sparkles:=20(#304)=20=EC=86=8C=EC=BC=93?= =?UTF-8?q?=20=ED=95=B8=EB=93=9C=EC=85=B0=EC=9D=B4=ED=81=AC=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EA=B2=80=EC=A6=9D=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/config/SecurityConfig.java | 9 +++++++++ .../global/security/filter/JWTFilter.java | 19 ++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/org/example/backend/global/security/config/SecurityConfig.java b/backend/src/main/java/org/example/backend/global/security/config/SecurityConfig.java index 21668aa5..f0b22476 100644 --- a/backend/src/main/java/org/example/backend/global/security/config/SecurityConfig.java +++ b/backend/src/main/java/org/example/backend/global/security/config/SecurityConfig.java @@ -14,6 +14,7 @@ import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -74,6 +75,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // .anyRequest().permitAll() .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/ws-connect/**").permitAll() .requestMatchers("/api/users","/api/users/verify-email","/api/users/login","/api/users/password/temp").permitAll() .anyRequest().authenticated()) .addFilterBefore(new FilterExceptionHandler(), LogoutFilter.class) // 예외처리 필터 @@ -98,4 +100,11 @@ public CorsConfigurationSource corsConfigurationSource() { return source; } + + // web socket 허용 + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring() + .requestMatchers("/ws-connect/**"); + } } diff --git a/backend/src/main/java/org/example/backend/global/security/filter/JWTFilter.java b/backend/src/main/java/org/example/backend/global/security/filter/JWTFilter.java index b43913bb..23ebdd73 100644 --- a/backend/src/main/java/org/example/backend/global/security/filter/JWTFilter.java +++ b/backend/src/main/java/org/example/backend/global/security/filter/JWTFilter.java @@ -41,7 +41,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse uri.equals("/api/users")|| uri.equals("/api/users/password/temp")|| uri.equals("/api/users/verify-email")|| - uri.equals("/api/users/refresh")) { + uri.equals("/api/users/refresh")|| + uri.startsWith("/ws-connect")) { filterChain.doFilter(request, response); return; } @@ -55,14 +56,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // request에서 Authorization 헤더를 찾음 String authorization = request.getHeader("Authorization"); -// // Authorization 헤더 검증 -// if(authorization == null || !authorization.startsWith("Bearer ")){ -// System.out.println("token null or invalid"); -// setErrorResponse(response, UserErrorCode._TOKEN_MISSING); -// -// // 조건이 해당되면 메소드 종료 -// return; -// } + // Authorization 헤더 검증 + if(authorization == null || !authorization.startsWith("Bearer ")){ + System.out.println("token null or invalid"); + setErrorResponse(response, UserErrorCode._TOKEN_MISSING); + + // 조건이 해당되면 메소드 종료 + return; + } // Bearer 제외하고 토큰만 획득 String token = authorization.substring(7); From e0b6672b8d7b63ff61fd26f48b387874a13d0d1e Mon Sep 17 00:00:00 2001 From: sunninz Date: Sat, 6 Sep 2025 10:02:20 +0900 Subject: [PATCH 07/14] =?UTF-8?q?:sparkles:=20(#304)=20=EC=86=8C=EC=BC=93?= =?UTF-8?q?=20config=20origin=20=EC=84=A4=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/backend/global/websocket/WebSocketConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java index d1024237..8c8d408a 100644 --- a/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java +++ b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java @@ -12,7 +12,7 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 핸드쉐이크 - registry.addEndpoint("/ws-connect").setAllowedOrigins("*").withSockJS(); + registry.addEndpoint("/ws-connect").setAllowedOriginPatterns("*").withSockJS(); } @Override From 1ae3db986400e1ed961dfc94602822cbc7f1095f Mon Sep 17 00:00:00 2001 From: sunninz Date: Thu, 11 Sep 2025 15:41:08 +0900 Subject: [PATCH 08/14] =?UTF-8?q?:sparkles:=20(#304)=20timestamp=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/question/controller/ChatController.java | 4 ---- .../domain/question/dto/request/MessageRequestDTO.java | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java index 552659b9..17910811 100644 --- a/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java +++ b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java @@ -32,10 +32,6 @@ public void sendMessage(@DestinationVariable UUID lectureId, MessageRequestDTO.M messageDTO.setLectureId(lectureId); - if (messageDTO.getTimestamp() == null) { - messageDTO.setTimestamp(LocalDateTime.now()); - } - chatService.sendMessage(messageDTO); } } diff --git a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java index a40e1f9a..192224c8 100644 --- a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -12,6 +12,6 @@ public static class MessageDTO { private UUID senderId; private UUID lectureId; private String content; - private LocalDateTime timestamp; } } + From e948e2e74c0b48d1171ffbd487085b5be5521301 Mon Sep 17 00:00:00 2001 From: sunninz Date: Thu, 11 Sep 2025 15:41:39 +0900 Subject: [PATCH 09/14] =?UTF-8?q?:sparkles:=20(#304)=20redisMessageSubscri?= =?UTF-8?q?ber=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/example/backend/global/redis/RedisConfig.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java index b03204c7..bc9735c5 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java @@ -6,6 +6,7 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.listener.PatternTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -33,10 +34,15 @@ public RedisTemplate redisTemplate() { } @Bean - public RedisMessageListenerContainer redisMessageListenerContainer() { + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory redisConnectionFactory, + RedisMessageSubscriber redisMessageSubscriber + ) { // redis pub/sub 메시지 처리 listener RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(redisConnectionFactory()); + + container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*")); return container; } } \ No newline at end of file From 63bb968d94d54d3f133a93ba18b8763dc853a58f Mon Sep 17 00:00:00 2001 From: sunninz Date: Thu, 11 Sep 2025 17:54:06 +0900 Subject: [PATCH 10/14] =?UTF-8?q?:sparkles:=20(#304)=20DTO=20senderName=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/domain/question/dto/request/MessageRequestDTO.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java index 192224c8..648726fe 100644 --- a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -10,8 +10,9 @@ public class MessageRequestDTO { @Setter public static class MessageDTO { private UUID senderId; - private UUID lectureId; + private String senderName; private String content; + private UUID lectureId; } } From aaee477f32955286772702c723bb2c53213942cc Mon Sep 17 00:00:00 2001 From: sunninz Date: Thu, 11 Sep 2025 19:24:34 +0900 Subject: [PATCH 11/14] =?UTF-8?q?:sparkles:=20(#304)=20stomp=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=84=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../question/controller/ChatController.java | 15 ++++-- .../websocket/StompChannelInterceptor.java | 47 +++++++++++++++++++ .../global/websocket/StompPrincipal.java | 11 +++++ .../global/websocket/WebSocketConfig.java | 11 +++++ 4 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/org/example/backend/global/websocket/StompChannelInterceptor.java create mode 100644 backend/src/main/java/org/example/backend/global/websocket/StompPrincipal.java diff --git a/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java index 17910811..d128c62c 100644 --- a/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java +++ b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java @@ -4,11 +4,13 @@ import lombok.extern.slf4j.Slf4j; import org.example.backend.domain.question.dto.request.MessageRequestDTO; import org.example.backend.domain.question.service.ChatService; +import org.example.backend.global.websocket.StompPrincipal; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Controller; -import java.time.LocalDateTime; +import java.security.Principal; import java.util.UUID; /** @@ -27,11 +29,16 @@ public class ChatController { * @param messageDTO */ @MessageMapping("/lecture/{lectureId}") - public void sendMessage(@DestinationVariable UUID lectureId, MessageRequestDTO.MessageDTO messageDTO) { + public void sendMessage(@DestinationVariable UUID lectureId, + @Payload MessageRequestDTO.MessageDTO messageDTO, + Principal principal) { log.info("메시지 수신: {}", messageDTO); + log.info("principal = {}", principal); - messageDTO.setLectureId(lectureId); + StompPrincipal stompPrincipal = (StompPrincipal) principal; + UUID userId = stompPrincipal.userId(); + String role = stompPrincipal.role(); - chatService.sendMessage(messageDTO); + chatService.sendMessage(lectureId,messageDTO,userId,role); } } diff --git a/backend/src/main/java/org/example/backend/global/websocket/StompChannelInterceptor.java b/backend/src/main/java/org/example/backend/global/websocket/StompChannelInterceptor.java new file mode 100644 index 00000000..d2a9beae --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/websocket/StompChannelInterceptor.java @@ -0,0 +1,47 @@ +package org.example.backend.global.websocket; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.backend.global.security.token.JWTUtil; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StompChannelInterceptor implements ChannelInterceptor { + + private final JWTUtil jwtUtil; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + log.info("CONNECT 헤더: {}", accessor.toNativeHeaderMap()); + + if(accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) { + log.info("WebSocket CONNECT 요청 도착"); + + String token = accessor.getFirstNativeHeader("Authorization"); + + if(token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + UUID userId = jwtUtil.getUserId(token); + String role = jwtUtil.getRole(token); + + log.info("✅ STOMP 연결 토큰 검증 성공: userId={}, role={}", userId, role); + accessor.setUser(new StompPrincipal(userId, role)); + }else { + log.warn("❌ STOMP 연결 시 토큰 누락 또는 형식 오류: {}", token); + } + } + return message; + } + +} diff --git a/backend/src/main/java/org/example/backend/global/websocket/StompPrincipal.java b/backend/src/main/java/org/example/backend/global/websocket/StompPrincipal.java new file mode 100644 index 00000000..7942f059 --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/websocket/StompPrincipal.java @@ -0,0 +1,11 @@ +package org.example.backend.global.websocket; + + +import java.security.Principal; +import java.util.UUID; +public record StompPrincipal(UUID userId, String role) implements Principal { + @Override + public String getName() { + return userId.toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java index 8c8d408a..1c2c45b9 100644 --- a/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java +++ b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java @@ -1,6 +1,8 @@ package org.example.backend.global.websocket; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -8,7 +10,16 @@ @Configuration @EnableWebSocketMessageBroker +@RequiredArgsConstructor public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompChannelInterceptor stompChannelInterceptor; + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // 인터셉터 등록 + registration.interceptors(stompChannelInterceptor); + } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { // 핸드쉐이크 From db42caa2375acf5e2075e920e25e59ac21d9307c Mon Sep 17 00:00:00 2001 From: sunninz Date: Thu, 11 Sep 2025 19:24:45 +0900 Subject: [PATCH 12/14] =?UTF-8?q?:sparkles:=20(#304)=20=EA=B0=95=EC=82=AC/?= =?UTF-8?q?=ED=95=99=EC=83=9D=20=EA=B5=AC=EB=8F=85=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/MessageRequestDTO.java | 2 +- .../domain/question/service/ChatService.java | 4 +- .../question/service/ChatServiceImpl.java | 56 +++++++++++++++---- .../backend/global/redis/RedisConfig.java | 2 +- .../global/redis/RedisMessageSubscriber.java | 12 +++- 5 files changed, 60 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java index 648726fe..98422527 100644 --- a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -7,7 +7,7 @@ public class MessageRequestDTO { @Getter - @Setter + @Builder public static class MessageDTO { private UUID senderId; private String senderName; diff --git a/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java b/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java index d4fe7f5a..665e8c25 100644 --- a/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java @@ -2,6 +2,8 @@ import org.example.backend.domain.question.dto.request.MessageRequestDTO; +import java.util.UUID; + public interface ChatService { - void sendMessage(MessageRequestDTO.MessageDTO messageDTO); + void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, UUID userId, String role); } diff --git a/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java index 28abb979..07f7af3a 100644 --- a/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java @@ -6,6 +6,10 @@ import org.example.backend.domain.question.dto.request.MessageRequestDTO; import org.example.backend.domain.question.exception.QuestionErrorCode; import org.example.backend.domain.question.exception.QuestionException; +import org.example.backend.domain.user.entity.User; +import org.example.backend.domain.user.exception.UserErrorCode; +import org.example.backend.domain.user.exception.UserException; +import org.example.backend.domain.user.repository.UserRepository; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; @@ -18,32 +22,62 @@ public class ChatServiceImpl implements ChatService { private final RedisTemplate redisTemplate; private final ObjectMapper objectMapper; + private final UserRepository userRepository; @Override - public void sendMessage(MessageRequestDTO.MessageDTO messageDTO) { + public void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, UUID userId, String role) { /** * STOMP로부터 수신한 메시지를 Redis Pub/Sub으로 전파하고, * Redis List에 저장하는 메서드 */ try{ - UUID lectureId = messageDTO.getLectureId(); - String messageJson = objectMapper.writeValueAsString(messageDTO); // DTO -> JSON 직렬화 + // 사용자 정보 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode._USER_NOT_FOUND)); + + String senderName = user.getName(); + + // 1. 메시지 저장 + // 저장용 DTO 구성 + MessageRequestDTO.MessageDTO originalMessage = MessageRequestDTO.MessageDTO.builder() + .lectureId(lectureId) + .senderId(userId) + .senderName(senderName) + .content(messageDTO.getContent()) + .build(); + + String originalMessageJson = objectMapper.writeValueAsString(originalMessage); // DTO -> JSON 직렬화 - // Redis 채널 이름 지정: lecture:{lectureId} - String redisChannel = "lecture:"+ lectureId; // Redis List 키 이름 지정(채팅 내용 저장용): chat:lecture:{lectureId} String redisListKey = "chat:lecture:"+ lectureId; + // Redis list - 메시지 저장 + redisTemplate.opsForList().rightPush(redisListKey, originalMessageJson); + + // 2. 메시지 전파 + // 2-1. teacher 메시지 전송 + // Redis 채널 이름 지정: lecture:{lectureId} + String teacherChannel = "lecture:" + lectureId + ":TEACHER"; // Redis pub/sub - 메시지 전파 - redisTemplate.convertAndSend(redisChannel, messageJson); + redisTemplate.convertAndSend(teacherChannel, originalMessageJson); - // Redis list - 메시지 저장 - redisTemplate.opsForList().rightPush(redisListKey, messageJson); + // 2-2. student 메시지 전송 + // 메시지 구성 + MessageRequestDTO.MessageDTO maskMessage = MessageRequestDTO.MessageDTO.builder() + .lectureId(lectureId) + .senderId(null) + .senderName(null) + .content(messageDTO.getContent()) + .build(); + + String maskMessageJson = objectMapper.writeValueAsString(maskMessage); // DTO -> JSON 직렬화 + + // Redis 채널 이름 지정: lecture:{lectureId} + String studentChannel = "lecture:" + lectureId + ":STUDENT"; + // Redis pub/sub - 메시지 전파 + redisTemplate.convertAndSend(studentChannel, maskMessageJson); - // 추후 제거 - log.info("채팅 메시지 전송 성공 - lectureId={}, senderId={}, content={}", - lectureId, messageDTO.getSenderId(), messageDTO.getContent()); } catch (Exception e){ log.error("채팅 메시지 전송 실패",e); diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java index bc9735c5..c450ab76 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java @@ -42,7 +42,7 @@ public RedisMessageListenerContainer redisMessageListenerContainer( RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(redisConnectionFactory()); - container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*")); + container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*:*")); return container; } } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java index 54873654..92494528 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java @@ -30,14 +30,22 @@ public class RedisMessageSubscriber implements MessageListener { @Override public void onMessage(Message message, byte[] pattern) { try{ - String channel = new String(pattern); + String channel = new String(message.getChannel()); + + System.out.println(channel); + String[] parts = channel.split(":"); + String lectureId = parts[1]; + String role = parts[2]; + + System.out.println("lectureId = " + lectureId); + System.out.println("role = " + role); String body = new String(message.getBody()); // JSON -> DTO 역직렬화 MessageRequestDTO.MessageDTO chatMessage = objectMapper.readValue(body, MessageRequestDTO.MessageDTO.class); // subscriber에게 STOMP 메시지 전송 - simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ chatMessage.getLectureId(), chatMessage); + simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ lectureId+"/"+role, chatMessage); } catch (Exception e) { log.error("Redis 메시지 수신 실패", e); } From 107cbf634b87b5dc5168625e4a5724fe716dab24 Mon Sep 17 00:00:00 2001 From: sunninz Date: Mon, 15 Sep 2025 14:58:40 +0900 Subject: [PATCH 13/14] =?UTF-8?q?:sparkles:=20(#307)=20DTO=EC=97=90=20time?= =?UTF-8?q?stamp=EC=99=80=20role=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/MessageRequestDTO.java | 4 +++- .../question/service/ChatServiceImpl.java | 21 ++++++++----------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java index 98422527..f96a8304 100644 --- a/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -1,6 +1,7 @@ package org.example.backend.domain.question.dto.request; import lombok.*; +import org.example.backend.domain.user.entity.Role; import java.time.LocalDateTime; import java.util.UUID; @@ -12,7 +13,8 @@ public static class MessageDTO { private UUID senderId; private String senderName; private String content; - private UUID lectureId; + private Role role; + private LocalDateTime timestamp; } } diff --git a/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java index 07f7af3a..fe5d4920 100644 --- a/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java @@ -13,6 +13,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.UUID; @Slf4j @@ -33,48 +34,44 @@ public void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, try{ // 사용자 정보 조회 - User user = userRepository.findById(userId) + User sender = userRepository.findById(userId) .orElseThrow(() -> new UserException(UserErrorCode._USER_NOT_FOUND)); - String senderName = user.getName(); + String senderName = sender.getName(); // 1. 메시지 저장 // 저장용 DTO 구성 MessageRequestDTO.MessageDTO originalMessage = MessageRequestDTO.MessageDTO.builder() - .lectureId(lectureId) .senderId(userId) .senderName(senderName) .content(messageDTO.getContent()) + .role(sender.getRole()) + .timestamp(LocalDateTime.now()) .build(); String originalMessageJson = objectMapper.writeValueAsString(originalMessage); // DTO -> JSON 직렬화 // Redis List 키 이름 지정(채팅 내용 저장용): chat:lecture:{lectureId} String redisListKey = "chat:lecture:"+ lectureId; + // Redis list - 메시지 저장 redisTemplate.opsForList().rightPush(redisListKey, originalMessageJson); // 2. 메시지 전파 - // 2-1. teacher 메시지 전송 - // Redis 채널 이름 지정: lecture:{lectureId} - String teacherChannel = "lecture:" + lectureId + ":TEACHER"; - // Redis pub/sub - 메시지 전파 - redisTemplate.convertAndSend(teacherChannel, originalMessageJson); - - // 2-2. student 메시지 전송 // 메시지 구성 MessageRequestDTO.MessageDTO maskMessage = MessageRequestDTO.MessageDTO.builder() - .lectureId(lectureId) .senderId(null) .senderName(null) .content(messageDTO.getContent()) + .role(sender.getRole()) + .timestamp(LocalDateTime.now()) .build(); String maskMessageJson = objectMapper.writeValueAsString(maskMessage); // DTO -> JSON 직렬화 // Redis 채널 이름 지정: lecture:{lectureId} - String studentChannel = "lecture:" + lectureId + ":STUDENT"; + String studentChannel = "lecture:" + lectureId; // Redis pub/sub - 메시지 전파 redisTemplate.convertAndSend(studentChannel, maskMessageJson); From 8fcc9ed0d7805ec8edb1a0772836b7efed12e1c8 Mon Sep 17 00:00:00 2001 From: sunninz Date: Mon, 15 Sep 2025 14:58:51 +0900 Subject: [PATCH 14/14] =?UTF-8?q?:sparkles:=20(#307)=20=EA=B5=AC=EB=8F=85?= =?UTF-8?q?=20=EC=B1=84=EB=84=90=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/example/backend/global/redis/RedisConfig.java | 2 +- .../example/backend/global/redis/RedisMessageSubscriber.java | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java index c450ab76..bc9735c5 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisConfig.java @@ -42,7 +42,7 @@ public RedisMessageListenerContainer redisMessageListenerContainer( RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(redisConnectionFactory()); - container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*:*")); + container.addMessageListener(redisMessageSubscriber, new PatternTopic("lecture:*")); return container; } } \ No newline at end of file diff --git a/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java index 92494528..b1da5ce3 100644 --- a/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java +++ b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java @@ -35,17 +35,14 @@ public void onMessage(Message message, byte[] pattern) { System.out.println(channel); String[] parts = channel.split(":"); String lectureId = parts[1]; - String role = parts[2]; - System.out.println("lectureId = " + lectureId); - System.out.println("role = " + role); String body = new String(message.getBody()); // JSON -> DTO 역직렬화 MessageRequestDTO.MessageDTO chatMessage = objectMapper.readValue(body, MessageRequestDTO.MessageDTO.class); // subscriber에게 STOMP 메시지 전송 - simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ lectureId+"/"+role, chatMessage); + simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ lectureId, chatMessage); } catch (Exception e) { log.error("Redis 메시지 수신 실패", e); }