Repository: HI-dle/table-eat-now
Pull request #133: [notification] 예약 알림 스케줄링 구성 및 테스트
Author: login: hanjihoon03
Labels: name: ✨ feat
description: 기능 구현
name: ✅ test
description: 테스트
Created: 2025-04-16T05:42:18Z | Merged: 2025-04-16T13:09:01Z

## #️⃣연관된 이슈
- resolve: #122 

## 변경 타입
- [x] 신규 기능 추가/수정

## 변경 내용


- **to-be**(변경 후 설명을 여기에 작성)

  - [x] [notification] 예약 알림 스케줄링 구성 및 테스트


## 체크리스트
- [x] 코드가 제대로 동작하는지 확인했습니다.
- [x] 관련 테스트를 추가했습니다.


## 코멘트
mvp를 위해서 단순 스케줄링을 구현 했습니다. 카프카를 사용하게 된다면 카프카와 레디스로 딜레이큐를 사용해 구성할 계획이고 알림 실패에 따른 재처리는 카프카 사용했을 때 더 구성하곘습니다...! 멀티 인스턴스 환경에서 한 곳만 스케줄러가 실행되는 것이나 스케줄러가 여러 인스턴스에서 동시에 실행되는 문제는 레디스 딜레이큐와 카프카를 사용할 때 해결 하겠습니다! 스케줄러 여러 인스턴스에서 실행 되는 건 분산락 예상 중입니다!


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **신규 기능**
  - 예약된 알림을 10분마다 자동으로 전송하는 스케줄러 기능이 추가되었습니다.
  - Redis 데이터 소스 구성이 추가되었습니다.
  - WebClient를 빈으로 제공하는 설정이 도입되었습니다.

- **버그 수정**
  - 알림 전송 상태 업데이트가 더 명확하게 처리되도록 개선되었습니다.

- **테스트**
  - 예약 알림 스케줄러 동작을 검증하는 테스트가 추가되었습니다.

- **기타**
  - 일부 패키지 구조 및 import 정리가 이루어졌습니다.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/notification/src/main/java/table/eat/now/notification/NotificationApplication.java b/notification/src/main/java/table/eat/now/notification/NotificationApplication.java
index 1cc498fc..b1588b80 100644
--- a/notification/src/main/java/table/eat/now/notification/NotificationApplication.java
+++ b/notification/src/main/java/table/eat/now/notification/NotificationApplication.java
@@ -2,8 +2,10 @@
 
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
 @SpringBootApplication
