diff --git a/docker-compose.yml b/docker-compose.yml index 2c265d6..bde06f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,8 @@ services: SPRING_RABBITMQ_PASSWORD: notification NOTIFICATION_CONSUMER_CONCURRENCY: ${NOTIFICATION_CONSUMER_CONCURRENCY:-1} RABBITMQ_PREFETCH: ${RABBITMQ_PREFETCH:-250} + MOCK_PROVIDER_FORCE_FAILURE: ${MOCK_PROVIDER_FORCE_FAILURE:-false} + MOCK_PROVIDER_FAIL_FIRST_ATTEMPTS: ${MOCK_PROVIDER_FAIL_FIRST_ATTEMPTS:-0} healthcheck: test: diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java index dded4ea..911cc5a 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java @@ -6,6 +6,17 @@ public record DeliveryMessage( Long notificationId, Long deliveryId, NotificationChannel channel, - String destination + String destination, + int attempt ) { + + public DeliveryMessage nextAttempt() { + return new DeliveryMessage( + notificationId, + deliveryId, + channel, + destination, + attempt + 1 + ); + } } diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessageHandler.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessageHandler.java new file mode 100644 index 0000000..9ec3814 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessageHandler.java @@ -0,0 +1,58 @@ +package com.backendsystemdesignlab.notification.messaging; + +import com.backendsystemdesignlab.notification.notification.provider.EmailProvider; +import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; +import com.backendsystemdesignlab.notification.notification.provider.PushProvider; +import com.backendsystemdesignlab.notification.notification.provider.SmsProvider; +import com.backendsystemdesignlab.notification.notification.service.NotificationTransactionService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class DeliveryMessageHandler { + + private static final int MAX_ATTEMPTS = 3; + + private final PushProvider pushProvider; + private final SmsProvider smsProvider; + private final EmailProvider emailProvider; + + private final DeliveryRetryPublisher retryPublisher; + private final NotificationTransactionService transactionService; + + public void handle(DeliveryMessage message) { + + boolean success = send(message); + + if (success) { + transactionService.recordSuccess(message.notificationId(), message.deliveryId()); + return; + } + + if (message.attempt() < MAX_ATTEMPTS) { + DeliveryMessage retryMessage = message.nextAttempt(); + retryPublisher.publishRetry(retryMessage); + transactionService.recordRetryFailure(message.deliveryId()); + return; + } + + retryPublisher.publishDlq(message); + transactionService.recordFinalFailure(message.notificationId(), message.deliveryId()); + } + + private boolean send(DeliveryMessage message) { + + try { + ProviderResult result = + switch (message.channel()) { + case PUSH -> pushProvider.send(message.destination()); + case SMS -> smsProvider.send(message.destination()); + case EMAIL -> emailProvider.send(message.destination()); + }; + return result.success(); + } catch (RuntimeException e) { + return false; + } + } +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java index 9f8fec4..5427d89 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java @@ -20,7 +20,7 @@ public void publishAll(Long notificationId, List deliveries) { } private void publish(Long notificationId, DeliveryCommand delivery) { - DeliveryMessage message = new DeliveryMessage(notificationId, delivery.deliveryId(), delivery.channel(), delivery.destination()); + DeliveryMessage message = new DeliveryMessage(notificationId, delivery.deliveryId(), delivery.channel(), delivery.destination(), 1); String routingKey = switch (delivery.channel()) { case PUSH -> RabbitMqConfig.PUSH_ROUTING_KEY; diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryRetryPublisher.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryRetryPublisher.java new file mode 100644 index 0000000..cee3db1 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryRetryPublisher.java @@ -0,0 +1,36 @@ +package com.backendsystemdesignlab.notification.messaging; + +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DeliveryRetryPublisher { + + private final RabbitTemplate rabbitTemplate; + + public void publishRetry(DeliveryMessage message) { + rabbitTemplate.convertAndSend( + RabbitMqConfig.RETRY_EXCHANGE, + routingKey(message), + message + ); + } + + public void publishDlq(DeliveryMessage message) { + rabbitTemplate.convertAndSend( + RabbitMqConfig.DLX, + routingKey(message), + message + ); + } + + private String routingKey(DeliveryMessage message) { + return switch (message.channel()) { + case PUSH -> RabbitMqConfig.PUSH_ROUTING_KEY; + case SMS -> RabbitMqConfig.SMS_ROUTING_KEY; + case EMAIL -> RabbitMqConfig.EMAIL_ROUTING_KEY; + }; + } +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java index bb6f345..ebe7e02 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java @@ -1,9 +1,6 @@ package com.backendsystemdesignlab.notification.messaging; -import org.springframework.amqp.core.Binding; -import org.springframework.amqp.core.BindingBuilder; -import org.springframework.amqp.core.DirectExchange; -import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.*; import org.springframework.amqp.support.converter.JacksonJsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.context.annotation.Bean; @@ -20,6 +17,15 @@ public class RabbitMqConfig { public static final String SMS_ROUTING_KEY = "notification.sms"; public static final String EMAIL_ROUTING_KEY = "notification.email"; + public static final String RETRY_EXCHANGE = "notification.retry.exchange"; + public static final String DLX = "notification.dlx"; + public static final String PUSH_RETRY_QUEUE = "notification.push.retry.queue"; + public static final String SMS_RETRY_QUEUE = "notification.sms.retry.queue"; + public static final String EMAIL_RETRY_QUEUE = "notification.email.retry.queue"; + public static final String PUSH_DLQ = "notification.push.dlq"; + public static final String SMS_DLQ = "notification.sms.dlq"; + public static final String EMAIL_DLQ = "notification.email.dlq"; + // Exchange: 메시지를 받아서 적절한 Queue로 전달하는 라우터 @Bean public DirectExchange notificationExchange() { @@ -27,6 +33,16 @@ public DirectExchange notificationExchange() { // durable: RabbitMQ 서버가 재시작되어도 Exchange 정의를 유지하겠다, autoDelete: Consumer 등이 없어지면 Exchange 자동 삭제 } + @Bean + public DirectExchange retryExchange() { + return new DirectExchange(RETRY_EXCHANGE, true, false); + } + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(DLX, true, false); + } + @Bean public Queue pushQueue() { return new Queue(PUSH_QUEUE, true); @@ -42,6 +58,57 @@ public Queue emailQueue() { return new Queue(EMAIL_QUEUE, true); } + @Bean + public Queue pushRetryQueue() { + return QueueBuilder + .durable(PUSH_RETRY_QUEUE) + .ttl(5000) // 5초 동안 Retry Queue에 보관 + .deadLetterExchange(EXCHANGE) // 시간이 지나면 다시 원래 Exchange + .deadLetterRoutingKey(PUSH_ROUTING_KEY) // PUSH Queue로 돌아가도록 + .build(); + } + + @Bean + public Queue smsRetryQueue() { + return QueueBuilder + .durable(SMS_RETRY_QUEUE) + .ttl(5000) + .deadLetterExchange(EXCHANGE) + .deadLetterRoutingKey(SMS_ROUTING_KEY) + .build(); + } + + @Bean + public Queue emailRetryQueue() { + return QueueBuilder + .durable(EMAIL_RETRY_QUEUE) + .ttl(5000) + .deadLetterExchange(EXCHANGE) + .deadLetterRoutingKey(EMAIL_ROUTING_KEY) + .build(); + } + + @Bean + public Queue pushDlq() { + return QueueBuilder + .durable(PUSH_DLQ) + .build(); + } + + @Bean + public Queue smsDlq() { + return QueueBuilder + .durable(SMS_DLQ) + .build(); + } + + @Bean + public Queue emailDlq() { + return QueueBuilder + .durable(EMAIL_DLQ) + .build(); + } + @Bean public Binding pushBinding(DirectExchange notificationExchange, Queue pushQueue) { // pushQueue를 notificationExchange에 연결하고, notification.push라는 Routing Key로 연결해라 @@ -67,6 +134,54 @@ public Binding emailBinding(DirectExchange notificationExchange, Queue emailQueu .with(EMAIL_ROUTING_KEY); } + @Bean + public Binding pushRetryBinding(DirectExchange retryExchange, Queue pushRetryQueue) { + return BindingBuilder + .bind(pushRetryQueue) + .to(retryExchange) + .with(PUSH_ROUTING_KEY); + } + + @Bean + public Binding smsRetryBinding(DirectExchange retryExchange, Queue smsRetryQueue) { + return BindingBuilder + .bind(smsRetryQueue) + .to(retryExchange) + .with(SMS_ROUTING_KEY); + } + + @Bean + public Binding emailRetryBinding(DirectExchange retryExchange, Queue emailRetryQueue) { + return BindingBuilder + .bind(emailRetryQueue) + .to(retryExchange) + .with(EMAIL_ROUTING_KEY); + } + + @Bean + public Binding pushDlqBinding(DirectExchange deadLetterExchange, Queue pushDlq) { + return BindingBuilder + .bind(pushDlq) + .to(deadLetterExchange) + .with(PUSH_ROUTING_KEY); + } + + @Bean + public Binding smsDlqBinding(DirectExchange deadLetterExchange, Queue smsDlq) { + return BindingBuilder + .bind(smsDlq) + .to(deadLetterExchange) + .with(SMS_ROUTING_KEY); + } + + @Bean + public Binding emailDlqBinding(DirectExchange deadLetterExchange, Queue emailDlq) { + return BindingBuilder + .bind(emailDlq) + .to(deadLetterExchange) + .with(EMAIL_ROUTING_KEY); + } + @Bean public MessageConverter messageConverter() { return new JacksonJsonMessageConverter(); diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java index e62de3f..f7c2626 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java @@ -1,10 +1,8 @@ package com.backendsystemdesignlab.notification.messaging.consumer; import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +import com.backendsystemdesignlab.notification.messaging.DeliveryMessageHandler; import com.backendsystemdesignlab.notification.messaging.RabbitMqConfig; -import com.backendsystemdesignlab.notification.notification.provider.EmailProvider; -import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; -import com.backendsystemdesignlab.notification.notification.service.NotificationTransactionService; import lombok.RequiredArgsConstructor; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; @@ -13,29 +11,13 @@ @RequiredArgsConstructor public class EmailDeliveryConsumer { - private final EmailProvider emailProvider; - private final NotificationTransactionService transactionService; + private final DeliveryMessageHandler messageHandler; @RabbitListener( queues = RabbitMqConfig.EMAIL_QUEUE, concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}" ) public void consume(DeliveryMessage message) { - - boolean success; - - try { - ProviderResult result = emailProvider.send(message.destination()); - success = result.success(); - } catch (RuntimeException e) { - success = false; - } - - transactionService.recordDeliveryResult( - message.notificationId(), - message.deliveryId(), - success - ); + messageHandler.handle(message); } - } diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java index 6f86991..dfb17be 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java @@ -1,10 +1,8 @@ package com.backendsystemdesignlab.notification.messaging.consumer; import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +import com.backendsystemdesignlab.notification.messaging.DeliveryMessageHandler; import com.backendsystemdesignlab.notification.messaging.RabbitMqConfig; -import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; -import com.backendsystemdesignlab.notification.notification.provider.PushProvider; -import com.backendsystemdesignlab.notification.notification.service.NotificationTransactionService; import lombok.RequiredArgsConstructor; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; @@ -13,29 +11,12 @@ @RequiredArgsConstructor public class PushDeliveryConsumer { - private final PushProvider pushProvider; - private final NotificationTransactionService transactionService; - + private final DeliveryMessageHandler messageHandler; @RabbitListener( queues = RabbitMqConfig.PUSH_QUEUE, concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}" ) public void consume(DeliveryMessage message) { - - boolean success; - - try { - ProviderResult result = pushProvider.send(message.destination()); - success = result.success(); - } catch (RuntimeException e) { - success = false; - } - - transactionService.recordDeliveryResult( - message.notificationId(), - message.deliveryId(), - success - ); + messageHandler.handle(message); } - } diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java index 5269a51..a14d64f 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java @@ -1,10 +1,8 @@ package com.backendsystemdesignlab.notification.messaging.consumer; import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +import com.backendsystemdesignlab.notification.messaging.DeliveryMessageHandler; import com.backendsystemdesignlab.notification.messaging.RabbitMqConfig; -import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; -import com.backendsystemdesignlab.notification.notification.provider.SmsProvider; -import com.backendsystemdesignlab.notification.notification.service.NotificationTransactionService; import lombok.RequiredArgsConstructor; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; @@ -13,29 +11,13 @@ @RequiredArgsConstructor public class SmsDeliveryConsumer { - private final SmsProvider smsProvider; - private final NotificationTransactionService transactionService; + private final DeliveryMessageHandler messageHandler; @RabbitListener( queues = RabbitMqConfig.SMS_QUEUE, concurrency = "${NOTIFICATION_CONSUMER_CONCURRENCY:1}" ) public void consume(DeliveryMessage message) { - - boolean success; - - try { - ProviderResult result = smsProvider.send(message.destination()); - success = result.success(); - } catch (RuntimeException e) { - success = false; - } - - transactionService.recordDeliveryResult( - message.notificationId(), - message.deliveryId(), - success - ); + messageHandler.handle(message); } - } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/dto/DeliveryResult.java b/src/main/java/com/backendsystemdesignlab/notification/notification/dto/DeliveryResult.java deleted file mode 100644 index c76256d..0000000 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/dto/DeliveryResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.backendsystemdesignlab.notification.notification.dto; - -public record DeliveryResult( - Long deliveryId, - boolean success -) { -} diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockEmailProvider.java b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockEmailProvider.java index 576c3b0..70b4403 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockEmailProvider.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockEmailProvider.java @@ -2,14 +2,39 @@ import com.backendsystemdesignlab.notification.notification.provider.EmailProvider; import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + @Component public class MockEmailProvider implements EmailProvider { + private final boolean forceFailure; + private final int failFirstAttempts; + + private final Map attempts = new ConcurrentHashMap<>(); + + public MockEmailProvider( + @Value("${mock.provider.force-failure:false}") boolean forceFailure, + @Value("${mock.provider.fail-first-attempts:0}") int failFirstAttempts) { + this.forceFailure = forceFailure; + this.failFirstAttempts = failFirstAttempts; + } + @Override - public ProviderResult send(String deviceToken) { + public ProviderResult send(String email) { simulateDelay(); + if (forceFailure) return new ProviderResult(false); + + int currentAttempt = attempts.computeIfAbsent(email, key -> new AtomicInteger()).incrementAndGet(); // 증가 연산을 원자적 처리 + + if (currentAttempt <= failFirstAttempts) { + return new ProviderResult(false); + } + return new ProviderResult(true); } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockPushProvider.java b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockPushProvider.java index 36bf887..1ac2cc7 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockPushProvider.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockPushProvider.java @@ -2,14 +2,22 @@ import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; import com.backendsystemdesignlab.notification.notification.provider.PushProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class MockPushProvider implements PushProvider { + private final boolean forceFailure; + + public MockPushProvider(@Value("${mock.provider.force-failure:false}") boolean forceFailure) { + this.forceFailure = forceFailure; + } + @Override public ProviderResult send(String deviceToken) { simulateDelay(); + if (forceFailure) return new ProviderResult(false); return new ProviderResult(true); } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockSmsProvider.java b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockSmsProvider.java index c07c252..ef1681f 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockSmsProvider.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/provider/mock/MockSmsProvider.java @@ -2,14 +2,22 @@ import com.backendsystemdesignlab.notification.notification.provider.ProviderResult; import com.backendsystemdesignlab.notification.notification.provider.SmsProvider; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component public class MockSmsProvider implements SmsProvider { + private final boolean forceFailure; + + public MockSmsProvider(@Value("${mock.provider.force-failure:false}") boolean forceFailure) { + this.forceFailure = forceFailure; + } + @Override public ProviderResult send(String deviceToken) { simulateDelay(); + if (forceFailure) return new ProviderResult(false); return new ProviderResult(true); } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java index ff1cb95..363224c 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java @@ -4,7 +4,6 @@ import com.backendsystemdesignlab.notification.notification.domain.Notification; import com.backendsystemdesignlab.notification.notification.domain.NotificationDelivery; import com.backendsystemdesignlab.notification.notification.dto.DeliveryCommand; -import com.backendsystemdesignlab.notification.notification.dto.DeliveryResult; import com.backendsystemdesignlab.notification.notification.dto.PreparedNotification; import com.backendsystemdesignlab.notification.notification.dto.SendNotificationRequest; import com.backendsystemdesignlab.notification.notification.repository.NotificationDeliveryRepository; @@ -22,9 +21,7 @@ import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Set; -import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -110,25 +107,55 @@ public PreparedNotification prepare(SendNotificationRequest request) { } @Transactional - public void recordDeliveryResult(Long notificationId, Long deliveryId, boolean success) { + public void recordRetryFailure(Long deliveryId) { + + NotificationDelivery delivery = deliveryRepository.findById(deliveryId) + .orElseThrow(() -> new IllegalArgumentException("전송 정보를 찾을 수 없습니다.")); + + if (delivery.getStatus() != DeliveryStatus.PENDING) { + return; + } + + delivery.recordAttempt(); + } + + @Transactional + public void recordSuccess(Long notificationId, Long deliveryId) { Notification notification = notificationRepository.findByIdForUpdate(notificationId) .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다.")); NotificationDelivery delivery = deliveryRepository.findById(deliveryId) .orElseThrow(() -> new IllegalArgumentException("전송 정보를 찾을 수 없습니다.")); - if (delivery.getStatus() == DeliveryStatus.SENT || delivery.getStatus() == DeliveryStatus.FAILED) { + if (delivery.getStatus() != DeliveryStatus.PENDING) { return; } delivery.recordAttempt(); + delivery.markSent(); - if (success) { - delivery.markSent(); - } else { - delivery.markFailed(); + updateNotificationStatus(notificationId, notification); + } + + @Transactional + public void recordFinalFailure(Long notificationId, Long deliveryId) { + Notification notification = notificationRepository.findByIdForUpdate(notificationId) + .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다.")); + + NotificationDelivery delivery = deliveryRepository.findById(deliveryId) + .orElseThrow(() -> new IllegalArgumentException("전송 정보를 찾을 수 없습니다.")); + + if (delivery.getStatus() != DeliveryStatus.PENDING) { + return; } + delivery.recordAttempt(); + delivery.markFailed(); + + updateNotificationStatus(notificationId, notification); + } + + private void updateNotificationStatus(Long notificationId, Notification notification) { deliveryRepository.flush(); long total = deliveryRepository.countByNotificationId(notificationId); @@ -147,44 +174,6 @@ public void recordDeliveryResult(Long notificationId, Long deliveryId, boolean s } } - @Transactional - public void complete(Long notificationId, List results) { - - // 기존 notification 객체를 쓰지 않는 이유는 첫 번째 Transaction이 끝났기 때문에 두 번째 Transaction에서는 새 영속성 컨텍스트에서 다시 조회 - Notification notification = notificationRepository.findById(notificationId) - .orElseThrow(() -> new IllegalArgumentException("알림을 찾을 수 없습니다.")); - - List deliveryIds = results.stream().map(DeliveryResult::deliveryId).toList(); - - Map deliveryMap = deliveryRepository.findAllById(deliveryIds) - .stream() - .collect(Collectors.toMap(NotificationDelivery::getId, Function.identity())); - - for (DeliveryResult result : results) { - NotificationDelivery delivery = deliveryMap.get(result.deliveryId()); - - if (delivery == null) { - throw new IllegalStateException("전송 정보를 찾을 수 없습니다. id=" + result.deliveryId()); - } - - delivery.recordAttempt(); - - if (result.success()) { - delivery.markSent(); - } else { - delivery.markFailed(); - } - } - - boolean allSucceeded = results.stream().allMatch(DeliveryResult::success); - - if (allSucceeded) { - notification.complete(); - } else { - notification.fail(); - } - } - @Transactional(readOnly = true) public long countDeliveries(Long notificationId) { return deliveryRepository.countByNotificationId(notificationId); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bc9d373..1e27963 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,4 +48,9 @@ management: distribution: percentiles-histogram: - http.server.requests: true \ No newline at end of file + http.server.requests: true + +mock: + provider: + force-failure: ${MOCK_PROVIDER_FORCE_FAILURE:false} + fail-first-attempts: ${MOCK_PROVIDER_FAIL_FIRST_ATTEMPTS:0} \ No newline at end of file