From 5157ef4d7a7469fcb53e60e91ee7406e6c6eb856 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Wed, 3 Sep 2025 23:14:48 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix=20:=20mysql=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EB=B6=88=EB=9F=AC=EC=98=A4=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `runtimeOnly` 사용 시 실행할 때에만 classpath에 올라가므로, IDE/빌드 환경에 따라 드라이버를 찾지 못하는 경우가 발생함 --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8612ce11..c4b3ed86 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,7 @@ dependencies { // DB runtimeOnly 'com.h2database:h2' - runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' From e9b554e16ce3ee9234eb3a8a4133921e5b959c0e Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Tue, 7 Oct 2025 22:26:49 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat=20:=20MongoDB=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mongo db에 트랜잭션을 도입하기 위해서 replica set 설정을 수행하고, 실제로 적용되었는지 확인하기 위해 테스트 수행 - 정상적으로 동작하는 것 확인 - 실제 배포 환경에 있는 mongo db 백업 후 replica set 적용해야 함 --- .gitignore | 1 + restart-mongodb-replica.sh | 53 +++++++++++++++++++ scripts/mongo-create-user.sh | 14 +++++ scripts/rs-init.sh | 28 ++++++++++ .../mongo/config/MongoTransactionConfig.java | 15 ++++++ .../service/NotificationEventListener.java | 2 + .../notification/MongoTransactionTest.java | 31 +++++++++++ 7 files changed, 144 insertions(+) create mode 100644 restart-mongodb-replica.sh create mode 100644 scripts/mongo-create-user.sh create mode 100644 scripts/rs-init.sh create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/mongo/config/MongoTransactionConfig.java create mode 100644 src/test/java/org/ezcode/codetest/infrastructure/notification/MongoTransactionTest.java diff --git a/.gitignore b/.gitignore index 40bacdf4..701f059b 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ src/main/resources/application.properties docker-compose.dev.yml /data/ /db/ +/replicaset # 환경변수 .env diff --git a/restart-mongodb-replica.sh b/restart-mongodb-replica.sh new file mode 100644 index 00000000..b114283c --- /dev/null +++ b/restart-mongodb-replica.sh @@ -0,0 +1,53 @@ +DATA_FILE_PATH="./replicaset" +DOCKER_FILE_PATH="./docker-compose.dev.yml" +MONGO_PRIMARY_NAME="rs01p" +REPLICA_INIT_FILE_PATH="./scripts/rs-init.sh" +MONGO_CREATE_USER_FILE_PATH="./scripts/mongo-create-user.sh" + +UP_CONTAINER_DELAY=10 +REPLICA_CONFIG_DELAY=25 + +echo "****************** Reset docker container Shell Script ******************" +echo "Data File Path: ${DATA_FILE_PATH}" +echo "Docker File Path: ${DOCKER_FILE_PATH}" +echo "MongoDB Primary name: ${MONGO_PRIMARY_NAME}" +echo "Replica set init Script File Path: ${REPLICA_INIT_FILE_PATH}" +echo "Mongo create user file path: ${MONGO_CREATE_USER_FILE_PATH}" + +sleep 1; + +echo "****************** Stop docker container ******************" +docker-compose -f ${DOCKER_FILE_PATH} stop +echo "****************** Completed Stop docker container ******************" + +sleep 1; + +echo "****************** Down docker container ******************" +docker-compose -f ${DOCKER_FILE_PATH} down +echo "****************** Completed Down docker container ******************" + +sleep 1; + +echo "****************** Remove Data ******************" +rm -rf ${DATA_FILE_PATH} +echo "****************** Completed Remove Data ******************" + +sleep 1; + +echo "****************** Up docker container ******************" +docker-compose -f ${DOCKER_FILE_PATH} up -d +echo "****************** Completed Up docker container ******************" + +echo "****** Waiting for ${UP_CONTAINER_DELAY} seconds ******" +sleep $UP_CONTAINER_DELAY; + +echo "****************** Run Replica Set Shell Script ******************" +docker exec -i ${MONGO_PRIMARY_NAME} bash < ${REPLICA_INIT_FILE_PATH} + +echo "****** Waiting for ${REPLICA_CONFIG_DELAY} seconds for replicaset configuration to be applied ******" +sleep $REPLICA_CONFIG_DELAY + +echo "****************** Run Create DB User Shell Script ******************" +docker exec -i ${MONGO_PRIMARY_NAME} bash < "${MONGO_CREATE_USER_FILE_PATH}" + +echo "****************** Completed Replica Shell Script ******************" diff --git a/scripts/mongo-create-user.sh b/scripts/mongo-create-user.sh new file mode 100644 index 00000000..4b6735e9 --- /dev/null +++ b/scripts/mongo-create-user.sh @@ -0,0 +1,14 @@ +mongosh --port 10021 < Date: Tue, 7 Oct 2025 23:54:48 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor=20:=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 코드에서는 @Transactional 메서드 안에서 DB 저장과 알림 발송이 순차적으로 일어난다. - 알림이 발송된 직후, 커밋 단계에서 데이터베이스 문제 등 예측하지 못한 이유로 트랜잭션이 롤백된다면? - 사용자는 알림을 받았지만, 정작 DB에는 해당 알림 데이터가 존재하지 않는 유령 데이터가 발생한다. - 스프링에서 제공하는 @TransactionalEventListener를 사용하여 트랜잭션 성공 이후 알림이 발송되는 것을 보장하고, 코드의 관심사를 분리했다. --- .../event/NotificationSavedEvent.java | 12 +++ .../service/NotificationEventListener.java | 73 ++----------------- .../service/NotificationQueueListener.java | 73 +++++++++++++++++++ .../service/NotificationService.java | 18 ++++- 4 files changed, 108 insertions(+), 68 deletions(-) create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/event/NotificationSavedEvent.java create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/event/NotificationSavedEvent.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/event/NotificationSavedEvent.java new file mode 100644 index 00000000..f758e635 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/event/NotificationSavedEvent.java @@ -0,0 +1,12 @@ +package org.ezcode.codetest.infrastructure.notification.event; + +import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse; + +public record NotificationSavedEvent( + + String principalName, + + NotificationResponse response + +) { +} 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 index 8e96036b..87915e16 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationEventListener.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationEventListener.java @@ -1,82 +1,25 @@ 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.ezcode.codetest.infrastructure.notification.event.NotificationSavedEvent; import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +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 -@Service +@Component @RequiredArgsConstructor public class NotificationEventListener { - private final NotificationService notificationService; - private final SimpMessagingTemplate messagingTemplate; - private final ObjectMapper objectMapper; - - @Transactional - @JmsListener(destination = NOTIFICATION_QUEUE_CREATE) - public void handleNotificationCreateEvent(String message) { - - NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class); - NotificationDocument notification = notificationService.createNewNotification(event); - + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleNotificationSavedEvent(NotificationSavedEvent event) { messagingTemplate.convertAndSendToUser( event.principalName(), "/queue/notification", - NotificationResponse.from(notification) + event.response() ); } - - @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/NotificationQueueListener.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java new file mode 100644 index 00000000..0a817286 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java @@ -0,0 +1,73 @@ +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.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 NotificationQueueListener { + + 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); + notificationService.createNewNotification(event); + } + + @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 index 4ac21bdb..ca4bc5a2 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -9,22 +9,27 @@ 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.event.NotificationSavedEvent; import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service public class NotificationService { private final NotificationMongoRepository mongoRepository; + private final ApplicationEventPublisher publisher; public NotificationService( - NotificationMongoRepository mongoRepository + NotificationMongoRepository mongoRepository, ApplicationEventPublisher publisher ) { this.mongoRepository = mongoRepository; + this.publisher = publisher; } public NotificationPageResponse getNotifications(NotificationListRequestEvent event) { @@ -45,11 +50,18 @@ public NotificationPageResponse getNotifications(Notificat ); } - public NotificationDocument createNewNotification(NotificationCreateEvent event) { + @Transactional + public void createNewNotification(NotificationCreateEvent event) { - return mongoRepository.save(NotificationDocument.from(event)); + NotificationDocument savedNotification = mongoRepository.save(NotificationDocument.from(event)); + + publisher.publishEvent(new NotificationSavedEvent( + event.principalName(), + NotificationResponse.from(savedNotification) + )); } + @Transactional public void markAsRead(NotificationMarkReadEvent event) { NotificationDocument notificationDocument = mongoRepository From 134c69bc7fb14f061267bd2a05e49b5b3d1a3e0e Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Thu, 9 Oct 2025 02:55:20 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat=20:=20=EC=95=8C=EB=A6=BC=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20DB=20?= =?UTF-8?q?=EC=9E=A5=EC=95=A0=20=EC=8B=9C=EB=A5=BC=20=EB=8C=80=EB=B9=84?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=84=9C=ED=82=B7=20=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=BB=A4=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메시지를 처리하는 과정에서 DB에 장애가 발생했을 시, 처리되지 않은 메시지가 누적되어 시스템이 과부하되는 것을 방지하기 위해 서킷 브레이커 패턴을 적용했다. - Resilience4j 라이브러리 사용 - 테스트 코드를 통해 실제로 서킷 브레이커가 동작하는지 확인함 --- build.gradle | 4 + .../exception/NotificationExceptionCode.java | 3 +- .../service/NotificationService.java | 22 +++ src/main/resources/application.properties | 16 +- .../NotificationCircuitBreakTest.java | 143 ++++++++++++++++++ .../resources/application-test.properties | 12 ++ 6 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java diff --git a/build.gradle b/build.gradle index c4b3ed86..f0c7c626 100644 --- a/build.gradle +++ b/build.gradle @@ -120,6 +120,10 @@ dependencies { //AES 암호화 implementation 'javax.xml.bind:jaxb-api:2.3.1' + + // Circuit Breaker + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' + implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0' } tasks.named('test') { 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 58e62be1..f94c8732 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 @@ -12,7 +12,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 데이터를 찾지 못했습니다") + NOTIFICATION_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 ID의 notification 데이터를 찾지 못했습니다"), + NOTIFICATION_DB_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR, "알림 데이터 저장 중 문제 발생. 서킷 브레이커가 열렸습니다.") ; private final boolean success; 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 index ca4bc5a2..941a22a2 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -19,7 +19,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.extern.slf4j.Slf4j; + @Service +@Slf4j public class NotificationService { private final NotificationMongoRepository mongoRepository; @@ -50,6 +54,7 @@ public NotificationPageResponse getNotifications(Notificat ); } + @CircuitBreaker(name = "db-circuit", fallbackMethod = "createNewNotificationFallback") @Transactional public void createNewNotification(NotificationCreateEvent event) { @@ -71,4 +76,21 @@ public void markAsRead(NotificationMarkReadEvent event) { notificationDocument.markAsRead(); mongoRepository.save(notificationDocument); } + + /** + * createNewNotification의 Fallback 메서드 + * 서킷이 열리면 이 메서드가 대신 실행됨 + * @param event 원본 메서드와 동일한 파라미터 + * @param ex 발생한 예외 + */ + private void createNewNotificationFallback(NotificationCreateEvent event, Throwable ex) { + // 어떤 예외 때문에 서킷이 열렸는지 로그를 남김 + log.warn("Circuit Breaker is open for createNewNotification. Event: {}, Error: {}", event, ex.getMessage()); + + // 메시지를 "처리 실패"로 알려서 MQ가 재시도하거나 DLQ로 보내도록 함 + throw new NotificationException( + NotificationExceptionCode.NOTIFICATION_DB_ERROR, + ex.getMessage() + ); + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bf1c9786..654cde9d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -159,4 +159,18 @@ spring.datasource.hikari.minimum-idle=10 spring.datasource.hikari.idle-timeout=30000 spring.datasource.hikari.max-lifetime=600000 spring.datasource.hikari.connection-timeout=30000 -spring.datasource.hikari.validation-timeout=5000 \ No newline at end of file +spring.datasource.hikari.validation-timeout=5000 + +# ======================== +# Circuit Breaker +# ======================== +# ?? 10?? DB ?? ? 50% ??(5?)? ????, ??? ?? 10? ?? DB ??? ?? +# 10?? ??? ? ?? ??? ??? ???, ???? ??? ?? ?? +resilience4j.circuitbreaker.instances.db-circuit.sliding-window-size=10 +resilience4j.circuitbreaker.instances.db-circuit.minimum-number-of-calls=5 +resilience4j.circuitbreaker.instances.db-circuit.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=10s +resilience4j.circuitbreaker.instances.db-circuit.slow-call-duration-threshold=2s +resilience4j.circuitbreaker.instances.db-circuit.slow-call-rate-threshold=60 +resilience4j.circuitbreaker.instances.db-circuit.register-health-indicator=true +resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.ezcode.codetest.application.notification.exception.NotificationException diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java new file mode 100644 index 00000000..5f50779c --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java @@ -0,0 +1,143 @@ +package org.ezcode.codetest.infrastructure.notification; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; + +import org.ezcode.codetest.application.notification.enums.NotificationType; +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.application.notification.exception.NotificationException; +import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; +import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; +import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; +import org.ezcode.codetest.infrastructure.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; + +@SpringBootTest(properties = { + // 테스트 실행 속도를 위해 서킷 대기 시간을 짧게 조정 + "resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=2s" +}) +@ActiveProfiles("test") +public class NotificationCircuitBreakTest { + + @Autowired + private NotificationService notificationService; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @MockitoBean + private NotificationMongoRepository mongoRepository; + + private CircuitBreaker dbCircuitBreaker; + + @BeforeEach + void setUp() { + // 테스트 시작 전에 서킷 브레이커를 초기화 (CLOSED 상태로 강제) + dbCircuitBreaker = circuitBreakerRegistry.circuitBreaker("db-circuit"); + dbCircuitBreaker.reset(); + } + + @Test + @DisplayName("1. 정상 상황: DB가 안정적일 때 서킷은 CLOSED 상태를 유지한다") + void whenDbIsStable_thenCircuitRemainsClosed() { + // Given + NotificationCreateEvent event = createNotificationCreateEvent(); + NotificationDocument dummyDocument = NotificationDocument.from(event); + + when(mongoRepository.save(any(NotificationDocument.class))).thenReturn(dummyDocument); + + // When & Then + for (int i = 0; i < 10; i++) { + assertDoesNotThrow(() -> notificationService.createNewNotification(createNotificationCreateEvent())); + } + assertThat(dbCircuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + System.out.println("✅ 성공: 정상 상황 테스트 완료"); + } + + @Test + @DisplayName("2. 장애 발생: DB 장애 반복 시 서킷이 OPEN 상태로 변경된다") + void whenDbFailureRepeats_thenCircuitOpens() { + // Given + String errorMessage = "DB Connection Failed"; + when(mongoRepository.save(any(NotificationDocument.class))) + .thenThrow(new NotificationException(NotificationExceptionCode.NOTIFICATION_DB_ERROR, errorMessage)); + + // When & Then + for (int i = 0; i < 5; i++) { + assertThatThrownBy(() -> notificationService.createNewNotification(createNotificationCreateEvent())) + .isInstanceOf(NotificationException.class) + .hasMessageContaining(errorMessage); + } + + assertThat(dbCircuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + System.out.println("✅ 성공: 장애 발생 시 OPEN 전환 테스트 완료"); + } + + @Test + @DisplayName("3. 자동 복구: 서킷이 열린 후, 시간이 지나면 HALF_OPEN을 거쳐 CLOSED로 복구된다") + void whenCircuitIsOpen_andAfterWaitDuration_thenTransitionsToHalfOpenAndClosed() throws InterruptedException { + // Given + NotificationCreateEvent event = createNotificationCreateEvent(); + NotificationDocument dummyDocument = NotificationDocument.from(event); + + dbCircuitBreaker.transitionToOpenState(); + Thread.sleep(Duration.ofSeconds(2).toMillis()); + when(mongoRepository.save(any(NotificationDocument.class))).thenReturn(dummyDocument); + + // When + for (int i = 0; i < 5; i++) { + notificationService.createNewNotification(createNotificationCreateEvent()); + } + + // Then + assertThat(dbCircuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + System.out.println("✅ 성공: 자동 복구 테스트 완료"); + } + + @Test + @DisplayName("4. 복구 실패: HALF_OPEN 상태에서 호출이 실패하면 다시 OPEN 상태로 돌아간다") + void whenCircuitIsHalfOpen_andCallFails_thenTransitionsBackToOpen() throws InterruptedException { + // Given + String errorMessage = "DB Still Down"; + int minimumCalls = 5; + + // 1. 서킷을 OPEN 상태로 만들고, HALF_OPEN이 될 때까지 대기 + dbCircuitBreaker.transitionToOpenState(); + Thread.sleep(Duration.ofSeconds(2).toMillis()); + + // 2. HALF_OPEN 상태에서 DB가 여전히 장애 상황임을 흉내 + when(mongoRepository.save(any(NotificationDocument.class))) + .thenThrow(new NotificationException(NotificationExceptionCode.NOTIFICATION_DB_ERROR, errorMessage)); + + // When: 최소 호출 횟수만큼 실패를 유도 + for (int i = 0; i < minimumCalls; i++) { + assertThatThrownBy(() -> notificationService.createNewNotification(createNotificationCreateEvent())) + .isInstanceOf(NotificationException.class); + } + + // Then: 이제 충분한 실패 데이터가 쌓였으므로 서킷은 다시 OPEN 상태로 돌아가야 함 + assertThat(dbCircuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + System.out.println("✅ 성공: 복구 실패 테스트 완료"); + } + + + private NotificationCreateEvent createNotificationCreateEvent() { + return NotificationCreateEvent.of( + "test@test.com", + NotificationType.COMMUNITY_DISCUSSION_VOTED_UP, + null + ); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index ceb203f0..ab8c73ab 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -105,3 +105,15 @@ discord.webhook.url=${DISCORD_WEBHOOK_URL} #logging.level.org.springframework.security.oauth2=DEBUG #logging.level.org.springframework.security.oauth2.client=TRACE #logging.level.org.springframework.web.client.RestTemplate=TRACE + +# ======================== +# Circuit Breaker +# ======================== +resilience4j.circuitbreaker.instances.db-circuit.sliding-window-size=10 +resilience4j.circuitbreaker.instances.db-circuit.minimum-number-of-calls=5 +resilience4j.circuitbreaker.instances.db-circuit.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=10s +resilience4j.circuitbreaker.instances.db-circuit.slow-call-duration-threshold=2s +resilience4j.circuitbreaker.instances.db-circuit.slow-call-rate-threshold=60 +resilience4j.circuitbreaker.instances.db-circuit.register-health-indicator=true +resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.ezcode.codetest.application.notification.exception.NotificationException From b1321c0646e998eaa1b80f7e649d92a3ae188726 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Thu, 9 Oct 2025 23:09:45 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat=20:=20=EC=84=9C=ED=82=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=BB=A4=20=EC=A0=95=EC=83=81=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서킷 브레이커에 대해 잘못 이해하고 있는 부분이 있어서 테스트 코드에 오류가 있었음 - 실제로 발생할만한 예외가 던져지도록 하고, 해당 예외가 fallback 메서드를 통해 NotificationException으로 던져지는 것을 확인하도록 함 - 새로 구현된 NotificationProcessLog document에 데이터가 있으면 서킷 브레이커 테스트 중 에러가 발생함. 추후 이 점 유의해야 함 --- .../exception/NotificationException.java | 9 +++++++ .../service/NotificationService.java | 1 + src/main/resources/application.properties | 2 +- .../NotificationCircuitBreakTest.java | 25 ++++++++++++++++--- .../resources/application-test.properties | 2 +- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationException.java b/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationException.java index c3fba9e5..28c12339 100644 --- a/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationException.java +++ b/src/main/java/org/ezcode/codetest/application/notification/exception/NotificationException.java @@ -24,4 +24,13 @@ public NotificationException(ResponseCode responseCode, String message) { this.httpStatus = responseCode.getStatus(); this.message = responseCode.getMessage() + " : " + message; } + + public NotificationException(ResponseCode responseCode, Throwable cause, String message) { + super(); + + this.responseCode = responseCode; + this.httpStatus = responseCode.getStatus(); + super.initCause(cause); + this.message = message; + } } 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 index 941a22a2..b879da86 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -90,6 +90,7 @@ private void createNewNotificationFallback(NotificationCreateEvent event, Throwa // 메시지를 "처리 실패"로 알려서 MQ가 재시도하거나 DLQ로 보내도록 함 throw new NotificationException( NotificationExceptionCode.NOTIFICATION_DB_ERROR, + ex, ex.getMessage() ); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 654cde9d..8a3bbf83 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -173,4 +173,4 @@ resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=10s resilience4j.circuitbreaker.instances.db-circuit.slow-call-duration-threshold=2s resilience4j.circuitbreaker.instances.db-circuit.slow-call-rate-threshold=60 resilience4j.circuitbreaker.instances.db-circuit.register-health-indicator=true -resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.ezcode.codetest.application.notification.exception.NotificationException +resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.springframework.dao.DataAccessResourceFailureException,org.springframework.dao.DataAccessException,com.mongodb.MongoTimeoutException,java.net.ConnectException,java.util.concurrent.TimeoutException diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java index 5f50779c..d8f6c098 100644 --- a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java @@ -9,7 +9,6 @@ import org.ezcode.codetest.application.notification.enums.NotificationType; import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; import org.ezcode.codetest.application.notification.exception.NotificationException; -import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; import org.ezcode.codetest.infrastructure.notification.service.NotificationService; @@ -18,9 +17,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataAccessResourceFailureException; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; @@ -72,13 +73,13 @@ void whenDbFailureRepeats_thenCircuitOpens() { // Given String errorMessage = "DB Connection Failed"; when(mongoRepository.save(any(NotificationDocument.class))) - .thenThrow(new NotificationException(NotificationExceptionCode.NOTIFICATION_DB_ERROR, errorMessage)); + .thenThrow(new DataAccessResourceFailureException(errorMessage)); // When & Then for (int i = 0; i < 5; i++) { assertThatThrownBy(() -> notificationService.createNewNotification(createNotificationCreateEvent())) .isInstanceOf(NotificationException.class) - .hasMessageContaining(errorMessage); + .hasCauseInstanceOf(DataAccessResourceFailureException.class); } assertThat(dbCircuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); @@ -119,7 +120,7 @@ void whenCircuitIsHalfOpen_andCallFails_thenTransitionsBackToOpen() throws Inter // 2. HALF_OPEN 상태에서 DB가 여전히 장애 상황임을 흉내 when(mongoRepository.save(any(NotificationDocument.class))) - .thenThrow(new NotificationException(NotificationExceptionCode.NOTIFICATION_DB_ERROR, errorMessage)); + .thenThrow(new DataAccessResourceFailureException(errorMessage)); // When: 최소 호출 횟수만큼 실패를 유도 for (int i = 0; i < minimumCalls; i++) { @@ -132,6 +133,22 @@ void whenCircuitIsHalfOpen_andCallFails_thenTransitionsBackToOpen() throws Inter System.out.println("✅ 성공: 복구 실패 테스트 완료"); } + @Test + @DisplayName("5. 서킷이 OPEN일 때 Fallback 동작 검증") + void whenCircuitIsOpen_thenFallbackIsExecuted() { + // Given + dbCircuitBreaker.transitionToOpenState(); + + // When & Then + assertThatThrownBy(() -> notificationService.createNewNotification(createNotificationCreateEvent())) + .isInstanceOf(NotificationException.class) + .hasCauseInstanceOf(CallNotPermittedException.class); + + // DB Repository는 절대 호출되지 않았어야 함 + verify(mongoRepository, never()).save(any(NotificationDocument.class)); + System.out.println("✅ 성공: OPEN 상태에서 Fallback 동작 검증 완료"); + } + private NotificationCreateEvent createNotificationCreateEvent() { return NotificationCreateEvent.of( diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index ab8c73ab..ef4df994 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -116,4 +116,4 @@ resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=10s resilience4j.circuitbreaker.instances.db-circuit.slow-call-duration-threshold=2s resilience4j.circuitbreaker.instances.db-circuit.slow-call-rate-threshold=60 resilience4j.circuitbreaker.instances.db-circuit.register-health-indicator=true -resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.ezcode.codetest.application.notification.exception.NotificationException +resilience4j.circuitbreaker.instances.db-circuit.record-exceptions=org.springframework.dao.DataAccessResourceFailureException,org.springframework.dao.DataAccessException,com.mongodb.MongoTimeoutException,java.net.ConnectException,java.util.concurrent.TimeoutException \ No newline at end of file From dead37d7ee6171e6dfc3056d66da59fe7430532e Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Fri, 10 Oct 2025 04:23:52 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat=20:=20consumer=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B2=98=EB=A6=AC=20=EC=A4=91=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EA=B0=80=20=EB=B0=9C=EC=83=9D=ED=96=88?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A5=BC=20=20=EB=8C=80=EB=B9=84=ED=95=B4?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EA=B8=B0=EB=B0=98=20=EC=9E=AC=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consumer에 메시지가 들어오면 로그를 db에 적재 (PENDING) - 이후 메시지 처리 성공 여부에 따라 상태를 업데이트함 (SUCCESS, FAILD) - 실패했던 메시지들은 별도의 스케쥴러에서 다시 재시도 처리함 - 만약 특정 횟수의 시도 이후에도 실패한다면 PERMANENTLY_FAILED로 상태 변경 - 해당 메시지들은 추후 개발자가 직접 처리할 수 있도록 함 (Grafana 등 연동) --- .../model/NotificationProcessLog.java | 72 +++++++ .../model/NotificationQueueConstants.java | 2 + .../publisher/NotificationEventPublisher.java | 12 +- .../NotificationProcessLogRepository.java | 12 ++ ...er.java => NotificationQueueConsumer.java} | 26 ++- .../service/NotificationRetryScheduler.java | 38 ++++ .../service/ProcessLogService.java | 85 +++++++++ src/main/resources/application.properties | 4 - .../NotificationIntegrationTest.java | 176 ++++++++++++++++++ .../resources/application-test.properties | 2 - 10 files changed, 415 insertions(+), 14 deletions(-) create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationProcessLog.java create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationProcessLogRepository.java rename src/main/java/org/ezcode/codetest/infrastructure/notification/service/{NotificationQueueListener.java => NotificationQueueConsumer.java} (71%) create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationRetryScheduler.java create mode 100644 src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java create mode 100644 src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationProcessLog.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationProcessLog.java new file mode 100644 index 00000000..69849b43 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationProcessLog.java @@ -0,0 +1,72 @@ +package org.ezcode.codetest.infrastructure.notification.model; + +import java.time.LocalDateTime; + +import org.springframework.data.mongodb.core.mapping.Document; + +import org.springframework.data.annotation.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Document(collection = "notification_process_log") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class NotificationProcessLog { + + @Id + private String messageId; + + private String payload; + + private ProcessStatus status; + + private int retryCount; + + private String errorMessage; + + private LocalDateTime lastAttemptAt; + + private LocalDateTime createdAt; + + public enum ProcessStatus { + PENDING, SUCCESS, FAILED, PERMANENTLY_FAILED + } + + public static NotificationProcessLog of(String messageId, String payload) { + + return new NotificationProcessLog( + messageId, + payload, + ProcessStatus.PENDING, + 0, + null, + LocalDateTime.now(), + LocalDateTime.now() + ); + } + + public void markAsSuccess() { + this.status = ProcessStatus.SUCCESS; + this.lastAttemptAt = LocalDateTime.now(); + this.errorMessage = null; + } + + public void markAsFailed(String errorMessage, int maxRetries) { + this.retryCount++; + this.lastAttemptAt = LocalDateTime.now(); + this.errorMessage = errorMessage; + + if (this.retryCount >= maxRetries) { + this.status = ProcessStatus.PERMANENTLY_FAILED; + } else { + this.status = ProcessStatus.FAILED; + } + } + + public void updateLastAttempt() { + this.lastAttemptAt = LocalDateTime.now(); + } +} 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 index c959a684..e93db4a5 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationQueueConstants.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/model/NotificationQueueConstants.java @@ -4,6 +4,8 @@ public final class NotificationQueueConstants { private NotificationQueueConstants() {} + public static final String CUSTOM_HEADER_MESSAGE_ID = "custom_message_id"; + 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 index bba3aa70..53605a34 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/publisher/NotificationEventPublisher.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/publisher/NotificationEventPublisher.java @@ -2,6 +2,8 @@ import static org.ezcode.codetest.infrastructure.notification.model.NotificationQueueConstants.*; +import java.util.UUID; + import org.ezcode.codetest.application.notification.event.*; import org.ezcode.codetest.application.notification.exception.NotificationException; import org.ezcode.codetest.application.notification.exception.NotificationExceptionCode; @@ -27,21 +29,18 @@ public class NotificationEventPublisher implements NotificationEventService { 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) { @@ -49,7 +48,12 @@ private void sendMessage(String destination, Object data) { try { String jsonMessage = objectMapper.writeValueAsString(data); - jmsTemplate.convertAndSend(destination, jsonMessage); + String customMessageId = "ID-" + UUID.randomUUID(); + jmsTemplate.convertAndSend(destination, jsonMessage, message -> { + message.setStringProperty(CUSTOM_HEADER_MESSAGE_ID, customMessageId); + return message; + }); + log.info("알림 메시지 전송 성공 ({}) : {}", destination, jsonMessage); } catch (JsonProcessingException ex) { log.error("알림 메시지 변환 및 전송 실패 : {}", ex.getMessage()); diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationProcessLogRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationProcessLogRepository.java new file mode 100644 index 00000000..51e2766c --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/repository/NotificationProcessLogRepository.java @@ -0,0 +1,12 @@ +package org.ezcode.codetest.infrastructure.notification.repository; + +import java.util.List; + +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface NotificationProcessLogRepository extends MongoRepository { + + // 재시도할 작업들을 찾는 쿼리 메서드 + List findByStatusAndRetryCountLessThan(NotificationProcessLog.ProcessStatus status, int maxRetries); +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java similarity index 71% rename from src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java rename to src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java index 0a817286..ef5dc5ff 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueListener.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java @@ -10,6 +10,7 @@ import org.ezcode.codetest.infrastructure.notification.dto.NotificationPageResponse; import org.ezcode.codetest.infrastructure.notification.dto.NotificationResponse; import org.springframework.jms.annotation.JmsListener; +import org.springframework.messaging.Message; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; @@ -22,19 +23,36 @@ @Slf4j @Service @RequiredArgsConstructor -public class NotificationQueueListener { +public class NotificationQueueConsumer { private final NotificationService notificationService; private final SimpMessagingTemplate messagingTemplate; private final ObjectMapper objectMapper; + private final ProcessLogService processLogService; @JmsListener(destination = NOTIFICATION_QUEUE_CREATE) - public void handleNotificationCreateEvent(String message) { + public void handleNotificationCreateEvent(Message message) { - NotificationCreateEvent event = convertObject(message, NotificationCreateEvent.class); - notificationService.createNewNotification(event); + log.info(">>>>>> JMS 메시지를 수신했습니다! <<<<<<"); + String messageId = (String) message.getHeaders().get(CUSTOM_HEADER_MESSAGE_ID); + String payload = message.getPayload(); + + if (!processLogService.startProcessing(messageId, payload)) { + log.warn("이미 처리되었거나 처리 중인 메시지입니다. messageId: {}", messageId); + return; + } + + try { + NotificationCreateEvent event = convertObject(payload, NotificationCreateEvent.class); + notificationService.createNewNotification(event); + + processLogService.finishProcessing(messageId); + } catch (Exception e) { + log.error("메시지 처리 실패. 재시도를 위해 FAILED로 기록합니다. messageId: {}", messageId); + processLogService.failProcessing(messageId, e.getMessage()); + } } @JmsListener(destination = NOTIFICATION_QUEUE_LIST) diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationRetryScheduler.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationRetryScheduler.java new file mode 100644 index 00000000..9cd3cdd0 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationRetryScheduler.java @@ -0,0 +1,38 @@ +package org.ezcode.codetest.infrastructure.notification.service; + +import static org.ezcode.codetest.infrastructure.notification.model.NotificationQueueConstants.*; + +import java.util.List; + +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog; +import org.springframework.jms.core.JmsTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationRetryScheduler { + + private final ProcessLogService processLogService; + private final JmsTemplate jmsTemplate; + + @Scheduled(fixedRate = 300000) // 5분마다 실행 + public void retryFailedNotifications() { + + log.info("실패할 알림 재처리를 시작합니다..."); + List retryableJobs = processLogService.findRetryableJobs(); + + for (NotificationProcessLog job : retryableJobs) { + log.info("재처리 시도: messageId={}", job.getMessageId()); + + jmsTemplate.convertAndSend(NOTIFICATION_QUEUE_CREATE, job.getPayload(), message -> { + message.setStringProperty(CUSTOM_HEADER_MESSAGE_ID, job.getMessageId()); + return message; + }); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java new file mode 100644 index 00000000..20a24c1e --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java @@ -0,0 +1,85 @@ +package org.ezcode.codetest.infrastructure.notification.service; + +import java.util.List; +import java.util.Optional; + +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog; +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog.ProcessStatus; +import org.ezcode.codetest.infrastructure.notification.repository.NotificationProcessLogRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@RequiredArgsConstructor +public class ProcessLogService { + + private final NotificationProcessLogRepository processLogRepository; + + @Value("${application.notification.max-retries:5}") + private int maxRetries; + + /** + * 메시지 처리 시작 및 중복 저장 검사 + * @param messageId 메시지 고유 ID + * @param payload 메시지 본문 + * @return 처리를 계속해야 하면 true, 중복 메시지이면 false를 반환 + */ + @Transactional + public boolean startProcessing(String messageId, String payload) { + + Optional existingLogOpt = processLogRepository.findById(messageId); + + if (existingLogOpt.isPresent()) { + NotificationProcessLog existingLog = existingLogOpt.get(); + + // 1. 이미 성공한 경우 -> 중복이므로 처리 중단 + if (existingLog.getStatus() == ProcessStatus.SUCCESS) { + log.warn("이미 성공적으로 처리된 메시지입니다. messageId={}", messageId); + return false; + } + + // 2. 실패했거나, 중간에 멈춘(PENDING) 경우 -> 재처리를 위해 계속 진행 + // 이때는 새 로그를 만드는 대신, 마지막 시도 시간만 업데이트 + log.info("실패했거나 PENDING 상태인 메시지를 재시도합니다. messageId={}", messageId); + existingLog.updateLastAttempt(); + processLogRepository.save(existingLog); + return true; + } + + NotificationProcessLog newLog = NotificationProcessLog.of(messageId, payload); + processLogRepository.save(newLog); + return true; + } + + // 메시지 처리 성공으로 기록 + @Transactional + public void finishProcessing(String messageId) { + + processLogRepository.findById(messageId).ifPresent(log -> { + log.markAsSuccess(); + processLogRepository.save(log); + }); + } + + // 메시지 처리 실패로 기록 + @Transactional + public void failProcessing(String messageId, String errorMessage) { + + processLogRepository.findById(messageId).ifPresent(log -> { + log.markAsFailed(errorMessage, maxRetries); + processLogRepository.save(log); + }); + } + + // 재시도할 작업 목록 조회 + @Transactional(readOnly = true) + public List findRetryableJobs() { + + return processLogRepository.findByStatusAndRetryCountLessThan(ProcessStatus.FAILED, maxRetries); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8a3bbf83..7d558b47 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -89,8 +89,6 @@ spring.security.oauth2.client.registration.google.scope=profile,email # ======================== spring.data.mongodb.uri=${SPRING_MONGODB_URI} spring.data.mongodb.auto-index-creation=true -logging.level.org.mongodb.driver.protocol.command=DEBUG -logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG # ======================== # Spring Cache Caffeine @@ -164,8 +162,6 @@ spring.datasource.hikari.validation-timeout=5000 # ======================== # Circuit Breaker # ======================== -# ?? 10?? DB ?? ? 50% ??(5?)? ????, ??? ?? 10? ?? DB ??? ?? -# 10?? ??? ? ?? ??? ??? ???, ???? ??? ?? ?? resilience4j.circuitbreaker.instances.db-circuit.sliding-window-size=10 resilience4j.circuitbreaker.instances.db-circuit.minimum-number-of-calls=5 resilience4j.circuitbreaker.instances.db-circuit.failure-rate-threshold=50 diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java new file mode 100644 index 00000000..b672a335 --- /dev/null +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java @@ -0,0 +1,176 @@ +package org.ezcode.codetest.infrastructure.notification; + +import static org.assertj.core.api.Assertions.*; +import static org.ezcode.codetest.infrastructure.notification.model.NotificationQueueConstants.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import org.awaitility.Awaitility; +import org.ezcode.codetest.application.notification.enums.NotificationType; +import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog; +import org.ezcode.codetest.infrastructure.notification.model.NotificationProcessLog.ProcessStatus; +import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; +import org.ezcode.codetest.infrastructure.notification.repository.NotificationProcessLogRepository; +import org.ezcode.codetest.infrastructure.notification.service.NotificationRetryScheduler; +import org.ezcode.codetest.infrastructure.notification.service.NotificationService; +import org.ezcode.codetest.infrastructure.notification.service.ProcessLogService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jms.core.JmsTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@ActiveProfiles("test") +public class NotificationIntegrationTest { + + @Autowired + private JmsTemplate jmsTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private NotificationMongoRepository notificationRepository; + + @Autowired + private NotificationProcessLogRepository processLogRepository; + + @Autowired + private NotificationRetryScheduler retryScheduler; + + // 실제 객체를 사용하되, 특정 메서드의 동작을 조작하기 위해 @SpyBean 사용 + @MockitoSpyBean + private NotificationService notificationService; + + @MockitoSpyBean + private ProcessLogService processLogService; + + @AfterEach + void tearDown() { + notificationRepository.deleteAll(); + processLogRepository.deleteAll(); + } + + @Test + @DisplayName("1. 정상 시나리오: 메시지가 성공적으로 처리되고 상태가 SUCCESS로 기록된다") + void happyPath_shouldProcessMessageSuccessfully() { + // Given: 정상 메시지 준비 + String messageId = "ID-" + UUID.randomUUID(); + String payload = createDummyPayload(); + + // When: 메시지를 JMS 큐로 전송 + sendMessageToQueue(messageId, payload); + + // Then: 비동기 처리가 완료될 때까지 대기 후 검증 + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + // 1. 처리 상태 로그가 SUCCESS로 저장되었는지 확인 + Optional logOpt = processLogRepository.findById(messageId); + assertThat(logOpt).isPresent(); + assertThat(logOpt.get().getStatus()).isEqualTo(ProcessStatus.SUCCESS); + + // 2. 실제 알림 데이터가 DB에 저장되었는지 확인 + assertThat(notificationRepository.count()).isEqualTo(1); + }); + } + + @Test + @DisplayName("2. 자동 복구 시나리오: 첫 시도 실패 후, 스케줄러에 의해 재처리되어 성공한다") + void whenTransientErrorOccurs_shouldBeRecoveredByScheduler() { + // Given: 메시지 준비 및 첫 시도에만 DB 장애가 발생하도록 설정 + String messageId = "ID-" + UUID.randomUUID(); + String payload = createDummyPayload(); + + // 첫 번째 processCreationEvent 호출 시에만 예외를 던지고, 그 이후에는 실제 메서드를 호출하도록 설정 + doThrow(new DataAccessResourceFailureException("DB is temporarily down")) + .doCallRealMethod() + .when(notificationService).createNewNotification(any(NotificationCreateEvent.class)); + + // When: 1. 첫 번째 메시지 전송 (실패 유도) + sendMessageToQueue(messageId, payload); + + // Then: 1. 상태가 FAILED로 기록될 때까지 대기 + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Optional logOpt = processLogRepository.findById(messageId); + assertThat(logOpt).isPresent(); + assertThat(logOpt.get().getStatus()).isEqualTo(ProcessStatus.FAILED); + // 아직 성공 전이므로 알림 데이터는 없어야 함 + assertThat(notificationRepository.count()).isZero(); + }); + + // When: 2. 재처리 스케줄러 수동 실행 + System.out.println("--- 재처리 스케줄러 실행 ---"); + retryScheduler.retryFailedNotifications(); + + // Then: 2. 재처리가 성공하여 상태가 SUCCESS로 변경될 때까지 대기 + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> { + Optional logOpt = processLogRepository.findById(messageId); + assertThat(logOpt).isPresent(); + assertThat(logOpt.get().getStatus()).isEqualTo(ProcessStatus.SUCCESS); + // 최종적으로 알림 데이터가 저장되었는지 확인 + assertThat(notificationRepository.count()).isEqualTo(1); + }); + } + + @Test + @DisplayName("3. 멱등성 시나리오: 성공한 메시지를 다시 보내도 중복 처리되지 않는다") + void whenSameMessageIsSent_shouldNotBeProcessedAgain() { + // Given: 메시지를 한 번 성공적으로 처리 + String messageId = "ID-" + UUID.randomUUID(); + String payload = createDummyPayload(); + sendMessageToQueue(messageId, payload); + Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> + assertThat(processLogRepository.findById(messageId).get().getStatus()).isEqualTo(ProcessStatus.SUCCESS) + ); + // UseCase가 1번 호출되었는지 확인 + verify(notificationService, times(1)).createNewNotification(any(NotificationCreateEvent.class)); + + // When: 동일한 메시지를 다시 전송 + System.out.println("--- 동일 메시지 재전송 ---"); + sendMessageToQueue(messageId, payload); + + // 잠시 대기 (소비자가 메시지를 처리할 시간) + try { Thread.sleep(2000); } catch (InterruptedException e) {} + + // Then: UseCase가 추가로 호출되지 않았어야 함 (총 호출 횟수가 여전히 1이어야 함) + verify(notificationService, times(1)).createNewNotification(any(NotificationCreateEvent.class)); + // 최종 데이터도 1개여야 함 + assertThat(notificationRepository.count()).isEqualTo(1); + } + + private void sendMessageToQueue(String messageId, String payload) { + jmsTemplate.convertAndSend(NOTIFICATION_QUEUE_CREATE, payload, message -> { + message.setStringProperty(CUSTOM_HEADER_MESSAGE_ID, messageId); + return message; + }); + } + + // private String createDummyPayload() { + // return "{\"principalName\":\"user@example.com\", \"notificationType\":\"TEST\", \"payload\":\"some data\"}"; + // } + + private String createDummyPayload() { + try { + return objectMapper.writeValueAsString( + NotificationCreateEvent.of( + "test@test.com", + NotificationType.COMMUNITY_DISCUSSION_VOTED_UP, + null + ) + ); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index ef4df994..8c2cb1ba 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -85,8 +85,6 @@ spring.security.oauth2.client.registration.google.scope=profile,email # ======================== spring.data.mongodb.uri=${SPRING_MONGODB_URI} spring.data.mongodb.auto-index-creation=true -logging.level.org.mongodb.driver.protocol.command=DEBUG -logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG # ======================== # Spring Cache Caffeine From e0faa1679d51b4a049d5d136910b6d775f43cef6 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Fri, 10 Oct 2025 04:37:27 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=80=20=ED=95=AD=EC=83=81=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AfterEach 추가해서 어떤 시점에 테스트를 실행해도 전부 통과할 수 있도록 함 - 그러나 DB를 매번 초기화하므로 개발, 배포 환경에 영향이 가지 않도록 disabled 해뒀음 --- .../notification/NotificationCircuitBreakTest.java | 8 ++++++++ .../notification/NotificationIntegrationTest.java | 6 ++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java index d8f6c098..ad232dc5 100644 --- a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationCircuitBreakTest.java @@ -12,7 +12,9 @@ import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; import org.ezcode.codetest.infrastructure.notification.service.NotificationService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -25,6 +27,7 @@ import io.github.resilience4j.circuitbreaker.CircuitBreaker; import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +@Disabled @SpringBootTest(properties = { // 테스트 실행 속도를 위해 서킷 대기 시간을 짧게 조정 "resilience4j.circuitbreaker.instances.db-circuit.wait-duration-in-open-state=2s" @@ -50,6 +53,11 @@ void setUp() { dbCircuitBreaker.reset(); } + @AfterEach + void tearDown() { + mongoRepository.deleteAll(); + } + @Test @DisplayName("1. 정상 상황: DB가 안정적일 때 서킷은 CLOSED 상태를 유지한다") void whenDbIsStable_thenCircuitRemainsClosed() { diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java index b672a335..fb005598 100644 --- a/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/NotificationIntegrationTest.java @@ -19,6 +19,7 @@ import org.ezcode.codetest.infrastructure.notification.service.NotificationService; import org.ezcode.codetest.infrastructure.notification.service.ProcessLogService; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +32,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +@Disabled @SpringBootTest @ActiveProfiles("test") public class NotificationIntegrationTest { @@ -156,10 +158,6 @@ private void sendMessageToQueue(String messageId, String payload) { }); } - // private String createDummyPayload() { - // return "{\"principalName\":\"user@example.com\", \"notificationType\":\"TEST\", \"payload\":\"some data\"}"; - // } - private String createDummyPayload() { try { return objectMapper.writeValueAsString( From cfa613ba9e8b14b8f7ecda4845378827e650fa3a Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Fri, 10 Oct 2025 04:46:34 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat=20:=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20shebang=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- restart-mongodb-replica.sh | 2 ++ scripts/mongo-create-user.sh | 2 ++ scripts/rs-init.sh | 2 ++ 3 files changed, 6 insertions(+) diff --git a/restart-mongodb-replica.sh b/restart-mongodb-replica.sh index b114283c..7979170e 100644 --- a/restart-mongodb-replica.sh +++ b/restart-mongodb-replica.sh @@ -1,3 +1,5 @@ +#!/bin/bash + DATA_FILE_PATH="./replicaset" DOCKER_FILE_PATH="./docker-compose.dev.yml" MONGO_PRIMARY_NAME="rs01p" diff --git a/scripts/mongo-create-user.sh b/scripts/mongo-create-user.sh index 4b6735e9..0bd73128 100644 --- a/scripts/mongo-create-user.sh +++ b/scripts/mongo-create-user.sh @@ -1,3 +1,5 @@ +#!/bin/bash + mongosh --port 10021 < Date: Fri, 10 Oct 2025 04:49:21 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat=20:=20Mongo=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EB=A7=A4=EB=8B=88=EC=A0=80=20Bean=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=B6=A9=EB=8F=8C=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MongoDB 트랜잭션 매니저를 transactionManager라는 기본 이름으로 등록하면, JPA 등 다른 데이터소스용 기본 트랜잭션 매니저와 충돌하거나 덮어써서 애플리케이션 부팅 실패 혹은 잘못된 매니저 선택으로 이어질 수 있음 --- .../mongo/config/MongoTransactionConfig.java | 2 +- .../notification/service/NotificationService.java | 4 ++-- .../notification/service/ProcessLogService.java | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/ezcode/codetest/infrastructure/mongo/config/MongoTransactionConfig.java b/src/main/java/org/ezcode/codetest/infrastructure/mongo/config/MongoTransactionConfig.java index 77881770..5adc72ec 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/mongo/config/MongoTransactionConfig.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/mongo/config/MongoTransactionConfig.java @@ -8,7 +8,7 @@ @Configuration public class MongoTransactionConfig { - @Bean + @Bean(name = "mongoTransactionManager") public MongoTransactionManager transactionManager(MongoDatabaseFactory mongoDatabaseFactory) { return new MongoTransactionManager(mongoDatabaseFactory); } 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 index b879da86..3974e05f 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationService.java @@ -55,7 +55,7 @@ public NotificationPageResponse getNotifications(Notificat } @CircuitBreaker(name = "db-circuit", fallbackMethod = "createNewNotificationFallback") - @Transactional + @Transactional(transactionManager = "mongoTransactionManager") public void createNewNotification(NotificationCreateEvent event) { NotificationDocument savedNotification = mongoRepository.save(NotificationDocument.from(event)); @@ -66,7 +66,7 @@ public void createNewNotification(NotificationCreateEvent event) { )); } - @Transactional + @Transactional(transactionManager = "mongoTransactionManager") public void markAsRead(NotificationMarkReadEvent event) { NotificationDocument notificationDocument = mongoRepository diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java index 20a24c1e..34f4cda5 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java @@ -29,7 +29,7 @@ public class ProcessLogService { * @param payload 메시지 본문 * @return 처리를 계속해야 하면 true, 중복 메시지이면 false를 반환 */ - @Transactional + @Transactional(transactionManager = "mongoTransactionManager") public boolean startProcessing(String messageId, String payload) { Optional existingLogOpt = processLogRepository.findById(messageId); @@ -57,7 +57,7 @@ public boolean startProcessing(String messageId, String payload) { } // 메시지 처리 성공으로 기록 - @Transactional + @Transactional(transactionManager = "mongoTransactionManager") public void finishProcessing(String messageId) { processLogRepository.findById(messageId).ifPresent(log -> { @@ -67,7 +67,7 @@ public void finishProcessing(String messageId) { } // 메시지 처리 실패로 기록 - @Transactional + @Transactional(transactionManager = "mongoTransactionManager") public void failProcessing(String messageId, String errorMessage) { processLogRepository.findById(messageId).ifPresent(log -> { @@ -77,7 +77,7 @@ public void failProcessing(String messageId, String errorMessage) { } // 재시도할 작업 목록 조회 - @Transactional(readOnly = true) + @Transactional(readOnly = true, transactionManager = "mongoTransactionManager") public List findRetryableJobs() { return processLogRepository.findByStatusAndRetryCountLessThan(ProcessStatus.FAILED, maxRetries); From 1712ec96317cb8bb7f22a910222a34489d8b2345 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Fri, 10 Oct 2025 04:51:52 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat=20:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20ID=20=EB=88=84=EB=9D=BD=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AC=B4=ED=95=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20?= =?UTF-8?q?=EC=9C=84=ED=97=98=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - null 예외 처리 추가해서 커스텀 message id 헤더가 존재하지 않는 메시지가 무한히 재시도 되는 상황을 방지함 --- .../notification/service/NotificationQueueConsumer.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java index ef5dc5ff..4dbb4019 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/NotificationQueueConsumer.java @@ -35,10 +35,14 @@ public class NotificationQueueConsumer { @JmsListener(destination = NOTIFICATION_QUEUE_CREATE) public void handleNotificationCreateEvent(Message message) { - log.info(">>>>>> JMS 메시지를 수신했습니다! <<<<<<"); String messageId = (String) message.getHeaders().get(CUSTOM_HEADER_MESSAGE_ID); String payload = message.getPayload(); + if (messageId == null) { + log.error("커스텀 메시지 ID 헤더가 없어 메시지를 처리할 수 없습니다. payload={}", payload); + return; + } + if (!processLogService.startProcessing(messageId, payload)) { log.warn("이미 처리되었거나 처리 중인 메시지입니다. messageId: {}", messageId); return; From ad90a9182af8e15c1c3a6a513d483dc0ed4c3089 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Tue, 25 Nov 2025 15:24:31 +0900 Subject: [PATCH 11/12] =?UTF-8?q?refact=20:=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=84=9C=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=20messageId=EC=97=90=20=ED=95=B4=EB=8B=B9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - finishProcessing, finishProcessing 메서드의 findById()가 비어있다면 `ifPresent` 블록은 아무 일도 하지 않고 넘어감 - 개발자는 에러가 발생했다는 사실조차 모르고 넘어감 -> 조용한 실패(Silent Failure) - finishProcessing에서 로그가 없다는 것은 비즈니스적인 문제(사용자 잘못 등)가 아니라, 시스템 내부의 심각한 상태 불일치를 의미 - 따라서 NotificationException보다는 IllegalStateException이나 SystemException 같은 런타임 예외를 사용하는 것이 의미상 더 적합함 --- .../service/ProcessLogService.java | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java index 34f4cda5..491e66f4 100644 --- a/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java +++ b/src/main/java/org/ezcode/codetest/infrastructure/notification/service/ProcessLogService.java @@ -60,20 +60,28 @@ public boolean startProcessing(String messageId, String payload) { @Transactional(transactionManager = "mongoTransactionManager") public void finishProcessing(String messageId) { - processLogRepository.findById(messageId).ifPresent(log -> { - log.markAsSuccess(); - processLogRepository.save(log); - }); + NotificationProcessLog log = processLogRepository.findById(messageId) + .orElseThrow(() -> { + ProcessLogService.log.error("처리 완료할 로그를 찾을 수 없습니다. messageId={}", messageId); + return new IllegalStateException("Process log not found: " + messageId); + }); + + log.markAsSuccess(); + processLogRepository.save(log); + } // 메시지 처리 실패로 기록 @Transactional(transactionManager = "mongoTransactionManager") public void failProcessing(String messageId, String errorMessage) { - processLogRepository.findById(messageId).ifPresent(log -> { - log.markAsFailed(errorMessage, maxRetries); - processLogRepository.save(log); - }); + NotificationProcessLog log = processLogRepository.findById(messageId) + .orElseThrow(() -> { + ProcessLogService.log.error("실패 기록할 로그를 찾을 수 없습니다. messageId={}", messageId); + return new IllegalStateException("Process log not found: " + messageId); + }); + log.markAsFailed(errorMessage, maxRetries); + processLogRepository.save(log); } // 재시도할 작업 목록 조회 From 6f780f3e442ac2ba15fef34c031120bfbf910057 Mon Sep 17 00:00:00 2001 From: SeungWoo Ryu Date: Tue, 25 Nov 2025 16:51:57 +0900 Subject: [PATCH 12/12] =?UTF-8?q?refact=20:=20=EB=8D=94=20=EC=9D=B4?= =?UTF-8?q?=EC=83=81=20=EC=82=AC=EC=9A=A9=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?disabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/notification/MongoTransactionTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/java/org/ezcode/codetest/infrastructure/notification/MongoTransactionTest.java b/src/test/java/org/ezcode/codetest/infrastructure/notification/MongoTransactionTest.java index b571b56e..cd8694c4 100644 --- a/src/test/java/org/ezcode/codetest/infrastructure/notification/MongoTransactionTest.java +++ b/src/test/java/org/ezcode/codetest/infrastructure/notification/MongoTransactionTest.java @@ -4,6 +4,7 @@ import org.ezcode.codetest.application.notification.event.NotificationCreateEvent; import org.ezcode.codetest.infrastructure.notification.model.NotificationDocument; import org.ezcode.codetest.infrastructure.notification.repository.NotificationMongoRepository; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -11,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; @SpringBootTest +@Disabled @ActiveProfiles("test") public class MongoTransactionTest {