diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedisConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedisConfig.java index 790d364..7797a84 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedisConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/RedisConfig.java @@ -1,9 +1,16 @@ package jpa.basic.alldayprojectcommerce.common.config; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jpa.basic.alldayprojectcommerce.domain.chat.redis.ChatRedisSubscriber; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -23,4 +30,62 @@ public RedisTemplate redisTemplate(RedisConnectionFactory factor return template; } + + /** + * 채팅 Pub/Sub용 RedisTemplate (JSON 직렬화) + * + * ChatMessageResponse 객체를 JSON으로 직렬화하여 Redis 채널에 발행 + * StringRedisSerializer로는 객체 직렬화 불가 -> Jackson 기반 JSON 직렬화 사용 + */ + @Bean + public RedisTemplate chatRedisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + /** + * JavaTimeModule 등록한 ObjectMapper 설정 + * LocalDateTime 등 Java 8 날짜 타입 직렬화 지원 + */ + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + // 타입 정보 포함 - 역직렬화 시 올바른 클래스로 복원 + objectMapper.activateDefaultTyping( + objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + + GenericJackson2JsonRedisSerializer serializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + StringRedisSerializer keySerializer = new StringRedisSerializer(); + template.setKeySerializer(keySerializer); + template.setValueSerializer(serializer); + template.setHashKeySerializer(keySerializer); + template.setHashValueSerializer(serializer); + + return template; + } + + /** + * Redis Pub/Sub 메시지 리스터 컨테이너 + * + * PatternTopic("chat:room:*") + * "chat:room:1", "chat:room:2" 등 모든 채팅방 채널을 단일 리스너로 처리 + */ + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory redisConnectionFactory, + ChatRedisSubscriber chatRedisSubscriber) { + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory); + + // "chat:room:*" - 모든 채팅방 채널을 단일 리스너로 처리 + container.addMessageListener(chatRedisSubscriber, + new PatternTopic("chat:room:*") + ); + + return container; + } } \ No newline at end of file diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java index f9fa695..03af7ba 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/StompChannelInterceptor.java @@ -59,6 +59,19 @@ public Message preSend(Message message, MessageChannel channel) { log.info("[STOMP] 연결 성공 userId: {}, role: {}", userId, role); } + /** + * SUBSCRIBE 시점 - 구독 경로 권한 검증 + * + * /sub/chat/{roomId} 구독 시 해당 방에 접근 권한이 있는지 확인 + * 지금은 인증된 유저만 구독 가능하도록 1차 방어 + */ + if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + if (!(accessor.getUser() instanceof StompPrincipal)) { + log.warn("[STOMP] 인증되지 않은 구독 시도 차단"); + throw new CustomException(ErrorCode.CHAT_UNAUTHORIZED); + } + } + return message; } diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java index 31ec197..7484468 100644 --- a/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/config/WebSocketConfig.java @@ -28,8 +28,9 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { */ @Override public void configureMessageBroker(MessageBrokerRegistry registry) { - registry.enableSimpleBroker("/sub"); // 구독 경로 prefix - registry.setApplicationDestinationPrefixes("/pub"); // 발행 경로 prefix + registry.enableSimpleBroker("/sub", "/queue"); // 구독 경로 prefix + registry.setApplicationDestinationPrefixes("/pub"); // 발행 경로 prefix + registry.setUserDestinationPrefix("/user"); // 개인 세션 라우팅 prefix } /** diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/WebSocketExceptionHandler.java b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/WebSocketExceptionHandler.java new file mode 100644 index 0000000..02a223f --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/common/exception/WebSocketExceptionHandler.java @@ -0,0 +1,38 @@ +package jpa.basic.alldayprojectcommerce.common.exception; + +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.web.bind.annotation.ControllerAdvice; + +@Slf4j +@ControllerAdvice +public class WebSocketExceptionHandler { + + /** + * WebSocket 전용 전역 예외 처리기 + * + * 에러 응답 경로: /user/queue/errors + * 예외 발생한 당사자 세션에만 전송 + * 클라이언트: SUBSCRIBE /user/queue/errors 구독 필요 + */ + @MessageExceptionHandler(CustomException.class) + @SendToUser("/queue/errors") + public ChatErrorResponse handleCustomException(CustomException e) { + log.warn("[WebSocket] 비즈니스 예외 - {}: {}", e.getErrorCode().getCode(), e.getErrorCode().getMessage()); + + return new ChatErrorResponse( + e.getErrorCode().getCode(), + e.getErrorCode().getMessage() + ); + } + + @MessageExceptionHandler(Exception.class) + @SendToUser("/queue/errors") + public ChatErrorResponse handleUnexpectedException(Exception e) { + log.error("[WebSocket] 예상치 못한 예외", e); + + return new ChatErrorResponse("SYS001", "서버 내부 오류가 발생했습니다."); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/controller/ChatWebSocketController.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/controller/ChatWebSocketController.java new file mode 100644 index 0000000..9ff4103 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/controller/ChatWebSocketController.java @@ -0,0 +1,72 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.controller; + +import jakarta.validation.Valid; +import jpa.basic.alldayprojectcommerce.common.config.StompPrincipal; +import jpa.basic.alldayprojectcommerce.common.exception.CustomException; +import jpa.basic.alldayprojectcommerce.common.exception.ErrorCode; +import jpa.basic.alldayprojectcommerce.domain.chat.dto.request.ChatMessageRequest; +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse; +import jpa.basic.alldayprojectcommerce.domain.chat.redis.ChatRedisPublisher; +import jpa.basic.alldayprojectcommerce.domain.chat.service.ChatMessageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.stereotype.Controller; + +import java.security.Principal; + +@Slf4j +@Controller +@RequiredArgsConstructor +public class ChatWebSocketController { + + private final ChatMessageService chatMessageService; + private final ChatRedisPublisher chatRedisPublisher; + + /** + * 클라이언트 -> 서버: 메시지 전송 + * + * chatRedisPublisher.publish(roomId, response) + * 1. Redis 채널에 발행 + * 2. 모든 서버의 ChatRedisSubscriber가 수신 + * 3. 각 서버가 자기 WebSocket 구독자에게 브로드캐스트 + * 4. 서버 A/B 어디 접속한 구독자든 메시지 수신 가능 + */ + @MessageMapping("/chat/{roomId}") + public void sendMessage( + @DestinationVariable Long roomId, + @Valid ChatMessageRequest request, + Principal principal) { + + if (!(principal instanceof StompPrincipal stompUser)) { + log.warn("[WebSocket] Principal이 StompPrincipal이 아님 - 비정상"); + throw new CustomException(ErrorCode.CHAT_UNAUTHORIZED); + } + + Long senderId = stompUser.getUserId(); + String role = stompUser.getRole(); + + /** + * 메시지 저장 + 권한 검증 + * + * 검증 실패 시 클라이언트에 STOMP ERROR 프레임 전송 + */ + ChatMessageResponse response = + chatMessageService.saveMessage(roomId, senderId, role, request); + + /** + * Redis 채널에 발행 + * + * 모든 서버의 구독자에게 브로드캐스트 + */ + + chatRedisPublisher.publish(roomId, response); + + log.debug("[WebSocket] 메시지 발행 roomId: {}, messageId: {}", + roomId, response.id()); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatErrorResponse.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatErrorResponse.java new file mode 100644 index 0000000..58c69f6 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/dto/response/ChatErrorResponse.java @@ -0,0 +1,6 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.dto.response; + +public record ChatErrorResponse( + String code, String message +) { +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisPublisher.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisPublisher.java new file mode 100644 index 0000000..37a3059 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisPublisher.java @@ -0,0 +1,27 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.redis; + +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatRedisPublisher { + + private final RedisTemplate chatRedisTemplate; + + /** + * Redis 채널에 채팅 메시지 발행 + * + * 채널명: "chat:room:{roomId}" + * 모든 서버의 ChatRedisSubscriber가 이 채널을 구독 중 + */ + public void publish(Long roomId, ChatMessageResponse message) { + String channel = "chat:room:" + roomId; + chatRedisTemplate.convertAndSend(channel, message); + log.debug("[Redis Pub] channel: {}, messageId: {}", channel, message.id()); + } +} diff --git a/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java new file mode 100644 index 0000000..2681070 --- /dev/null +++ b/src/main/java/jpa/basic/alldayprojectcommerce/domain/chat/redis/ChatRedisSubscriber.java @@ -0,0 +1,49 @@ +package jpa.basic.alldayprojectcommerce.domain.chat.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jpa.basic.alldayprojectcommerce.domain.chat.dto.response.ChatMessageResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatRedisSubscriber implements MessageListener { + + private final SimpMessagingTemplate simpMessagingTemplate; + private final ObjectMapper objectMapper; + + /** + * Redis 채널 메시지 수신 -> WebSocket 브로드캐스트 + * + * 흐름: + * 1. 어떤 서버에서든 "chat:room:{roomId}" 채널에 발행 + * 2. 모든 서버의 이 메서드가 호출됨 + * 3. 각 서버는 자신이 보유한 WebSocket 구독자에게 브로드캐스트 + * + * "chat:room:".length() = 10 이후 문자열이 roomId + */ + @Override + public void onMessage(Message message, @Nullable byte[] pattern) { + try { + String channel = new String(message.getChannel()); + String roomId = channel.substring("chat:room:".length()); + + ChatMessageResponse response = objectMapper.readValue( + message.getBody(), ChatMessageResponse.class); + + // 구독자 전체에게 브로드캐스트 + simpMessagingTemplate.convertAndSend("/sub/chat/" + roomId, response); + + log.debug("[Redis Sub] roomId: {}, messageId: {}", roomId, response.id()); + + } catch (Exception e) { + log.error("[Redis Sub] 메시지 처리 실패", e); + } + } +}