-
Notifications
You must be signed in to change notification settings - Fork 1
feat: deliver notification SSE across instances via Redis pub/sub #60
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
4 commits
Select commit
Hold shift + click to select a range
7532b61
feat: deliver notification SSE across instances via Redis pub/sub
idktomorrow bc1f640
fix: guard against malformed Redis SSE messages
idktomorrow 1cc927c
refactor: apply review feedback to notification SSE Redis pub/sub
idktomorrow 1f07fc3
fix: use app ObjectMapper for Redis pub/sub JSON deserialization
idktomorrow 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
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
...-api/src/main/java/com/momogo/api/notification/redis/NotificationRedisExecutorConfig.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,41 @@ | ||
| package com.momogo.api.notification.redis; | ||
|
|
||
| import java.util.concurrent.ThreadPoolExecutor; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; | ||
|
|
||
| /* | ||
| * 알림 Redis Pub/Sub 전용 스레드 풀 설정. | ||
| */ | ||
| @Configuration | ||
| public class NotificationRedisExecutorConfig { | ||
|
|
||
| // 개별 Pub/Sub 메시지 비동기 처리 전용 스레드 풀 | ||
| @Bean(name = "notificationRedisTaskExecutor") | ||
| public ThreadPoolTaskExecutor notificationRedisTaskExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(10); | ||
| executor.setMaxPoolSize(50); | ||
| executor.setQueueCapacity(500); | ||
| executor.setThreadNamePrefix("notification-redis-task-"); | ||
| // 큐+최대스레드가 모두 꽉 찼을 때 기본 정책(AbortPolicy)은 메시지를 버린다. | ||
| // CallerRunsPolicy로 바꿔서, 꽉 찼을 땐 발행자 스레드가 대신 처리하게 해 | ||
| // 처리 속도가 느려지더라도 메시지가 유실되지 않도록 한다. | ||
| executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
|
|
||
| // Redis SUBSCRIBE 커넥션 유지 전용 독립 스레드 풀 | ||
| @Bean(name = "notificationRedisSubscriptionExecutor") | ||
| public ThreadPoolTaskExecutor notificationRedisSubscriptionExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(5); | ||
| executor.setQueueCapacity(10); | ||
| executor.setThreadNamePrefix("notification-redis-sub-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
| } |
61 changes: 61 additions & 0 deletions
61
...go-api/src/main/java/com/momogo/api/notification/redis/NotificationRedisPubSubConfig.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,61 @@ | ||
| package com.momogo.api.notification.redis; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 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.data.redis.serializer.Jackson2JsonRedisSerializer; | ||
|
|
||
| /* | ||
| * 알림 Redis Pub/Sub 채널/리스너/컨테이너 설정. | ||
| */ | ||
| @Slf4j | ||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class NotificationRedisPubSubConfig { | ||
|
|
||
| // 알림 SSE 중계에 사용할 Redis 채널 토픽 정의 | ||
| @Bean | ||
| public ChannelTopic notificationTopic() { | ||
| return new ChannelTopic("notification-sse-channel"); | ||
| } | ||
|
|
||
| // Redis 메시지를 수신할 어댑터 설정. Jackson2JsonRedisSerializer로 역직렬화를 위임해 | ||
| // subscriber가 수동으로 JSON을 파싱하지 않고 NotificationSseMessage를 바로 받도록 함. | ||
| // Jackson2JsonRedisSerializer(Class)는 내부적으로 순정 ObjectMapper를 새로 만들어서 | ||
| // JavaTimeModule이 없어 OffsetDateTime(NotificationResponse.createdAt)을 못 읽는다. | ||
| // 앱이 이미 쓰는(JavaTimeModule 등록된) ObjectMapper 빈을 그대로 주입해 사용한다. | ||
| @Bean | ||
| public MessageListenerAdapter notificationListenerAdapter( | ||
| NotificationRedisSubscriber subscriber, ObjectMapper objectMapper) { | ||
| MessageListenerAdapter adapter = new MessageListenerAdapter(subscriber, "handleMessage"); | ||
| adapter.setSerializer(new Jackson2JsonRedisSerializer<>(objectMapper, NotificationSseMessage.class)); | ||
| return adapter; | ||
| } | ||
|
|
||
| @Bean | ||
| public RedisMessageListenerContainer notificationRedisMessageListenerContainer( | ||
| RedisConnectionFactory connectionFactory, | ||
| MessageListenerAdapter notificationListenerAdapter, | ||
| ChannelTopic notificationTopic, | ||
| @Qualifier("notificationRedisTaskExecutor") Executor taskExecutor, | ||
| @Qualifier("notificationRedisSubscriptionExecutor") Executor subscriptionExecutor | ||
| ) { | ||
| RedisMessageListenerContainer container = new RedisMessageListenerContainer(); | ||
| container.setConnectionFactory(connectionFactory); | ||
| container.addMessageListener(notificationListenerAdapter, notificationTopic); | ||
| container.setTaskExecutor(taskExecutor); | ||
| container.setSubscriptionExecutor(subscriptionExecutor); | ||
| container.setErrorHandler(e -> | ||
| log.error("[Notification Redis Pub/Sub Error] 비동기 메시지 수신/처리 중 오류 발생", e) | ||
| ); | ||
| return container; | ||
| } | ||
| } | ||
56 changes: 56 additions & 0 deletions
56
momogo-api/src/main/java/com/momogo/api/notification/redis/NotificationRedisSubscriber.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,56 @@ | ||
| package com.momogo.api.notification.redis; | ||
|
|
||
| import com.momogo.api.notification.registry.NotificationEmitterRegistry; | ||
| import com.momogo.core.domain.notification.dto.response.NotificationResponse; | ||
| import java.io.IOException; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||
|
|
||
| /* | ||
| * NotificationRedisPubSubConfig의 MessageListenerAdapter가 호출하는 구독자. | ||
| * Redis 채널로 발행된 알림을 받아, 이 인스턴스에 실제로 연결된 유저가 있으면 SSE로 전송 | ||
| * 연결된 유저가 없으면(다른 인스턴스에 연결된 유저면) 조용히 무시 | ||
| * | ||
| * 역직렬화는 MessageListenerAdapter에 등록한 Jackson2JsonRedisSerializer가 대신 처리하므로 | ||
| * 여기서는 이미 NotificationSseMessage 객체로 전달받는다. 역직렬화 자체가 실패하면 | ||
| * RedisMessageListenerContainer의 errorHandler가 잡아서 로그만 남긴다. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class NotificationRedisSubscriber { | ||
|
|
||
| private final NotificationEmitterRegistry emitterRegistry; | ||
|
|
||
| public void handleMessage(NotificationSseMessage message) { | ||
| if (message == null || message.userId() == null || message.notification() == null | ||
| || message.notification().id() == null) { | ||
| log.warn("[NotificationRedisSubscriber] 필수 필드가 없는 Redis 메시지를 무시합니다 - message: {}", message); | ||
| return; | ||
| } | ||
|
|
||
| NotificationResponse notification = message.notification(); | ||
| var emitters = emitterRegistry.findAllByUserId(message.userId()); | ||
| if (emitters.isEmpty()) { | ||
| log.info("[NotificationRedisSubscriber] Redis 메시지 수신 - userId: {} (이 인스턴스엔 연결 없음, 무시)", | ||
| message.userId()); | ||
| return; | ||
| } | ||
| log.info("[NotificationRedisSubscriber] Redis 메시지 수신 - userId: {}, 이 인스턴스의 연결 수: {} -> SSE 전송", | ||
| message.userId(), emitters.size()); | ||
|
|
||
| for (SseEmitter emitter : emitters) { | ||
|
idktomorrow marked this conversation as resolved.
|
||
| try { | ||
| emitter.send(SseEmitter.event() | ||
| .id(notification.id().toString()) | ||
| .name("notifications") | ||
| .data(notification)); | ||
| } catch (IOException e) { | ||
| log.warn("[NotificationRedisSubscriber] SSE 알림 전송 실패 - userId: {}", message.userId(), e); | ||
| emitter.completeWithError(e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
14 changes: 14 additions & 0 deletions
14
momogo-api/src/main/java/com/momogo/api/notification/redis/NotificationSseMessage.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,14 @@ | ||
| package com.momogo.api.notification.redis; | ||
|
|
||
| import com.momogo.core.domain.notification.dto.response.NotificationResponse; | ||
| import java.util.UUID; | ||
|
|
||
| /* | ||
| * SSE 알림을 인스턴스 간에 중계하기 위해 Redis 채널에 실어보내는 메시지. | ||
| * userId로 "누구에게 보낼지"를, notification으로 "무엇을 보낼지"를 담는다. | ||
| */ | ||
| public record NotificationSseMessage( | ||
| UUID userId, | ||
| NotificationResponse notification | ||
| ) { | ||
| } |
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
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
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.