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') { 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..d128c62c --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/controller/ChatController.java @@ -0,0 +1,44 @@ +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.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.security.Principal; +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, + @Payload MessageRequestDTO.MessageDTO messageDTO, + Principal principal) { + log.info("메시지 수신: {}", messageDTO); + log.info("principal = {}", principal); + + StompPrincipal stompPrincipal = (StompPrincipal) principal; + UUID userId = stompPrincipal.userId(); + String role = stompPrincipal.role(); + + chatService.sendMessage(lectureId,messageDTO,userId,role); + } +} 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..f96a8304 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/dto/request/MessageRequestDTO.java @@ -0,0 +1,20 @@ +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; + +public class MessageRequestDTO { + @Getter + @Builder + public static class MessageDTO { + private UUID senderId; + private String senderName; + private String content; + private Role role; + private LocalDateTime timestamp; + } +} + 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..665e8c25 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatService.java @@ -0,0 +1,9 @@ +package org.example.backend.domain.question.service; + +import org.example.backend.domain.question.dto.request.MessageRequestDTO; + +import java.util.UUID; + +public interface ChatService { + 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 new file mode 100644 index 00000000..fe5d4920 --- /dev/null +++ b/backend/src/main/java/org/example/backend/domain/question/service/ChatServiceImpl.java @@ -0,0 +1,84 @@ +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.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; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChatServiceImpl implements ChatService { + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final UserRepository userRepository; + + @Override + public void sendMessage(UUID lectureId, MessageRequestDTO.MessageDTO messageDTO, UUID userId, String role) { + /** + * STOMP로부터 수신한 메시지를 Redis Pub/Sub으로 전파하고, + * Redis List에 저장하는 메서드 + */ + + try{ + // 사용자 정보 조회 + User sender = userRepository.findById(userId) + .orElseThrow(() -> new UserException(UserErrorCode._USER_NOT_FOUND)); + + String senderName = sender.getName(); + + // 1. 메시지 저장 + // 저장용 DTO 구성 + MessageRequestDTO.MessageDTO originalMessage = MessageRequestDTO.MessageDTO.builder() + .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. 메시지 전파 + // 메시지 구성 + MessageRequestDTO.MessageDTO maskMessage = MessageRequestDTO.MessageDTO.builder() + .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; + // Redis pub/sub - 메시지 전파 + redisTemplate.convertAndSend(studentChannel, maskMessageJson); + + + } catch (Exception e){ + log.error("채팅 메시지 전송 실패",e); + throw new QuestionException(QuestionErrorCode._CHAT_MESSAGE_SEND_FAIL); + } + } +} 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..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,8 @@ 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; @Configuration @@ -30,4 +32,17 @@ public RedisTemplate redisTemplate() { redisTemplate.setValueSerializer(new StringRedisSerializer()); // value를 문자열로 직렬화 return redisTemplate; } + + @Bean + 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 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..b1da5ce3 --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/redis/RedisMessageSubscriber.java @@ -0,0 +1,50 @@ +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(message.getChannel()); + + System.out.println(channel); + String[] parts = channel.split(":"); + String lectureId = parts[1]; + + String body = new String(message.getBody()); + + // JSON -> DTO 역직렬화 + MessageRequestDTO.MessageDTO chatMessage = objectMapper.readValue(body, MessageRequestDTO.MessageDTO.class); + + // subscriber에게 STOMP 메시지 전송 + simpMessageSendingOperations.convertAndSend("/sub/lecture/"+ lectureId, chatMessage); + } catch (Exception e) { + log.error("Redis 메시지 수신 실패", e); + } + } +} 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); 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 new file mode 100644 index 00000000..1c2c45b9 --- /dev/null +++ b/backend/src/main/java/org/example/backend/global/websocket/WebSocketConfig.java @@ -0,0 +1,34 @@ +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; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@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) { + // 핸드쉐이크 + registry.addEndpoint("/ws-connect").setAllowedOriginPatterns("*").withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); // 메시지 받을 경로 + registry.setApplicationDestinationPrefixes("/pub"); // 메시지 보낼 경로 + } +}