diff --git a/build.gradle b/build.gradle index ccf557b..8ee9733 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,8 @@ dependencies { testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' + + implementation 'org.springframework.boot:spring-boot-starter-amqp' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index d25f78e..3aff84c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,10 @@ services: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/notification SPRING_DATASOURCE_USERNAME: notification SPRING_DATASOURCE_PASSWORD: notification + SPRING_RABBITMQ_HOST: rabbitmq + SPRING_RABBITMQ_PORT: 5672 + SPRING_RABBITMQ_USERNAME: notification + SPRING_RABBITMQ_PASSWORD: notification healthcheck: test: @@ -27,6 +31,12 @@ services: retries: 5 start_period: 20s + depends_on: + mysql: + condition: service_healthy + rabbitmq: + condition: service_healthy + prometheus: image: prom/prometheus:v3.13.0 @@ -99,6 +109,21 @@ services: timeout: 3s retries: 10 + rabbitmq: + image: rabbitmq:4-management + container_name: notification-rabbitmq + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: notification + RABBITMQ_DEFAULT_PASS: notification + healthcheck: + test: [ "CMD", "rabbitmq-diagnostics", "-q", "ping" ] + interval: 10s + timeout: 5s + retries: 5 + volumes: prometheus-data: grafana-data: diff --git a/docs/04-experiment.md b/docs/04-experiment.md index 39c964e..13ca2ad 100644 --- a/docs/04-experiment.md +++ b/docs/04-experiment.md @@ -178,6 +178,58 @@ Transaction 2 Provider 호출 방식과 지연 시간은 변경하지 않고, DB Transaction의 범위만 변경하여 Connection 점유가 성능에 미치는 영향을 비교했다. +### Experiment 2. RabbitMQ 비동기 처리 + +Transaction Boundary 분리 후 DB Connection Pool 병목은 완화되었지만, +Notification API는 여전히 Mock Provider 호출이 완료될 때까지 응답을 반환하지 않았다. + +한 요청은 Push 2건, SMS 1건, Email 1건으로 총 4번의 Provider 호출을 수행하며, +각 Provider에 100ms의 지연이 존재하므로 최소 약 400ms의 응답 시간이 발생했다. + +Provider 호출을 HTTP 요청 처리 경로에서 제거하기 위해 RabbitMQ를 도입했다. + +```text +기존 + +HTTP Request + ↓ +DB 저장 + ↓ +Provider 호출 + ↓ +DB 상태 변경 + ↓ +HTTP Response +``` +```text +RabbitMQ 적용 후 + +HTTP Request + ↓ +DB 저장 + ↓ +RabbitMQ Publish + ↓ +202 Accepted + +--------------------- + +RabbitMQ Queue + ↓ +Consumer + ↓ +Mock Provider + ↓ +Delivery 상태 변경 +``` + +Push, SMS, Email은 서로 다른 외부 Provider의 장애와 처리량에 독립적으로 대응할 수 있도록 채널별 Queue로 분리하였다. +* `notification.push.queue` +* `notification.sms.queue` +* `notification.email.queue` + +API는 실제 알림 전송 완료를 기다리지 않고 요청 접수 후 `202 Accepted`를 반환한다. + ## 11. 개선 후 결과 ### Experiment 1. Transaction Boundary 분리 결과 @@ -203,6 +255,47 @@ Connection Pool의 지속적인 포화가 사라졌다. +### Experiment 2. RabbitMQ 비동기 처리 결과 + +| VU | 지표 | Transaction Boundary | RabbitMQ Async | 변화 | +|---:|---|---:|---:|---:| +| 30 | RPS | 65.88 | 496.78 | 7.54배 | +| 30 | Avg | 454.66ms | 60.05ms | 86.8% 감소 | +| 30 | p95 | 560.98ms | 168.54ms | 70.0% 감소 | +| 30 | p99 | 1.47s | 316.49ms | 78.5% 감소 | +| 30 | Error Rate | 0% | 0% | 동일 | +| 50 | RPS | 117.74 | 511.23 | 4.34배 | +| 50 | Avg | 421.79ms | 97.41ms | 76.9% 감소 | +| 50 | p95 | 456.86ms | 266.21ms | 41.7% 감소 | +| 50 | p99 | 515.22ms | 514.63ms | 유사 | +| 50 | Error Rate | 0% | 0% | 동일 | + +RabbitMQ 비동기화 후 Provider 호출이 HTTP 요청 경로에서 제거되면서 +API 처리량이 크게 증가하고 평균 및 p95 응답 시간이 감소했다. + +50 VU에서는 최초 Baseline의 23.55 RPS에서 511.23 RPS로 +약 21.7배의 API 처리량 증가를 확인했다. + +그러나 높은 Producer 처리량으로 인해 Consumer가 메시지 유입 속도를 따라가지 못하면서 +Queue에 대량의 메시지가 적체되었다. + +#### Queue 상태 +| VU | PUSH Ready | SMS Ready | EMAIL Ready | Total Ready | Unacked | +|---:|---:|---:|---:|---:|---:| +| 30 | 28,494 | 13,572 | 13,577 | 55,643 | 750 | +| 50 | 29,416 | 14,048 | 14,050 | 57,514 | 750 | + +따라서 RabbitMQ 적용으로 API의 응답 성능은 개선되었지만, +실제 알림 전달 처리량의 병목은 Consumer 영역으로 이동했다. + +

30 VU

+ + + +

50 VU

+ + + ## 12. 결과 분석 ### 12.1 DB Connection Pool 병목 확인 @@ -242,12 +335,12 @@ Provider 호출 중에는 DB Connection을 점유하지 않으므로, 50 VU / 약 0.422초 ≈ 118 RPS ``` -실제 처리량인 117.74 RPS와 유사핟. +실제 처리량인 117.74 RPS와 유사하다. -이를 통해 Transcation Boundary 분리 이후에는 DB Connection Pool보다 +이를 통해 Transaction Boundary 분리 이후에는 DB Connection Pool보다 동기식 Provider 호출 시간이 처리량과 응답 시간에 더 직접적인 영향을 주는 것으로 판단했다. -### 12.3 남아 있는 병목 +### 12.3 Transaction Boundary 분리 후 남아 있던 병목 DB Connection Pool 병목은 완화되었지만, Notification API는 여전히 모든 Provider 호출이 끝날 때까지 HTTP 응답을 반환하지 않는다. @@ -259,6 +352,54 @@ Notification API는 여전히 모든 Provider 호출이 끝날 때까지 HTTP Provider 호출을 HTTP 요청 처리 경로에서 분리하고, API가 알림 요청을 Queue에 등록한 뒤 즉시 응답하도록 비동기 구조로 변경한다. +### 12.4 Rabbit 비동기화 효과 + +RabbitMQ 적용 후 HTTP 요청은 Mock Provider의 실행 완료를 기다리지 않고, +Notification 및 Delivery를 저장한 뒤 메시지를 Queue에 발행하고 즉시 응답한다. + +30 VU에서는 RPS는 65.88에서 496.78로 약 7.54배 증가했고, +p95는 560.98ms에서 168.54ms로 약 70% 감소했다. + +50 VU에서도 RPS는 117.74에서 511.23으로 4.34배 증가했으며, +p95는 456.86ms에서 266.21ms로 감소했다. + +최초 Baseline과 비교하면 50 VU 기준 RPS는 +23.55에서 511.23으로 약 21.7배 증가했다. + +이는 Provider의 블로킹 I/O를 HTTP 요청 경로에서 분리함으로써 +API가 알림의 실제 전송 시간과 독립적으로 요청을 처리할 수 있게 된 결과다. + +### 12.5 새로운 병목: Consumer 처리량 + +비동기화 이후 API 처리량은 크게 증가했지만, +실제 알림 처리 속도가 함께 증가한 것은 아니다. + +30 VU 테스트에서 14,921개의 Notification 요청이 발생했다. +테스트 사용자는 요청당 Push 2건, SMS 1건, Email 1건을 생성하므로 +약 59,684개의 Delivery 메시지가 RabbitMQ에 발행된다. + +테스트 종료 후 약 55,643개의 메시지가 Ready 상태로 남아 있었으며, +각 Queue에서는 250개의 메시지가 Unacked 상태로 Consumer에 전달되어 있었다. + +50 VU에서도 약 57,514개의 Ready 메시지가 남았다. + +따라서 Producer인 Notification API의 처리량이 Consumer의 처리량보다 높아지면서 +Queue Backlog가 지속적으로 증가하는 새로운 병목이 발생했다. + +이는 비동기 시스템에서 API RPS만으로 전체 시스템 처리량을 평가할 수 업으며, +다음 지표를 함께 측정해야 힘을 보여준다. + +- API 요청 처리량 +- Queue Backlog +- 메시지 유입률 +- Consumer 처리량 +- End-to-End 알림 전달 지연 + +다음 단계에서는 Consumer concurrency와 RabbitMQ prefetch 설정을 분석하고, +Consumer 확장을 통해 Queue 적체를 완화할 수 있는지 실험한다. + + + ## 13. Platform Thread와 Virtual Thread 비교 실제 블로킹 I/O가 존재하는 경우 반드시 수행한다. diff --git a/docs/images/rabbitmq-async-30vu.png b/docs/images/rabbitmq-async-30vu.png new file mode 100644 index 0000000..cc99ecf Binary files /dev/null and b/docs/images/rabbitmq-async-30vu.png differ diff --git a/docs/images/rabbitmq-async-50vu.png b/docs/images/rabbitmq-async-50vu.png new file mode 100644 index 0000000..74a34c0 Binary files /dev/null and b/docs/images/rabbitmq-async-50vu.png differ diff --git a/docs/images/rabbitmq-queue-30vu.png b/docs/images/rabbitmq-queue-30vu.png new file mode 100644 index 0000000..c672356 Binary files /dev/null and b/docs/images/rabbitmq-queue-30vu.png differ diff --git a/docs/images/rabbitmq-queue-50vu.png b/docs/images/rabbitmq-queue-50vu.png new file mode 100644 index 0000000..f12d9ac Binary files /dev/null and b/docs/images/rabbitmq-queue-50vu.png differ diff --git a/k6/notification-async.js b/k6/notification-async.js new file mode 100644 index 0000000..82a20ee --- /dev/null +++ b/k6/notification-async.js @@ -0,0 +1,47 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + vus: 50, + duration: '30s', + + summaryTrendStats: [ + 'avg', + 'min', + 'med', + 'max', + 'p(90)', + 'p(95)', + 'p(99)', + ], + + thresholds: { + http_req_failed: ['rate<0.01'], + }, +}; + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + +export default function () { + const eventId = `baseline-${__VU}-${__ITER}-${Date.now()}`; + + const payload = JSON.stringify({ + eventId: eventId, + userId: 1, + channels: ['PUSH', 'SMS', 'EMAIL'], + }); + + const response = http.post( + `${BASE_URL}/api/v1/notifications`, + payload, + { + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + check(response, { + 'status is 202': (r) => r.status === 202, + }); +} \ No newline at end of file diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java new file mode 100644 index 0000000..dded4ea --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryMessage.java @@ -0,0 +1,11 @@ +package com.backendsystemdesignlab.notification.messaging; + +import com.backendsystemdesignlab.notification.user.domain.NotificationChannel; + +public record DeliveryMessage( + Long notificationId, + Long deliveryId, + NotificationChannel channel, + String destination +) { +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java new file mode 100644 index 0000000..9f8fec4 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/DeliveryPublisher.java @@ -0,0 +1,33 @@ +package com.backendsystemdesignlab.notification.messaging; + +import com.backendsystemdesignlab.notification.notification.dto.DeliveryCommand; +import lombok.RequiredArgsConstructor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class DeliveryPublisher { + + private final RabbitTemplate rabbitTemplate; + + public void publishAll(Long notificationId, List deliveries) { + for (DeliveryCommand delivery : deliveries) { + publish(notificationId, delivery); + } + } + + private void publish(Long notificationId, DeliveryCommand delivery) { + DeliveryMessage message = new DeliveryMessage(notificationId, delivery.deliveryId(), delivery.channel(), delivery.destination()); + + String routingKey = switch (delivery.channel()) { + case PUSH -> RabbitMqConfig.PUSH_ROUTING_KEY; + case SMS -> RabbitMqConfig.SMS_ROUTING_KEY; + case EMAIL -> RabbitMqConfig.EMAIL_ROUTING_KEY; + }; + + rabbitTemplate.convertAndSend(RabbitMqConfig.EXCHANGE, routingKey, message); + } +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java new file mode 100644 index 0000000..bb6f345 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/RabbitMqConfig.java @@ -0,0 +1,74 @@ +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.support.converter.JacksonJsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMqConfig { + + public static final String EXCHANGE = "notification.exchange"; + public static final String PUSH_QUEUE = "notification.push.queue"; + public static final String SMS_QUEUE = "notification.sms.queue"; + public static final String EMAIL_QUEUE = "notification.email.queue"; + public static final String PUSH_ROUTING_KEY = "notification.push"; + public static final String SMS_ROUTING_KEY = "notification.sms"; + public static final String EMAIL_ROUTING_KEY = "notification.email"; + + // Exchange: 메시지를 받아서 적절한 Queue로 전달하는 라우터 + @Bean + public DirectExchange notificationExchange() { + return new DirectExchange(EXCHANGE, true, false); // Routing Key가 정확하게 일치하는 Queue로 보내기 + // durable: RabbitMQ 서버가 재시작되어도 Exchange 정의를 유지하겠다, autoDelete: Consumer 등이 없어지면 Exchange 자동 삭제 + } + + @Bean + public Queue pushQueue() { + return new Queue(PUSH_QUEUE, true); + } + + @Bean + public Queue smsQueue() { + return new Queue(SMS_QUEUE, true); + } + + @Bean + public Queue emailQueue() { + return new Queue(EMAIL_QUEUE, true); + } + + @Bean + public Binding pushBinding(DirectExchange notificationExchange, Queue pushQueue) { + // pushQueue를 notificationExchange에 연결하고, notification.push라는 Routing Key로 연결해라 + return BindingBuilder + .bind(pushQueue) + .to(notificationExchange) + .with(PUSH_ROUTING_KEY); + } + + @Bean + public Binding smsBinding(DirectExchange notificationExchange, Queue smsQueue) { + return BindingBuilder + .bind(smsQueue) + .to(notificationExchange) + .with(SMS_ROUTING_KEY); + } + + @Bean + public Binding emailBinding(DirectExchange notificationExchange, Queue emailQueue) { + return BindingBuilder + .bind(emailQueue) + .to(notificationExchange) + .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 new file mode 100644 index 0000000..69c24d1 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/EmailDeliveryConsumer.java @@ -0,0 +1,38 @@ +package com.backendsystemdesignlab.notification.messaging.consumer; + +import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +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; + +@Component +@RequiredArgsConstructor +public class EmailDeliveryConsumer { + + private final EmailProvider emailProvider; + private final NotificationTransactionService transactionService; + + @RabbitListener(queues = RabbitMqConfig.EMAIL_QUEUE) + 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 + ); + } + +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java new file mode 100644 index 0000000..2d67c6e --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/PushDeliveryConsumer.java @@ -0,0 +1,38 @@ +package com.backendsystemdesignlab.notification.messaging.consumer; + +import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +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; + +@Component +@RequiredArgsConstructor +public class PushDeliveryConsumer { + + private final PushProvider pushProvider; + private final NotificationTransactionService transactionService; + + @RabbitListener(queues = RabbitMqConfig.PUSH_QUEUE) + 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 + ); + } + +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java new file mode 100644 index 0000000..943dcd0 --- /dev/null +++ b/src/main/java/com/backendsystemdesignlab/notification/messaging/consumer/SmsDeliveryConsumer.java @@ -0,0 +1,38 @@ +package com.backendsystemdesignlab.notification.messaging.consumer; + +import com.backendsystemdesignlab.notification.messaging.DeliveryMessage; +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; + +@Component +@RequiredArgsConstructor +public class SmsDeliveryConsumer { + + private final SmsProvider smsProvider; + private final NotificationTransactionService transactionService; + + @RabbitListener(queues = RabbitMqConfig.SMS_QUEUE) + 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 + ); + } + +} diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/controller/NotificationController.java b/src/main/java/com/backendsystemdesignlab/notification/notification/controller/NotificationController.java index 5f14af9..64b6f13 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/controller/NotificationController.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/controller/NotificationController.java @@ -21,7 +21,7 @@ public class NotificationController { @PostMapping public ResponseEntity send(@Valid @RequestBody SendNotificationRequest request) { SendNotificationResponse response = notificationService.send(request); - return ResponseEntity.ok(response); + return ResponseEntity.accepted().body(response); } } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationDeliveryRepository.java b/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationDeliveryRepository.java index 2a7c821..3a092db 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationDeliveryRepository.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationDeliveryRepository.java @@ -1,8 +1,11 @@ package com.backendsystemdesignlab.notification.notification.repository; +import com.backendsystemdesignlab.notification.notification.domain.DeliveryStatus; import com.backendsystemdesignlab.notification.notification.domain.NotificationDelivery; import org.springframework.data.jpa.repository.JpaRepository; public interface NotificationDeliveryRepository extends JpaRepository { long countByNotificationId(Long notificationId); + + long countByNotificationIdAndStatus(Long notificationId, DeliveryStatus status); } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationRepository.java b/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationRepository.java index 803438c..be07ca9 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationRepository.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/repository/NotificationRepository.java @@ -1,10 +1,22 @@ package com.backendsystemdesignlab.notification.notification.repository; import com.backendsystemdesignlab.notification.notification.domain.Notification; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.Optional; public interface NotificationRepository extends JpaRepository { Optional findByEventId(String eventId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select n + from Notification n + where n.id = :id + """) + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java index 52ed969..eaeea0f 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationService.java @@ -1,28 +1,17 @@ package com.backendsystemdesignlab.notification.notification.service; -import com.backendsystemdesignlab.notification.notification.domain.NotificationStatus; +import com.backendsystemdesignlab.notification.messaging.DeliveryPublisher; import com.backendsystemdesignlab.notification.notification.dto.*; -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 lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import java.util.ArrayList; -import java.util.List; - @Service @RequiredArgsConstructor public class NotificationService { private final NotificationTransactionService transactionService; - - private final PushProvider pushProvider; - private final SmsProvider smsProvider; - private final EmailProvider emailProvider; - + private final DeliveryPublisher deliveryPublisher; public SendNotificationResponse send(SendNotificationRequest request) { @@ -39,47 +28,13 @@ public SendNotificationResponse send(SendNotificationRequest request) { ); } - // Provider 호출 (DB 트랜잭션을 사용하지 않음) - List results = sendDeliveries(prepared.deliveries()); - - // DB 작업 - transactionService.complete(prepared.notificationId(), results); - - boolean allSucceeded = results.stream().allMatch(DeliveryResult::success); + deliveryPublisher.publishAll(prepared.notificationId(), prepared.deliveries()); return new SendNotificationResponse( prepared.notificationId(), - allSucceeded - ? NotificationStatus.COMPLETED - : NotificationStatus.FAILED, + prepared.status(), // PROCESSING 비동기 이기 때문에 아직 Provider 전송이 안끝남 prepared.deliveryCount() ); } - - private List sendDeliveries(List deliveries) { - - List results = new ArrayList<>(); - - for (DeliveryCommand delivery : deliveries) { - boolean success; - - try { - ProviderResult result = - switch (delivery.channel()) { - case PUSH -> pushProvider.send(delivery.destination()); - case SMS -> smsProvider.send(delivery.destination()); - case EMAIL -> emailProvider.send(delivery.destination()); - }; - success = result.success(); - } catch (RuntimeException e) { - success = false; - } - - results.add(new DeliveryResult(delivery.deliveryId(), success)); - } - - return results; - } - } 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 73cb3ce..ff1cb95 100644 --- a/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java +++ b/src/main/java/com/backendsystemdesignlab/notification/notification/service/NotificationTransactionService.java @@ -1,5 +1,6 @@ package com.backendsystemdesignlab.notification.notification.service; +import com.backendsystemdesignlab.notification.notification.domain.DeliveryStatus; import com.backendsystemdesignlab.notification.notification.domain.Notification; import com.backendsystemdesignlab.notification.notification.domain.NotificationDelivery; import com.backendsystemdesignlab.notification.notification.dto.DeliveryCommand; @@ -108,6 +109,44 @@ public PreparedNotification prepare(SendNotificationRequest request) { ); } + @Transactional + public void recordDeliveryResult(Long notificationId, Long deliveryId, boolean success) { + 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) { + return; + } + + delivery.recordAttempt(); + + if (success) { + delivery.markSent(); + } else { + delivery.markFailed(); + } + + deliveryRepository.flush(); + + long total = deliveryRepository.countByNotificationId(notificationId); + long sent = deliveryRepository.countByNotificationIdAndStatus(notificationId, DeliveryStatus.SENT); + long failed = deliveryRepository.countByNotificationIdAndStatus(notificationId, DeliveryStatus.FAILED); + + // 아직 처리 중인 Delivery 존재 + if (sent + failed < total) { + return; + } + + if (failed > 0) { + notification.fail(); + } else { + notification.complete(); + } + } + @Transactional public void complete(Long notificationId, List results) { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d96c8a8..5dedcec 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -19,6 +19,12 @@ spring: ddl-auto: update open-in-view: false + rabbitmq: + host: ${SPRING_RABBITMQ_HOST:localhost} + port: ${SPRING_RABBITMQ_PORT:5672} + username: ${SPRING_RABBITMQ_USERNAME:notification} + password: ${SPRING_RABBITMQ_PASSWORD:notification} + management: endpoints: web: