-
Notifications
You must be signed in to change notification settings - Fork 1
Feat: impl STOMP Websocket Redis pub/sub message relay with DTO and constants #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
b17555a
feat: add Redis pub/sub messaging infra for websocket
SungHuii 020d903
feat: impl STOMP websocket Redis pub/sub relay with DTO records and c…
SungHuii 6d06f9e
refactor: apply coderabbit security, validation, exception handling r…
SungHuii 258b9e5
refactor: separate json serialization and redis publish exceptions in…
SungHuii 273e280
refactor: apply thread pool, error handler, dto refactor
SungHuii 47d5a87
feat: check Redis Pub/Sub receiver count and log warning on zero subs…
SungHuii File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
28 changes: 28 additions & 0 deletions
28
momogo-core/src/main/java/com/momogo/core/common/exception/RealtimeErrorCode.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| package com.momogo.core.common.exception; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.http.HttpStatus; | ||
|
|
||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum RealtimeErrorCode implements ErrorCode { | ||
|
|
||
| REDIS_PUBLISH_FAILED(8001, "REDIS_PUBLISH_FAILED", HttpStatus.SERVICE_UNAVAILABLE, "실시간 메시지 발행 중 오류가 발생했습니다."), | ||
| JSON_SERIALIZATION_FAILED(8002, "JSON_SERIALIZATION_FAILED", HttpStatus.INTERNAL_SERVER_ERROR, "실시간 메시지 직렬화 중 오류가 발생했습니다."); | ||
|
|
||
| private final int numeric; | ||
| private final String errorKey; | ||
| private final HttpStatus httpStatus; | ||
| private final String message; | ||
|
|
||
| @Override | ||
| public String getDomain() { | ||
| return "REALTIME"; | ||
| } | ||
|
|
||
| @Override | ||
| public String getCode() { | ||
| return getDomain() + "-" + getErrorKey(); | ||
| } | ||
| } |
84 changes: 84 additions & 0 deletions
84
momogo-realtime/src/main/java/com/momogo/realtime/config/RedisPubSubConfig.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package com.momogo.realtime.config; | ||
|
|
||
| import com.momogo.realtime.websocket.redis.RedisMessageSubscriber; | ||
| import java.util.concurrent.Executor; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
| import org.springframework.data.redis.listener.ChannelTopic; | ||
| import org.springframework.data.redis.listener.RedisMessageListenerContainer; | ||
| import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; | ||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; | ||
|
|
||
| @Slf4j | ||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class RedisPubSubConfig { | ||
|
|
||
| // 실시간 시험방 통신에 사용할 Redis 채널 토픽 정의 | ||
| @Bean | ||
| public ChannelTopic roomTopic() { | ||
| return new ChannelTopic("room-realtime-channel"); | ||
| } | ||
|
|
||
| // Redis Message를 수신할 어댑터 설정 (subscriber의 handleMessage 메소드를 호출) | ||
| @Bean | ||
| public MessageListenerAdapter listenerAdapter(RedisMessageSubscriber subscriber) { | ||
| return new MessageListenerAdapter(subscriber, "handleMessage"); | ||
| } | ||
|
|
||
| /** | ||
| * 1. 개별 Pub/Sub 메시지 비동기 처리 전용 스레드 풀 (Spring Bean으로 등록하여 Graceful Shutdown 보장) | ||
| */ | ||
| @Bean(name = "redisTaskExecutor") | ||
| public ThreadPoolTaskExecutor redisTaskExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(10); | ||
| executor.setMaxPoolSize(50); | ||
| executor.setQueueCapacity(500); | ||
| executor.setThreadNamePrefix("redis-task-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
|
|
||
| /** | ||
| * 2. Redis SUBSCRIBE 커넥션 롱폴링/유지 전용 독립 스레드 풀 (Spring Bean 등록) | ||
| */ | ||
| @Bean(name = "redisSubscriptionExecutor") | ||
| public ThreadPoolTaskExecutor redisSubscriptionExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(5); | ||
| executor.setQueueCapacity(10); | ||
| executor.setThreadNamePrefix("redis-sub-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
|
|
||
| @Bean | ||
| public RedisMessageListenerContainer redisMessageListenerContainer( | ||
| RedisConnectionFactory connectionFactory, | ||
| MessageListenerAdapter listenerAdapter, | ||
| ChannelTopic roomTopic, | ||
| @Qualifier("redisTaskExecutor") Executor redisTaskExecutor, | ||
| @Qualifier("redisSubscriptionExecutor") Executor redisSubscriptionExecutor | ||
| ) { | ||
| RedisMessageListenerContainer container = new RedisMessageListenerContainer(); | ||
| container.setConnectionFactory(connectionFactory); | ||
| container.addMessageListener(listenerAdapter, roomTopic); | ||
|
|
||
| // Spring Bean으로 생명주기가 안전하게 관리되는 두 스레드 풀 주입 | ||
| container.setTaskExecutor(redisTaskExecutor); | ||
| container.setSubscriptionExecutor(redisSubscriptionExecutor); | ||
|
|
||
| // 비동기 메시지 처리 중 예외 발생 시 에러 로깅 ErrorHandler | ||
| container.setErrorHandler(e -> | ||
| log.error("[Redis Pub/Sub Error] 비동기 메시지 수신/처리 중 오류 발생", e) | ||
| ); | ||
|
|
||
| return container; | ||
|
SungHuii marked this conversation as resolved.
|
||
| } | ||
| } | ||
10 changes: 10 additions & 0 deletions
10
momogo-realtime/src/main/java/com/momogo/realtime/websocket/constant/WebSocketConstants.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.momogo.realtime.websocket.constant; | ||
|
|
||
| public final class WebSocketConstants { | ||
|
|
||
| public static final String SUB_ROOM_PREFIX = "/sub/rooms/"; | ||
|
|
||
| private WebSocketConstants() { | ||
|
|
||
| } | ||
| } |
38 changes: 38 additions & 0 deletions
38
...altime/src/main/java/com/momogo/realtime/websocket/controller/RoomRealtimeController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package com.momogo.realtime.websocket.controller; | ||
|
|
||
| import com.momogo.realtime.websocket.dto.request.RealtimeMessageRequest; | ||
| import com.momogo.realtime.websocket.dto.response.RealtimeMessageResponse; | ||
| import com.momogo.realtime.websocket.redis.RedisMessagePublisher; | ||
| import jakarta.validation.Valid; | ||
| import java.security.Principal; | ||
| import java.util.UUID; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| 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; | ||
|
|
||
| @Slf4j | ||
| @Controller | ||
| @RequiredArgsConstructor | ||
| public class RoomRealtimeController { | ||
|
|
||
| private final RedisMessagePublisher redisMessagePublisher; | ||
|
|
||
| @MessageMapping("/room/{roomId}/send") | ||
| public void sendMessage( | ||
| @DestinationVariable("roomId")UUID roomId, | ||
| Principal principal, | ||
| @Payload @Valid RealtimeMessageRequest request | ||
| ) { | ||
|
|
||
| UUID authenticatedUserId = UUID.fromString(principal.getName()); | ||
|
|
||
| log.info("[RoomRealtimeController] 시험방({}) 상태 변경 수신 - authenticatedUserId: {}, status: {}", | ||
| roomId, authenticatedUserId, request.status()); | ||
|
|
||
| RealtimeMessageResponse response = RealtimeMessageResponse.of(roomId, authenticatedUserId, request); | ||
| redisMessagePublisher.publish(response); | ||
|
SungHuii marked this conversation as resolved.
|
||
| } | ||
| } | ||
21 changes: 21 additions & 0 deletions
21
...ltime/src/main/java/com/momogo/realtime/websocket/dto/request/RealtimeMessageRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.momogo.realtime.websocket.dto.request; | ||
|
|
||
| import com.momogo.realtime.websocket.dto.type.RoomRealtimeStatus; | ||
| import jakarta.validation.constraints.Min; | ||
| import jakarta.validation.constraints.NotNull; | ||
|
|
||
| /** | ||
| * 웹소켓 실시간 응시 상태 변경 요청 DTO | ||
| * @param status 응시 상태 | ||
| * @param solvedCount 푼 문제 개수 | ||
| * @param details 기타 실시간 상세 정보 | ||
| */ | ||
| public record RealtimeMessageRequest( | ||
| @NotNull(message = "응시 상태(status)는 필수 입력값입니다.") | ||
| RoomRealtimeStatus status, | ||
| @Min(value = 0, message = "푼 문제 개수(solvedCount)는 0 이상이어야 합니다.") | ||
| int solvedCount, | ||
| Object details | ||
| ) { | ||
|
SungHuii marked this conversation as resolved.
|
||
|
|
||
| } | ||
42 changes: 42 additions & 0 deletions
42
...ime/src/main/java/com/momogo/realtime/websocket/dto/response/RealtimeMessageResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package com.momogo.realtime.websocket.dto.response; | ||
|
|
||
| import com.momogo.realtime.websocket.dto.request.RealtimeMessageRequest; | ||
| import com.momogo.realtime.websocket.dto.type.RoomRealtimeStatus; | ||
| import java.time.LocalDateTime; | ||
| import java.time.OffsetDateTime; | ||
| import java.util.UUID; | ||
|
|
||
| /** | ||
| * 웹소켓 실시간 응시 상태 브로드캐스팅 응답 DTO | ||
| * @param roomId 시험방 ID | ||
| * @param userId 수험생 ID | ||
| * @param status 응시 상태 | ||
| * @param solvedCount 푼 문제 개수 | ||
| * @param details 기타 실시간 상세 정보 | ||
| * @param timestamp 응답 시간 | ||
| */ | ||
| public record RealtimeMessageResponse( | ||
| UUID roomId, | ||
| UUID userId, | ||
| RoomRealtimeStatus status, | ||
| int solvedCount, | ||
| Object details, | ||
| OffsetDateTime timestamp | ||
| ) { | ||
|
|
||
| /** | ||
| * Request DTO로부터 Response DTO를 정적 팩토리 메서드로 생성 | ||
| * @param request | ||
| * @return | ||
| */ | ||
| public static RealtimeMessageResponse of(UUID roomId, UUID userId, RealtimeMessageRequest request) { | ||
| return new RealtimeMessageResponse( | ||
| roomId, | ||
| userId, | ||
| request.status(), | ||
| request.solvedCount(), | ||
| request.details(), | ||
| OffsetDateTime.now() | ||
| ); | ||
| } | ||
| } |
19 changes: 19 additions & 0 deletions
19
momogo-realtime/src/main/java/com/momogo/realtime/websocket/dto/type/RoomRealtimeStatus.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package com.momogo.realtime.websocket.dto.type; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| /** | ||
| * 실시간 시험방 응시 상태 구분용 ENUM | ||
| */ | ||
| @Getter | ||
| @RequiredArgsConstructor | ||
| public enum RoomRealtimeStatus { | ||
|
|
||
| ENTER("시험방 입장"), | ||
| PROGRESS("문제 풀이 진행 중"), | ||
| SUBMIT("답안 제출 완료"), | ||
| LEAVE("시험방 퇴장"); | ||
|
|
||
| private final String description; | ||
| } |
54 changes: 54 additions & 0 deletions
54
momogo-realtime/src/main/java/com/momogo/realtime/websocket/redis/RedisMessagePublisher.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| package com.momogo.realtime.websocket.redis; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.momogo.core.common.exception.BusinessException; | ||
| import com.momogo.core.common.exception.RealtimeErrorCode; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.data.redis.listener.ChannelTopic; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RedisMessagePublisher { | ||
|
|
||
| private final StringRedisTemplate redisTemplate; | ||
| private final ChannelTopic roomTopic; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| /** | ||
| * 메시지를 JSON으로 직렬화하여 Redis 채널로 Publish 합니다. | ||
| * @param messagePayload | ||
| */ | ||
| public void publish(Object messagePayload) { | ||
|
|
||
| String jsonMessage; | ||
|
|
||
| try { | ||
| jsonMessage = objectMapper.writeValueAsString(messagePayload); | ||
| } catch (JsonProcessingException e) { | ||
| log.error("[Redis Publisher] JSON 직렬화 실패", e); | ||
| throw new BusinessException(RealtimeErrorCode.JSON_SERIALIZATION_FAILED); | ||
| } | ||
|
|
||
| // 2. Redis 메시지 발행 수행 (실패 시 503 REDIS_PUBLISH_FAILED) | ||
| try { | ||
| log.debug("[Redis Publisher] Redis 채널({})로 메시지 발행: {}", roomTopic.getTopic(), jsonMessage); | ||
|
|
||
| Long receiversCount = redisTemplate.convertAndSend(roomTopic.getTopic(), jsonMessage); | ||
|
|
||
| if (receiversCount == null || receiversCount == 0) { | ||
| log.warn("[Redis Publisher] 수신자 0명 - 채널({})로 발행되었으나 메시지를 수신한 구독자/인스턴스가 없습니다.", roomTopic.getTopic()); | ||
| } else { | ||
| log.info("[Redis Publisher] Redis 채널({}) 메시지 발행 성공 (수신 인스턴스: {}개)", roomTopic.getTopic(), receiversCount); | ||
| } | ||
| } catch (Exception e) { | ||
| log.error("[Redis Publisher] Redis 메시지 전송 실패 - topic: {}", roomTopic.getTopic(), e); | ||
| throw new BusinessException(RealtimeErrorCode.REDIS_PUBLISH_FAILED); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| } | ||
39 changes: 39 additions & 0 deletions
39
...go-realtime/src/main/java/com/momogo/realtime/websocket/redis/RedisMessageSubscriber.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.momogo.realtime.websocket.redis; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.momogo.realtime.websocket.constant.WebSocketConstants; | ||
| import com.momogo.realtime.websocket.dto.response.RealtimeMessageResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.messaging.simp.SimpMessagingTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RedisMessageSubscriber { | ||
|
|
||
| private final ObjectMapper objectMapper; | ||
| private final SimpMessagingTemplate messagingTemplate; | ||
|
|
||
| /** | ||
| * RedisPubSubConfig의 MessageListenerAdapter에 의해 호출되는 메소드 | ||
| * Redis에서 발행된 메시지를 받아 웹소켓 구독자(/sub/...)들에게 전파합니다. | ||
| * @param messageJson | ||
| */ | ||
| public void handleMessage(String messageJson) { | ||
| try { | ||
| log.info("[Redis Subscriber] 수신된 Pub/Sub 메시지: {}", messageJson); | ||
|
|
||
| RealtimeMessageResponse response = objectMapper.readValue(messageJson, RealtimeMessageResponse.class); | ||
| String destination = WebSocketConstants.SUB_ROOM_PREFIX + response.roomId(); | ||
|
|
||
| messagingTemplate.convertAndSend(destination, response); | ||
| log.info("[WebSocket Broadcast] 목적지({})로 상태 메시지 전파 완료", destination); | ||
|
|
||
| } catch (Exception e) { | ||
| log.error("[Redis Subscriber] 메시지 처리 중 오류 발생", e); | ||
| } | ||
| } | ||
|
|
||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.