Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b082f37
fix: 게시글 단위 키워드 알림 중복 발송 방지
taejinn Mar 3, 2026
0dfe03f
test: 키워드 알림 중복 방지 테스트 추가
taejinn Mar 3, 2026
9396384
fix: 알림 상태 저장을 원자적 업서트로 변경
taejinn Mar 3, 2026
89619ba
fix: 키워드 이벤트 흐름의 지연 로딩 의존 제거
taejinn Mar 3, 2026
2b84fc0
fix: 키워드 구독 조회시 사용자 fetch join 적용
taejinn Mar 3, 2026
e3e6cd4
fix: 업서트 쿼리에서 deprecated VALUES 함수 제거
taejinn Mar 3, 2026
0e89d89
fix: 기발송 사용자 조회를 일괄 쿼리로 최적화
taejinn Mar 3, 2026
655f652
fix: 키워드 알림 상태를 전송 성공 후 기록하도록 변경
taejinn Mar 3, 2026
a6ba4f6
fix: FCM 전송 결과 메서드의 예외 처리 범위 확장
taejinn Mar 3, 2026
472e177
test: 키워드 알림 전송 실패시 상태 미기록 테스트 추가
taejinn Mar 3, 2026
2e0dd02
fix: 알림 전송 메서드의 트랜잭션 경계 분리
taejinn Mar 3, 2026
a9a4473
fix: 기발송 조회 대상을 매칭 사용자로 한정
taejinn Mar 3, 2026
b5137f5
fix: 전송 성공 시에만 알림 레코드 저장
taejinn Mar 3, 2026
22195a2
fix: guard against blank FCM device tokens in sendMessageWithResult (…
Copilot Mar 5, 2026
47b8591
fix: 알림 전송 메서드가 호출자 트랜잭션에 참여하도록 수정
taejinn Mar 5, 2026
edd971c
Merge branch 'fix/2164-notification-duplication' of https://github.co…
taejinn Mar 5, 2026
e28c9ec
fix: 배치 알림 전송 예외를 개별 처리하도록 수정
taejinn Mar 10, 2026
8656f63
fix: 키워드 알림 조회와 푸시 전송 흐름을 개선
taejinn Mar 10, 2026
803b48b
fix: 전송 결과 알림은 성공 시에만 저장하도록 수정
taejinn Mar 10, 2026
766a3e7
Merge branch 'develop' into fix/2164-notification-duplication
taejinn Mar 10, 2026
d359bd8
fix: 발송 후 알림 저장을 별도 트랜잭션으로 분리
taejinn Mar 10, 2026
9636f72
Merge branch 'develop' into fix/2164-notification-duplication
taejinn Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package in.koreatech.koin.common.event;

import in.koreatech.koin.domain.community.keyword.model.ArticleKeyword;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;

public record ArticleKeywordEvent(
Integer articleId,
Integer authorId,
ArticleKeyword keyword
Map<Integer, String> matchedKeywordByUserId
) {

public ArticleKeywordEvent {
matchedKeywordByUserId = Collections.unmodifiableMap(new LinkedHashMap<>(matchedKeywordByUserId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public interface ArticleRepository extends Repository<Article, Integer> {

Optional<Article> findById(Integer articleId);

List<Article> findAllByIdIn(List<Integer> articleIds);

Page<Article> findAll(Pageable pageable);

Page<Article> findAllByBoardIdNot(Integer boardId, PageRequest pageRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ public UserNotificationStatus(Integer userId, Integer notifiedArticleId) {
this.userId = userId;
this.notifiedArticleId = notifiedArticleId;
}

public void updateNotifiedArticleId(Integer notifiedArticleId) {
this.notifiedArticleId = notifiedArticleId;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ Optional<ArticleKeywordUserMap> findByArticleKeywordIdAndUserIdIncludingDeleted(
@Param("articleKeywordId") Integer articleKeywordId,
@Param("userId") Integer userId
);

List<ArticleKeywordUserMap> findAllByArticleKeywordIdIn(List<Integer> articleKeywordIds);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
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;
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;

Expand All @@ -14,4 +18,26 @@ public interface UserNotificationStatusRepository extends Repository<UserNotific
Optional<UserNotificationStatus> findByUserId(Integer userId);

boolean existsByNotifiedArticleIdAndUserId(Integer notifiedArticleId, Integer userId);

@Query("""
SELECT status.userId
FROM UserNotificationStatus status
WHERE status.notifiedArticleId = :notifiedArticleId
AND status.userId IN :userIds
""")
List<Integer> findUserIdsByNotifiedArticleIdAndUserIdIn(
@Param("notifiedArticleId") Integer notifiedArticleId,
@Param("userIds") Collection<Integer> userIds
);

@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 = :notifiedArticleId
""", nativeQuery = true)
void upsertLastNotifiedArticleId(
@Param("userId") Integer userId,
@Param("notifiedArticleId") Integer notifiedArticleId
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -148,22 +147,30 @@ public ArticleKeywordsSuggestionResponse suggestKeywords() {
}

public void sendKeywordNotification(KeywordNotificationRequest request) {
List<Integer> updateNotificationIds = request.updateNotification();

if (!updateNotificationIds.isEmpty()) {
List<Article> articles = new ArrayList<>();

for (Integer id : updateNotificationIds) {
articles.add(articleRepository.getById(id));
}
List<Integer> updateNotificationIds = request.updateNotification().stream()
.distinct()
.toList();

List<ArticleKeywordEvent> keywordEvents = keywordExtractor.matchKeyword(articles, null);
if (updateNotificationIds.isEmpty()) {
return;
}

if (!keywordEvents.isEmpty()) {
for (ArticleKeywordEvent event : keywordEvents) {
eventPublisher.publishEvent(event);
List<Article> fetchedArticles = articleRepository.findAllByIdIn(updateNotificationIds);
var articleById = fetchedArticles.stream()
.collect(Collectors.toMap(Article::getId, article -> article));
List<Article> articles = updateNotificationIds.stream()
.map(articleId -> {
Article article = articleById.get(articleId);
if (article == null) {
throw ArticleNotFoundException.withDetail("articleId: " + articleId);
}
}
return article;
})
.toList();

List<ArticleKeywordEvent> keywordEvents = keywordExtractor.matchKeyword(articles, null);
for (ArticleKeywordEvent event : keywordEvents) {
eventPublisher.publishEvent(event);
}
}

Expand Down Expand Up @@ -201,6 +208,6 @@ public void fetchTopKeywordsFromLastWeek() {

@Transactional
public void createNotifiedArticleStatus(Integer userId, Integer articleId) {
userNotificationStatusRepository.save(new UserNotificationStatus(userId, articleId));
userNotificationStatusRepository.upsertLastNotifiedArticleId(userId, articleId);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package in.koreatech.koin.domain.community.util;

import java.util.ArrayList;
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;
Expand All @@ -10,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
Expand All @@ -22,9 +27,10 @@ public class KeywordExtractor {
private static final int KEYWORD_BATCH_SIZE = 100;

private final ArticleKeywordRepository articleKeywordRepository;
private final ArticleKeywordUserMapRepository articleKeywordUserMapRepository;

public List<ArticleKeywordEvent> matchKeyword(List<Article> articles, Integer authorId) {
List<ArticleKeywordEvent> keywordEvents = new ArrayList<>();
Map<Integer, Map<Integer, String>> matchedKeywordByUserIdByArticleId = new LinkedHashMap<>();
int offset = 0;

while (true) {
Expand All @@ -34,18 +40,57 @@ public List<ArticleKeywordEvent> matchKeyword(List<Article> articles, Integer au
if (keywords.isEmpty()) {
break;
}
List<Integer> keywordIds = keywords.stream()
.map(ArticleKeyword::getId)
.toList();
Map<Integer, List<ArticleKeywordUserMap>> 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())) {
keywordEvents.add(new ArticleKeywordEvent(article.getId(), authorId, keyword));
if (!title.contains(keyword.getKeyword())) {
continue;
}
Map<Integer, String> 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
);
}
}
}
offset += KEYWORD_BATCH_SIZE;
}

List<ArticleKeywordEvent> keywordEvents = new ArrayList<>();
for (Article article : articles) {
Map<Integer, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,24 @@
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;

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;
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.repository.UserNotificationStatusRepository;
import in.koreatech.koin.domain.community.keyword.service.KeywordService;
import in.koreatech.koin.domain.notification.model.Notification;
Expand All @@ -38,35 +44,67 @@ public class ArticleKeywordEventListener { // TODO : 리팩터링 필요 (비즈

@TransactionalEventListener
public void onKeywordRequest(ArticleKeywordEvent event) {
Map<Integer, String> matchedKeywordByUserId = event.matchedKeywordByUserId();

if (matchedKeywordByUserId.isEmpty()) {
return;
}

Article article = articleRepository.getById(event.articleId());
Board board = article.getBoard();

List<Notification> notifications = notificationSubscribeRepository
.findAllBySubscribeTypeAndDetailTypeIsNull(ARTICLE_KEYWORD)
Map<Integer, NotificationSubscribe> keywordSubscribersByUserId = notificationSubscribeRepository
.findAllBySubscribeTypeAndDetailTypeIsNullWithUser(ARTICLE_KEYWORD)
.stream()
.filter(this::hasDeviceToken)
.filter(subscribe -> isKeywordRegistered(event, subscribe))
.filter(subscribe -> isNewNotifiedArticleId(event.articleId(), subscribe))
.collect(Collectors.toMap(
subscribe -> subscribe.getUser().getId(),
Function.identity(),
(existing, ignored) -> existing,
LinkedHashMap::new
));
Comment thread
taejinn marked this conversation as resolved.

Set<Integer> matchedUserIds = keywordSubscribersByUserId.keySet().stream()
.filter(matchedKeywordByUserId::containsKey)
.collect(Collectors.toSet());

Set<Integer> alreadyNotifiedUserIds = getAlreadyNotifiedUserIds(
event.articleId(),
matchedUserIds
);

List<Notification> notifications = keywordSubscribersByUserId.values().stream()
.filter(subscribe -> matchedUserIds.contains(subscribe.getUser().getId()))
.filter(subscribe -> !alreadyNotifiedUserIds.contains(subscribe.getUser().getId()))
.filter(subscribe -> !isMyArticle(event, subscribe))
Comment thread
taejinn marked this conversation as resolved.
.map(subscribe -> createAndRecordNotification(article, board, event.keyword(), subscribe))
.map(subscribe -> createNotification(
article,
board,
matchedKeywordByUserId.get(subscribe.getUser().getId()),
subscribe
))
.toList();

notificationService.pushNotifications(notifications);
List<NotificationService.NotificationDeliveryResult> 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) {
return subscribe.getUser().getDeviceToken() != null;
return StringUtils.hasText(subscribe.getUser().getDeviceToken());
}

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 boolean isNewNotifiedArticleId(Integer articleId, NotificationSubscribe subscribe) {
Integer userId = subscribe.getUser().getId();
return !userNotificationStatusRepository.existsByNotifiedArticleIdAndUserId(articleId, userId);
private Set<Integer> getAlreadyNotifiedUserIds(Integer articleId, Set<Integer> subscriberUserIds) {
if (subscriberUserIds.isEmpty()) {
return Set.of();
}
return new HashSet<>(
userNotificationStatusRepository.findUserIdsByNotifiedArticleIdAndUserIdIn(articleId, subscriberUserIds)
);
}

private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe subscribe) {
Expand All @@ -75,26 +113,23 @@ private boolean isMyArticle(ArticleKeywordEvent event, NotificationSubscribe sub
return Objects.equals(authorId, subscriberId);
}

private Notification createAndRecordNotification(
private Notification createNotification(
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,
subscribe.getUser()
);

keywordService.createNotifiedArticleStatus(userId, article.getId());
return notification;
}

Expand Down
Loading
Loading