Repository: 4monument/sb1-monew-team04
Pull request #69: feat: 관심사 관련 새 기사 등록 시 알림 생성 기능, 알림 조회 페이지네이션 기능 서비스 구현
Author: login: yudility
Labels: name: enhancement
description: New feature or request
Created: 2025-04-22T15:52:59Z | Merged: 2025-04-23T01:19:23Z

## 🛰️ Issue Number
- #64 

## 🪐 작업 내용
### 알림 생성 - 관심사에 속하는 새 기사 등록 
- 아직 읽지 않은 기사를 가져오기 위해 ArticleView 를 추가하였습니다.
- 사용자의 관심사에 속하지만 사용자가 아직 읽지 않은 기사 수를 구하기 위해 UserInterestRespository 에 쿼리를 구현하였습니다.
> 레포지토리 메서드를 여러번 호출해서 서비스단에서 구하는 것 보다 조인으로 한번에 가져오는게 낫지 않을까 해서 해당 레포지토리에 구현하였는데, 혹시 다른 방법이 생각나신다면 언제든 말씀해주시면 감사하겠습니다!

### 알림 생성 - 댓글 좋아요
- 아직 진행 중입니다.

### 알림 조회 페이지네이션
- 관심사와 달리 JSONB 비교가 없어 JPQL 사용하였습니다. 제 생각보다는 진도가 빨라서, 기능 구현이 다 완료되면 QueryDSL 공부해서 페이지네이션에 적용해 볼 예정입니다.

### 테스트 코드
- 알림 생성
 - 성공: 구독 중인 관심사 관련 기사 등록 시
- 알림 조회
 - 성공: 다음 페이지 없음 (cursor null / afterAt null / limit 50) 
 - 성공: 다음 페이지 있음 (cursor null / afterAt null / limit 1)
 - 성공: 다음 페이지 있고 커서 있음 (cursor not null / afterAt not null / limit 1)

알림 생성/조회 실패 케이스는 아직 작성 중 입니다. 

## 📚 Reference

## ✅ Check List
- [x] 코드가 정상적으로 컴파일되나요?
- [x] 테스트 코드를 통과했나요?
- [x] merge할 브랜치의 위치를 확인했나요?
- [x] Label을 지정했나요?



Diff:
diff --git a/src/main/java/com/sprint/monew/domain/interest/userinterest/UserInterestRepository.java b/src/main/java/com/sprint/monew/domain/interest/userinterest/UserInterestRepository.java
index 457517f9..5f050010 100644
--- a/src/main/java/com/sprint/monew/domain/interest/userinterest/UserInterestRepository.java
+++ b/src/main/java/com/sprint/monew/domain/interest/userinterest/UserInterestRepository.java
@@ -1,11 +1,33 @@
 package com.sprint.monew.domain.interest.userinterest;
 
+import com.sprint.monew.domain.notification.dto.UnreadInterestArticleCount;
+import com.sprint.monew.domain.user.User;
+import java.util.List;
 import java.util.UUID;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 public interface UserInterestRepository extends JpaRepository<UserInterest, UserInterestKey> {
 
   int countDistinctByInterestId(UUID interestId);
 
   boolean existsByUserIdAndInterestId(UUID userId, UUID interestId);
+
+  List<UserInterest> findByUser(User user);
+
+  @Query(
+      "SELECT ui.interest.id as interestId, ui.interest.name as interestName, COUNT(DISTINCT ai.article.id) as unreadCount "
+          + "FROM UserInterest ui "
+          // 기사 관심사-관심사-사용자 를 관심사 Id로 조인 (특정 사용자의 관심사에 속하는 모든 기사)
+          + "JOIN ArticleInterest ai ON ui.interest.id = ai.interest.id "
+          // article_view 가 없는 article_interest 도 있어야함 (아직 읽지 않은 기사)
+          // 따라서 article_interest 조인 후, article_view 를 left 조인
+          + "LEFT JOIN ArticleView av ON av.article.id = ai.article.id AND av.user.id = ui.user.id "
+          // 해당 유저가 아직 읽지 않은(av가 null 인) 경우만
+          + "WHERE ui.user.id = :userId AND av IS NULL "
+          // 관심사 별로 안 읽은 기사 count 하기 위해서 Group by 사용
+          + "GROUP BY ui.interest.id, ui.interest.name")
+  List<UnreadInterestArticleCount> countUnreadArticlesByInterest(@Param("userId") UUID userId);
+
 }
