From b082f376e7e1b6bdd0c3e47df6ae18a2ce0a226b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:00:27 +0900 Subject: [PATCH 01/19] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A4=91=EB=B3=B5=20=EB=B0=9C=EC=86=A1=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/ArticleKeywordEvent.java | 4 +- .../keyword/model/UserNotificationStatus.java | 4 ++ .../UserNotificationStatusRepository.java | 1 - .../keyword/service/KeywordService.java | 32 +++++----- .../community/util/KeywordExtractor.java | 16 ++++- .../ArticleKeywordEventListener.java | 58 +++++++++++++++---- 6 files changed, 87 insertions(+), 28 deletions(-) diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java index b472d2a794..66a72a6d45 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java @@ -1,11 +1,13 @@ package in.koreatech.koin.common.event; +import java.util.List; + import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; public record ArticleKeywordEvent( Integer articleId, Integer authorId, - ArticleKeyword keyword + List matchedKeywords ) { } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java index 611cdfab34..262f71fc79 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/model/UserNotificationStatus.java @@ -34,4 +34,8 @@ public UserNotificationStatus(Integer userId, Integer notifiedArticleId) { this.userId = userId; this.notifiedArticleId = notifiedArticleId; } + + public void updateNotifiedArticleId(Integer notifiedArticleId) { + this.notifiedArticleId = notifiedArticleId; + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index a405128ef2..57d28287a1 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -2,7 +2,6 @@ import java.util.Optional; -import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 0c083a2067..fe59454470 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -148,22 +148,22 @@ public ArticleKeywordsSuggestionResponse suggestKeywords() { } public void sendKeywordNotification(KeywordNotificationRequest request) { - List updateNotificationIds = request.updateNotification(); - - if (!updateNotificationIds.isEmpty()) { - List
articles = new ArrayList<>(); + List updateNotificationIds = request.updateNotification().stream() + .distinct() + .toList(); - for (Integer id : updateNotificationIds) { - articles.add(articleRepository.getById(id)); - } + if (updateNotificationIds.isEmpty()) { + return; + } - List keywordEvents = keywordExtractor.matchKeyword(articles, null); + List
articles = new ArrayList<>(); + for (Integer id : updateNotificationIds) { + articles.add(articleRepository.getById(id)); + } - if (!keywordEvents.isEmpty()) { - for (ArticleKeywordEvent event : keywordEvents) { - eventPublisher.publishEvent(event); - } - } + List keywordEvents = keywordExtractor.matchKeyword(articles, null); + for (ArticleKeywordEvent event : keywordEvents) { + eventPublisher.publishEvent(event); } } @@ -201,6 +201,10 @@ public void fetchTopKeywordsFromLastWeek() { @Transactional public void createNotifiedArticleStatus(Integer userId, Integer articleId) { - userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId)); + userNotificationStatusRepository.findByUserId(userId) + .ifPresentOrElse( + status -> status.updateNotifiedArticleId(articleId), + () -> userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId)) + ); } } diff --git a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java index b90e80141f..ca3161c3d8 100644 --- a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java +++ b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java @@ -1,7 +1,9 @@ package in.koreatech.koin.domain.community.util; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -24,7 +26,7 @@ public class KeywordExtractor { private final ArticleKeywordRepository articleKeywordRepository; public List matchKeyword(List
articles, Integer authorId) { - List keywordEvents = new ArrayList<>(); + Map> matchedKeywordsByArticleId = new LinkedHashMap<>(); int offset = 0; while (true) { @@ -39,13 +41,23 @@ public List matchKeyword(List
articles, Integer au String title = article.getTitle(); for (ArticleKeyword keyword : keywords) { if (title.contains(keyword.getKeyword())) { - keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, keyword)); + matchedKeywordsByArticleId + .computeIfAbsent(article.getId(), ignored -> new ArrayList<>()) + .add(keyword); } } } offset += KEYWORD_BATCH_SIZE; } + List keywordEvents = new ArrayList<>(); + for (Article article : articles) { + List matchedKeywords = matchedKeywordsByArticleId.get(article.getId()); + if (matchedKeywords != null && !matchedKeywords.isEmpty()) { + keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, matchedKeywords)); + } + } + return keywordEvents; } } diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 30be4c43e2..f03bc3455e 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -3,8 +3,12 @@ import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -15,6 +19,7 @@ import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.domain.notification.model.Notification; @@ -40,15 +45,33 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 public void onKeywordRequest(ArticleKeywordEvent event) { Article article = articleRepository.getById(event.articleId()); Board board = article.getBoard(); + Map matchedKeywordByUserId = getMatchedKeywordByUserId(event.matchedKeywords()); - List notifications = notificationSubscribeRepository + if (matchedKeywordByUserId.isEmpty()) { + return; + } + + Map keywordSubscribersByUserId = notificationSubscribeRepository .findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD) .stream() .filter(this::hasDeviceToken) - .filter(subscribe -> isKeywordRegistered(event, subscribe)) + .collect(Collectors.toMap( + subscribe -> subscribe.getUser().getId(), + Function.identity(), + (existing, ignored) -> existing, + LinkedHashMap::new + )); + + List notifications = keywordSubscribersByUserId.values().stream() + .filter(subscribe -> matchedKeywordByUserId.containsKey(subscribe.getUser().getId())) .filter(subscribe -> isNewNotifiedArticleId(event.articleId(), subscribe)) .filter(subscribe -> !isMyArticle(event, subscribe)) - .map(subscribe -> createAndRecordNotification(article, board, event.keyword(), subscribe)) + .map(subscribe -> createAndRecordNotification( + article, + board, + matchedKeywordByUserId.get(subscribe.getUser().getId()), + subscribe + )) .toList(); notificationService.pushNotifications(notifications); @@ -58,10 +81,25 @@ private boolean hasDeviceToken(NotificationSubscribe subscribe) { return subscribe.getUser().getDeviceToken() != null; } - private boolean isKeywordRegistered(ArticleKeywordEvent event, NotificationSubscribe subscribe) { - return event.keyword().getArticleKeywordUserMaps().stream() - .filter(map -> !map.getIsDeleted()) - .anyMatch(map -> map.getUser().getId().equals(subscribe.getUser().getId())); + private Map getMatchedKeywordByUserId(List matchedKeywords) { + Map matchedKeywordByUserId = new LinkedHashMap<>(); + for (ArticleKeyword keyword : matchedKeywords) { + for (ArticleKeywordUserMap keywordUserMap : keyword.getArticleKeywordUserMaps()) { + if (keywordUserMap.getIsDeleted()) { + continue; + } + Integer userId = keywordUserMap.getUser().getId(); + matchedKeywordByUserId.merge(userId, keyword.getKeyword(), this::pickHigherPriorityKeyword); + } + } + return matchedKeywordByUserId; + } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; + } + return previousKeyword; } private boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) { @@ -78,16 +116,16 @@ private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe sub private Notification createAndRecordNotification( Article article, Board board, - ArticleKeyword keyword, + String keyword, NotificationSubscribe subscribe ) { Integer userId = subscribe.getUser().getId(); - String description = generateDescription(keyword.getKeyword()); + String description = generateDescription(keyword); Notification notification = notificationFactory.generateKeywordNotification( KEYWORD, article.getId(), - keyword.getKeyword(), + keyword, article.getTitle(), board.getId(), description, From 0dfe03f287974a7a69747e2d1fa015dc1c5a1b93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:01:00 +0900 Subject: [PATCH 02/19] =?UTF-8?q?test:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../keyword/service/KeywordServiceTest.java | 103 +++++++++ .../community/util/KeywordExtractorTest.java | 55 +++++ .../ArticleKeywordEventListenerTest.java | 204 ++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java create mode 100644 src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java new file mode 100644 index 0000000000..c4a8363646 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -0,0 +1,103 @@ +package in.koreatech.koin.unit.domain.community.keyword.service; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.context.ApplicationEventPublisher; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class KeywordServiceTest { + + @InjectMocks + private KeywordService keywordService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Mock + private ArticleKeywordSuggestRepository articleKeywordSuggestRepository; + + @Mock + private ArticleRepository articleRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordExtractor keywordExtractor; + + @Test + @DisplayName("중복 게시글 ID 요청은 제거 후 키워드 알림 이벤트를 발행한다.") + void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArticle() { + KeywordNotificationRequest request = new KeywordNotificationRequest(List.of(10, 10, 11, 11)); + Article article10 = mock(Article.class); + Article article11 = mock(Article.class); + when(articleRepository.getById(10)).thenReturn(article10); + when(articleRepository.getById(11)).thenReturn(article11); + + ArticleKeywordEvent event10 = new ArticleKeywordEvent( + 10, + null, + List.of(ArticleKeyword.builder().keyword("A").build()) + ); + ArticleKeywordEvent event11 = new ArticleKeywordEvent( + 11, + null, + List.of(ArticleKeyword.builder().keyword("B").build()) + ); + when(keywordExtractor.matchKeyword(List.of(article10, article11), null)).thenReturn(List.of(event10, event11)); + + keywordService.sendKeywordNotification(request); + + verify(articleRepository, times(1)).getById(10); + verify(articleRepository, times(1)).getById(11); + verify(keywordExtractor).matchKeyword(List.of(article10, article11), null); + verify(eventPublisher).publishEvent(event10); + verify(eventPublisher).publishEvent(event11); + verifyNoMoreInteractions(eventPublisher); + } + + @Test + @DisplayName("업데이트 알림 대상 게시글이 없으면 아무 작업도 수행하지 않는다.") + void sendKeywordNotification_withEmptyArticleIds_doesNothing() { + keywordService.sendKeywordNotification(new KeywordNotificationRequest(List.of())); + + verifyNoInteractions(articleRepository); + verifyNoInteractions(keywordExtractor); + verifyNoInteractions(eventPublisher); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java new file mode 100644 index 0000000000..448bc0c009 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java @@ -0,0 +1,55 @@ +package in.koreatech.koin.unit.domain.community.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.Pageable; + +import in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.util.KeywordExtractor; + +@ExtendWith(MockitoExtension.class) +class KeywordExtractorTest { + + @InjectMocks + private KeywordExtractor keywordExtractor; + + @Mock + private ArticleKeywordRepository articleKeywordRepository; + + @Test + @DisplayName("한 게시글에 여러 키워드가 매칭되면 이벤트는 한 번만 생성된다.") + void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("ABCD"); + + ArticleKeyword keywordA = ArticleKeyword.builder().keyword("A").build(); + ArticleKeyword keywordC = ArticleKeyword.builder().keyword("C").build(); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keywordA, keywordC)) + .thenReturn(List.of()); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).hasSize(1); + ArticleKeywordEvent event = result.get(0); + assertThat(event.articleId()).isEqualTo(1); + assertThat(event.authorId()).isNull(); + assertThat(event.matchedKeywords()).containsExactly(keywordA, keywordC); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java new file mode 100644 index 0000000000..5477783d33 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -0,0 +1,204 @@ +package in.koreatech.koin.unit.domain.notification.eventlistener; + +import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; +import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 in.koreatech.koin.common.event.ArticleKeywordEvent; +import in.koreatech.koin.domain.community.article.model.Article; +import in.koreatech.koin.domain.community.article.model.Board; +import in.koreatech.koin.domain.community.article.repository.ArticleRepository; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; +import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; +import in.koreatech.koin.domain.community.keyword.service.KeywordService; +import in.koreatech.koin.domain.notification.eventlistener.ArticleKeywordEventListener; +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.model.NotificationSubscribe; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class ArticleKeywordEventListenerTest { + + @InjectMocks + private ArticleKeywordEventListener articleKeywordEventListener; + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationFactory notificationFactory; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private UserNotificationStatusRepository userNotificationStatusRepository; + + @Mock + private KeywordService keywordService; + + @Mock + private ArticleRepository articleRepository; + + @Test + @DisplayName("중복 구독/다중 키워드 매칭이어도 사용자당 알림은 한 번만 발송된다.") + void onKeywordRequest_withDuplicateSubscriptionsAndMatchedKeywords_sendsSingleNotification() { + Integer articleId = 100; + Integer boardId = 12; + Integer userId = 1; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribeA = createKeywordSubscribe(subscriber); + NotificationSubscribe subscribeB = createKeywordSubscribe(subscriber); + ArticleKeyword keywordA = createKeyword("근로", subscriber); + ArticleKeyword keywordB = createKeyword("근로장학", subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, List.of(keywordA, keywordB)); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribeA, subscribeB)); + when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(keywordService, times(1)).createNotifiedArticleStatus(userId, articleId); + verify(notificationService).pushNotifications(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + } + + @Test + @DisplayName("게시글 작성자 본인에게는 키워드 알림을 보내지 않는다.") + void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { + Integer articleId = 200; + Integer userId = 2; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeyword keyword = createKeyword("A", subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, userId, List.of(keyword)); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotifications(argThat(List::isEmpty)); + } + + @Test + @DisplayName("이미 해당 게시글 알림을 받은 사용자는 다시 발송하지 않는다.") + void onKeywordRequest_whenAlreadyNotified_skipsNotification() { + Integer articleId = 300; + Integer userId = 3; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeyword keyword = createKeyword("C", subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, List.of(keyword)); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getBoard()).thenReturn(board); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(true); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, never()).generateKeywordNotification( + any(), + anyInt(), + anyString(), + anyString(), + anyInt(), + anyString(), + any() + ); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + verify(notificationService).pushNotifications(argThat(List::isEmpty)); + } + + private NotificationSubscribe createKeywordSubscribe(User user) { + return NotificationSubscribe.builder() + .subscribeType(ARTICLE_KEYWORD) + .user(user) + .build(); + } + + private ArticleKeyword createKeyword(String keyword, User... users) { + ArticleKeyword articleKeyword = ArticleKeyword.builder() + .keyword(keyword) + .build(); + for (User user : users) { + ArticleKeywordUserMap userMap = ArticleKeywordUserMap.builder() + .articleKeyword(articleKeyword) + .user(user) + .build(); + articleKeyword.addUserMap(userMap); + } + return articleKeyword; + } +} From 9396384142d5d0e6e2338a14539c3b0cf7bfc603 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:17:24 +0900 Subject: [PATCH 03/19] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EC=9B=90=EC=9E=90?= =?UTF-8?q?=EC=A0=81=20=EC=97=85=EC=84=9C=ED=8A=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserNotificationStatusRepository.java | 14 ++++++++++++++ .../community/keyword/service/KeywordService.java | 7 +------ .../keyword/service/KeywordServiceTest.java | 8 ++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index 57d28287a1..6377edd9a5 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -2,7 +2,10 @@ import java.util.Optional; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; @@ -13,4 +16,15 @@ public interface UserNotificationStatusRepository extends Repository findByUserId(Integer userId); boolean existsByNotifiedArticleIdAndUserId(Integer notifiedArticleId, Integer userId); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_notification_status (user_id, last_notified_article_id) + VALUES (:userId, :notifiedArticleId) + ON DUPLICATE KEY UPDATE last_notified_article_id = VALUES(last_notified_article_id) + """, nativeQuery = true) + void upsertLastNotifiedArticleId( + @Param("userId") Integer userId, + @Param("notifiedArticleId") Integer notifiedArticleId + ); } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index fe59454470..0bb55d5677 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -26,7 +26,6 @@ import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordSuggestCache; import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; -import in.koreatech.koin.domain.community.keyword.model.UserNotificationStatus; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; @@ -201,10 +200,6 @@ public void fetchTopKeywordsFromLastWeek() { @Transactional public void createNotifiedArticleStatus(Integer userId, Integer articleId) { - userNotificationStatusRepository.findByUserId(userId) - .ifPresentOrElse( - status -> status.updateNotifiedArticleId(articleId), - () -> userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId)) - ); + userNotificationStatusRepository.upsertLastNotifiedArticleId(userId, articleId); } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java index c4a8363646..0382ad53fc 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -100,4 +100,12 @@ void sendKeywordNotification_withEmptyArticleIds_doesNothing() { verifyNoInteractions(keywordExtractor); verifyNoInteractions(eventPublisher); } + + @Test + @DisplayName("발송 이력 저장은 DB upsert를 사용한다.") + void createNotifiedArticleStatus_usesAtomicUpsert() { + keywordService.createNotifiedArticleStatus(1, 100); + + verify(userNotificationStatusRepository).upsertLastNotifiedArticleId(1, 100); + } } From 89619ba549977ca4793c140f0371dc171ff3172c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:53:14 +0900 Subject: [PATCH 04/19] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=9D=90=EB=A6=84=EC=9D=98=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=A1=9C=EB=94=A9=20=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/ArticleKeywordEvent.java | 6 +-- .../ArticleKeywordUserMapRepository.java | 2 + .../community/util/KeywordExtractor.java | 49 ++++++++++++++++--- .../ArticleKeywordEventListener.java | 25 +--------- .../keyword/service/KeywordServiceTest.java | 6 +-- .../community/util/KeywordExtractorTest.java | 42 +++++++++++++--- .../ArticleKeywordEventListenerTest.java | 31 +++--------- 7 files changed, 91 insertions(+), 70 deletions(-) diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java index 66a72a6d45..0336b6da62 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java @@ -1,13 +1,11 @@ package in.koreatech.koin.common.event; -import java.util.List; - -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import java.util.Map; public record ArticleKeywordEvent( Integer articleId, Integer authorId, - List matchedKeywords + Map matchedKeywordByUserId ) { } diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java index 48cc034a50..c1c00bc818 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java @@ -45,4 +45,6 @@ Optional findByArticleKeywordIdAndUserIdIncludingDeleted( @Param("articleKeywordId") Integer articleKeywordId, @Param("userId") Integer userId ); + + List findAllByArticleKeywordIdIn(List articleKeywordIds); } diff --git a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java index ca3161c3d8..2fce59b974 100644 --- a/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java +++ b/src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java @@ -4,6 +4,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -12,8 +13,10 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import lombok.RequiredArgsConstructor; @Service @@ -24,9 +27,10 @@ public class KeywordExtractor { private static final int KEYWORD_BATCH_SIZE = 100; private final ArticleKeywordRepository articleKeywordRepository; + private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository; public List matchKeyword(List
articles, Integer authorId) { - Map> matchedKeywordsByArticleId = new LinkedHashMap<>(); + Map> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>(); int offset = 0; while (true) { @@ -36,14 +40,36 @@ public List matchKeyword(List
articles, Integer au if (keywords.isEmpty()) { break; } + List keywordIds = keywords.stream() + .map(ArticleKeyword::getId) + .toList(); + Map> userMapsByKeywordId = articleKeywordUserMapRepository + .findAllByArticleKeywordIdIn(keywordIds) + .stream() + .filter(keywordUserMap -> !keywordUserMap.getIsDeleted()) + .collect(Collectors.groupingBy( + keywordUserMap -> keywordUserMap.getArticleKeyword().getId(), + LinkedHashMap::new, + Collectors.toList() + )); for (Article article : articles) { String title = article.getTitle(); for (ArticleKeyword keyword : keywords) { - if (title.contains(keyword.getKeyword())) { - matchedKeywordsByArticleId - .computeIfAbsent(article.getId(), ignored -> new ArrayList<>()) - .add(keyword); + if (!title.contains(keyword.getKeyword())) { + continue; + } + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId + .computeIfAbsent(article.getId(), ignored -> new LinkedHashMap<>()); + + for (ArticleKeywordUserMap keywordUserMap : + userMapsByKeywordId.getOrDefault(keyword.getId(), List.of())) { + Integer userId = keywordUserMap.getUser().getId(); + matchedKeywordByUserId.merge( + userId, + keyword.getKeyword(), + this::pickHigherPriorityKeyword + ); } } } @@ -52,12 +78,19 @@ public List matchKeyword(List
articles, Integer au List keywordEvents = new ArrayList<>(); for (Article article : articles) { - List matchedKeywords = matchedKeywordsByArticleId.get(article.getId()); - if (matchedKeywords != null && !matchedKeywords.isEmpty()) { - keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, matchedKeywords)); + Map matchedKeywordByUserId = matchedKeywordByUserIdByArticleId.get(article.getId()); + if (matchedKeywordByUserId != null && !matchedKeywordByUserId.isEmpty()) { + keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, matchedKeywordByUserId)); } } return keywordEvents; } + + private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { + if (candidateKeyword.length() > previousKeyword.length()) { + return candidateKeyword; + } + return previousKeyword; + } } diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index f03bc3455e..e6585b3ac4 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -18,8 +18,6 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.domain.notification.model.Notification; @@ -45,7 +43,7 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 public void onKeywordRequest(ArticleKeywordEvent event) { Article article = articleRepository.getById(event.articleId()); Board board = article.getBoard(); - Map matchedKeywordByUserId = getMatchedKeywordByUserId(event.matchedKeywords()); + Map matchedKeywordByUserId = event.matchedKeywordByUserId(); if (matchedKeywordByUserId.isEmpty()) { return; @@ -81,27 +79,6 @@ private boolean hasDeviceToken(NotificationSubscribe subscribe) { return subscribe.getUser().getDeviceToken() != null; } - private Map getMatchedKeywordByUserId(List matchedKeywords) { - Map matchedKeywordByUserId = new LinkedHashMap<>(); - for (ArticleKeyword keyword : matchedKeywords) { - for (ArticleKeywordUserMap keywordUserMap : keyword.getArticleKeywordUserMaps()) { - if (keywordUserMap.getIsDeleted()) { - continue; - } - Integer userId = keywordUserMap.getUser().getId(); - matchedKeywordByUserId.merge(userId, keyword.getKeyword(), this::pickHigherPriorityKeyword); - } - } - return matchedKeywordByUserId; - } - - private String pickHigherPriorityKeyword(String previousKeyword, String candidateKeyword) { - if (candidateKeyword.length() > previousKeyword.length()) { - return candidateKeyword; - } - return previousKeyword; - } - private boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) { Integer userId = subscribe.getUser().getId(); return !userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java index 0382ad53fc..e0edd1d5cd 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,7 +22,6 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; import in.koreatech.koin.domain.community.keyword.dto.KeywordNotificationRequest; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordSuggestRepository; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; @@ -72,12 +72,12 @@ void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArti ArticleKeywordEvent event10 = new ArticleKeywordEvent( 10, null, - List.of(ArticleKeyword.builder().keyword("A").build()) + Map.of(1, "A") ); ArticleKeywordEvent event11 = new ArticleKeywordEvent( 11, null, - List.of(ArticleKeyword.builder().keyword("B").build()) + Map.of(2, "B") ); when(keywordExtractor.matchKeyword(List.of(article10, article11), null)).thenReturn(List.of(event10, event11)); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java index 448bc0c009..f4db7488d7 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java @@ -6,6 +6,7 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,12 +15,17 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; import in.koreatech.koin.common.event.ArticleKeywordEvent; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; +import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordRepository; +import in.koreatech.koin.domain.community.keyword.repository.ArticleKeywordUserMapRepository; import in.koreatech.koin.domain.community.util.KeywordExtractor; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.unit.fixture.UserFixture; @ExtendWith(MockitoExtension.class) class KeywordExtractorTest { @@ -30,19 +36,28 @@ class KeywordExtractorTest { @Mock private ArticleKeywordRepository articleKeywordRepository; + @Mock + private ArticleKeywordUserMapRepository articleKeywordUserMapRepository; + @Test - @DisplayName("한 게시글에 여러 키워드가 매칭되면 이벤트는 한 번만 생성된다.") + @DisplayName("한 게시글에 여러 키워드가 매칭되면 사용자별 키워드를 병합한 이벤트 한 건만 생성된다.") void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent() { Article article = mock(Article.class); when(article.getId()).thenReturn(1); - when(article.getTitle()).thenReturn("ABCD"); + when(article.getTitle()).thenReturn("근로장학생 모집"); - ArticleKeyword keywordA = ArticleKeyword.builder().keyword("A").build(); - ArticleKeyword keywordC = ArticleKeyword.builder().keyword("C").build(); + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keywordA = createKeyword(1, "근로", subscriber); + ArticleKeyword keywordB = createKeyword(2, "근로장학", subscriber); when(articleKeywordRepository.findAll(any(Pageable.class))) - .thenReturn(List.of(keywordA, keywordC)) + .thenReturn(List.of(keywordA, keywordB)) .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + keywordA.getArticleKeywordUserMaps().get(0), + keywordB.getArticleKeywordUserMaps().get(0) + )); List result = keywordExtractor.matchKeyword(List.of(article), null); @@ -50,6 +65,21 @@ void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent( ArticleKeywordEvent event = result.get(0); assertThat(event.articleId()).isEqualTo(1); assertThat(event.authorId()).isNull(); - assertThat(event.matchedKeywords()).containsExactly(keywordA, keywordC); + assertThat(event.matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로장학")); + } + + private ArticleKeyword createKeyword(Integer keywordId, String keyword, User... users) { + ArticleKeyword articleKeyword = ArticleKeyword.builder() + .keyword(keyword) + .build(); + ReflectionTestUtils.setField(articleKeyword, "id", keywordId); + for (User user : users) { + ArticleKeywordUserMap userMap = ArticleKeywordUserMap.builder() + .articleKeyword(articleKeyword) + .user(user) + .build(); + articleKeyword.addUserMap(userMap); + } + return articleKeyword; } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index 5477783d33..026ba5f1e1 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -15,6 +15,7 @@ import static org.mockito.Mockito.when; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -27,8 +28,6 @@ import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.model.Board; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword; -import in.koreatech.koin.domain.community.keyword.model.ArticleKeywordUserMap; import in.koreatech.koin.domain.community.keyword.repository.UserNotificationStatusRepository; import in.koreatech.koin.domain.community.keyword.service.KeywordService; import in.koreatech.koin.domain.notification.eventlistener.ArticleKeywordEventListener; @@ -65,8 +64,8 @@ class ArticleKeywordEventListenerTest { private ArticleRepository articleRepository; @Test - @DisplayName("중복 구독/다중 키워드 매칭이어도 사용자당 알림은 한 번만 발송된다.") - void onKeywordRequest_withDuplicateSubscriptionsAndMatchedKeywords_sendsSingleNotification() { + @DisplayName("중복 구독이 있어도 사용자당 알림은 한 번만 발송된다.") + void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { Integer articleId = 100; Integer boardId = 12; Integer userId = 1; @@ -75,9 +74,7 @@ void onKeywordRequest_withDuplicateSubscriptionsAndMatchedKeywords_sendsSingleNo NotificationSubscribe subscribeA = createKeywordSubscribe(subscriber); NotificationSubscribe subscribeB = createKeywordSubscribe(subscriber); - ArticleKeyword keywordA = createKeyword("근로", subscriber); - ArticleKeyword keywordB = createKeyword("근로장학", subscriber); - ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, List.of(keywordA, keywordB)); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); Article article = mock(Article.class); Board board = mock(Board.class); @@ -120,8 +117,7 @@ void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { subscriber.permitNotification("device-token"); NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); - ArticleKeyword keyword = createKeyword("A", subscriber); - ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, userId, List.of(keyword)); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, userId, Map.of(userId, "A")); Article article = mock(Article.class); Board board = mock(Board.class); @@ -155,8 +151,7 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { subscriber.permitNotification("device-token"); NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); - ArticleKeyword keyword = createKeyword("C", subscriber); - ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, List.of(keyword)); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "C")); Article article = mock(Article.class); Board board = mock(Board.class); @@ -187,18 +182,4 @@ private NotificationSubscribe createKeywordSubscribe(User user) { .user(user) .build(); } - - private ArticleKeyword createKeyword(String keyword, User... users) { - ArticleKeyword articleKeyword = ArticleKeyword.builder() - .keyword(keyword) - .build(); - for (User user : users) { - ArticleKeywordUserMap userMap = ArticleKeywordUserMap.builder() - .articleKeyword(articleKeyword) - .user(user) - .build(); - articleKeyword.addUserMap(userMap); - } - return articleKeyword; - } } From 2b84fc01a5e44fb7db5dc87ccae0128ec2b80b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:23:24 +0900 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=8F=85=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20fetch=20join=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eventlistener/ArticleKeywordEventListener.java | 2 +- .../repository/NotificationSubscribeRepository.java | 11 +++++++++++ .../ArticleKeywordEventListenerTest.java | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index e6585b3ac4..2ae01c8db4 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -50,7 +50,7 @@ public void onKeywordRequest(ArticleKeywordEvent event) { } Map keywordSubscribersByUserId = notificationSubscribeRepository - .findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD) + .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) .stream() .filter(this::hasDeviceToken) .collect(Collectors.toMap( diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java index c868f1ec87..f9d00d2541 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java @@ -17,6 +17,17 @@ public interface NotificationSubscribeRepository extends Repository findAllBySubscribeTypeAndDetailTypeIsNull(NotificationSubscribeType type); + @Query(""" + SELECT ns + FROM NotificationSubscribe ns + JOIN FETCH ns.user + WHERE ns.subscribeType = :subscribeType + AND ns.detailType IS NULL + """) + List findAllBySubscribeTypeAndDetailTypeIsNullWithUser( + @Param("subscribeType") NotificationSubscribeType subscribeType + ); + boolean existsByUserIdAndSubscribeTypeAndDetailTypeIsNull(Integer userId, NotificationSubscribeType type); boolean existsByUserIdAndSubscribeTypeAndDetailType( diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index 026ba5f1e1..27f6c0926e 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -83,7 +83,7 @@ void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { when(article.getTitle()).thenReturn("근로장학생 모집"); when(article.getBoard()).thenReturn(board); when(board.getId()).thenReturn(boardId); - when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribeA, subscribeB)); when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); @@ -123,7 +123,7 @@ void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { Board board = mock(Board.class); when(articleRepository.getById(articleId)).thenReturn(article); when(article.getBoard()).thenReturn(board); - when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribe)); when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); @@ -157,7 +157,7 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { Board board = mock(Board.class); when(articleRepository.getById(articleId)).thenReturn(article); when(article.getBoard()).thenReturn(board); - when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)) + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribe)); when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(true); From e3e6cd4cdd6a8a99b3e97fc548a6620b1fc598e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:25:52 +0900 Subject: [PATCH 06/19] =?UTF-8?q?fix:=20=EC=97=85=EC=84=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EC=97=90=EC=84=9C=20deprecated=20VALUES=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../keyword/repository/UserNotificationStatusRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index 6377edd9a5..7c98fc20c9 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -21,7 +21,7 @@ public interface UserNotificationStatusRepository extends Repository Date: Tue, 3 Mar 2026 23:52:15 +0900 Subject: [PATCH 07/19] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=BF=BC=EB=A6=AC=EB=A1=9C=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserNotificationStatusRepository.java | 13 +++++++++++++ .../ArticleKeywordEventListener.java | 19 +++++++++++++++---- .../ArticleKeywordEventListenerTest.java | 9 ++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java index 7c98fc20c9..9ab80b0e63 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/repository/UserNotificationStatusRepository.java @@ -1,5 +1,7 @@ package in.koreatech.koin.domain.community.keyword.repository; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.Modifying; @@ -17,6 +19,17 @@ public interface UserNotificationStatusRepository extends Repository findUserIdsByNotifiedArticleIdAndUserIdIn( + @Param("notifiedArticleId") Integer notifiedArticleId, + @Param("userIds") Collection userIds + ); + @Modifying(flushAutomatically = true, clearAutomatically = true) @Query(value = """ INSERT INTO user_notification_status (user_id, last_notified_article_id) diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 2ae01c8db4..17b79a0baf 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -3,10 +3,12 @@ import static in.koreatech.koin.common.model.MobileAppPath.KEYWORD; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.ARTICLE_KEYWORD; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -60,9 +62,14 @@ public void onKeywordRequest(ArticleKeywordEvent event) { LinkedHashMap::new )); + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds( + event.articleId(), + keywordSubscribersByUserId.keySet() + ); + List notifications = keywordSubscribersByUserId.values().stream() .filter(subscribe -> matchedKeywordByUserId.containsKey(subscribe.getUser().getId())) - .filter(subscribe -> isNewNotifiedArticleId(event.articleId(), subscribe)) + .filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !isMyArticle(event, subscribe)) .map(subscribe -> createAndRecordNotification( article, @@ -79,9 +86,13 @@ private boolean hasDeviceToken(NotificationSubscribe subscribe) { return subscribe.getUser().getDeviceToken() != null; } - private boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) { - Integer userId = subscribe.getUser().getId(); - return !userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId); + private Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { + if (subscriberUserIds.isEmpty()) { + return Set.of(); + } + return new HashSet<>( + userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds) + ); } private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) { diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index 27f6c0926e..673f238e80 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -85,7 +85,8 @@ void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { when(board.getId()).thenReturn(boardId); when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribeA, subscribeB)); - when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); Notification notification = mock(Notification.class); when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) @@ -125,7 +126,8 @@ void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { when(article.getBoard()).thenReturn(board); when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribe)); - when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(false); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); articleKeywordEventListener.onKeywordRequest(event); @@ -159,7 +161,8 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { when(article.getBoard()).thenReturn(board); when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) .thenReturn(List.of(subscribe)); - when(userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId)).thenReturn(true); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of(userId)); articleKeywordEventListener.onKeywordRequest(event); From 655f65226aa52c9d506916bd27475c3dfd80eb97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:59:21 +0900 Subject: [PATCH 08/19] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EC=84=B1=EA=B3=B5=20=ED=9B=84=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordEventListener.java | 15 +++++++----- .../service/NotificationService.java | 24 +++++++++++++++++++ .../koin/infrastructure/fcm/FcmClient.java | 16 ++++++++++++- .../ArticleKeywordEventListenerTest.java | 11 ++++++--- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 17b79a0baf..f694d4c794 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -71,7 +71,7 @@ public void onKeywordRequest(ArticleKeywordEvent event) { .filter(subscribe -> matchedKeywordByUserId.containsKey(subscribe.getUser().getId())) .filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !isMyArticle(event, subscribe)) - .map(subscribe -> createAndRecordNotification( + .map(subscribe -> createNotification( article, board, matchedKeywordByUserId.get(subscribe.getUser().getId()), @@ -79,7 +79,13 @@ public void onKeywordRequest(ArticleKeywordEvent event) { )) .toList(); - notificationService.pushNotifications(notifications); + List deliveryResults = + notificationService.pushNotificationsWithResult(notifications); + for (NotificationService.NotificationDeliveryResult deliveryResult : deliveryResults) { + if (deliveryResult.delivered()) { + keywordService.createNotifiedArticleStatus(deliveryResult.notification().getUser().getId(), article.getId()); + } + } } private boolean hasDeviceToken(NotificationSubscribe subscribe) { @@ -101,13 +107,12 @@ private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe sub return Objects.equals(authorId, subscriberId); } - private Notification createAndRecordNotification( + private Notification createNotification( Article article, Board board, String keyword, NotificationSubscribe subscribe ) { - Integer userId = subscribe.getUser().getId(); String description = generateDescription(keyword); Notification notification = notificationFactory.generateKeywordNotification( @@ -119,8 +124,6 @@ private Notification createAndRecordNotification( description, subscribe.getUser() ); - - keywordService.createNotifiedArticleStatus(userId, article.getId()); return notification; } diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index f449dc8a96..7133d4da67 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -30,6 +30,8 @@ @Transactional(readOnly = true) public class NotificationService { + public record NotificationDeliveryResult(Notification notification, boolean delivered) {} + private final UserRepository userRepository; private final NotificationRepository notificationRepository; private final FcmClient fcmClient; @@ -42,6 +44,13 @@ public void pushNotifications(List notifications) { } } + @Transactional + public List pushNotificationsWithResult(List notifications) { + return notifications.stream() + .map(this::pushNotificationWithResult) + .toList(); + } + @Transactional public void pushNotification(Notification notification) { notificationRepository.save(notification); @@ -57,6 +66,21 @@ public void pushNotification(Notification notification) { ); } + private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { + notificationRepository.save(notification); + String deviceToken = notification.getUser().getDeviceToken(); + boolean delivered = fcmClient.sendMessageWithResult( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + return new NotificationDeliveryResult(notification, delivered); + } + public NotificationStatusResponse getNotificationInfo(Integer userId) { User user = userRepository.getById(userId); boolean isPermit = user.getDeviceToken() != null; diff --git a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java index f14b013319..bf8d552c1c 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java +++ b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java @@ -32,9 +32,21 @@ public void sendMessage( MobileAppPath path, String schemeUri, String type + ) { + sendMessageWithResult(targetDeviceToken, title, content, imageUrl, path, schemeUri, type); + } + + public boolean sendMessageWithResult( + String targetDeviceToken, + String title, + String content, + String imageUrl, + MobileAppPath path, + String schemeUri, + String type ) { if (targetDeviceToken == null) { - return; + return false; } log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); @@ -48,8 +60,10 @@ public void sendMessage( try { String result = FirebaseMessaging.getInstance().send(message); log.info("FCM 알림 전송 성공: {}", result); + return true; } catch (Exception e) { log.warn("FCM 알림 전송 실패", e); + return false; } } diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index 673f238e80..94f8066693 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -89,8 +89,11 @@ void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { .thenReturn(List.of()); Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(subscriber); when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, true))); articleKeywordEventListener.onKeywordRequest(event); @@ -104,7 +107,7 @@ void onKeywordRequest_withDuplicateSubscriptions_sendsSingleNotification() { eq(subscriber) ); verify(keywordService, times(1)).createNotifiedArticleStatus(userId, articleId); - verify(notificationService).pushNotifications(argThat(notifications -> + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> notifications.size() == 1 && notifications.contains(notification) )); } @@ -128,6 +131,7 @@ void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { .thenReturn(List.of(subscribe)); when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) .thenReturn(List.of()); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); articleKeywordEventListener.onKeywordRequest(event); @@ -141,7 +145,7 @@ void onKeywordRequest_whenSubscriberIsAuthor_skipsNotification() { any() ); verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); - verify(notificationService).pushNotifications(argThat(List::isEmpty)); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); } @Test @@ -163,6 +167,7 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { .thenReturn(List.of(subscribe)); when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) .thenReturn(List.of(userId)); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); articleKeywordEventListener.onKeywordRequest(event); @@ -176,7 +181,7 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { any() ); verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); - verify(notificationService).pushNotifications(argThat(List::isEmpty)); + verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); } private NotificationSubscribe createKeywordSubscribe(User user) { From a6ba4f6f890913dad2510e64b8431e70395880fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:12:45 +0900 Subject: [PATCH 09/19] =?UTF-8?q?fix:=20FCM=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../koin/infrastructure/fcm/FcmClient.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java index bf8d552c1c..286fb59315 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java +++ b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java @@ -48,16 +48,18 @@ public boolean sendMessageWithResult( if (targetDeviceToken == null) { return false; } - log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); + try { + log.info("call FcmClient sendMessage: title: {}, content: {}", title, content); - ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); - AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + ApnsConfig apnsConfig = generateAppleConfig(title, content, imageUrl, path, type, schemeUri); + AndroidConfig androidConfig = generateAndroidConfig(title, content, imageUrl, schemeUri, type); + + Message message = Message.builder() + .setToken(targetDeviceToken) + .setApnsConfig(apnsConfig) + .setAndroidConfig(androidConfig) + .build(); - Message message = Message.builder() - .setToken(targetDeviceToken) - .setApnsConfig(apnsConfig) - .setAndroidConfig(androidConfig).build(); - try { String result = FirebaseMessaging.getInstance().send(message); log.info("FCM 알림 전송 성공: {}", result); return true; From 472e177db282b03e3e61dd4fe296923b4bb834dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:19:27 +0900 Subject: [PATCH 10/19] =?UTF-8?q?test:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A0=84=EC=86=A1=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EC=8B=9C=20=EC=83=81=ED=83=9C=20=EB=AF=B8=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordEventListenerTest.java | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index 94f8066693..e75ef7e6e3 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -184,6 +184,53 @@ void onKeywordRequest_whenAlreadyNotified_skipsNotification() { verify(notificationService).pushNotificationsWithResult(argThat(List::isEmpty)); } + @Test + @DisplayName("알림 전송 실패 시 발송 이력을 저장하지 않는다.") + void onKeywordRequest_whenDeliveryFails_doesNotRecordNotifiedStatus() { + Integer articleId = 400; + Integer boardId = 15; + Integer userId = 4; + User subscriber = UserFixture.id_설정_코인_유저(userId); + subscriber.permitNotification("device-token"); + + NotificationSubscribe subscribe = createKeywordSubscribe(subscriber); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(userId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(subscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(eq(articleId), any())) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())) + .thenReturn(List.of(new NotificationService.NotificationDeliveryResult(notification, false))); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(notificationFactory, times(1)).generateKeywordNotification( + eq(KEYWORD), + eq(articleId), + eq("근로장학"), + eq("근로장학생 모집"), + eq(boardId), + contains("근로장학"), + eq(subscriber) + ); + verify(notificationService).pushNotificationsWithResult(argThat(notifications -> + notifications.size() == 1 && notifications.contains(notification) + )); + verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); + } + private NotificationSubscribe createKeywordSubscribe(User user) { return NotificationSubscribe.builder() .subscribeType(ARTICLE_KEYWORD) From 2e0dd020b5da3745abf07987d3b2b8d783e91e36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:22:41 +0900 Subject: [PATCH 11/19] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=A9=94=EC=84=9C=EB=93=9C=EC=9D=98=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EA=B2=BD=EA=B3=84=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/service/NotificationService.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 7133d4da67..19e5cd3280 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -7,6 +7,7 @@ import java.util.List; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.dining.model.DiningType; @@ -38,20 +39,21 @@ public record NotificationDeliveryResult(Notification notification, boolean deli private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void pushNotifications(List notifications) { for (Notification notification : notifications) { pushNotification(notification); } } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public List pushNotificationsWithResult(List notifications) { return notifications.stream() .map(this::pushNotificationWithResult) .toList(); } - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void pushNotification(Notification notification) { notificationRepository.save(notification); String deviceToken = notification.getUser().getDeviceToken(); @@ -146,6 +148,7 @@ public void rejectNotificationByDetailType(Integer userId, NotificationDetailSub notificationSubscribeRepository.deleteByUserIdAndDetailType(userId, detailType); } + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void sendDiningSoldOutNotifications(Integer dinningId, String place, DiningType diningType) { NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(diningType); var notifications = notificationSubscribeRepository.findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, detailType) From a9a4473bfdbf1a4dad4e57a28e56eb237f573489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:26:19 +0900 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20=EA=B8=B0=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=8C=80=EC=83=81=EC=9D=84=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A1=9C=20=ED=95=9C?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ArticleKeywordEventListener.java | 8 +++- .../ArticleKeywordEventListenerTest.java | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index f694d4c794..113b003518 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -62,13 +62,17 @@ public void onKeywordRequest(ArticleKeywordEvent event) { LinkedHashMap::new )); + Set matchedUserIds = keywordSubscribersByUserId.keySet().stream() + .filter(matchedKeywordByUserId::containsKey) + .collect(Collectors.toSet()); + Set alreadyNotifiedUserIds = getAlreadyNotifiedUserIds( event.articleId(), - keywordSubscribersByUserId.keySet() + matchedUserIds ); List notifications = keywordSubscribersByUserId.values().stream() - .filter(subscribe -> matchedKeywordByUserId.containsKey(subscribe.getUser().getId())) + .filter(subscribe -> matchedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId())) .filter(subscribe -> !isMyArticle(event, subscribe)) .map(subscribe -> createNotification( diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java index e75ef7e6e3..2b8b4271e3 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -231,6 +232,46 @@ void onKeywordRequest_whenDeliveryFails_doesNotRecordNotifiedStatus() { verify(keywordService, never()).createNotifiedArticleStatus(anyInt(), anyInt()); } + @Test + @DisplayName("기발송 사용자 조회는 매칭된 사용자 ID만 대상으로 수행한다.") + void onKeywordRequest_queriesNotifiedStatusOnlyForMatchedUsers() { + Integer articleId = 500; + Integer boardId = 16; + Integer matchedUserId = 5; + Integer unmatchedUserId = 6; + + User matchedUser = UserFixture.id_설정_코인_유저(matchedUserId); + matchedUser.permitNotification("matched-device-token"); + User unmatchedUser = UserFixture.id_설정_코인_유저(unmatchedUserId); + unmatchedUser.permitNotification("unmatched-device-token"); + + NotificationSubscribe matchedSubscribe = createKeywordSubscribe(matchedUser); + NotificationSubscribe unmatchedSubscribe = createKeywordSubscribe(unmatchedUser); + ArticleKeywordEvent event = new ArticleKeywordEvent(articleId, 999, Map.of(matchedUserId, "근로장학")); + + Article article = mock(Article.class); + Board board = mock(Board.class); + when(articleRepository.getById(articleId)).thenReturn(article); + when(article.getId()).thenReturn(articleId); + when(article.getTitle()).thenReturn("근로장학생 모집"); + when(article.getBoard()).thenReturn(board); + when(board.getId()).thenReturn(boardId); + when(notificationSubscribeRepository.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)) + .thenReturn(List.of(matchedSubscribe, unmatchedSubscribe)); + when(userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId))) + .thenReturn(List.of()); + + Notification notification = mock(Notification.class); + when(notificationFactory.generateKeywordNotification(any(), anyInt(), anyString(), anyString(), anyInt(), anyString(), any())) + .thenReturn(notification); + when(notificationService.pushNotificationsWithResult(any())).thenReturn(List.of()); + + articleKeywordEventListener.onKeywordRequest(event); + + verify(userNotificationStatusRepository) + .findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, Set.of(matchedUserId)); + } + private NotificationSubscribe createKeywordSubscribe(User user) { return NotificationSubscribe.builder() .subscribeType(ARTICLE_KEYWORD) From b5137f5ac8e3a8c0b6a7878e8866bb7b643ab04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Wed, 4 Mar 2026 00:36:36 +0900 Subject: [PATCH 13/19] =?UTF-8?q?fix:=20=EC=A0=84=EC=86=A1=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=8B=9C=EC=97=90=EB=A7=8C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A0=88=EC=BD=94=EB=93=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 4 +- .../service/NotificationServiceTest.java | 97 +++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 19e5cd3280..10387f29be 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -69,7 +69,6 @@ public void pushNotification(Notification notification) { } private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { - notificationRepository.save(notification); String deviceToken = notification.getUser().getDeviceToken(); boolean delivered = fcmClient.sendMessageWithResult( deviceToken, @@ -80,6 +79,9 @@ private NotificationDeliveryResult pushNotificationWithResult(Notification notif notification.getSchemeUri(), notification.getType().toLowerCase() ); + if (delivered) { + notificationRepository.save(notification); + } return new NotificationDeliveryResult(notification, delivered); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000000..689c39f97f --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -0,0 +1,97 @@ +package in.koreatech.koin.unit.domain.notification.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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 in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.model.NotificationFactory; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationService; +import in.koreatech.koin.domain.user.model.User; +import in.koreatech.koin.domain.user.repository.UserRepository; +import in.koreatech.koin.infrastructure.fcm.FcmClient; +import in.koreatech.koin.unit.fixture.UserFixture; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @InjectMocks + private NotificationService notificationService; + + @Mock + private UserRepository userRepository; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private FcmClient fcmClient; + + @Mock + private NotificationSubscribeRepository notificationSubscribeRepository; + + @Mock + private NotificationFactory notificationFactory; + + @Test + @DisplayName("알림 전송 성공 시 알림 레코드를 저장한다.") + void pushNotificationsWithResult_whenDelivered_savesNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isTrue(); + verify(notificationRepository).save(notification); + } + + @Test + @DisplayName("알림 전송 실패 시 알림 레코드를 저장하지 않는다.") + void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { + Notification notification = createNotification("device-token"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(false); + + List result = notificationService.pushNotificationsWithResult( + List.of(notification) + ); + + assertThat(result).hasSize(1); + assertThat(result.get(0).delivered()).isFalse(); + verify(notificationRepository, never()).save(notification); + } + + private Notification createNotification(String deviceToken) { + User user = UserFixture.id_설정_코인_유저(1); + user.permitNotification(deviceToken); + + Notification notification = mock(Notification.class); + when(notification.getUser()).thenReturn(user); + when(notification.getTitle()).thenReturn("title"); + when(notification.getMessage()).thenReturn("message"); + when(notification.getImageUrl()).thenReturn(null); + when(notification.getMobileAppPath()).thenReturn(null); + when(notification.getSchemeUri()).thenReturn("scheme-uri"); + when(notification.getType()).thenReturn("message"); + return notification; + } +} From 22195a25b9900956b4bc62f1a728a88f5f77f401 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 19:46:18 +0900 Subject: [PATCH 14/19] fix: guard against blank FCM device tokens in sendMessageWithResult (#2168) * Initial plan * fix: use StringUtils.hasText to guard against blank FCM tokens Co-authored-by: taejinn <140797244+taejinn@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: taejinn <140797244+taejinn@users.noreply.github.com> --- .../java/in/koreatech/koin/infrastructure/fcm/FcmClient.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java index 286fb59315..776e98d5fc 100644 --- a/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java +++ b/src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import com.google.firebase.messaging.AndroidConfig; import com.google.firebase.messaging.ApnsConfig; @@ -45,7 +46,7 @@ public boolean sendMessageWithResult( String schemeUri, String type ) { - if (targetDeviceToken == null) { + if (!StringUtils.hasText(targetDeviceToken)) { return false; } try { From 47b85912704f61878604952f517d431ecd5efbfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Thu, 5 Mar 2026 20:02:36 +0900 Subject: [PATCH 15/19] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EB=A9=94=EC=84=9C=EB=93=9C=EA=B0=80=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=9E=90=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98?= =?UTF-8?q?=EC=97=90=20=EC=B0=B8=EC=97=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/service/NotificationService.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 10387f29be..5dfd9a1bd3 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -7,7 +7,6 @@ import java.util.List; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import in.koreatech.koin.domain.dining.model.DiningType; @@ -39,21 +38,21 @@ public record NotificationDeliveryResult(Notification notification, boolean deli private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional public void pushNotifications(List notifications) { for (Notification notification : notifications) { pushNotification(notification); } } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional public List pushNotificationsWithResult(List notifications) { return notifications.stream() .map(this::pushNotificationWithResult) .toList(); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional public void pushNotification(Notification notification) { notificationRepository.save(notification); String deviceToken = notification.getUser().getDeviceToken(); @@ -150,7 +149,7 @@ public void rejectNotificationByDetailType(Integer userId, NotificationDetailSub notificationSubscribeRepository.deleteByUserIdAndDetailType(userId, detailType); } - @Transactional(propagation = Propagation.NOT_SUPPORTED) + @Transactional public void sendDiningSoldOutNotifications(Integer dinningId, String place, DiningType diningType) { NotificationDetailSubscribeType detailType = NotificationDetailSubscribeType.from(diningType); var notifications = notificationSubscribeRepository.findAllBySubscribeTypeAndDetailType(DINING_SOLD_OUT, detailType) From e28c9ec75046527fb93b2a8b039796b3624aeb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:43:35 +0900 Subject: [PATCH 16/19] =?UTF-8?q?fix:=20=EB=B0=B0=EC=B9=98=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=A0=84=EC=86=A1=20=EC=98=88=EC=99=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 11 ++++- .../service/NotificationServiceTest.java | 40 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 5dfd9a1bd3..e1a27377c2 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -24,7 +24,9 @@ import in.koreatech.koin.infrastructure.fcm.FcmClient; import io.micrometer.common.util.StringUtils; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -48,7 +50,14 @@ public void pushNotifications(List notifications) { @Transactional public List pushNotificationsWithResult(List notifications) { return notifications.stream() - .map(this::pushNotificationWithResult) + .map(notification -> { + try { + return pushNotificationWithResult(notification); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + return new NotificationDeliveryResult(notification, false); + } + }) .toList(); } diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java index 689c39f97f..c6c6866747 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -80,6 +81,45 @@ void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { verify(notificationRepository, never()).save(notification); } + @Test + @DisplayName("배치 알림 중 일부 전송 결과 저장이 실패해도 다음 알림을 계속 처리한다.") + void pushNotificationsWithResult_whenSaveFails_continuesNextNotification() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, true); + doThrow(new RuntimeException("save fail")).when(notificationRepository).save(firstNotification); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isFalse(); + assertThat(result.get(1).delivered()).isTrue(); + verify(notificationRepository).save(firstNotification); + verify(notificationRepository).save(secondNotification); + } + + @Test + @DisplayName("배치 알림은 전송 성공 여부를 각각 반환하고 성공한 알림만 저장한다.") + void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, false); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isFalse(); + verify(notificationRepository).save(firstNotification); + verify(notificationRepository, never()).save(secondNotification); + } + private Notification createNotification(String deviceToken) { User user = UserFixture.id_설정_코인_유저(1); user.permitNotification(deviceToken); From 8656f63061a9c3897534a695b5aff501f11d141c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:52:48 +0900 Subject: [PATCH 17/19] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=ED=91=B8?= =?UTF-8?q?=EC=8B=9C=20=EC=A0=84=EC=86=A1=20=ED=9D=90=EB=A6=84=EC=9D=84=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/ArticleKeywordEvent.java | 5 + .../article/repository/ArticleRepository.java | 2 + .../keyword/service/KeywordService.java | 18 ++- .../ArticleKeywordEventListener.java | 8 +- .../repository/NotificationRepository.java | 2 + .../service/NotificationService.java | 114 ++++++++++++------ .../keyword/service/KeywordServiceTest.java | 9 +- .../community/util/KeywordExtractorTest.java | 69 +++++++++++ .../service/NotificationServiceTest.java | 57 +++++---- 9 files changed, 205 insertions(+), 79 deletions(-) diff --git a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java index 0336b6da62..7dafab30f5 100644 --- a/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java +++ b/src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java @@ -1,5 +1,7 @@ package in.koreatech.koin.common.event; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; public record ArticleKeywordEvent( @@ -8,4 +10,7 @@ public record ArticleKeywordEvent( Map matchedKeywordByUserId ) { + public ArticleKeywordEvent { + matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId)); + } } diff --git a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java index da0d9ac0ba..daabf61dc3 100644 --- a/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java +++ b/src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java @@ -28,6 +28,8 @@ public interface ArticleRepository extends Repository { Optional
findById(Integer articleId); + List
findAllByIdIn(List articleIds); + Page
findAll(Pageable pageable); Page
findAllByBoardIdNot(Integer boardId, PageRequest pageRequest); diff --git a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java index 0bb55d5677..b8e79e4759 100644 --- a/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java +++ b/src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java @@ -1,7 +1,6 @@ package in.koreatech.koin.domain.community.keyword.service; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -12,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import in.koreatech.koin.domain.community.article.exception.ArticleNotFoundException; import in.koreatech.koin.domain.community.article.dto.ArticleKeywordResult; import in.koreatech.koin.domain.community.article.model.Article; import in.koreatech.koin.domain.community.article.repository.ArticleRepository; @@ -155,10 +155,18 @@ public void sendKeywordNotification(KeywordNotificationRequest request) { return; } - List
articles = new ArrayList<>(); - for (Integer id : updateNotificationIds) { - articles.add(articleRepository.getById(id)); - } + List
fetchedArticles = articleRepository.findAllByIdIn(updateNotificationIds); + var articleById = fetchedArticles.stream() + .collect(Collectors.toMap(Article::getId, article -> article)); + List
articles = updateNotificationIds.stream() + .map(articleId -> { + Article article = articleById.get(articleId); + if (article == null) { + throw ArticleNotFoundException.withDetail("articleId: " + articleId); + } + return article; + }) + .toList(); List keywordEvents = keywordExtractor.matchKeyword(articles, null); for (ArticleKeywordEvent event : keywordEvents) { diff --git a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java index 113b003518..53f985fe77 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java +++ b/src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java @@ -14,6 +14,7 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.transaction.event.TransactionalEventListener; import in.koreatech.koin.common.event.ArticleKeywordEvent; @@ -43,14 +44,15 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈 @TransactionalEventListener public void onKeywordRequest(ArticleKeywordEvent event) { - Article article = articleRepository.getById(event.articleId()); - Board board = article.getBoard(); Map matchedKeywordByUserId = event.matchedKeywordByUserId(); if (matchedKeywordByUserId.isEmpty()) { return; } + Article article = articleRepository.getById(event.articleId()); + Board board = article.getBoard(); + Map keywordSubscribersByUserId = notificationSubscribeRepository .findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD) .stream() @@ -93,7 +95,7 @@ public void onKeywordRequest(ArticleKeywordEvent event) { } private boolean hasDeviceToken(NotificationSubscribe subscribe) { - return subscribe.getUser().getDeviceToken() != null; + return StringUtils.hasText(subscribe.getUser().getDeviceToken()); } private Set getAlreadyNotifiedUserIds(Integer articleId, Set subscriberUserIds) { diff --git a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java index 575e51ee1f..9e066cb298 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java @@ -7,4 +7,6 @@ public interface NotificationRepository extends Repository { Notification save(Notification notification); + + void saveAll(Iterable notifications); } diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index e1a27377c2..6f83de72eb 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -4,10 +4,13 @@ import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.DINING_SOLD_OUT; import static in.koreatech.koin.domain.notification.model.NotificationSubscribeType.getParentType; +import java.util.ArrayList; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import in.koreatech.koin.domain.dining.model.DiningType; import in.koreatech.koin.domain.notification.dto.NotificationStatusResponse; @@ -42,60 +45,46 @@ public record NotificationDeliveryResult(Notification notification, boolean deli @Transactional public void pushNotifications(List notifications) { - for (Notification notification : notifications) { - pushNotification(notification); + if (notifications.isEmpty()) { + return; } + notificationRepository.saveAll(notifications); + runAfterCommit(() -> notifications.forEach(this::sendNotificationSafely)); } @Transactional public List pushNotificationsWithResult(List notifications) { - return notifications.stream() - .map(notification -> { - try { - return pushNotificationWithResult(notification); - } catch (Exception e) { - log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); - return new NotificationDeliveryResult(notification, false); - } - }) - .toList(); + if (notifications.isEmpty()) { + return List.of(); + } + + notificationRepository.saveAll(notifications); + List deliveryResults = new ArrayList<>(notifications.size()); + // afterCommit 콜백은 트랜잭션 프록시가 반환되기 전에 실행되므로 호출자는 채워진 결과를 받는다. + runAfterCommit(() -> notifications.forEach(notification -> + deliveryResults.add(pushNotificationWithResult(notification)) + )); + return deliveryResults; } @Transactional public void pushNotification(Notification notification) { - notificationRepository.save(notification); - String deviceToken = notification.getUser().getDeviceToken(); - fcmClient.sendMessage( - deviceToken, - notification.getTitle(), - notification.getMessage(), - notification.getImageUrl(), - notification.getMobileAppPath(), - notification.getSchemeUri(), - notification.getType().toLowerCase() - ); + pushNotifications(List.of(notification)); } private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { - String deviceToken = notification.getUser().getDeviceToken(); - boolean delivered = fcmClient.sendMessageWithResult( - deviceToken, - notification.getTitle(), - notification.getMessage(), - notification.getImageUrl(), - notification.getMobileAppPath(), - notification.getSchemeUri(), - notification.getType().toLowerCase() - ); - if (delivered) { - notificationRepository.save(notification); + try { + boolean delivered = sendNotificationWithResult(notification); + return new NotificationDeliveryResult(notification, delivered); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + return new NotificationDeliveryResult(notification, false); } - return new NotificationDeliveryResult(notification, delivered); } public NotificationStatusResponse getNotificationInfo(Integer userId) { User user = userRepository.getById(userId); - boolean isPermit = user.getDeviceToken() != null; + boolean isPermit = StringUtils.isNotBlank(user.getDeviceToken()); List subscribeList = notificationSubscribeRepository.findAllByUserId(userId); return NotificationStatusResponse.of(isPermit, subscribeList); } @@ -173,6 +162,57 @@ public void sendDiningSoldOutNotifications(Integer dinningId, String place, Dini pushNotifications(notifications); } + private void sendNotificationSafely(Notification notification) { + try { + sendNotification(notification); + } catch (Exception e) { + log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); + } + } + + private void sendNotification(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + fcmClient.sendMessage( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private boolean sendNotificationWithResult(Notification notification) { + String deviceToken = notification.getUser().getDeviceToken(); + return fcmClient.sendMessageWithResult( + deviceToken, + notification.getTitle(), + notification.getMessage(), + notification.getImageUrl(), + notification.getMobileAppPath(), + notification.getSchemeUri(), + notification.getType().toLowerCase() + ); + } + + private void runAfterCommit(Runnable task) { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) { + task.run(); + return; + } + + // Rollback된 데이터에 대한 푸시 전송을 막기 위해 커밋 이후에만 FCM을 호출한다. + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + + @Override + public void afterCommit() { + task.run(); + } + }); + } + private void ensureUserDeviceToken(String deviceToken) { if (StringUtils.isBlank(deviceToken)) { throw NotificationNotPermitException.withDetail("user.deviceToken: null"); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java index e0edd1d5cd..6ff874eb0c 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java @@ -1,7 +1,6 @@ package in.koreatech.koin.unit.domain.community.keyword.service; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -66,8 +65,9 @@ void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArti KeywordNotificationRequest request = new KeywordNotificationRequest(List.of(10, 10, 11, 11)); Article article10 = mock(Article.class); Article article11 = mock(Article.class); - when(articleRepository.getById(10)).thenReturn(article10); - when(articleRepository.getById(11)).thenReturn(article11); + when(article10.getId()).thenReturn(10); + when(article11.getId()).thenReturn(11); + when(articleRepository.findAllByIdIn(List.of(10, 11))).thenReturn(List.of(article11, article10)); ArticleKeywordEvent event10 = new ArticleKeywordEvent( 10, @@ -83,8 +83,7 @@ void sendKeywordNotification_withDuplicatedArticleIds_publishesEventsOncePerArti keywordService.sendKeywordNotification(request); - verify(articleRepository, times(1)).getById(10); - verify(articleRepository, times(1)).getById(11); + verify(articleRepository).findAllByIdIn(List.of(10, 11)); verify(keywordExtractor).matchKeyword(List.of(article10, article11), null); verify(eventPublisher).publishEvent(event10); verify(eventPublisher).publishEvent(event11); diff --git a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java index f4db7488d7..0c9c4f6076 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.List; @@ -68,6 +69,74 @@ void matchKeyword_withMultipleMatchedKeywordsInSingleArticle_createsSingleEvent( assertThat(event.matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로장학")); } + @Test + @DisplayName("매칭되는 키워드가 없으면 이벤트를 생성하지 않는다.") + void matchKeyword_whenNoKeywordsMatch_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(article.getTitle()).thenReturn("근로장학생 모집"); + + User subscriber = UserFixture.id_설정_코인_유저(1); + ArticleKeyword keyword = createKeyword(1, "장학금", subscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(keyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of(keyword.getArticleKeywordUserMaps().get(0))); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("여러 게시글이 각각 다른 키워드에 매칭되면 게시글별 이벤트를 생성한다.") + void matchKeyword_withMultipleArticles_createsEventPerArticle() { + Article firstArticle = mock(Article.class); + when(firstArticle.getId()).thenReturn(1); + when(firstArticle.getTitle()).thenReturn("근로장학생 모집"); + + Article secondArticle = mock(Article.class); + when(secondArticle.getId()).thenReturn(2); + when(secondArticle.getTitle()).thenReturn("국가장학금 신청 안내"); + + User firstSubscriber = UserFixture.id_설정_코인_유저(1); + User secondSubscriber = UserFixture.id_설정_코인_유저(2); + ArticleKeyword firstKeyword = createKeyword(1, "근로", firstSubscriber); + ArticleKeyword secondKeyword = createKeyword(2, "장학금", secondSubscriber); + + when(articleKeywordRepository.findAll(any(Pageable.class))) + .thenReturn(List.of(firstKeyword, secondKeyword)) + .thenReturn(List.of()); + when(articleKeywordUserMapRepository.findAllByArticleKeywordIdIn(any())) + .thenReturn(List.of( + firstKeyword.getArticleKeywordUserMaps().get(0), + secondKeyword.getArticleKeywordUserMaps().get(0) + )); + + List result = keywordExtractor.matchKeyword(List.of(firstArticle, secondArticle), null); + + assertThat(result).hasSize(2); + assertThat(result.get(0).articleId()).isEqualTo(1); + assertThat(result.get(0).matchedKeywordByUserId()).isEqualTo(Map.of(1, "근로")); + assertThat(result.get(1).articleId()).isEqualTo(2); + assertThat(result.get(1).matchedKeywordByUserId()).isEqualTo(Map.of(2, "장학금")); + } + + @Test + @DisplayName("등록된 키워드가 없으면 빈 결과를 반환한다.") + void matchKeyword_whenNoKeywordsExist_returnsEmptyResult() { + Article article = mock(Article.class); + when(article.getId()).thenReturn(1); + when(articleKeywordRepository.findAll(any(Pageable.class))).thenReturn(List.of()); + + List result = keywordExtractor.matchKeyword(List.of(article), null); + + assertThat(result).isEmpty(); + verifyNoInteractions(articleKeywordUserMapRepository); + } + private ArticleKeyword createKeyword(Integer keywordId, String keyword, User... users) { ArticleKeyword articleKeyword = ArticleKeyword.builder() .keyword(keyword) diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java index c6c6866747..f630e9b32c 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -3,7 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -50,8 +51,8 @@ class NotificationServiceTest { private NotificationFactory notificationFactory; @Test - @DisplayName("알림 전송 성공 시 알림 레코드를 저장한다.") - void pushNotificationsWithResult_whenDelivered_savesNotification() { + @DisplayName("알림 전송 결과 조회는 전송 전에 알림 레코드를 저장한다.") + void pushNotificationsWithResult_whenDelivered_savesNotificationBeforeSend() { Notification notification = createNotification("device-token"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) .thenReturn(true); @@ -62,12 +63,16 @@ void pushNotificationsWithResult_whenDelivered_savesNotification() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isTrue(); - verify(notificationRepository).save(notification); + InOrder inOrder = inOrder(notificationRepository, fcmClient); + inOrder.verify(notificationRepository).saveAll(List.of(notification)); + inOrder.verify(fcmClient).sendMessageWithResult( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); } @Test - @DisplayName("알림 전송 실패 시 알림 레코드를 저장하지 않는다.") - void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { + @DisplayName("알림 전송 실패 시에도 저장된 알림 기준으로 실패 결과를 반환한다.") + void pushNotificationsWithResult_whenDeliveryFails_returnsFailedResult() { Notification notification = createNotification("device-token"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) .thenReturn(false); @@ -78,46 +83,40 @@ void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isFalse(); - verify(notificationRepository, never()).save(notification); + verify(notificationRepository).saveAll(List.of(notification)); } @Test - @DisplayName("배치 알림 중 일부 전송 결과 저장이 실패해도 다음 알림을 계속 처리한다.") - void pushNotificationsWithResult_whenSaveFails_continuesNextNotification() { + @DisplayName("배치 알림은 전송 성공 여부를 각각 반환한다.") + void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { Notification firstNotification = createNotification("device-token-1"); Notification secondNotification = createNotification("device-token-2"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) - .thenReturn(true, true); - doThrow(new RuntimeException("save fail")).when(notificationRepository).save(firstNotification); + .thenReturn(true, false); List result = notificationService.pushNotificationsWithResult( List.of(firstNotification, secondNotification) ); assertThat(result).hasSize(2); - assertThat(result.get(0).delivered()).isFalse(); - assertThat(result.get(1).delivered()).isTrue(); - verify(notificationRepository).save(firstNotification); - verify(notificationRepository).save(secondNotification); + assertThat(result.get(0).delivered()).isTrue(); + assertThat(result.get(1).delivered()).isFalse(); + verify(notificationRepository).saveAll(List.of(firstNotification, secondNotification)); } @Test - @DisplayName("배치 알림은 전송 성공 여부를 각각 반환하고 성공한 알림만 저장한다.") - void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { - Notification firstNotification = createNotification("device-token-1"); - Notification secondNotification = createNotification("device-token-2"); - when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) - .thenReturn(true, false); + @DisplayName("단건 알림 전송은 저장 후 FCM 전송을 수행한다.") + void pushNotification_savesNotificationBeforeSend() { + Notification notification = createNotification("device-token"); - List result = notificationService.pushNotificationsWithResult( - List.of(firstNotification, secondNotification) - ); + notificationService.pushNotification(notification); - assertThat(result).hasSize(2); - assertThat(result.get(0).delivered()).isTrue(); - assertThat(result.get(1).delivered()).isFalse(); - verify(notificationRepository).save(firstNotification); - verify(notificationRepository, never()).save(secondNotification); + InOrder inOrder = inOrder(notificationRepository, fcmClient); + inOrder.verify(notificationRepository).saveAll(List.of(notification)); + inOrder.verify(fcmClient).sendMessage( + anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() + ); + verify(notificationRepository, never()).save(notification); } private Notification createNotification(String deviceToken) { From 803b48b7082d14c5c4b06caa936a7e1480176dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 10 Mar 2026 20:56:47 +0900 Subject: [PATCH 18/19] =?UTF-8?q?fix:=20=EC=A0=84=EC=86=A1=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=95=8C=EB=A6=BC=EC=9D=80=20=EC=84=B1=EA=B3=B5=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=A7=8C=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/NotificationService.java | 5 ++- .../service/NotificationServiceTest.java | 38 +++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 6f83de72eb..407a2c12a2 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -58,7 +58,6 @@ public List pushNotificationsWithResult(List deliveryResults = new ArrayList<>(notifications.size()); // afterCommit 콜백은 트랜잭션 프록시가 반환되기 전에 실행되므로 호출자는 채워진 결과를 받는다. runAfterCommit(() -> notifications.forEach(notification -> @@ -75,6 +74,10 @@ public void pushNotification(Notification notification) { private NotificationDeliveryResult pushNotificationWithResult(Notification notification) { try { boolean delivered = sendNotificationWithResult(notification); + if (!delivered) { + return new NotificationDeliveryResult(notification, false); + } + notificationRepository.save(notification); return new NotificationDeliveryResult(notification, delivered); } catch (Exception e) { log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java index f630e9b32c..cadb01518c 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -51,8 +52,8 @@ class NotificationServiceTest { private NotificationFactory notificationFactory; @Test - @DisplayName("알림 전송 결과 조회는 전송 전에 알림 레코드를 저장한다.") - void pushNotificationsWithResult_whenDelivered_savesNotificationBeforeSend() { + @DisplayName("알림 전송 결과 조회는 전송 성공 시에만 알림 레코드를 저장한다.") + void pushNotificationsWithResult_whenDelivered_savesNotification() { Notification notification = createNotification("device-token"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) .thenReturn(true); @@ -64,15 +65,15 @@ void pushNotificationsWithResult_whenDelivered_savesNotificationBeforeSend() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isTrue(); InOrder inOrder = inOrder(notificationRepository, fcmClient); - inOrder.verify(notificationRepository).saveAll(List.of(notification)); inOrder.verify(fcmClient).sendMessageWithResult( anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() ); + inOrder.verify(notificationRepository).save(notification); } @Test - @DisplayName("알림 전송 실패 시에도 저장된 알림 기준으로 실패 결과를 반환한다.") - void pushNotificationsWithResult_whenDeliveryFails_returnsFailedResult() { + @DisplayName("알림 전송 실패 시 알림 레코드를 저장하지 않는다.") + void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { Notification notification = createNotification("device-token"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) .thenReturn(false); @@ -83,11 +84,31 @@ void pushNotificationsWithResult_whenDeliveryFails_returnsFailedResult() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isFalse(); - verify(notificationRepository).saveAll(List.of(notification)); + verify(notificationRepository, never()).save(notification); + } + + @Test + @DisplayName("배치 알림 중 일부 저장이 실패해도 다음 알림을 계속 처리한다.") + void pushNotificationsWithResult_whenSaveFails_continuesNextNotification() { + Notification firstNotification = createNotification("device-token-1"); + Notification secondNotification = createNotification("device-token-2"); + when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) + .thenReturn(true, true); + doThrow(new RuntimeException("save fail")).when(notificationRepository).save(firstNotification); + + List result = notificationService.pushNotificationsWithResult( + List.of(firstNotification, secondNotification) + ); + + assertThat(result).hasSize(2); + assertThat(result.get(0).delivered()).isFalse(); + assertThat(result.get(1).delivered()).isTrue(); + verify(notificationRepository).save(firstNotification); + verify(notificationRepository).save(secondNotification); } @Test - @DisplayName("배치 알림은 전송 성공 여부를 각각 반환한다.") + @DisplayName("배치 알림은 전송 성공 여부를 각각 반환하고 성공한 알림만 저장한다.") void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult() { Notification firstNotification = createNotification("device-token-1"); Notification secondNotification = createNotification("device-token-2"); @@ -101,7 +122,8 @@ void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult assertThat(result).hasSize(2); assertThat(result.get(0).delivered()).isTrue(); assertThat(result.get(1).delivered()).isFalse(); - verify(notificationRepository).saveAll(List.of(firstNotification, secondNotification)); + verify(notificationRepository).save(firstNotification); + verify(notificationRepository, never()).save(secondNotification); } @Test From d359bd84b3443d4d4c13b8678e2cba68fdfe2510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=ED=83=9C=EC=A7=84?= <140797244+taejinn@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:13:59 +0900 Subject: [PATCH 19/19] =?UTF-8?q?fix:=20=EB=B0=9C=EC=86=A1=20=ED=9B=84=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=EB=B3=84?= =?UTF-8?q?=EB=8F=84=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationPersistenceService.java | 21 +++++++++++++++ .../service/NotificationService.java | 13 ++++++++-- .../service/NotificationServiceTest.java | 26 +++++++++++-------- 3 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java new file mode 100644 index 0000000000..63deb3cb5a --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationPersistenceService.java @@ -0,0 +1,21 @@ +package in.koreatech.koin.domain.notification.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import in.koreatech.koin.domain.notification.model.Notification; +import in.koreatech.koin.domain.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationPersistenceService { + + private final NotificationRepository notificationRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveAfterSend(Notification notification) { + notificationRepository.save(notification); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java index 407a2c12a2..7a7c686cde 100644 --- a/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java +++ b/src/main/java/in/koreatech/koin/domain/notification/service/NotificationService.java @@ -39,6 +39,7 @@ public record NotificationDeliveryResult(Notification notification, boolean deli private final UserRepository userRepository; private final NotificationRepository notificationRepository; + private final NotificationPersistenceService notificationPersistenceService; private final FcmClient fcmClient; private final NotificationSubscribeRepository notificationSubscribeRepository; private final NotificationFactory notificationFactory; @@ -77,8 +78,8 @@ private NotificationDeliveryResult pushNotificationWithResult(Notification notif if (!delivered) { return new NotificationDeliveryResult(notification, false); } - notificationRepository.save(notification); - return new NotificationDeliveryResult(notification, delivered); + saveNotificationAfterSend(notification); + return new NotificationDeliveryResult(notification, true); } catch (Exception e) { log.warn("알림 전송 처리 중 예외가 발생했습니다.", e); return new NotificationDeliveryResult(notification, false); @@ -199,6 +200,14 @@ private boolean sendNotificationWithResult(Notification notification) { ); } + private void saveNotificationAfterSend(Notification notification) { + try { + notificationPersistenceService.saveAfterSend(notification); + } catch (Exception e) { + log.warn("발송된 알림 저장 중 예외가 발생했습니다.", e); + } + } + private void runAfterCommit(Runnable task) { if (!TransactionSynchronizationManager.isActualTransactionActive() || !TransactionSynchronizationManager.isSynchronizationActive()) { diff --git a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java index cadb01518c..7e51728538 100644 --- a/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/in/koreatech/koin/unit/domain/notification/service/NotificationServiceTest.java @@ -24,6 +24,7 @@ import in.koreatech.koin.domain.notification.model.NotificationFactory; import in.koreatech.koin.domain.notification.repository.NotificationRepository; import in.koreatech.koin.domain.notification.repository.NotificationSubscribeRepository; +import in.koreatech.koin.domain.notification.service.NotificationPersistenceService; import in.koreatech.koin.domain.notification.service.NotificationService; import in.koreatech.koin.domain.user.model.User; import in.koreatech.koin.domain.user.repository.UserRepository; @@ -42,6 +43,9 @@ class NotificationServiceTest { @Mock private NotificationRepository notificationRepository; + @Mock + private NotificationPersistenceService notificationPersistenceService; + @Mock private FcmClient fcmClient; @@ -64,11 +68,11 @@ void pushNotificationsWithResult_whenDelivered_savesNotification() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isTrue(); - InOrder inOrder = inOrder(notificationRepository, fcmClient); + InOrder inOrder = inOrder(fcmClient, notificationPersistenceService); inOrder.verify(fcmClient).sendMessageWithResult( anyString(), anyString(), anyString(), any(), any(), anyString(), anyString() ); - inOrder.verify(notificationRepository).save(notification); + inOrder.verify(notificationPersistenceService).saveAfterSend(notification); } @Test @@ -84,27 +88,27 @@ void pushNotificationsWithResult_whenDeliveryFails_doesNotSaveNotification() { assertThat(result).hasSize(1); assertThat(result.get(0).delivered()).isFalse(); - verify(notificationRepository, never()).save(notification); + verify(notificationPersistenceService, never()).saveAfterSend(notification); } @Test - @DisplayName("배치 알림 중 일부 저장이 실패해도 다음 알림을 계속 처리한다.") - void pushNotificationsWithResult_whenSaveFails_continuesNextNotification() { + @DisplayName("배치 알림 중 일부 저장이 실패해도 발송 결과는 유지하고 다음 알림을 계속 처리한다.") + void pushNotificationsWithResult_whenSaveFails_keepsDeliveryResultAndContinuesNextNotification() { Notification firstNotification = createNotification("device-token-1"); Notification secondNotification = createNotification("device-token-2"); when(fcmClient.sendMessageWithResult(anyString(), anyString(), anyString(), any(), any(), anyString(), anyString())) .thenReturn(true, true); - doThrow(new RuntimeException("save fail")).when(notificationRepository).save(firstNotification); + doThrow(new RuntimeException("save fail")).when(notificationPersistenceService).saveAfterSend(firstNotification); List result = notificationService.pushNotificationsWithResult( List.of(firstNotification, secondNotification) ); assertThat(result).hasSize(2); - assertThat(result.get(0).delivered()).isFalse(); + assertThat(result.get(0).delivered()).isTrue(); assertThat(result.get(1).delivered()).isTrue(); - verify(notificationRepository).save(firstNotification); - verify(notificationRepository).save(secondNotification); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService).saveAfterSend(secondNotification); } @Test @@ -122,8 +126,8 @@ void pushNotificationsWithResult_whenBatchContainsMixedResults_returnsEachResult assertThat(result).hasSize(2); assertThat(result.get(0).delivered()).isTrue(); assertThat(result.get(1).delivered()).isFalse(); - verify(notificationRepository).save(firstNotification); - verify(notificationRepository, never()).save(secondNotification); + verify(notificationPersistenceService).saveAfterSend(firstNotification); + verify(notificationPersistenceService, never()).saveAfterSend(secondNotification); } @Test