diff --git a/.gitignore b/.gitignore index b59493e0..40bacdf4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ src/main/resources/application.properties # 로컬 개발 설정 docker-compose.dev.yml +/data/ +/db/ # 환경변수 -.env \ No newline at end of file +.env diff --git a/src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java b/src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java index 15d656f9..a1be2ce3 100644 --- a/src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java +++ b/src/main/java/org/ezcode/codetest/application/community/service/DiscussionVoteService.java @@ -1,11 +1,13 @@ package org.ezcode.codetest.application.community.service; +import java.util.Collections; +import java.util.List; import java.util.Optional; import org.ezcode.codetest.application.community.dto.request.VoteRequest; import org.ezcode.codetest.application.community.dto.response.VoteResponse; import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.port.NotificationEventService; +import org.ezcode.codetest.application.notification.service.NotificationExecutor; import org.ezcode.codetest.domain.community.model.entity.Discussion; import org.ezcode.codetest.domain.community.model.entity.DiscussionVote; import org.ezcode.codetest.domain.community.service.DiscussionDomainService; @@ -15,21 +17,24 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Service public class DiscussionVoteService extends BaseVoteService { private final DiscussionDomainService discussionDomainService; - private final NotificationEventService notificationEventService; + private final NotificationExecutor notificationExecutor; public DiscussionVoteService( DiscussionVoteDomainService domainService, UserDomainService userDomainService, DiscussionDomainService discussionDomainService, - NotificationEventService notificationEventService + NotificationExecutor notificationExecutor ) { super(domainService, userDomainService); this.discussionDomainService = discussionDomainService; - this.notificationEventService = notificationEventService; + this.notificationExecutor = notificationExecutor; } @Transactional @@ -43,10 +48,18 @@ public VoteResponse manageVoteOnDiscussion(Long problemId, Long discussionId, Vo @Override protected void afterVote(User voter, Long targetId) { - Discussion discussion = discussionDomainService.getDiscussionById(targetId); + notificationExecutor.execute(() -> { + + try { + Discussion discussion = discussionDomainService.getDiscussionById(targetId); - Optional notificationEvent = voteDomainService.createDiscussionVoteNotification(voter, discussion); + Optional notificationEvent = voteDomainService.createDiscussionVoteNotification(voter, discussion); - notificationEvent.ifPresent(notificationEventService::saveAndNotify); + return notificationEvent.map(List::of).orElse(Collections.emptyList()); + } catch (Exception ex) { + log.error("토론글 추천 알림 생성 중 에러 발생 : {}", ex.getMessage()); + return Collections.emptyList(); + } + }); } } diff --git a/src/main/java/org/ezcode/codetest/application/community/service/ReplyService.java b/src/main/java/org/ezcode/codetest/application/community/service/ReplyService.java index c8e345c9..c99226f1 100644 --- a/src/main/java/org/ezcode/codetest/application/community/service/ReplyService.java +++ b/src/main/java/org/ezcode/codetest/application/community/service/ReplyService.java @@ -1,12 +1,12 @@ package org.ezcode.codetest.application.community.service; +import java.util.Collections; import java.util.List; import org.ezcode.codetest.application.community.dto.request.ReplyCreateRequest; import org.ezcode.codetest.application.community.dto.request.ReplyModifyRequest; import org.ezcode.codetest.application.community.dto.response.ReplyResponse; -import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.port.NotificationEventService; +import org.ezcode.codetest.application.notification.service.NotificationExecutor; import org.ezcode.codetest.domain.community.dto.ReplyQueryResult; import org.ezcode.codetest.domain.community.model.entity.Discussion; import org.ezcode.codetest.domain.community.model.entity.Reply; @@ -20,7 +20,9 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class ReplyService { @@ -30,7 +32,7 @@ public class ReplyService { private final DiscussionDomainService discussionDomainService; private final UserDomainService userDomainService; - private final NotificationEventService notificationEventService; + private final NotificationExecutor notificationExecutor; @Transactional public ReplyResponse createReply( @@ -46,14 +48,22 @@ public ReplyResponse createReply( Reply reply = replyDomainService.createReply(discussion, user, request.parentReplyId(), request.content()); - List notificationTargets = reply.generateNotificationTargets(); + notificationExecutor.execute(() -> { + try { + List notificationTargets = reply.generateNotificationTargets(); - if (!notificationTargets.isEmpty()) { - for (User target : notificationTargets) { - NotificationCreateEvent notificationEvent = replyDomainService.createReplyNotification(target, reply); - notificationEventService.saveAndNotify(notificationEvent); + if (notificationTargets.isEmpty()) { + return Collections.emptyList(); + } + + return notificationTargets.stream() + .map(target -> replyDomainService.createReplyNotification(target, reply)) + .toList(); + } catch (Exception ex) { + log.error("댓글 알림 생성 중 에러 발생 : {}", ex.getMessage()); + return Collections.emptyList(); } - } + }); return ReplyResponse.fromEntity(reply); } diff --git a/src/main/java/org/ezcode/codetest/application/community/service/ReplyVoteService.java b/src/main/java/org/ezcode/codetest/application/community/service/ReplyVoteService.java index 0c0ac572..9819b63c 100644 --- a/src/main/java/org/ezcode/codetest/application/community/service/ReplyVoteService.java +++ b/src/main/java/org/ezcode/codetest/application/community/service/ReplyVoteService.java @@ -1,11 +1,13 @@ package org.ezcode.codetest.application.community.service; +import java.util.Collections; +import java.util.List; import java.util.Optional; import org.ezcode.codetest.application.community.dto.request.VoteRequest; import org.ezcode.codetest.application.community.dto.response.VoteResponse; import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.port.NotificationEventService; +import org.ezcode.codetest.application.notification.service.NotificationExecutor; import org.ezcode.codetest.domain.community.model.entity.Reply; import org.ezcode.codetest.domain.community.model.entity.ReplyVote; import org.ezcode.codetest.domain.community.service.ReplyDomainService; @@ -15,22 +17,25 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Service public class ReplyVoteService extends BaseVoteService { private final ReplyDomainService replyDomainService; + private final NotificationExecutor notificationExecutor; - private final NotificationEventService notificationEventService; public ReplyVoteService( ReplyVoteDomainService domainService, UserDomainService userDomainService, ReplyDomainService replyDomainService, - NotificationEventService notificationEventService + NotificationExecutor notificationExecutor ) { super(domainService, userDomainService); this.replyDomainService = replyDomainService; - this.notificationEventService = notificationEventService; + this.notificationExecutor = notificationExecutor; } @Transactional @@ -44,10 +49,18 @@ public VoteResponse manageVoteOnReply(Long problemId, Long discussionId, Long re @Override protected void afterVote(User voter, Long targetId) { - Reply reply = replyDomainService.getReplyById(targetId); + notificationExecutor.execute(() -> { + + try { + Reply reply = replyDomainService.getReplyById(targetId); - Optional notificationEvent = voteDomainService.createReplyVoteNotification(voter, reply); + Optional notificationEvent = voteDomainService.createReplyVoteNotification(voter, reply); - notificationEvent.ifPresent(notificationEventService::saveAndNotify); + return notificationEvent.map(List::of).orElse(Collections.emptyList()); + } catch (Exception ex) { + log.error("댓글 추천 알림 생성 중 에러 발생 : {}", ex.getMessage()); + return Collections.emptyList(); + } + }); } } diff --git a/src/main/java/org/ezcode/codetest/application/notification/event/NotificationReadEvent.java b/src/main/java/org/ezcode/codetest/application/notification/event/NotificationMarkReadEvent.java similarity index 73% rename from src/main/java/org/ezcode/codetest/application/notification/event/NotificationReadEvent.java rename to src/main/java/org/ezcode/codetest/application/notification/event/NotificationMarkReadEvent.java index eda076ca..f3cf4d81 100644 --- a/src/main/java/org/ezcode/codetest/application/notification/event/NotificationReadEvent.java +++ b/src/main/java/org/ezcode/codetest/application/notification/event/NotificationMarkReadEvent.java @@ -1,6 +1,6 @@ package org.ezcode.codetest.application.notification.event; -public record NotificationReadEvent( +public record NotificationMarkReadEvent( String principalName, diff --git a/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationExceptionCode.java b/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationExceptionCode.java index b52ea120..58e62be1 100644 --- a/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationExceptionCode.java +++ b/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationExceptionCode.java @@ -11,6 +11,8 @@ public enum NotificationExceptionCode implements ResponseCode { NOTIFICATION_CANNOT_FIND_EVENT_TYPE(false, HttpStatus.INTERNAL_SERVER_ERROR, "해당 이벤트 타입의 mapper를 찾을 수 없습니다."), + NOTIFICATION_CONVERT_MESSAGE_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "메시지 변환 과정에서 에러가 발생했습니다."), + NOTIFICATION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 notification 데이터를 찾지 못했습니다") ; private final boolean success; diff --git a/src/main/java/org/ezcode/codetest/application/notification/port/NotificationEventService.java b/src/main/java/org/ezcode/codetest/application/notification/port/NotificationEventService.java index 554524a5..74ed12ac 100644 --- a/src/main/java/org/ezcode/codetest/application/notification/port/NotificationEventService.java +++ b/src/main/java/org/ezcode/codetest/application/notification/port/NotificationEventService.java @@ -2,14 +2,14 @@ import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; -import org.ezcode.codetest.application.notification.event.NotificationReadEvent; +import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent; public interface NotificationEventService { - void saveAndNotify(NotificationCreateEvent dto); + void notify(NotificationCreateEvent dto); void notifyList(NotificationListRequestEvent dto); - void setRead(NotificationReadEvent dto); + void setRead(NotificationMarkReadEvent dto); } diff --git a/src/main/java/org/ezcode/codetest/application/notification/service/NotificationExecutor.java b/src/main/java/org/ezcode/codetest/application/notification/service/NotificationExecutor.java new file mode 100644 index 00000000..d51c53db --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/notification/service/NotificationExecutor.java @@ -0,0 +1,38 @@ +package org.ezcode.codetest.application.notification.service; + +import java.util.List; +import java.util.function.Supplier; + +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.application.notification.port.NotificationEventService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationExecutor { + + private final NotificationEventService notificationEventService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void execute(Supplier> notificationsEventSupplier) { + + try { + List events = notificationsEventSupplier.get(); + + if (events != null && !events.isEmpty()) { + for (NotificationCreateEvent event : events) { + notificationEventService.notify(event); + } + log.info("알림 이벤트 {}개 발행 성공", events.size()); + } + } catch (Exception ex) { + log.error("알림 이벤트 발행 실패: {}", ex.getMessage(), ex); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/application/notification/service/NotificationUseCase.java b/src/main/java/org/ezcode/codetest/application/notification/service/NotificationUseCase.java index 08d99c87..4e0c15f3 100644 --- a/src/main/java/org/ezcode/codetest/application/notification/service/NotificationUseCase.java +++ b/src/main/java/org/ezcode/codetest/application/notification/service/NotificationUseCase.java @@ -1,7 +1,7 @@ package org.ezcode.codetest.application.notification.service; import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; -import org.ezcode.codetest.application.notification.event.NotificationReadEvent; +import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent; import org.ezcode.codetest.application.notification.port.NotificationEventService; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -24,7 +24,7 @@ public void getNotificationList(String email, Pageable pageable) { public void modifyNotificationMarksRead(String email, String notificationId) { notificationEventService.setRead( - new NotificationReadEvent(email, notificationId) + new NotificationMarkReadEvent(email, notificationId) ); } } diff --git a/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java b/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java index 1214ba99..b19b1196 100644 --- a/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java +++ b/src/main/java/org/ezcode/codetest/domain/community/exception/CommunityExceptionCode.java @@ -10,11 +10,11 @@ @RequiredArgsConstructor public enum CommunityExceptionCode implements ResponseCode { - DISCUSSION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 자유글이 존재하지 않습니다."), + DISCUSSION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 토론글이 존재하지 않습니다."), DISCUSSION_PROBLEM_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 글이 요청된 문제에 속하지 않습니다."), REPLY_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 댓글이 존재하지 않습니다."), - REPLY_DISCUSSION_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 댓글이 요청된 자유글에 속하지 않습니다."), + REPLY_DISCUSSION_MISMATCH(false, HttpStatus.BAD_REQUEST, "해당 댓글이 요청된 토론글에 속하지 않습니다."), USER_NOT_AUTHOR(false, HttpStatus.FORBIDDEN, "작성자만 수정/삭제할 수 있습니다."), ; diff --git a/src/main/java/org/ezcode/codetest/infrastructure/cache/config/CaffeineCacheConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/cache/config/CaffeineCacheConfig.java index dfd228df..0bfab666 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/cache/config/CaffeineCacheConfig.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/cache/config/CaffeineCacheConfig.java @@ -9,6 +9,7 @@ import org.springframework.cache.support.SimpleCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import com.github.benmanes.caffeine.cache.Caffeine; @@ -16,6 +17,7 @@ @EnableCaching public class CaffeineCacheConfig { + @Primary @Bean public CacheManager cacheManager() { diff --git a/src/main/java/org/ezcode/codetest/infrastructure/cache/config/RedisCacheConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/cache/config/RedisCacheConfig.java index 7c4347cb..497ef141 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/cache/config/RedisCacheConfig.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/cache/config/RedisCacheConfig.java @@ -1,14 +1,12 @@ package org.ezcode.codetest.infrastructure.cache.config; import org.ezcode.codetest.application.chatting.port.cache.ChatRoomCache; -import org.ezcode.codetest.infrastructure.event.dto.NotificationRecord; 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.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; -import org.springframework.data.redis.serializer.StringRedisSerializer; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; @@ -39,22 +37,4 @@ public RedisTemplate cacheRedisTemplate( return template; } - - @Bean - public RedisTemplate notificationRedisTemplate( - RedisConnectionFactory factory, - ObjectMapper objectMapper - ) { - - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(factory); - - template.setKeySerializer(new StringRedisSerializer()); - - Jackson2JsonRedisSerializer valueSerializer = - new Jackson2JsonRedisSerializer<>(objectMapper, NotificationRecord.class); - template.setValueSerializer(valueSerializer); - - return template; - } } diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationRecord.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationRecord.java deleted file mode 100644 index 31b62a40..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationRecord.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.ezcode.codetest.infrastructure.event.dto; - -import java.io.Serializable; -import java.time.LocalDateTime; -import java.util.UUID; - -import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.event.payload.NotificationPayload; -import org.ezcode.codetest.application.notification.enums.NotificationType; - -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; - -/* - * 인프라 구현체(Redis, Mongo 등)들이 - * 저장 및 조회용으로 공통으로 사용하는 DTO - */ -@Getter -public class NotificationRecord implements Serializable { - - private final String id; - private final String principalName; - private final NotificationType type; - private final String message; - private final String redirectUrl; - private final NotificationPayload payload; - - @Setter - private boolean isRead; - - private final LocalDateTime createdAt; - - @Builder - public NotificationRecord( - String id, - String principalName, - NotificationType type, - String message, - String redirectUrl, - NotificationPayload payload, - boolean isRead, - LocalDateTime createdAt - ) { - this.id = id; - this.principalName = principalName; - this.type = type; - this.message = message; - this.redirectUrl = redirectUrl; - this.payload = payload; - this.isRead = isRead; - this.createdAt = createdAt; - } - - public static NotificationRecord from(NotificationCreateEvent dto) { - return NotificationRecord - .builder() - .id(UUID.randomUUID().toString()) - .principalName(dto.principalName()) - .type(dto.notificationType()) - .message(dto.notificationType().getMessage()) - .redirectUrl(dto.notificationType().getRedirectUrl()) - .payload(dto.payload()) - .isRead(dto.isRead()) - .createdAt(dto.createdAt()) - .build(); - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationResponse.java deleted file mode 100644 index bf57d5ae..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/dto/NotificationResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package org.ezcode.codetest.infrastructure.event.dto; - -import java.time.LocalDateTime; - -import org.ezcode.codetest.application.notification.event.payload.NotificationPayload; -import org.ezcode.codetest.application.notification.enums.NotificationType; - -public record NotificationResponse( - - String id, - - NotificationType type, - - String message, - - String redirectUrl, - - NotificationPayload payload, - - boolean isRead, - - LocalDateTime createdAt - -) { - - public static NotificationResponse from(NotificationRecord record) { - return new NotificationResponse( - record.getId(), - record.getType(), - record.getMessage(), - record.getRedirectUrl(), - record.getPayload(), - record.isRead(), - record.getCreatedAt() - ); - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/NotificationEventListener.java b/src/main/java/org/ezcode/codetest/infrastructure/event/listener/NotificationEventListener.java deleted file mode 100644 index 62e37ec4..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/listener/NotificationEventListener.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.ezcode.codetest.infrastructure.event.listener; - -import java.util.List; - -import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; -import org.ezcode.codetest.application.notification.event.NotificationReadEvent; -import org.ezcode.codetest.infrastructure.event.dto.NotificationRecord; -import org.ezcode.codetest.infrastructure.event.dto.NotificationResponse; -import org.ezcode.codetest.infrastructure.event.publisher.StompMessageService; -import org.ezcode.codetest.infrastructure.persistence.repository.notification.NotificationRepository; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Component -@RequiredArgsConstructor -public class NotificationEventListener { - - private final NotificationRepository repository; - private final StompMessageService messageService; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleNotificationCreateEvent(NotificationCreateEvent dto) { - - NotificationRecord record = NotificationRecord.from(dto); - repository.save(record); - messageService.handleNotification(NotificationResponse.from(record), dto.principalName()); - } - - @EventListener - public void handleNotificationListRequestEvent(NotificationListRequestEvent dto) { - - List notificationList = repository.findAll(dto.principalName(), dto.page(), dto.size()); - messageService.handleNotificationList( - notificationList.stream().map(NotificationResponse::from).toList(), - dto.principalName() - ); - } - - @EventListener - public void handleNotificationReadEvent(NotificationReadEvent dto) { - - repository.markAsRead(dto.principalName(), dto.notificationId()); - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/NotificationEventPublisher.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/NotificationEventPublisher.java deleted file mode 100644 index 460e1826..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/NotificationEventPublisher.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.ezcode.codetest.infrastructure.event.publisher; - -import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; -import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; -import org.ezcode.codetest.application.notification.event.NotificationReadEvent; -import org.ezcode.codetest.application.notification.port.NotificationEventService; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class NotificationEventPublisher implements NotificationEventService { - - private final ApplicationEventPublisher publisher; - - @Override - public void saveAndNotify(NotificationCreateEvent dto) { - - publisher.publishEvent(dto); - } - - @Override - public void notifyList(NotificationListRequestEvent dto) { - - publisher.publishEvent(dto); - } - - @Override - public void setRead(NotificationReadEvent dto) { - - publisher.publishEvent(dto); - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java index ba45d6ef..853f2509 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/event/publisher/StompMessageService.java @@ -10,8 +10,6 @@ import java.util.List; -import org.ezcode.codetest.infrastructure.event.dto.NotificationResponse; - import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Component; @@ -57,24 +55,6 @@ public void handleChatRoomHistoryLoad(T chatData, String principalName, Stri ); } - public void handleNotification(NotificationResponse data, String principalName) { - - messagingTemplate.convertAndSendToUser( - principalName, - "/queue/notification", - data - ); - } - - public void handleNotificationList(List dataList, String principalName) { - - messagingTemplate.convertAndSendToUser( - principalName, - "/queue/notifications", - dataList - ); - } - public void sendInitTestcases(String sessionKey, List dataList) { messagingTemplate.convertAndSend( diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java new file mode 100644 index 00000000..c3c5d47e --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/config/NotificationRedisCacheConfig.java @@ -0,0 +1,78 @@ +package org.ezcode.codetest.infrastructure.notification.config; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +@Configuration +@EnableCaching +public class NotificationRedisCacheConfig { + + @Bean("notificationRedisCacheManager") + public CacheManager notificationRedisCacheManager(RedisConnectionFactory redisConnectionFactory) { + + PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder() + .allowIfSubType("org.ezcode.codetest.application.notification.event.payload") + .allowIfSubType("org.ezcode.codetest.infrastructure.notification") + .allowIfSubType("java.util") + .allowIfSubType("org.springframework.data.domain") + .build(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL); + + GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) + .entryTtl(Duration.ofMinutes(30)); + + // 특정 캐시를 위한 별도 설정 정의 + RedisCacheConfiguration notificationCacheConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer)) + .entryTtl(Duration.ofMinutes(5)); + + Map cacheConfigurations = new HashMap<>(); + cacheConfigurations.put("notificationList", notificationCacheConfig); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Bean("notificationRedisTemplate") + public RedisTemplate notificationRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setConnectionFactory(redisConnectionFactory); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationPageResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationPageResponse.java new file mode 100644 index 00000000..e75d9b09 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationPageResponse.java @@ -0,0 +1,23 @@ +package org.ezcode.codetest.infrastructure.notification.dto; + +import java.io.Serializable; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class NotificationPageResponse implements Serializable { + + private List content; + + private int page; + + private int size; + + private long totalElements; + +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationResponse.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationResponse.java new file mode 100644 index 00000000..c8b5e0b1 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/dto/NotificationResponse.java @@ -0,0 +1,33 @@ +package org.ezcode.codetest.infrastructure.notification.dto; + +import java.time.LocalDateTime; + +import org.ezcode.codetest.application.notification.event.payload.NotificationPayload; +import org.ezcode.codetest.application.notification.enums.NotificationType; +import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; + +public record NotificationResponse( + + String id, + + NotificationType notificationType, + + NotificationPayload payload, + + boolean isRead, + + LocalDateTime createdAt + +) { + + public static NotificationResponse from(NotificationDocument document) { + + return new NotificationResponse( + document.getId(), + document.getNotificationType(), + document.getPayload(), + document.isRead(), + document.getCreatedAt() + ); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java new file mode 100644 index 00000000..fb553458 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationDocument.java @@ -0,0 +1,52 @@ +package org.ezcode.codetest.infrastructure.notification.model; + +import java.time.LocalDateTime; + +import org.ezcode.codetest.application.notification.enums.NotificationType; +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.application.notification.event.payload.NotificationPayload; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Document(collection = "notifications") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class NotificationDocument { + + @Id + private String id; + + private String principalName; + + private NotificationType notificationType; + + private NotificationPayload payload; + + @JsonProperty("read") + private boolean isRead; + + private LocalDateTime createdAt; + + public static NotificationDocument from(NotificationCreateEvent event) { + + return new NotificationDocument( + null, + event.principalName(), + event.notificationType(), + event.payload(), + event.isRead(), + event.createdAt() + ); + } + + public void markAsRead() { + this.isRead = true; + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationQueueConstants.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationQueueConstants.java new file mode 100644 index 00000000..c959a684 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationQueueConstants.java @@ -0,0 +1,11 @@ +package org.ezcode.codetest.infrastructure.notification.model; + +public final class NotificationQueueConstants { + + private NotificationQueueConstants() {} + + public static final String NOTIFICATION_QUEUE_CREATE = "notification.queue.create"; + public static final String NOTIFICATION_QUEUE_LIST = "notification.queue.list"; + public static final String NOTIFICATION_QUEUE_MARK_READ = "notification.queue.read"; + +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/publisher/NotificationEventPublisher.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/publisher/NotificationEventPublisher.java new file mode 100644 index 00000000..bba3aa70 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/publisher/NotificationEventPublisher.java @@ -0,0 +1,59 @@ +package org.ezcode.codetest.infrastructure.notification.publisher; + +import static org.ezcode.codetest.infrastructure.notification.model.NotificationQueueConstants.*; + +import org.ezcode.codetest.application.notification.event.*; +import org.ezcode.codetest.application.notification.exception.NotificationException; +import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; +import org.ezcode.codetest.application.notification.port.NotificationEventService; +import org.springframework.jms.core.JmsTemplate; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventPublisher implements NotificationEventService { + + private final JmsTemplate jmsTemplate; + private final ObjectMapper objectMapper; + + @Override + public void notify(NotificationCreateEvent dto) { + + sendMessage(NOTIFICATION_QUEUE_CREATE, dto); + // publisher.publishEvent(dto); + } + + @Override + public void notifyList(NotificationListRequestEvent dto) { + + sendMessage(NOTIFICATION_QUEUE_LIST, dto); + // publisher.publishEvent(dto); + } + + @Override + public void setRead(NotificationMarkReadEvent dto) { + + sendMessage(NOTIFICATION_QUEUE_MARK_READ, dto); + // publisher.publishEvent(dto); + } + + private void sendMessage(String destination, Object data) { + + try { + String jsonMessage = objectMapper.writeValueAsString(data); + + jmsTemplate.convertAndSend(destination, jsonMessage); + log.info("알림 메시지 전송 성공 ({}) : {}", destination, jsonMessage); + } catch (JsonProcessingException ex) { + log.error("알림 메시지 변환 및 전송 실패 : {}", ex.getMessage()); + throw new NotificationException(NotificationExceptionCode.NOTIFICATION_CONVERT_MESSAGE_ERROR); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationMongoRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationMongoRepository.java new file mode 100644 index 00000000..ebd53f46 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationMongoRepository.java @@ -0,0 +1,16 @@ +package org.ezcode.codetest.infrastructure.notification.repository; + +import java.util.Optional; + +import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface NotificationMongoRepository extends MongoRepository { + + Page findAllByPrincipalName(String principalName, Pageable pageable); + + Optional findByIdAndPrincipalName(String id, String principalName); + +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationEventListener.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationEventListener.java new file mode 100644 index 00000000..1c62db01 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationEventListener.java @@ -0,0 +1,80 @@ +package org.ezcode.codetest.infrastructure.notification.service; + +import static org.ezcode.codetest.infrastructure.notification.model.NotificationQueueConstants.*; + +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; +import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent; +import org.ezcode.codetest.application.notification.exception.NotificationException; +import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; +import org.ezcode.codetest.infrastructure.notification.dto.NotificationPageResponse; +import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; +import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse; +import org.springframework.jms.annotation.JmsListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationService notificationService; + + private final SimpMessagingTemplate messagingTemplate; + + private final ObjectMapper objectMapper; + + @JmsListener(destination = NOTIFICATION_QUEUE_CREATE) + public void handleNotificationCreateEvent(String message) { + + NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class); + NotificationDocument notification = notificationService.createNewNotification(event); + + messagingTemplate.convertAndSendToUser( + event.principalName(), + "/queue/notification", + NotificationResponse.from(notification) + ); + } + + @JmsListener(destination = NOTIFICATION_QUEUE_LIST) + public void handleNotificationListRequestEvent(String message) { + + NotificationListRequestEvent event = convertObject(message, NotificationListRequestEvent.class); + NotificationPageResponse notifications = notificationService.getNotifications(event); + + messagingTemplate.convertAndSendToUser( + event.principalName(), + "/queue/notifications", + notifications + ); + } + + @JmsListener(destination = NOTIFICATION_QUEUE_MARK_READ) + public void handleNotificationReadEvent(String message) { + + NotificationMarkReadEvent event = convertObject(message, NotificationMarkReadEvent.class); + notificationService.markAsRead(event); + } + + private T convertObject(String message, Class targetClass) { + + try { + T dto = objectMapper.readValue(message, targetClass); + log.info("알림 객체 {} 변환 성공", targetClass.getSimpleName()); + return dto; + } catch (JsonProcessingException ex) { + log.error("알림 객체 {} 변환 실패", targetClass.getSimpleName(), ex); + throw new NotificationException( + NotificationExceptionCode.NOTIFICATION_CONVERT_MESSAGE_ERROR + ); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java new file mode 100644 index 00000000..4f55e88e --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -0,0 +1,100 @@ +package org.ezcode.codetest.infrastructure.notification.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.application.notification.event.NotificationListRequestEvent; +import org.ezcode.codetest.application.notification.event.NotificationMarkReadEvent; +import org.ezcode.codetest.application.notification.exception.NotificationException; +import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; +import org.ezcode.codetest.infrastructure.notification.dto.NotificationPageResponse; +import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse; +import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; +import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.RedisCallback; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ScanOptions; +import org.springframework.stereotype.Service; + +@Service +public class NotificationService { + + private final NotificationMongoRepository mongoRepository; + + private final RedisTemplate redisTemplate; + + public NotificationService( + NotificationMongoRepository mongoRepository, + @Qualifier("notificationRedisTemplate") RedisTemplate redisTemplate + ) { + this.mongoRepository = mongoRepository; + this.redisTemplate = redisTemplate; + } + + public NotificationDocument createNewNotification(NotificationCreateEvent event) { + + NotificationDocument document = mongoRepository.save(NotificationDocument.from(event)); + + evictNotificationListCache(event.principalName()); + + return document; + } + + @Cacheable(value = "notificationList", key = "#event.principalName + ':' + #event.page + ':' + #event.size", cacheManager = "notificationRedisCacheManager") + public NotificationPageResponse getNotifications(NotificationListRequestEvent event) { + + PageRequest pageRequest = PageRequest.of(event.page(), event.size(), Sort.by("createdAt").descending()); + + Page page = mongoRepository.findAllByPrincipalName(event.principalName(), pageRequest); + + List notificationList = page.stream() + .map(NotificationResponse::from) + .toList(); + + return new NotificationPageResponse<>( + notificationList, + page.getNumber(), + page.getSize(), + page.getTotalElements() + ); + } + + public void markAsRead(NotificationMarkReadEvent event) { + + NotificationDocument notificationDocument = mongoRepository + .findByIdAndPrincipalName(event.notificationId(), event.principalName()) + .orElseThrow(() -> new NotificationException(NotificationExceptionCode.NOTIFICATION_NOT_FOUND)); + + notificationDocument.markAsRead(); + mongoRepository.save(notificationDocument); + + evictNotificationListCache(event.principalName()); + } + + private void evictNotificationListCache(String principalName) { + + String pattern = "notificationList::" + principalName.replaceAll("[*?\\[\\]]", "\\\\$0") + ":*"; + + Set keys = redisTemplate.execute((RedisCallback>)connection -> { + Set matchingKeys = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build(); + Cursor cursor = connection.scan(options); + while (cursor.hasNext()) { + matchingKeys.add(new String(cursor.next())); + } + return matchingKeys; + }); + + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRedisRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRedisRepository.java deleted file mode 100644 index 4d51e097..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRedisRepository.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.ezcode.codetest.infrastructure.persistence.repository.notification; - -import java.time.ZoneId; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -import org.ezcode.codetest.infrastructure.event.dto.NotificationRecord; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ZSetOperations; -import org.springframework.stereotype.Repository; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class NotificationRedisRepository implements NotificationRepository { - - private final RedisTemplate redisTemplate; - - private static final String KEY_PREFIX = "notifications:%s"; - - @Override - public void save(NotificationRecord record) { - - String key = String.format(KEY_PREFIX, record.getPrincipalName()); - ZSetOperations zSetOps = redisTemplate.opsForZSet(); - - long score = record.getCreatedAt() - .atZone(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli(); - zSetOps.add(key, record, score); - } - - @Override - public List findAll(String principalName, int page, int size) { - - String key = String.format(KEY_PREFIX, principalName); - ZSetOperations zSetOps = redisTemplate.opsForZSet(); - - int start = (page - 1) * size; - int end = start + size - 1; - Set set = zSetOps.reverseRange(key, start, end); - return (set == null) ? List.of() : new ArrayList<>(set); - } - - @Override - public void markAsRead(String principalName, String notificationId) { - - String key = String.format(KEY_PREFIX, principalName); - ZSetOperations zSetOps = redisTemplate.opsForZSet(); - - // 전체 스캔 → 개선 필요 시 Hash+ZSet 구조 고려 - Set all = zSetOps.range(key, 0, -1); - if (all == null) return; - - for (NotificationRecord rec : all) { - if (rec.getId().equals(notificationId)) { - long score = rec.getCreatedAt() - .atZone(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli(); - - // 기존 데이터 삭제 및 수정한 데이터 삽입 - zSetOps.remove(key, rec); - rec.setRead(true); - zSetOps.add(key, rec, score); - - break; - } - } - } -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRepository.java deleted file mode 100644 index dd0d5790..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/notification/NotificationRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.ezcode.codetest.infrastructure.persistence.repository.notification; - -import java.util.List; - -import org.ezcode.codetest.infrastructure.event.dto.NotificationRecord; - -public interface NotificationRepository { - - void save(NotificationRecord record); - - List findAll(String principalName, int page, int size); - - void markAsRead(String principalName, String notificationId); - -}