diff --git a/src/main/java/com/sprint/monew/domain/notification/CursorPageResponseNotificationDto.java b/src/main/java/com/sprint/monew/domain/notification/CursorPageResponseNotificationDto.java
deleted file mode 100644
index 64f1ba96..00000000
--- a/src/main/java/com/sprint/monew/domain/notification/CursorPageResponseNotificationDto.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.sprint.monew.domain.notification;
-
-import java.time.Instant;
-import java.util.List;
-
-public class CursorPageResponseNotificationDto {
-
-  private List<NotificationDto> content;
-  private String nextCursor;
-  private Instant nextAfter;
-  private int size;
-  private int totalElements;
-  private boolean hasNext;
-}
diff --git a/src/main/java/com/sprint/monew/domain/notification/NotificationDto.java b/src/main/java/com/sprint/monew/domain/notification/NotificationDto.java
index 0765a3d0..5d5ad3ea 100644
--- a/src/main/java/com/sprint/monew/domain/notification/NotificationDto.java
+++ b/src/main/java/com/sprint/monew/domain/notification/NotificationDto.java
@@ -2,19 +2,18 @@
 
 import java.time.Instant;
 import java.util.UUID;
-import lombok.AllArgsConstructor;
 
-@AllArgsConstructor
-public class NotificationDto {
+public record NotificationDto(
 
-  private UUID id;
-  private Instant createdAt;
-  private Instant updatedAt;
-  private boolean confirmed;
-  private UUID userId;
-  private String content;
-  private String resourceType;
-  private UUID resourceId;
+    UUID id,
+    Instant createdAt,
+    Instant updatedAt,
+    boolean confirmed,
+    UUID userId,
+    String content,
+    String resourceType,
+    UUID resourceId
+) {
 
   public static NotificationDto from(Notification notification) {
     return new NotificationDto(
diff --git a/src/main/java/com/sprint/monew/domain/notification/NotificationRepository.java b/src/main/java/com/sprint/monew/domain/notification/NotificationRepository.java
index e8119d56..83a947c0 100644
--- a/src/main/java/com/sprint/monew/domain/notification/NotificationRepository.java
+++ b/src/main/java/com/sprint/monew/domain/notification/NotificationRepository.java
@@ -1,10 +1,10 @@
 package com.sprint.monew.domain.notification;
 
 import com.sprint.monew.domain.user.User;
-
 import java.time.Instant;
 import java.util.List;
 import java.util.UUID;
+import org.springframework.data.domain.Pageable;
 import org.springframework.data.jpa.repository.JpaRepository;
 import org.springframework.data.jpa.repository.Modifying;
 import org.springframework.data.jpa.repository.Query;
@@ -19,4 +19,21 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
       "where n.confirmed = true " +
       "and n.updatedAt > :confirmedAt")
   void deleteConfirmedNotificationsOlderThan(@Param("confirmedAt") Instant confirmedAt);
+
+  @Query(
+      "SELECT n "
+          + "FROM Notification n "
+          + "WHERE n.user.id = :userId AND n.confirmed = false "
+          // 커서가 없거나, 생성 시간이 이것보다 작거나, 생성 시간이 같다면 ID가 이것보다 작은
+          + "AND (:cursorId IS NULL OR n.createdAt < :afterAt OR (n.createdAt = :afterAt AND n.id < :cursorId))"
+          + "ORDER BY n.createdAt DESC, n.id DESC")
+  List<Notification> findUnconfirmedWithCursor(UUID userId, UUID cursorId,
+      Instant afterAt, Pageable pageable);
+
+  @Query(
+      "SELECT n "
+          + "FROM Notification n "
+          + "WHERE n.user.id = :userId AND n.confirmed = false")
+  int countUnconfirmedByUserId(UUID userId);
+
 }
diff --git a/src/main/java/com/sprint/monew/domain/notification/NotificationService.java b/src/main/java/com/sprint/monew/domain/notification/NotificationService.java
index 15c3a202..5d056012 100644
--- a/src/main/java/com/sprint/monew/domain/notification/NotificationService.java
+++ b/src/main/java/com/sprint/monew/domain/notification/NotificationService.java
@@ -1,11 +1,16 @@
 package com.sprint.monew.domain.notification;
 
+import com.sprint.monew.common.util.CursorPageResponseDto;
+import com.sprint.monew.domain.interest.userinterest.UserInterestRepository;
+import com.sprint.monew.domain.notification.dto.UnreadInterestArticleCount;
 import com.sprint.monew.domain.user.User;
 import com.sprint.monew.domain.user.UserRepository;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.UUID;
 import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.PageRequest;
 import org.springframework.stereotype.Service;
 
 @Service
@@ -14,20 +19,35 @@ public class NotificationService {
 
   private final NotificationRepository notificationRepository;
   private final UserRepository userRepository;
+  private final UserInterestRepository subscriberRepository;
 
-  //알림 목록 조회
-  public CursorPageResponseNotificationDto getAllNotifications(String cursor, String after,
-      int limit, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 등록 - 일괄 등록
+  public List<NotificationDto> createNotifications(UUID userId) {
 
-    List<Notification> notifications = notificationRepository.findByUser(user);
-    //todo-페이지네이션 구현 및 적용
-    return null;
+    User user = userRepository.findById(userId)
+        .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+    List<UnreadInterestArticleCount> unreadInterestArticleCounts = subscriberRepository.countUnreadArticlesByInterest(
+        user.getId());
+
+    List<NotificationDto> notificationDtos = new ArrayList<>();
+
+    for (UnreadInterestArticleCount unreadInterestArticleCount : unreadInterestArticleCounts) {
+      Notification notification = new Notification(user, unreadInterestArticleCount.getInterestId(),
+          ResourceType.INTEREST, unreadInterestArticleCount.getInterestName() + "와/과 관련된 기사가 "
+          + unreadInterestArticleCount.getUnreadCount() + "건 등록되었습니다.");
+
+      notificationRepository.save(notification);
+      notificationDtos.add(NotificationDto.from(notification));
+    }
+
+    return notificationDtos;
   }
 
-  //전체 알림 확인
-  public void checkAllNotifications(String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 수정 - 전체 알림 확인
+  public void checkAllNotifications(UUID userId) {
+
+    User user = userRepository.findById(userId).orElseThrow();
 
     List<Notification> notifications = notificationRepository.findByUser(user);
     Instant updatedAt = Instant.now();
@@ -37,16 +57,62 @@ public void checkAllNotifications(String userId) {
     });
 
     notificationRepository.saveAll(notifications);
-
   }
 
-  //알림 확인(단일)
-  public void checkNotification(String notificationId, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
-    Notification notification = notificationRepository.findById(UUID.fromString(notificationId))
-        .orElseThrow();
+  //알림 수정 - 알림 확인(단일)
+  public void checkNotification(UUID notificationId, UUID userId) {
+
+    User user = userRepository.findById(userId).orElseThrow(
+        () -> new IllegalArgumentException("User not found")
+    );
+
+    Notification notification = notificationRepository.findById(notificationId)
+        .orElseThrow(() -> new IllegalArgumentException("Notification not found"));
+
     notification.confirm(Instant.now());
     notificationRepository.save(notification);
   }
 
+  //알림 목록 조회
+  public CursorPageResponseDto<NotificationDto> getAllNotifications(UUID cursor, Instant after,
+      int limit, UUID userId) {
+    User user = userRepository.findById(userId).orElseThrow(
+        () -> new IllegalArgumentException("User not found")
+    );
+
+    createNotifications(user.getId());
+
+    PageRequest pagerequest = PageRequest.of(0, limit + 1);
+
+    List<Notification> notifications
+        = notificationRepository.findUnconfirmedWithCursor(userId, cursor, after,
+        pagerequest);
+
+    boolean hasNext = notifications.size() > limit;
+
+    if (hasNext) {
+      notifications = notifications.subList(0, limit);
+    }
+
+    UUID nextCursor = notifications.get(notifications.size() - 1).getId();
+
+    Instant nextAfter = notifications.get(notifications.size() - 1).getCreatedAt();
+
+    int size = Math.min(notifications.size(), limit);
+
+    long totalElements = notificationRepository.countUnconfirmedByUserId(userId);
+
+    List<NotificationDto> notificationDtos = notifications.stream()
+        .map(NotificationDto::from)
+        .toList();
+
+    return new CursorPageResponseDto(
+        notificationDtos,
+        nextCursor,
+        nextAfter,
+        size,
+        totalElements,
+        hasNext
+    );
+  }
 }
diff --git a/src/main/java/com/sprint/monew/domain/notification/dto/UnreadInterestArticleCount.java b/src/main/java/com/sprint/monew/domain/notification/dto/UnreadInterestArticleCount.java
new file mode 100644
index 00000000..7ddb474f
--- /dev/null
+++ b/src/main/java/com/sprint/monew/domain/notification/dto/UnreadInterestArticleCount.java
@@ -0,0 +1,12 @@
+package com.sprint.monew.domain.notification.dto;
+
+import java.util.UUID;
+
+public interface UnreadInterestArticleCount {
+
+  UUID getInterestId();
+
+  String getInterestName();
+
+  Long getUnreadCount();
+}
diff --git a/src/test/java/com/sprint/monew/domain/notification/NotificationServiceTest.java b/src/test/java/com/sprint/monew/domain/notification/NotificationServiceTest.java
new file mode 100644
index 00000000..3877e0a0
--- /dev/null
+++ b/src/test/java/com/sprint/monew/domain/notification/NotificationServiceTest.java
@@ -0,0 +1,275 @@
+package com.sprint.monew.domain.notification;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+import com.sprint.monew.common.util.CursorPageResponseDto;
+import com.sprint.monew.domain.interest.Interest;
+import com.sprint.monew.domain.interest.userinterest.UserInterestRepository;
+import com.sprint.monew.domain.notification.dto.UnreadInterestArticleCount;
+import com.sprint.monew.domain.user.User;
+import com.sprint.monew.domain.user.UserRepository;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.test.util.ReflectionTestUtils;
+
+@ExtendWith(MockitoExtension.class)
+class NotificationServiceTest {
+
+  @Mock
+  private NotificationRepository notificationRepository;
+
+  @Mock
+  private UserInterestRepository subscriberRepository;
+
+  @Mock
+  private UserRepository userRepository;
+
+  @InjectMocks
+  private NotificationService notificationService;
+
+
+  // 테스트용 Interest
+  private List<Interest> interests;
+
+  // 테스트용 User
+  private User user;
+
+
+  @BeforeEach
+  void setUp() {
+    UUID id = UUID.randomUUID();
+    String email = "test@example.com";
+    String nickname = "테스트유저";
+    String password = "test1234";
+    Instant createdAt = Instant.now();
+    boolean deleted = false;
+    user = new User(id, email, nickname, password, createdAt, deleted);
+
+    interests = new ArrayList<>();
+
+    String name = "경제/투자";
+    List<String> keywords = List.of("주식", "투자", "경제지표", "부동산", "금융시장");
+    interests.add(new Interest(name, keywords));
+
+    String healthName = "건강/웰빙";
+    List<String> healthKeywords = List.of("다이어트", "운동", "영양", "명상", "웰니스");
+    interests.add(new Interest(healthName, healthKeywords));
+
+  }
+
+  @Nested
+  @DisplayName("알림 생성")
+  class createNotificationTest {
+
+    @Test
+    @DisplayName("성공: 구독 중인 관심사 관련 기사 등록 시")
+    void createNotificationInInterestSuccess() {
+      //given
+      Interest interest = interests.get(0);
+
+      List<UnreadInterestArticleCount> queryResult = new ArrayList<>();
+      queryResult.add(new TestUnreadInterestArticleCount(
+          interest.getId(),
+          interest.getName(),
+          1L
+      ));
+
+      when(userRepository.findById(user.getId())).thenReturn(Optional.ofNullable(user));
+      when(subscriberRepository.countUnreadArticlesByInterest(user.getId()))
+          .thenReturn(queryResult);
+
+      NotificationDto expectedNotification = NotificationDto.from(
+          new Notification(user,
+              queryResult.get(0).getInterestId(),
+              ResourceType.INTEREST,
+              queryResult.get(0).getInterestName() + "와/과 관련된 기사가 "
+                  + queryResult.get(0).getUnreadCount() + "건 등록되었습니다."));
+      //when
+      List<NotificationDto> notifications = notificationService.createNotifications(user.getId());
+
+      //then
+      assertEquals(expectedNotification.userId(), notifications.get(0).userId());
+      assertEquals(expectedNotification.resourceId(), notifications.get(0).resourceId());
+      assertEquals(expectedNotification.resourceType(), notifications.get(0).resourceType());
+      assertEquals(expectedNotification.content(), notifications.get(0).content());
+      assertFalse(expectedNotification.confirmed());
+    }
+
+
+    @Test
+    @DisplayName("성공: 내가 작성한 댓글에 좋아요 눌릴 시")
+    void createNotificationMyCommentSuccess() {
+
+    }
+
+
+  }
+
+  @Nested
+  @DisplayName("알림 수정")
+  class updateNotificationTest {
+
+  }
+
+  @Nested
+  @DisplayName("알림 조회")
+  class getNotificationTest {
+
+    @Test
+    @DisplayName("성공: 다음 페이지 없음 (cursor null / afterAt null / limit 50)")
+    void getNotificationHasNextFalseSuccess() {
+      //given
+      Interest interest = interests.get(0);
+
+      UUID cursor = null;
+      Instant afterAt = null;
+      int limit = 50;
+      UUID userId = user.getId();
+
+      PageRequest pageRequest = PageRequest.of(0, limit + 1);
+
+      List<Notification> expectNotifications = new ArrayList<>();
+      expectNotifications.add(new Notification(user, interest.getId(), ResourceType.INTEREST,
+          interest.getName() + "와/과 관련된 기사가 " + 1 + "건 등록되었습니다."));
+
+      when(userRepository.findById(userId)).thenReturn(Optional.ofNullable(user));
+      when(notificationRepository
+          .findUnconfirmedWithCursor(userId, cursor, afterAt, pageRequest))
+          .thenReturn(expectNotifications);
+
+      //when
+      CursorPageResponseDto<NotificationDto> allNotifications
+          = notificationService.getAllNotifications(null, null, limit, userId);
+
+      //then
+      assertEquals(expectNotifications.get(0).getId(), allNotifications.content().get(0).id());
+
+      assertEquals(expectNotifications.get(0).getUser().getId(),
+          allNotifications.content().get(0).userId());
+
+      assertEquals(expectNotifications.get(0).getResourceId(),
+          allNotifications.content().get(0).resourceId());
+
+      assertEquals(expectNotifications.get(0).getResourceType().toString(),
+          allNotifications.content().get(0).resourceType());
+
+      assertEquals(expectNotifications.get(0).getContent(),
+          allNotifications.content().get(0).content());
+
+    }
+
+    @Test
+    @DisplayName("성공: 다음 페이지 있음 (cursor null / afterAt null / limit 1)")
+    void getNotificationHasNextTrueSuccess() {
+      //given
+
+      UUID cursor = null;
+      Instant afterAt = null;
+      int limit = 1;
+
+      UUID userId = user.getId();
+
+      PageRequest pageRequest = PageRequest.of(0, limit + 1);
+
+      List<Notification> expectNotifications = new ArrayList<>();
+
+      Notification notification1 = new Notification(user, interests.get(0).getId(),
+          ResourceType.INTEREST,
+          interests.get(0).getName() + "와/과 관련된 기사가 " + 1 + "건 등록되었습니다.");
+      Notification notification2 = new Notification(user, interests.get(1).getId(),
+          ResourceType.INTEREST,
+          interests.get(1).getName() + "와/과 관련된 기사가 " + 1 + "건 등록되었습니다.");
+
+      UUID notificationId1 = UUID.randomUUID();
+      UUID notificationId2 = UUID.randomUUID();
+
+      ReflectionTestUtils.setField(notification1, "id", notificationId1);
+      ReflectionTestUtils.setField(notification2, "id", notificationId2);
+
+      expectNotifications.add(notification1);
+      expectNotifications.add(notification2);
+
+      expectNotifications.sort(Comparator.comparing(Notification::getCreatedAt).reversed());
+
+      when(userRepository.findById(userId)).thenReturn(Optional.ofNullable(user));
+      when(notificationRepository
+          .findUnconfirmedWithCursor(userId, cursor, afterAt, pageRequest))
+          .thenReturn(expectNotifications);
+
+      //when
+      CursorPageResponseDto<NotificationDto> allNotifications
+          = notificationService.getAllNotifications(null, null, limit, userId);
+
+      //then
+      assertEquals(notification2.getId(), allNotifications.nextCursor());
+      assertEquals(notification2.getCreatedAt(), allNotifications.nextAfter());
+      assertTrue(allNotifications.hasNext());
+
+    }
+
+    @Test
+    @DisplayName("성공: 다음 페이지 있고 커서 있음 (cursor not null / afterAt not null / limit 1)")
+    void getNotificationHasNextTrueWithCursorSuccess() {
+      //given
+      int limit = 1;
+      UUID userId = user.getId();
+
+      PageRequest pageRequest = PageRequest.of(0, limit + 1);
+
+      List<Notification> expectNotifications = new ArrayList<>();
+
+      Notification notification1 = new Notification(user, interests.get(0).getId(),
+          ResourceType.INTEREST,
+          interests.get(0).getName() + "와/과 관련된 기사가 " + 1 + "건 등록되었습니다.");
+      Notification notification2 = new Notification(user, interests.get(1).getId(),
+          ResourceType.INTEREST,
+          interests.get(1).getName() + "와/과 관련된 기사가 " + 1 + "건 등록되었습니다.");
+
+      UUID notificationId1 = UUID.randomUUID();
+      UUID notificationId2 = UUID.randomUUID();
+
+      ReflectionTestUtils.setField(notification1, "id", notificationId1);
+      ReflectionTestUtils.setField(notification2, "id", notificationId2);
+
+      expectNotifications.add(notification1);
+      expectNotifications.add(notification2);
+
+      expectNotifications.sort(Comparator.comparing(Notification::getCreatedAt).reversed());
+      expectNotifications.remove(expectNotifications.size() - 1 - limit);
+
+      UUID cursor = notification2.getId();
+      Instant afterAt = notification2.getCreatedAt();
+
+      when(userRepository.findById(userId)).thenReturn(Optional.ofNullable(user));
+      when(notificationRepository
+          .findUnconfirmedWithCursor(userId, cursor, afterAt, pageRequest))
+          .thenReturn(expectNotifications);
+
+      //when
+      CursorPageResponseDto<NotificationDto> allNotifications
+          = notificationService.getAllNotifications(cursor, afterAt, limit, userId);
+
+      //then
+      assertEquals(notification1.getId(), allNotifications.nextCursor());
+      assertEquals(notification1.getCreatedAt(), allNotifications.nextAfter());
+      assertFalse(allNotifications.hasNext());
+
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/test/java/com/sprint/monew/domain/notification/TestUnreadInterestArticleCount.java b/src/test/java/com/sprint/monew/domain/notification/TestUnreadInterestArticleCount.java
new file mode 100644
index 00000000..9a526d66
--- /dev/null
+++ b/src/test/java/com/sprint/monew/domain/notification/TestUnreadInterestArticleCount.java
@@ -0,0 +1,32 @@
+package com.sprint.monew.domain.notification;
+
+import com.sprint.monew.domain.notification.dto.UnreadInterestArticleCount;
+import java.util.UUID;
+
+public class TestUnreadInterestArticleCount implements UnreadInterestArticleCount {
+
+  private UUID interestId;
+  private String interestName;
+  private Long unreadCount;
+
+  public TestUnreadInterestArticleCount(UUID interestId, String interestName, Long unreadCount) {
+    this.interestId = interestId;
+    this.interestName = interestName;
+    this.unreadCount = unreadCount;
+  }
+
+  @Override
+  public UUID getInterestId() {
+    return interestId;
+  }
+
+  @Override
+  public String getInterestName() {
+    return interestName;
+  }
+
+  @Override
+  public Long getUnreadCount() {
+    return unreadCount;
+  }
+}


Reviews:
user: login: janghoosa
body: 좋은 코드였습니다! 제 코멘트는 참고만 해주셔도 될 것 같아요 :)
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-23T00:50:37Z
user: login: yudility
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T00:54:37Z
user: login: janghoosa
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T01:01:50Z
user: login: yudility
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-23T02:30:13Z

Review comments:
diff_hunk: @@ -1,11 +1,33 @@
 package com.sprint.monew.domain.interest.userinterest;
 
+import com.sprint.monew.domain.notification.dto.UnreadInterestArticleCount;
+import com.sprint.monew.domain.user.User;
+import java.util.List;
 import java.util.UUID;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 public interface UserInterestRepository extends JpaRepository<UserInterest, UserInterestKey> {
 
   int countDistinctByInterestId(UUID interestId);
 
   boolean existsByUserIdAndInterestId(UUID userId, UUID interestId);
+
+  List<UserInterest> findByUser(User user);
+
+  @Query(
+      "SELECT ui.interest.id as interestId, ui.interest.name as interestName, COUNT(DISTINCT ai.article.id) as unreadCount "
+          + "FROM UserInterest ui "
+          // 기사 관심사-관심사-사용자 를 관심사 Id로 조인 (특정 사용자의 관심사에 속하는 모든 기사)
+          + "JOIN ArticleInterest ai ON ui.interest.id = ai.interest.id "
+          // article_view 가 없는 article_interest 도 있어야함 (아직 읽지 않은 기사)
+          // 따라서 article_interest 조인 후, article_view 를 left 조인
+          + "LEFT JOIN ArticleView av ON av.article.id = ai.article.id AND av.user.id = ui.user.id "
+          // 해당 유저가 아직 읽지 않은(av가 null 인) 경우만
+          + "WHERE ui.user.id = :userId AND av IS NULL "
+          // 관심사 별로 안 읽은 기사 count 하기 위해서 Group by 사용
+          + "GROUP BY ui.interest.id, ui.interest.name")
path: src/main/java/com/sprint/monew/domain/interest/userinterest/UserInterestRepository.java
user: login: janghoosa
body: 가독성있게 쿼리를 잘 짜주셨네요👍
created_at: 2025-04-23T00:42:21Z
author_association: COLLABORATOR
diff_hunk: @@ -14,20 +19,35 @@ public class NotificationService {
 
   private final NotificationRepository notificationRepository;
   private final UserRepository userRepository;
+  private final UserInterestRepository subscriberRepository;
 
-  //알림 목록 조회
-  public CursorPageResponseNotificationDto getAllNotifications(String cursor, String after,
-      int limit, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 등록 - 일괄 등록
+  public List<NotificationDto> createNotifications(UUID userId) {
 
-    List<Notification> notifications = notificationRepository.findByUser(user);
-    //todo-페이지네이션 구현 및 적용
-    return null;
+    User user = userRepository.findById(userId)
+        .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+    List<UnreadInterestArticleCount> unreadInterestArticleCounts = subscriberRepository.countUnreadArticlesByInterest(
+        user.getId());
+
+    List<NotificationDto> notificationDtos = new ArrayList<>();
+
+    for (UnreadInterestArticleCount unreadInterestArticleCount : unreadInterestArticleCounts) {
+      Notification notification = new Notification(user, unreadInterestArticleCount.getInterestId(),
+          ResourceType.INTEREST, unreadInterestArticleCount.getInterestName() + "와/과 관련된 기사가 "
+          + unreadInterestArticleCount.getUnreadCount() + "건 등록되었습니다.");
+
+      notificationRepository.save(notification);
+      notificationDtos.add(NotificationDto.from(notification));
+    }
+
+    return notificationDtos;
path: src/main/java/com/sprint/monew/domain/notification/NotificationService.java
user: login: janghoosa
body: 이미 만들어진 기사를 보고 안읽은게 있을때 알림을 생성하는것보다 article이 만들어지는 이벤트일때 생성하는게 나아보여요. 
우선은 유진님이 구현하셨는데 나중에 옮기는게 나아보입니다!
created_at: 2025-04-23T00:49:31Z
author_association: COLLABORATOR
diff_hunk: @@ -14,20 +19,35 @@ public class NotificationService {
 
   private final NotificationRepository notificationRepository;
   private final UserRepository userRepository;
+  private final UserInterestRepository subscriberRepository;
 
-  //알림 목록 조회
-  public CursorPageResponseNotificationDto getAllNotifications(String cursor, String after,
-      int limit, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 등록 - 일괄 등록
+  public List<NotificationDto> createNotifications(UUID userId) {
 
-    List<Notification> notifications = notificationRepository.findByUser(user);
-    //todo-페이지네이션 구현 및 적용
-    return null;
+    User user = userRepository.findById(userId)
+        .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+    List<UnreadInterestArticleCount> unreadInterestArticleCounts = subscriberRepository.countUnreadArticlesByInterest(
+        user.getId());
+
+    List<NotificationDto> notificationDtos = new ArrayList<>();
+
+    for (UnreadInterestArticleCount unreadInterestArticleCount : unreadInterestArticleCounts) {
+      Notification notification = new Notification(user, unreadInterestArticleCount.getInterestId(),
+          ResourceType.INTEREST, unreadInterestArticleCount.getInterestName() + "와/과 관련된 기사가 "
+          + unreadInterestArticleCount.getUnreadCount() + "건 등록되었습니다.");
+
+      notificationRepository.save(notification);
+      notificationDtos.add(NotificationDto.from(notification));
+    }
+
+    return notificationDtos;
path: src/main/java/com/sprint/monew/domain/notification/NotificationService.java
user: login: yudility
body: 저도 이 방식에 의문이 들었는데, 이벤트를 한번도 사용해보지 않아서 해당 방식으로 일단 구현했던 것 같아요 😂 지금 보니 같은 키워드의 기사가 또 등록되면 다시 새로운 알림을 생성할 것 같다는 점도 문제일 것 같아 수정이 필요할 것 같습니다. 이벤트 기반 생성으로 수정하면서 같이 수정해볼게요!
created_at: 2025-04-23T00:54:37Z
author_association: COLLABORATOR
diff_hunk: @@ -14,20 +19,35 @@ public class NotificationService {
 
   private final NotificationRepository notificationRepository;
   private final UserRepository userRepository;
+  private final UserInterestRepository subscriberRepository;
 
-  //알림 목록 조회
-  public CursorPageResponseNotificationDto getAllNotifications(String cursor, String after,
-      int limit, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 등록 - 일괄 등록
+  public List<NotificationDto> createNotifications(UUID userId) {
 
-    List<Notification> notifications = notificationRepository.findByUser(user);
-    //todo-페이지네이션 구현 및 적용
-    return null;
+    User user = userRepository.findById(userId)
+        .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+    List<UnreadInterestArticleCount> unreadInterestArticleCounts = subscriberRepository.countUnreadArticlesByInterest(
+        user.getId());
+
+    List<NotificationDto> notificationDtos = new ArrayList<>();
+
+    for (UnreadInterestArticleCount unreadInterestArticleCount : unreadInterestArticleCounts) {
+      Notification notification = new Notification(user, unreadInterestArticleCount.getInterestId(),
+          ResourceType.INTEREST, unreadInterestArticleCount.getInterestName() + "와/과 관련된 기사가 "
+          + unreadInterestArticleCount.getUnreadCount() + "건 등록되었습니다.");
+
+      notificationRepository.save(notification);
+      notificationDtos.add(NotificationDto.from(notification));
+    }
+
+    return notificationDtos;
path: src/main/java/com/sprint/monew/domain/notification/NotificationService.java
user: login: janghoosa
body: 앗 이벤트가 따로 있는게 아니라 기사를 생성할때 해당 기사의 관심사를 구독한 유저들에게 다 보내는 것을 말한거에요ㅎㅎ
기사 생성할때랑 댓글 생성할때 알림을 만들고 보내는게 좋아보여요.
created_at: 2025-04-23T01:01:50Z
author_association: COLLABORATOR
diff_hunk: @@ -14,20 +19,35 @@ public class NotificationService {
 
   private final NotificationRepository notificationRepository;
   private final UserRepository userRepository;
+  private final UserInterestRepository subscriberRepository;
 
-  //알림 목록 조회
-  public CursorPageResponseNotificationDto getAllNotifications(String cursor, String after,
-      int limit, String userId) {
-    User user = userRepository.findById(UUID.fromString(userId)).orElseThrow();
+  //알림 등록 - 일괄 등록
+  public List<NotificationDto> createNotifications(UUID userId) {
 
-    List<Notification> notifications = notificationRepository.findByUser(user);
-    //todo-페이지네이션 구현 및 적용
-    return null;
+    User user = userRepository.findById(userId)
+        .orElseThrow(() -> new IllegalArgumentException("User not found"));
+
+    List<UnreadInterestArticleCount> unreadInterestArticleCounts = subscriberRepository.countUnreadArticlesByInterest(
+        user.getId());
+
+    List<NotificationDto> notificationDtos = new ArrayList<>();
+
+    for (UnreadInterestArticleCount unreadInterestArticleCount : unreadInterestArticleCounts) {
+      Notification notification = new Notification(user, unreadInterestArticleCount.getInterestId(),
+          ResourceType.INTEREST, unreadInterestArticleCount.getInterestName() + "와/과 관련된 기사가 "
+          + unreadInterestArticleCount.getUnreadCount() + "건 등록되었습니다.");
+
+      notificationRepository.save(notification);
+      notificationDtos.add(NotificationDto.from(notification));
+    }
+
+    return notificationDtos;
path: src/main/java/com/sprint/monew/domain/notification/NotificationService.java
user: login: yudility
body: 이해했습니다! 그럼 기사 등록 배치 작업 이후에 유저-관심사마다 알림 만들어야겠네요. 기사 등록 이후에 일괄적으로 알림 생성해야할 것 같아서 배치 담당인 요한님과 논의해보겠습니다!
created_at: 2025-04-23T02:30:13Z
author_association: COLLABORATOR

PR comments:
user: login: hodu31
created_at: 2025-04-23T00:44:48Z
body: 수고하셨습니다~ 
근데 혹시 알림이 언제 등록이 되는건가요?
author_association: COLLABORATOR
user: login: yudility
created_at: 2025-04-23T00:49:55Z
body: 저도 알림생성 시점이 언제인지 잘 모르겠어서, 일단은 조회 요청을 보내면 해당 유저의 알림을 한번에 생성하도록 구현해두었습니다! 
author_association: COLLABORATOR