+@EnableScheduling
 public class NotificationApplication {
 
 	public static void main(String[] args) {
diff --git a/notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java b/notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
new file mode 100644
index 00000000..3e6e0794
--- /dev/null
+++ b/notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
@@ -0,0 +1,64 @@
+package table.eat.now.notification.application.schedule;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class NotificationScheduler {
+
+  private final NotificationRepository notificationRepository;
+  private final NotificationFormatterStrategySelector formatterSelector;
+  private final NotificationSenderStrategySelector sendSelector;
+  private final NotificationParamExtractor paramExtractor;
+
+  @Scheduled(cron = "0 */10 * * * *")
+  @Transactional
+  public void sendScheduledNotifications() {
+    LocalDateTime now = LocalDateTime.now();
+
+    List<Notification> notifications = notificationRepository.
+        findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        NotificationStatus.PENDING, now);
+
+    for (Notification notification : notifications) {
+      try {
+        NotificationFormatterStrategy strategy = formatterSelector.select(
+            notification.getNotificationType());
+        Map<String, String> params = paramExtractor.extract(notification);
+        NotificationTemplate formattedMessage = strategy.format(params);
+
+        NotificationSenderStrategy senderStrategy = sendSelector.select(
+            notification.getNotificationMethod());
+
+        senderStrategy.send(notification.getUserId(), formattedMessage);
+
+        notification.modifyNotificationStatusIsSent();
+
+      } catch (Exception e) {
+        log.error("스케줄링 알림 전송 실패: {}", notification.getId(), e);
+        notification.modifyNotificationStatusIsFailed();
+      }
+    }
+  }
+}
diff --git a/notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java b/notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java
index d2b04f47..d6b8071b 100644
--- a/notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java
+++ b/notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java
@@ -106,7 +106,7 @@ public void sendNotification(String notificationUuid) {
 
     senderStrategy.send(notification.getUserId(), formattedMessage);
 
-    notification.modifyNotificationStatus();
+    notification.modifyNotificationStatusIsSent();
   }
 
 
diff --git a/notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java b/notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
index 5db5d7de..9c945c00 100644
--- a/notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
+++ b/notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
@@ -9,6 +9,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import jakarta.persistence.Transient;
 import java.time.LocalDateTime;
 import java.util.UUID;
 import lombok.AccessLevel;
@@ -55,6 +56,10 @@ public class Notification extends BaseEntity{
   @Column(name = "scheduled_time")
   private LocalDateTime scheduledTime;
 
+  //// DB에 저장되지 않고 알림 재시도를 위한 필드
+  @Transient
+  private int retryCount = 0;
+
   private Notification(Long userId, NotificationType notificationType,
       String customerName, LocalDateTime reservationTime, String restaurantName,
       NotificationStatus status, NotificationMethod notificationMethod,
@@ -89,7 +94,10 @@ public void modifyNotification(Long userId, String notificationType,
     this.scheduledTime = scheduledTime;
   }
 
-  public void modifyNotificationStatus() {
+  public void modifyNotificationStatusIsSent() {
     this.status = NotificationStatus.SENT;
   }
+  public void modifyNotificationStatusIsFailed() {
+    this.status = NotificationStatus.FAILED;
+  }
 }
diff --git a/notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java b/notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java
index 44003bff..12d8f88e 100644
--- a/notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java
+++ b/notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java
@@ -1,8 +1,11 @@
 package table.eat.now.notification.domain.repository;
 
 
+import java.time.LocalDateTime;
+import java.util.List;
 import java.util.Optional;
 import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
 import table.eat.now.notification.domain.repository.search.NotificationSearchCriteria;
 import table.eat.now.notification.domain.repository.search.NotificationSearchCriteriaQuery;
 import table.eat.now.notification.domain.repository.search.PaginatedResult;
@@ -19,4 +22,7 @@ public interface NotificationRepository {
 
   PaginatedResult<NotificationSearchCriteriaQuery> searchNotification(NotificationSearchCriteria searchCriteria);
 
+  List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+      NotificationStatus status, LocalDateTime localDateTime);
+
 }
diff --git a/notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java b/notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java
index e833004d..c19301f2 100644
--- a/notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java
+++ b/notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java
@@ -1,8 +1,11 @@
 package table.eat.now.notification.infrastructure.persistence;
 
+import java.time.LocalDateTime;
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
 import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
 import table.eat.now.notification.domain.repository.NotificationRepository;
 
 /**
@@ -12,5 +15,8 @@
 public interface JpaNotificationRepository extends
     JpaRepository<Notification, Long>, NotificationRepository, JpaNotificationRepositoryCustom {
   Optional<Notification> findByNotificationUuidAndDeletedByIsNull(String notificationUuid);
+
+  List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+      NotificationStatus status, LocalDateTime localDateTime);
 }
 
diff --git a/notification/src/main/java/table/eat/now/notification/application/strategy/send/EmailSender.java b/notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java
similarity index 87%
rename from notification/src/main/java/table/eat/now/notification/application/strategy/send/EmailSender.java
rename to notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java
index b17fb70a..605a042e 100644
--- a/notification/src/main/java/table/eat/now/notification/application/strategy/send/EmailSender.java
+++ b/notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java
@@ -1,10 +1,11 @@
-package table.eat.now.notification.application.strategy.send;
+package table.eat.now.notification.infrastructure.sender;
 
 import lombok.RequiredArgsConstructor;
 import org.springframework.mail.SimpleMailMessage;
 import org.springframework.mail.javamail.JavaMailSender;
 import org.springframework.stereotype.Component;
 import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
 import table.eat.now.notification.domain.entity.NotificationMethod;
 
 /**
diff --git a/notification/src/main/java/table/eat/now/notification/application/strategy/send/SlackSender.java b/notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java
similarity index 90%
rename from notification/src/main/java/table/eat/now/notification/application/strategy/send/SlackSender.java
rename to notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java
index 58743247..bc211d95 100644
--- a/notification/src/main/java/table/eat/now/notification/application/strategy/send/SlackSender.java
+++ b/notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java
@@ -1,4 +1,4 @@
-package table.eat.now.notification.application.strategy.send;
+package table.eat.now.notification.infrastructure.sender;
 
 import java.util.Map;
 import lombok.RequiredArgsConstructor;
@@ -8,6 +8,7 @@
 import org.springframework.web.reactive.function.client.WebClient;
 import reactor.core.publisher.Mono;
 import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
 import table.eat.now.notification.domain.entity.NotificationMethod;
 
 /**
diff --git a/notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java b/notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java
new file mode 100644
index 00000000..b0719c98
--- /dev/null
+++ b/notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java
@@ -0,0 +1,18 @@
+package table.eat.now.notification.infrastructure.webclient;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Configuration
+public class WebClientConfig {
+
+  @Bean
+  public WebClient webClient() {
+    return WebClient.create();
+  }
+}
diff --git a/notification/src/main/resources/application.yml b/notification/src/main/resources/application.yml
index ac4e1688..f71c73af 100644
--- a/notification/src/main/resources/application.yml
+++ b/notification/src/main/resources/application.yml
@@ -20,6 +20,11 @@ spring:
         jdbc:
           batch_size: 30
     show-sql: true
+  data:
+    redis:
+      host: localhost
+      port: ${DEV_REDIS_PORT}
+      username: ${DEV_REDIS_USERNAME}
   mail:
     host: smtp.gmail.com
     port: 587
diff --git a/notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java b/notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
new file mode 100644
index 00000000..12d300c4
--- /dev/null
+++ b/notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
@@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@ExtendWith(MockitoExtension.class)
+class NotificationSchedulerTest {
+
+  @Mock
+  private NotificationRepository notificationRepository;
+
+  @Mock
+  private NotificationFormatterStrategySelector formatterSelector;
+
+  @Mock
+  private NotificationSenderStrategySelector sendSelector;
+
+  @Mock
+  private NotificationParamExtractor paramExtractor;
+
+  @Mock
+  private NotificationFormatterStrategy formatterStrategy;
+
+  @Mock
+  private NotificationSenderStrategy senderStrategy;
+
+  private NotificationScheduler scheduler;
+
+  @BeforeEach
+  void setUp() {
+    scheduler = new NotificationScheduler(
+        notificationRepository,
+        formatterSelector,
+        sendSelector,
+        paramExtractor
+    );
+  }
+
+  @DisplayName("스케줄러가 정상적으로 알림을 전송한다")
+  @Test
+  void scheduler_success_notification_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+    verify(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+  }
+
+  @DisplayName("스케줄러가 알림 전송에 실패하면 상태는 FAILED가 된다")
+  @Test
+  void scheduler_failed_notification_no_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+
+    doThrow(new RuntimeException("전송 실패")).when(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED);
+  }
+
+}
\ No newline at end of file
diff --git a/notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java b/notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java
index 3720474d..12eb677b 100644
--- a/notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java
+++ b/notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java
@@ -55,10 +55,8 @@
 import table.eat.now.notification.application.strategy.message.formatter.NoShowFormatter;
 import table.eat.now.notification.application.strategy.message.formatter.Reminder1HrFormatter;
 import table.eat.now.notification.application.strategy.message.formatter.Reminder9AmFormatter;
-import table.eat.now.notification.application.strategy.send.EmailSender;
 import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
 import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
-import table.eat.now.notification.application.strategy.send.SlackSender;
 import table.eat.now.notification.domain.entity.Notification;
 import table.eat.now.notification.domain.entity.NotificationMethod;
 import table.eat.now.notification.domain.entity.NotificationStatus;
@@ -68,6 +66,8 @@
 import table.eat.now.notification.domain.repository.search.NotificationSearchCriteria;
 import table.eat.now.notification.domain.repository.search.NotificationSearchCriteriaQuery;
 import table.eat.now.notification.domain.repository.search.PaginatedResult;
+import table.eat.now.notification.infrastructure.sender.EmailSender;
+import table.eat.now.notification.infrastructure.sender.SlackSender;
 
 /**
  * @author : hanjihoon
diff --git a/ten-private b/ten-private
index de92b83a..739e5eb8 160000
--- a/ten-private
+++ b/ten-private
@@ -1 +1 @@
-Subproject commit de92b83ae48514cc0a8036210eebb506539d72f6
+Subproject commit 739e5eb8edcfac2660e058ddba10b647fe0156a5


Reviews:
user: login: je-pa
body: 고생하셨습니다!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T06:43:25Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 11**

<details>
<summary>🔭 Outside diff range comments (1)</summary><blockquote>

<details>
<summary>notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java (1)</summary><blockquote>

`95-110`: _🛠️ Refactor suggestion_

**알림 전송 실패 처리 로직 부족**

현재 `sendNotification` 메서드는 알림 전송 후 성공 상태로만 처리하고 있으며, 예외 처리가 없습니다. 전송 실패 시 상태를 `FAILED`로 변경하는 로직이나 재시도 메커니즘이 필요합니다.

다음과 같이 예외 처리를 추가하는 것을 고려해보세요:

```diff
  @Transactional
  public void sendNotification(String notificationUuid) {
    Notification notification = findByNotification(notificationUuid);

    NotificationFormatterStrategy strategy = formatterSelector.select(notification.getNotificationType());
    Map<String, String> params = paramExtractor.extract(notification);
    NotificationTemplate formattedMessage = strategy.format(params);

    NotificationSenderStrategy senderStrategy = sendSelector.select(
        notification.getNotificationMethod());

+   try {
      senderStrategy.send(notification.getUserId(), formattedMessage);
      notification.modifyNotificationStatusIsSent();
+   } catch (Exception e) {
+     notification.modifyNotificationStatusIsFailed();
+     // 로깅 또는 추가적인 예외 처리
+   }
  }
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: ASSERTIVE**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 8eda6dd3fe0eedf81553879185f0ea529e5f9992 and 23edc9f2874f9923b786f835420374e79b1f6a0e.

</details>

<details>
<summary>📒 Files selected for processing (13)</summary>

* `notification/src/main/java/table/eat/now/notification/NotificationApplication.java` (1 hunks)
* `notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java` (1 hunks)
* `notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java` (1 hunks)
* `notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java` (3 hunks)
* `notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java` (2 hunks)
* `notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java` (2 hunks)
* `notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java` (1 hunks)
* `notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java` (2 hunks)
* `notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java` (1 hunks)
* `notification/src/main/resources/application.yml` (1 hunks)
* `notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java` (1 hunks)
* `notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java` (1 hunks)
* `ten-private` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java (1)</summary><blockquote>

<details>
<summary>notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java (1)</summary>

* `Component` (18-53)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (16)</summary><blockquote>

<details>
<summary>ten-private (1)</summary>

`1-1`: **서브모듈 커밋 해시 변경 확인됨**

해당 변경은 `ten-private` 서브모듈의 커밋 해시만 업데이트하는 것으로, 별도의 코드 변경 사항은 없습니다. 연관된 서브모듈의 변경 내역도 함께 확인해 주세요.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/infrastructure/sender/EmailSender.java (2)</summary>

`1-8`: **패키지 구조 개선이 잘 이루어졌습니다.**

도메인 계층 분리 관점에서 이메일 발송 기능을 애플리케이션 계층에서 인프라스트럭처 계층으로 이동시킨 것은 좋은 결정입니다. 이는 헥사고날 아키텍처 원칙에 부합하며 의존성 방향을 명확하게 합니다.

---

`30-30`: **이메일 수신자 설정 구현 필요**

현재는 이메일 수신자가 "이메일"이라는 하드코딩된 값으로 설정되어 있습니다. 향후 실제 사용자 이메일을 가져오는 구현이 필요합니다.

</details>
<details>
<summary>notification/src/test/java/table/eat/now/notification/application/service/NotificationServiceImplTest.java (1)</summary>

`69-70`: **패키지 경로 업데이트가 적절히 반영되었습니다.**

`EmailSender`와 `SlackSender` 클래스의 패키지 위치 변경이 테스트 코드에 올바르게 반영되었습니다. 이는 애플리케이션 계층과 인프라스트럭처 계층의 명확한 분리를 지원합니다.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/application/service/NotificationServiceImpl.java (1)</summary>

`109-109`: **더 명확한 메서드 이름으로 개선되었습니다.**

기존의 일반적인 `modifyNotificationStatus()`에서 `modifyNotificationStatusIsSent()`로 메서드 이름이 변경되어 상태 변경의 목적이 보다 명확해졌습니다. 이는 예약 알림 스케줄링 시스템에서 상태 관리의 가독성과 정확성을 향상시킵니다.

</details>
<details>
<summary>notification/src/main/resources/application.yml (2)</summary>

`23-27`: **Redis 설정이 추가되었습니다.**

예약 알림 스케줄링 시스템 구현을 위한 Redis 설정이 적절히 추가되었습니다. 이는 향후 분산 환경에서의 스케줄러 관리와 지연 큐 구현의 기반이 됩니다.

---

`23-27`: **Redis 패스워드 설정 필요 확인**

현재 Redis 구성에는 username만 설정되어 있고 password 설정이 누락되었습니다. 보안 강화를 위해 password 설정도 고려해보세요.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/infrastructure/sender/SlackSender.java (2)</summary>

`1-11`: **슬랙 발송 컴포넌트의 패키지 위치 변경이 잘 반영되었습니다.**

패키지 구조 변경이 도메인 주도 설계 원칙에 맞게 잘 정리되었습니다. 인프라스트럭처 레이어로 `SlackSender` 클래스를 이동시키고 필요한 임포트 구문도 적절히 추가되었습니다.

---

`21-48`: <details>
<summary>❓ Verification inconclusive</summary>

**슬랙 메시지 발송 로직 검토**

WebClient를 사용한 슬랙 알림 발송 구현이 적절합니다. 다만, 스케줄러에서 사용될 경우 아래 사항을 고려해 보세요:

1. 비동기 발송 처리(`subscribe()`)는 스케줄러 실행 시 완료를 보장하지 않을 수 있습니다.
2. 알림 실패 시 재시도 메커니즘이 없습니다.

PR 설명에 언급된 대로 Kafka와 Redis 도입 시 이 부분을 개선할 계획이 있으신 것으로 보입니다.

---



</details>

**슬랙 알림 전송 로직 관련 확인 사항**

- WebClient를 활용한 슬랙 메시지 전송 구현 자체는 적절합니다.
- 단, 스케줄러 환경에서 실행될 경우 비동기 처리(`subscribe()`)로 인해 메시지 전송 완료를 보장하기 어려울 수 있으므로, 스케줄러에 미치는 영향을 재검토해 주세요.
- 또한, 알림 전송 실패 시 재시도 메커니즘이 적용되지 않은 점은 개선이 필요할 수 있습니다.
- PR 설명에 언급된 대로 Kafka와 Redis 도입을 통해 해당 부분을 보완할 계획이 있으신 것으로 보입니다. 추후 개선사항 적용 시 해당 개선 사항이 올바르게 반영되었는지 추가 검증 부탁드립니다.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/NotificationApplication.java (1)</summary>

`5-8`: **스케줄링 기능 활성화 적절히 추가되었습니다.**

`@EnableScheduling` 어노테이션이 메인 애플리케이션 클래스에 추가되어 스케줄러 기능이 활성화되었습니다. 이는 예약 알림 스케줄링 구현에 필수적인 변경사항입니다.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java (1)</summary>

`7-10`: **JavaDoc 주석이 잘 작성되었습니다.**

작성자 정보와 날짜가 포함된 JavaDoc 주석이 잘 작성되었습니다.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java (1)</summary>

`4-5`: **필수 의존성 가져오기 확인**  
`LocalDateTime`, `List`, `NotificationStatus` 등의 필요한 의존성을 정상적으로 import하고 있어 보입니다. 현재 코드 흐름에 문제가 없으므로 그대로 진행하셔도 좋습니다.  



Also applies to: 8-8

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java (2)</summary>

`59-61`: **`retryCount` 필드 초기화 관련**  
`retryCount`가 항상 0으로 초기화됩니다. 단순한 구조에서는 문제 없지만, 만약 재시도 로직이 늘어나면 주의가 필요합니다. 활용도가 높아지면 DB에 반영하거나 별도 관리가 필요할 수 있습니다.

---

`97-99`: **성공 상태 변경 메서드**  
`modifyNotificationStatusIsSent`는 메서드 명이 직관적이며, 상태 전환을 명확하게 처리합니다. 현재 구현 수준에서는 적절해 보입니다.

</details>
<details>
<summary>notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java (1)</summary>

`1-10`: **스케줄링 클래스 전반 검토**  
새로운 `NotificationScheduler` 클래스가 10분 주기로 알림을 전송하도록 구현되었습니다. `@Slf4j`, `@RequiredArgsConstructor`로 롬복을 적극 활용해 코드 간결성을 높인 점이 좋습니다.

</details>
<details>
<summary>notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java (1)</summary>

`69-103`: **테스트 설계가 잘 되어 있습니다.**

성공 케이스에 대한 테스트가 명확하게 구성되어 있습니다. 모의 객체 설정, 실행 조건 및 검증이 모두 잘 정의되어 있습니다.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T09:57:41Z
user: login: hyezuu
body: 코드 잘봤습니다 ! 고생하셨어요~~
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-16T10:09:30Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T10:09:54Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T10:09:55Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-16T10:09:56Z

Review comments:
diff_hunk: @@ -0,0 +1,18 @@
+package table.eat.now.notification.infrastructure.webclient;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Configuration
+public class WebClientConfig {
+
+  @Bean
+  public WebClient webClient() {
+    return WebClient.create();
+  }
+}
path: notification/src/main/java/table/eat/now/notification/infrastructure/webclient/WebClientConfig.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**WebClient 설정 클래스에 추가 구성 옵션 고려 필요**

기본 WebClient 빈 생성은 단순하고 명확하게 구현되었습니다. 하지만 다음과 같은 개선사항을 고려해보세요:

1. 타임아웃 설정
2. 커넥션 풀 설정
3. 에러 핸들링 전략 구성
4. 로깅 추가

특히 외부 시스템과의 통신에서는 위 설정들이 안정성에 중요합니다.


```diff
@Configuration
public class WebClientConfig {

  @Bean
  public WebClient webClient() {
-    return WebClient.create();
+    return WebClient.builder()
+        .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))
+        .filter(ExchangeFilterFunction.ofRequestProcessor(
+            clientRequest -> {
+              log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
+              return Mono.just(clientRequest);
+            }
+        ))
+        .filter(ExchangeFilterFunction.ofResponseProcessor(
+            clientResponse -> {
+              log.debug("Response status: {}", clientResponse.statusCode());
+              return Mono.just(clientResponse);
+            }
+        ))
+        .build();
  }
}
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
@Configuration
public class WebClientConfig {

  @Bean
  public WebClient webClient() {
    return WebClient.builder()
        .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(2 * 1024 * 1024))
        .filter(ExchangeFilterFunction.ofRequestProcessor(
            clientRequest -> {
              log.debug("Request: {} {}", clientRequest.method(), clientRequest.url());
              return Mono.just(clientRequest);
            }
        ))
        .filter(ExchangeFilterFunction.ofResponseProcessor(
            clientResponse -> {
              log.debug("Response status: {}", clientResponse.statusCode());
              return Mono.just(clientResponse);
            }
        ))
        .build();
  }
}
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:39Z
author_association: NONE
diff_hunk: @@ -12,5 +15,8 @@
 public interface JpaNotificationRepository extends
     JpaRepository<Notification, Long>, NotificationRepository, JpaNotificationRepositoryCustom {
   Optional<Notification> findByNotificationUuidAndDeletedByIsNull(String notificationUuid);
+
+  List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+      NotificationStatus status, LocalDateTime localDateTime);
path: notification/src/main/java/table/eat/now/notification/infrastructure/persistence/JpaNotificationRepository.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**스케줄링된 알림 조회 쿼리 메서드 구현이 적절합니다.**

스케줄링된 알림을 조회하는 쿼리 메서드가 적절하게 추가되었습니다. 상태, 예약 시간, 삭제 여부를 기준으로 알림을 조회하는 로직이 잘 구현되었습니다.


다만, 대량의 알림이 존재할 경우 성능 이슈가 발생할 수 있으므로 추후 페이징 처리나 제한 설정을 고려해보세요:

```diff
List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
    NotificationStatus status, LocalDateTime localDateTime);

+ List<Notification> findTop100ByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNullOrderByScheduledTimeAsc(
+     NotificationStatus status, LocalDateTime localDateTime);
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

`````suggestion
List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
    NotificationStatus status, LocalDateTime localDateTime);

List<Notification> findTop100ByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNullOrderByScheduledTimeAsc(
    NotificationStatus status, LocalDateTime localDateTime);
`````

</details>

<!-- suggestion_end -->

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -19,4 +22,7 @@ public interface NotificationRepository {
 
   PaginatedResult<NotificationSearchCriteriaQuery> searchNotification(NotificationSearchCriteria searchCriteria);
 
+  List<Notification> findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+      NotificationStatus status, LocalDateTime localDateTime);
path: notification/src/main/java/table/eat/now/notification/domain/repository/NotificationRepository.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**새로운 메서드의 유연성**  
`findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull` 메서드는 스케줄링 기능에서 필요한 데이터를 효율적으로 가져오기 좋습니다. 향후 상태가 여러 개로 확장되는 경우, 필요하다면 Querydsl 등으로 조건 로직을 더 유연하게 관리할 수 있습니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -9,6 +9,7 @@
 import jakarta.persistence.GenerationType;
 import jakarta.persistence.Id;
 import jakarta.persistence.Table;
+import jakarta.persistence.Transient;
path: notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**`@Transient` 사용 의도 확인**  
`retryCount` 필드를 영속화 대상에서 제외하기 위해 `@Transient`를 사용하신 것으로 보입니다. 재시도 로직이 복잡해질 경우, 데이터베이스에도 저장해 관리할 필요가 생길 수 있으니 추후 요구사항 확장 시에는 고려해 보세요.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -89,7 +94,10 @@ public void modifyNotification(Long userId, String notificationType,
     this.scheduledTime = scheduledTime;
   }
 
-  public void modifyNotificationStatus() {
+  public void modifyNotificationStatusIsSent() {
     this.status = NotificationStatus.SENT;
   }
+  public void modifyNotificationStatusIsFailed() {
+    this.status = NotificationStatus.FAILED;
+  }
path: notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**실패 상태 처리 개선 제안**  
`modifyNotificationStatusIsFailed` 메서드 역시 알림 전송 실패 상황을 명확하게 나타내고 있습니다. 다만, 재시도 로직 도입 시 실패 횟수를 카운팅하거나, 일정 횟수 이상 실패 시 알림을 중단하는 등의 처리도 필요해질 수 있습니다.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,64 @@
+package table.eat.now.notification.application.schedule;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class NotificationScheduler {
+
+  private final NotificationRepository notificationRepository;
+  private final NotificationFormatterStrategySelector formatterSelector;
+  private final NotificationSenderStrategySelector sendSelector;
+  private final NotificationParamExtractor paramExtractor;
+
+  @Scheduled(cron = "0 */10 * * * *")
+  @Transactional
+  public void sendScheduledNotifications() {
+    LocalDateTime now = LocalDateTime.now();
+
+    List<Notification> notifications = notificationRepository.
+        findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        NotificationStatus.PENDING, now);
+
path: notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**스케줄링 주기 적절성 검토**  
`@Scheduled(cron = "0 */10 * * * *")`로 10분 간격을 지정하셨는데, 서비스 요구사항에 따라 간격이 짧거나 길 수 있습니다. 트래픽 및 서버 리소스 사용량을 고려하여 적절히 조정 가능하도록 환경 변수나 설정 파일에서 동적으로 관리하는 방안을 고려해 보세요.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,64 @@
+package table.eat.now.notification.application.schedule;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class NotificationScheduler {
+
+  private final NotificationRepository notificationRepository;
+  private final NotificationFormatterStrategySelector formatterSelector;
+  private final NotificationSenderStrategySelector sendSelector;
+  private final NotificationParamExtractor paramExtractor;
+
+  @Scheduled(cron = "0 */10 * * * *")
+  @Transactional
+  public void sendScheduledNotifications() {
+    LocalDateTime now = LocalDateTime.now();
+
+    List<Notification> notifications = notificationRepository.
+        findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        NotificationStatus.PENDING, now);
+
+    for (Notification notification : notifications) {
+      try {
+        NotificationFormatterStrategy strategy = formatterSelector.select(
+            notification.getNotificationType());
+        Map<String, String> params = paramExtractor.extract(notification);
+        NotificationTemplate formattedMessage = strategy.format(params);
+
+        NotificationSenderStrategy senderStrategy = sendSelector.select(
+            notification.getNotificationMethod());
+
+        senderStrategy.send(notification.getUserId(), formattedMessage);
+
+        notification.modifyNotificationStatusIsSent();
+
+      } catch (Exception e) {
+        log.error("스케줄링 알림 전송 실패: {}", notification.getId(), e);
+        notification.modifyNotificationStatusIsFailed();
+      }
+    }
path: notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**알림 전송 로직 및 예외 처리 검토**  
- `NotificationFormatterStrategySelector`, `NotificationSenderStrategySelector`를 활용해 전송 방식을 유연하게 처리하는 설계가 좋습니다.  
- 예외 발생 시 즉시 로그를 남기고 `modifyNotificationStatusIsFailed`를 호출해 상태를 기록하는 구현이 적합해 보입니다. 다만, 향후 재시도 시점 및 최대 횟수를 제어하려면 재시도 로직(예: retryCount 증감 등)을 추가로 고려해야 합니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@ExtendWith(MockitoExtension.class)
+class NotificationSchedulerTest {
+
+  @Mock
+  private NotificationRepository notificationRepository;
+
+  @Mock
+  private NotificationFormatterStrategySelector formatterSelector;
+
+  @Mock
+  private NotificationSenderStrategySelector sendSelector;
+
+  @Mock
+  private NotificationParamExtractor paramExtractor;
+
+  @Mock
+  private NotificationFormatterStrategy formatterStrategy;
+
+  @Mock
+  private NotificationSenderStrategy senderStrategy;
+
+  private NotificationScheduler scheduler;
+
+  @BeforeEach
+  void setUp() {
+    scheduler = new NotificationScheduler(
+        notificationRepository,
+        formatterSelector,
+        sendSelector,
+        paramExtractor
+    );
+  }
+
+  @DisplayName("스케줄러가 정상적으로 알림을 전송한다")
+  @Test
+  void scheduler_success_notification_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+    verify(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+  }
+
+  @DisplayName("스케줄러가 알림 전송에 실패하면 상태는 FAILED가 된다")
+  @Test
+  void scheduler_failed_notification_no_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+
+    doThrow(new RuntimeException("전송 실패")).when(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**불필요한 빈 줄이 있습니다.**

연속된 빈 줄이 있습니다. 코드 가독성을 위해 하나의 빈 줄만 남기는 것이 좋겠습니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**Javadoc에 미래 날짜가 포함되어 있습니다.**

Javadoc 태그에 날짜가 "2025. 04. 16."으로 설정되어 있습니다. 현재는 2024년이므로 이는 미래 날짜입니다. 올바른 현재 날짜로 수정하는 것이 좋겠습니다.

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@ExtendWith(MockitoExtension.class)
+class NotificationSchedulerTest {
+
+  @Mock
+  private NotificationRepository notificationRepository;
+
+  @Mock
+  private NotificationFormatterStrategySelector formatterSelector;
+
+  @Mock
+  private NotificationSenderStrategySelector sendSelector;
+
+  @Mock
+  private NotificationParamExtractor paramExtractor;
+
+  @Mock
+  private NotificationFormatterStrategy formatterStrategy;
+
+  @Mock
+  private NotificationSenderStrategy senderStrategy;
+
+  private NotificationScheduler scheduler;
+
+  @BeforeEach
+  void setUp() {
+    scheduler = new NotificationScheduler(
+        notificationRepository,
+        formatterSelector,
+        sendSelector,
+        paramExtractor
+    );
+  }
+
+  @DisplayName("스케줄러가 정상적으로 알림을 전송한다")
+  @Test
+  void scheduler_success_notification_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+    verify(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+  }
+
+  @DisplayName("스케줄러가 알림 전송에 실패하면 상태는 FAILED가 된다")
+  @Test
+  void scheduler_failed_notification_no_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+
+    doThrow(new RuntimeException("전송 실패")).when(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED);
+  }
+
+}
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**추가 테스트 케이스 제안**

현재 테스트는 기본적인 성공 및 실패 시나리오를 다루고 있습니다. 다음과 같은 추가 테스트 케이스를 고려해보세요:

1. 여러 개의 알림이 있을 때 모두 처리되는지 확인하는 테스트
2. 알림 목록이 비어있는 경우에 대한 테스트
3. 매개변수에 null 값이 있는 경우에 대한 테스트
4. PR 설명에 언급된 `retryCount` 관련 기능이 제대로 동작하는지 확인하는 테스트

이러한 테스트 케이스를 추가하면 코드의 견고성과 신뢰성을 더 향상시킬 수 있습니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@ExtendWith(MockitoExtension.class)
+class NotificationSchedulerTest {
+
+  @Mock
+  private NotificationRepository notificationRepository;
+
+  @Mock
+  private NotificationFormatterStrategySelector formatterSelector;
+
+  @Mock
+  private NotificationSenderStrategySelector sendSelector;
+
+  @Mock
+  private NotificationParamExtractor paramExtractor;
+
+  @Mock
+  private NotificationFormatterStrategy formatterStrategy;
+
+  @Mock
+  private NotificationSenderStrategy senderStrategy;
+
+  private NotificationScheduler scheduler;
+
+  @BeforeEach
+  void setUp() {
+    scheduler = new NotificationScheduler(
+        notificationRepository,
+        formatterSelector,
+        sendSelector,
+        paramExtractor
+    );
+  }
+
+  @DisplayName("스케줄러가 정상적으로 알림을 전송한다")
+  @Test
+  void scheduler_success_notification_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.SENT);
+    verify(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+  }
+
+  @DisplayName("스케줄러가 알림 전송에 실패하면 상태는 FAILED가 된다")
+  @Test
+  void scheduler_failed_notification_no_sent() {
+    // given
+    Notification notification = Notification.of(
+        1L,
+        NotificationType.CONFIRM_CUSTOMER,
+        "11조",
+        LocalDateTime.of(2025, 4, 20, 18, 30),
+        "HI-dle 식당",
+        NotificationStatus.PENDING,
+        NotificationMethod.EMAIL,
+        LocalDateTime.now().minusMinutes(5)
+    );
+
+    given(notificationRepository.findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        any(), any())).willReturn(List.of(notification));
+
+    given(formatterSelector.select(any())).willReturn(formatterStrategy);
+    given(paramExtractor.extract(any())).willReturn(Map.of(
+        "customerName", "11조",
+        "reservationTime", "2025-04-20 18:30",
+        "restaurantName", "HI-dle 식당"
+    ));
+    given(formatterStrategy.format(any())).willReturn(new NotificationTemplate("제목", "본문"));
+    given(sendSelector.select(any())).willReturn(senderStrategy);
+
+
+    doThrow(new RuntimeException("전송 실패")).when(senderStrategy).send(eq(1L), any(NotificationTemplate.class));
+
+    // when
+    scheduler.sendScheduledNotifications();
+
+    // then
+    assertThat(notification.getStatus()).isEqualTo(NotificationStatus.FAILED);
+  }
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: coderabbitai[bot]
body: _🧹 Nitpick (assertive)_

**실패 시나리오에 대한 테스트도 잘 구현되었습니다.**

예외 상황에서의 알림 상태 전환이 올바르게 테스트되고 있습니다. 다만 테스트 메서드 이름 `scheduler_failed_notification_no_sent`를 더 명확하게 표현하는 것이 좋겠습니다. 예를 들어 `scheduler_updates_status_to_failed_when_sending_fails`와 같이 변경하면 테스트의 목적이 더 명확해집니다.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-04-16T09:57:40Z
author_association: NONE
diff_hunk: @@ -0,0 +1,64 @@
+package table.eat.now.notification.application.schedule;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class NotificationScheduler {
+
+  private final NotificationRepository notificationRepository;
+  private final NotificationFormatterStrategySelector formatterSelector;
+  private final NotificationSenderStrategySelector sendSelector;
+  private final NotificationParamExtractor paramExtractor;
+
+  @Scheduled(cron = "0 */10 * * * *")
+  @Transactional
+  public void sendScheduledNotifications() {
+    LocalDateTime now = LocalDateTime.now();
+
+    List<Notification> notifications = notificationRepository.
+        findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        NotificationStatus.PENDING, now);
+
path: notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
user: login: hyezuu
body: 오 이거 알고리즘 특강에서 나온 1번사례같네요 ㅎㅎ
created_at: 2025-04-16T10:06:17Z
author_association: MEMBER
diff_hunk: @@ -89,7 +94,10 @@ public void modifyNotification(Long userId, String notificationType,
     this.scheduledTime = scheduledTime;
   }
 
-  public void modifyNotificationStatus() {
+  public void modifyNotificationStatusIsSent() {
     this.status = NotificationStatus.SENT;
   }
+  public void modifyNotificationStatusIsFailed() {
+    this.status = NotificationStatus.FAILED;
+  }
path: notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
user: login: hyezuu
body: 상태변환 메서드로 뺀거 좋은것같습니다!
created_at: 2025-04-16T10:07:54Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: hyezuu
body: 이건뭐야ㅋㅋㅋㅋㅋㅋ
created_at: 2025-04-16T10:08:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,64 @@
+package table.eat.now.notification.application.schedule;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class NotificationScheduler {
+
+  private final NotificationRepository notificationRepository;
+  private final NotificationFormatterStrategySelector formatterSelector;
+  private final NotificationSenderStrategySelector sendSelector;
+  private final NotificationParamExtractor paramExtractor;
+
+  @Scheduled(cron = "0 */10 * * * *")
+  @Transactional
+  public void sendScheduledNotifications() {
+    LocalDateTime now = LocalDateTime.now();
+
+    List<Notification> notifications = notificationRepository.
+        findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(
+        NotificationStatus.PENDING, now);
+
path: notification/src/main/java/table/eat/now/notification/application/schedule/NotificationScheduler.java
user: login: coderabbitai[bot]
body: @hyezuu 네, 알고리즘 강의에서 배운 사례와 비슷하네요! 스케줄링 간격을 고정값이 아닌 설정 파일에서 관리하면 실제 운영 환경에서 더 유연하게 대응할 수 있어서 좋은 패턴이죠. 이런 작은 변경이 나중에 시스템 확장 시 큰 도움이 될 수 있습니다. 😊


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@hyezuu 네, 알고리즘 강의에서 배운 사례와 비슷하네요! 스케줄링 간격을 고정값이 아닌 설정 파일에서 관리하면 실제 운영 환경에서 더 유연하게 대응할 수 있어서 좋은 패턴이죠. 이런 작은 변경이 나중에 시스템 확장 시 큰 도움이 될 수 있습니다. 😊</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T10:09:54Z
author_association: NONE
diff_hunk: @@ -0,0 +1,142 @@
+package table.eat.now.notification.application.schedule;
+
+
+import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategy;
+import table.eat.now.notification.application.strategy.NotificationFormatterStrategySelector;
+import table.eat.now.notification.application.strategy.NotificationParamExtractor;
+import table.eat.now.notification.application.strategy.NotificationTemplate;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategy;
+import table.eat.now.notification.application.strategy.send.NotificationSenderStrategySelector;
+import table.eat.now.notification.domain.entity.Notification;
+import table.eat.now.notification.domain.entity.NotificationMethod;
+import table.eat.now.notification.domain.entity.NotificationStatus;
+import table.eat.now.notification.domain.entity.NotificationType;
+import table.eat.now.notification.domain.repository.NotificationRepository;
+
+/**
+ * @author : hanjihoon
+ * @Date : 2025. 04. 16.
+ */
path: notification/src/test/java/table/eat/now/notification/application/schedule/NotificationSchedulerTest.java
user: login: coderabbitai[bot]
body: @hyezuu 네, 이 자동 검토는 재미있네요! 😄 JavaDoc에 2025년 날짜가 있어서 미래 날짜라고 지적했는데, 테스트 코드에서 예약 알림을 시뮬레이션하는 데 미래 날짜(2025년)를 사용하는 것은 완전히 타당합니다. 테스트 시나리오의 일관성을 위해 작성자가 의도적으로 문서에도 같은 날짜를 사용했을 수 있겠네요. 이런 컨텍스트를 자동 검토 시스템이 놓친 것 같습니다!


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@hyezuu 네, 이 자동 검토는 재미있네요! 😄 JavaDoc에 2025년 날짜가 있어서 미래 날짜라고 지적했는데, 테스트 코드에서 예약 알림을 시뮬레이션하는 데 미래 날짜(2025년)를 사용하는 것은 완전히 타당합니다. 테스트 시나리오의 일관성을 위해 작성자가 의도적으로 문서에도 같은 날짜를 사용했을 수 있겠네요. 이런 컨텍스트를 자동 검토 시스템이 놓친 것 같습니다!</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T10:09:55Z
author_association: NONE
diff_hunk: @@ -89,7 +94,10 @@ public void modifyNotification(Long userId, String notificationType,
     this.scheduledTime = scheduledTime;
   }
 
-  public void modifyNotificationStatus() {
+  public void modifyNotificationStatusIsSent() {
     this.status = NotificationStatus.SENT;
   }
+  public void modifyNotificationStatusIsFailed() {
+    this.status = NotificationStatus.FAILED;
+  }
path: notification/src/main/java/table/eat/now/notification/domain/entity/Notification.java
user: login: coderabbitai[bot]
body: @hyezuu 네, 상태변환 메서드로 분리한 접근법은 코드의 가독성과 의도를 명확하게 만들어 주죠! 이렇게 하면 각 상태 변경의 의미가 더 명확해지고, 향후 상태 관리 로직이 더 복잡해지더라도 변경이 용이해질 것 같습니다. 👍

🐰✨ *happy coding!*


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@hyezuu 네, 상태변환 메서드로 분리한 접근법은 코드의 가독성과 의도를 명확하게 만들어 주죠! 이렇게 하면 각 상태 변경의 의미가 더 명확해지고, 향후 상태 관리 로직이 더 복잡해지더라도 변경이 용이해질 것 같습니다. 👍

🐰✨ *happy coding!*</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-04-16T10:09:56Z
author_association: NONE

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-16T05:47:56Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=HI-dle_table-eat-now&pullRequest=133) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=HI-dle_table-eat-now&pullRequest=133&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=HI-dle_table-eat-now&pullRequest=133&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=HI-dle_table-eat-now&pullRequest=133&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=HI-dle_table-eat-now&pullRequest=133&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=HI-dle_table-eat-now&pullRequest=133&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=HI-dle_table-eat-now&pullRequest=133)


author_association: NONE
user: login: hanjihoon03
created_at: 2025-04-16T09:53:39Z
body: @coderabbitai review
author_association: CONTRIBUTOR
user: login: coderabbitai[bot]
created_at: 2025-04-16T09:53:45Z
body: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>:white_check_mark: Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-04-16T09:53:48Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

이번 변경 사항은 예약된 알림 전송 기능의 도입과 관련된 여러 클래스 및 설정의 추가 및 수정으로 구성되어 있습니다. Spring Boot 애플리케이션에 `@EnableScheduling` 어노테이션이 추가되어 스케줄러 기능이 활성화되었습니다. 새로운 `NotificationScheduler` 컴포넌트가 도입되어, 10분마다 예약된 알림을 조회하고, 알림 유형에 따라 포맷팅 후 전송하며, 성공 시 상태를 "SENT"로, 실패 시 "FAILED"로 변경합니다. 이를 위해 `Notification` 엔티티에 `retryCount` 필드와 상태 변경 메서드(`modifyNotificationStatusIsSent`, `modifyNotificationStatusIsFailed`)가 추가되었습니다. 또한, 알림을 조회하는 새로운 쿼리 메서드가 `NotificationRepository` 및 `JpaNotificationRepository`에 추가되었습니다. 이메일 및 슬랙 발송 클래스의 패키지 위치가 변경되었으며, WebClient 빈을 제공하는 설정 클래스가 추가되었습니다. Redis 설정이 `application.yml`에 추가되었고, 스케줄러 동작을 검증하는 단위 테스트가 도입되었습니다.

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant Scheduler as NotificationScheduler
    participant Repo as NotificationRepository
    participant FormatterSel as NotificationFormatterStrategySelector
    participant SenderSel as NotificationSenderStrategySelector
    participant Formatter as NotificationFormatterStrategy
    participant Sender as NotificationSenderStrategy
    participant Notification as Notification Entity

    Scheduler->>Repo: findByStatusAndScheduledTimeLessThanEqualAndDeletedByIsNull(PENDING, now)
    Repo-->>Scheduler: List<Notification>
    loop for each Notification
        Scheduler->>FormatterSel: select(notification.type)
        FormatterSel-->>Scheduler: Formatter
        Scheduler->>Notification: getParams()
        Scheduler->>Formatter: format(params)
        Formatter-->>Scheduler: formattedMessage
        Scheduler->>SenderSel: select(notification.method)
        SenderSel-->>Scheduler: Sender
        Scheduler->>Sender: send(notification, formattedMessage)
        alt send success
            Scheduler->>Notification: modifyNotificationStatusIsSent()
        else send failure
            Scheduler->>Notification: modifyNotificationStatusIsFailed()
        end
    end
```

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!TIP]
> <details>
> <summary>⚡💬 Agentic Chat (Pro Plan, General Availability)</summary>
> 
> - We're introducing multi-step agentic chat in review comments and issue comments, within and outside of PR's. This feature enhances review and issue discussions with the CodeRabbit agentic chat by enabling advanced interactions, including the ability to create pull requests directly from comments and add commits to existing pull requests.
> 
> </details>

<!-- announcements_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=HI-dle/table-eat-now&utm_content=133):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE