Skip to content

refactor: 키워드 알림 EventListener 책임 분리#2237

Merged
Soundbar91 merged 29 commits into
developfrom
refactor/2219-article-keyword-event-listener
May 25, 2026
Merged

refactor: 키워드 알림 EventListener 책임 분리#2237
Soundbar91 merged 29 commits into
developfrom
refactor/2219-article-keyword-event-listener

Conversation

@Soundbar91
Copy link
Copy Markdown
Collaborator

@Soundbar91 Soundbar91 commented May 8, 2026

🔍 개요


🚀 주요 변경 내용

  • 게시글/분실물 키워드 알림 처리 흐름을 역할별 서비스로 분리했습니다.

    • 기존 리스너에 있던 키워드 매칭/구독 조회/중복 알림 제외/알림 생성 책임을 서비스로 이동했습니다.
    • 리스너는 트랜잭션 커밋 이후 이벤트를 받아 알림 서비스에 위임하는 역할만 담당합니다.
    • 일반 게시글 키워드 알림과 분실물 키워드 알림의 이벤트 및 알림 생성 로직을 분리했습니다.
  • 게시글 키워드 알림 API 처리 흐름

    • KeywordService가 알림 대상 게시글 제목에서 KOREATECH 카테고리 키워드를 추출합니다.
    • ArticleKeywordUserMatcher가 매칭된 키워드별 구독 유저 ID를 조회합니다.
    • 한 유저가 여러 키워드에 매칭되면 더 긴 키워드 하나만 선택합니다.
    • 매칭 대상이 있으면 KoreatechArticleKeywordEvent를 발행합니다.
    • 트랜잭션 커밋 후 ArticleKeywordEventListenerArticleKeywordNotificationService에 처리를 위임합니다.
  • ArticleKeywordNotificationService 처리 흐름

    • 이벤트의 userIdsByKeyword에서 중복을 제거한 전체 매칭 유저 ID 목록을 만듭니다.
    • 매칭 유저가 없으면 구독 조회, 중복 알림 조회, 알림 발송을 하지 않고 종료합니다.
    • ARTICLE_KEYWORD 타입으로 알림 수신 가능한 구독 정보를 조회합니다.
    • 한 유저가 여러 구독으로 조회되면 첫 구독만 사용합니다.
    • 게시글 ID 기반 scheme URI 패턴으로 이미 같은 게시글 알림을 받은 유저를 조회합니다.
    • 키워드별 매칭 유저를 순회하며 이미 알림을 받은 유저와 구독 정보가 없는 유저를 제외합니다.
    • 남은 대상에게 키워드, 게시글 ID, 게시판 ID, 제목을 담은 게시글 키워드 알림을 생성한 뒤 일괄 발송합니다.
  • 분실물 키워드 알림 API 처리 흐름

    • 분실물 게시글 생성 시 LostItemArticleService가 제목에서 LOST_ITEM 카테고리 키워드를 추출합니다.
    • ArticleKeywordUserMatcher가 매칭된 키워드별 구독 유저 ID를 조회합니다.
    • 한 유저가 여러 키워드에 매칭되면 더 긴 키워드 하나만 선택합니다.
    • 매칭 대상이 있으면 작성자 ID를 포함한 LostItemKeywordEvent를 발행합니다.
    • 트랜잭션 커밋 후 LostItemKeywordEventListenerLostItemKeywordNotificationService에 처리를 위임합니다.
  • LostItemKeywordNotificationService 처리 흐름

    • 이벤트의 userIdsByKeyword에서 중복을 제거한 전체 매칭 유저 ID 목록을 만듭니다.
    • 매칭 유저가 없으면 구독 조회, 중복 알림 조회, 알림 발송을 하지 않고 종료합니다.
    • LOST_ITEM_KEYWORD 타입으로 알림 수신 가능한 구독 정보를 조회합니다.
    • 한 유저가 여러 구독으로 조회되면 첫 구독만 사용합니다.
    • 분실물 게시글 ID 기반 scheme URI 패턴으로 이미 같은 분실물 게시글 알림을 받은 유저를 조회합니다.
    • 키워드별 매칭 유저를 순회하며 이미 알림을 받은 유저, 작성자 본인, 구독 정보가 없는 유저를 제외합니다.
    • 남은 대상에게 키워드, 분실물 게시글 ID, 제목을 담은 분실물 키워드 알림을 생성한 뒤 일괄 발송합니다.
  • 기타 정리

    • 사용하지 않는 키워드 repository/service 메서드를 제거했습니다.
    • 변경된 책임 구조와 맞지 않는 리스너 중심 테스트를 제거했습니다.

💬 참고 사항

  • 관련 테스트 코드는 다음 작업에서 진행할 예정입니다.

✅ Checklist (완료 조건)

  • 코드 스타일 가이드 준수
  • 테스트 코드 포함됨
  • Reviewers / Assignees / Labels 지정 완료
  • 보안 및 민감 정보 검증 (API 키, 환경 변수, 개인정보 등)

Summary by CodeRabbit

  • New Features

    • Added dedicated lost-item keyword notifications alongside regular article keyword notifications.
    • Notifications now include article title and board context for clearer messages.
  • Refactor

    • Keyword matching now extracts keywords from titles and selects best matches per user for improved relevance.
    • Dispatch flow consolidated to publish per-article events and deduplicate recipients.
  • Bug Fixes

    • Skip notifying the article author and avoid resending to already-matched users.
  • Style

    • Notification messages standardized to a consistent, templated format.

Review Change Stack

@Soundbar91 Soundbar91 self-assigned this May 8, 2026
@github-actions github-actions Bot added the 리팩터링 리팩터링을 위한 이슈입니다 label May 8, 2026
@github-actions github-actions Bot requested review from ImTotem and dh2906 May 8, 2026 20:59
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Warning

Review limit reached

@Soundbar91, we couldn't start this review because you've used your available PR reviews for now.

Your plan includes 1 review of capacity. Refill in 26 minutes and 39 seconds.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more review capacity refills, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 964daccb-a611-4a7e-afcf-91c831b05dbd

📥 Commits

Reviewing files that changed from the base of the PR and between 031f4ae and ba8f04b.

📒 Files selected for processing (9)
  • src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java
  • src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java
  • src/main/java/in/koreatech/koin/common/event/MatchedKeywordUsers.java
  • src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java
  • src/main/java/in/koreatech/koin/domain/notification/eventlistener/KeywordEventListener.java
  • src/main/java/in/koreatech/koin/domain/notification/service/KeywordNotificationService.java
  • src/test/java/in/koreatech/koin/acceptance/AcceptanceTest.java
📝 Walkthrough

Walkthrough

Refactors keyword notification: replaces old per-user event with per-article and lost-item events, simplifies keyword extraction to title matching, adds ArticleKeywordUserMatcher, introduces dedicated notification services and listeners, updates repository queries and DTOs, and removes prior unit tests.

Changes

Keyword Notification System Refactoring

Layer / File(s) Summary
Event Type Contracts
src/main/java/in/koreatech/koin/common/event/*
ArticleKeywordEvent removed; KoreatechArticleKeywordEvent and LostItemKeywordEvent added with nested MatchedKeywordUsers carrying Map<String, List<Integer>> keyword→userIDs.
Keyword Extraction & User Resolution
src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java, src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java, src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java
KeywordExtractor simplified to matchKeywords(title, category) returning matched keyword strings; new ArticleKeywordUserMatcher resolves keywords→user IDs and selects longest keyword per user; ArticleKeyword adds length comparison helper.
Repository Queries
src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java, src/main/java/in/koreatech/koin/domain/community/keyword/repository/*, src/main/java/in/koreatech/koin/domain/notification/repository/*
ArticleRepository#findAllByIdIn now accepts Collection<Integer>; ArticleKeywordUserMapRepository switched to join-fetch entity queries filtered by category/keywords; NotificationRepository and NotificationSubscribeRepository add JPQL methods for scheme/subscribe lookups.
Service Integration & Publication
src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java, src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java
KeywordService and LostItemArticleService publish per-article events using KeywordExtractor + ArticleKeywordUserMatcher; removed notification-history upsert method.
Listeners & Notification Services
src/main/java/in/koreatech/koin/domain/notification/eventlistener/*, src/main/java/in/koreatech/koin/domain/notification/service/*
ArticleKeywordEventListener simplified to delegate KoreatechArticleKeywordEvent to ArticleKeywordNotificationService; added LostItemKeywordEventListener; added ArticleKeywordNotificationService and LostItemKeywordNotificationService to assemble, filter, and push notifications.
Notification Factory & URIs
src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java
generateKeywordNotification no longer accepts caller description; added generateLostItemKeywordNotification and a lost-item URI helper; keyword scheme URI behavior adjusted.
DTO & Model Updates
src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java
updateNotification changed from List<Integer> to Set<Integer>.
Test Removal
src/test/java/...
Removed unit tests for previous batch-matching and listener-embedded logic: KeywordServiceTest, KeywordExtractorTest, and ArticleKeywordEventListenerTest.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

공통

Suggested reviewers

  • taejinn
  • dh2906

Poem

🐰 I hopped through code with nimble paws,
Split big listeners into tidy laws.
Keywords now found in titles clear,
Events dispatched to services near.
The rabbit cheers — notifications steer!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly summarizes the main refactoring objective: separating event listener responsibilities for keyword notification.
Linked Issues check ✅ Passed The PR successfully implements all coding objectives from issue #2219: separates keyword notification logic into dedicated services, reduces ArticleKeywordEventListener from ~140 lines to ~30 lines, creates new notification services with proper delegation pattern, and removes tests that were verifying moved business logic.
Out of Scope Changes check ✅ Passed All changes directly support the primary objective of separating event listener responsibilities. Changes include new event types, notification services, repository updates, and helper classes that implement the separation of concerns.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/2219-article-keyword-event-listener

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Soundbar91 Soundbar91 requested a review from Copilot May 8, 2026 21:01
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

Unit Test Results

658 tests   655 ✔️  1m 19s ⏱️
163 suites      3 💤
163 files        0

Results for commit ba8f04b.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (9)
src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java (2)

24-49: 💤 Low value

Add an empty-input short-circuit.

When matchedKeywords is empty, the repository query still executes (... IN ()), incurring a needless roundtrip and, depending on JPA provider settings, potentially generating an awkward SQL. Returning an empty map immediately is cheaper and explicit.

♻️ Suggested guard
     public Map<String, List<Integer>> findUserIdsByMatchedKeyword(
         KeywordCategory category,
         List<String> matchedKeywords
     ) {
+        if (matchedKeywords == null || matchedKeywords.isEmpty()) {
+            return Map.of();
+        }
         Map<Integer, ArticleKeyword> keywordByUserId = new LinkedHashMap<>();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java`
around lines 24 - 49, Add a short-circuit at the start of
findUserIdsByMatchedKeyword: if the matchedKeywords list is null or empty,
return an empty LinkedHashMap immediately to avoid calling
articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn
with an empty collection; ensure you check the matchedKeywords parameter (and
treat null as empty) before any repository call so the method returns quickly
when there's nothing to query.

29-49: ⚖️ Poor tradeoff

Consider letting KeywordExtractor return ArticleKeyword entities to avoid double-fetching.

KeywordExtractor.matchKeywords paginates and loads ArticleKeyword entities, then returns only their keyword text. This service then re-fetches the same ArticleKeyword rows via the new IN :keywords query (joined to ArticleKeywordUserMap). For categories with many keywords or high publish frequency, this is a redundant DB hit per article.

If you keep the current shape, this is fine; otherwise consider returning List<ArticleKeyword> (or IDs) from the extractor and querying the user-map by articleKeyword.id IN :ids, which is also a more selective index path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java`
around lines 29 - 49, KeywordExtractor.matchKeywords currently returns
List<String> causing ArticleKeyword rows to be refetched via
articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn;
change the extractor to return List<ArticleKeyword> (or List<Long> ids) so the
service can use those directly and avoid the redundant lookup, then update the
repository call to use articleKeyword IDs (e.g.,
findAllByArticleKeywordCategoryAndArticleKeywordIdIn) or compare ArticleKeyword
entities directly (findAllByArticleKeywordCategoryAndArticleKeywordIn) and adapt
the loop that builds keywordByUserId to use ArticleKeyword objects instead of
re-querying by keyword text.
src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java (1)

235-240: ⚡ Quick win

URL-encode keyword when building the scheme URI.

keyword is interpolated raw into the deep-link query string. If a user's keyword contains &, =, ?, #, whitespace, or non-ASCII characters, the resulting URI is malformed and the downstream LIKE :schemeUriPattern dedupe in NotificationRepository.findUserIdsBySchemeUriLikeAndUserIdIn may also under-/over-match. The same concern applies to the existing generateKeywordSchemeUri on line 232.

♻️ Suggested fix
+    import java.net.URLEncoder;
+    import java.nio.charset.StandardCharsets;
     ...
     private String generateLostItemKeywordSchemeUri(MobileAppPath path, Integer eventId, String keyword) {
         if (keyword == null) {
             return generateSchemeUri(path, eventId);
         }
-        return String.format("%s?id=%d&keyword=%s", path.getPath(), eventId, keyword);
+        String encoded = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
+        return String.format("%s?id=%d&keyword=%s", path.getPath(), eventId, encoded);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java`
around lines 235 - 240, The methods generateLostItemKeywordSchemeUri and
generateKeywordSchemeUri interpolate raw keyword into the deep-link query and
must URL-encode keyword before building the scheme URI; update both functions to
return generateSchemeUri(path,eventId) when keyword is null, otherwise encode
the keyword using UTF-8 (e.g., via URLEncoder.encode(keyword,
StandardCharsets.UTF_8.name()) or equivalent) and use the encoded value in the
String.format call so reserved characters and non-ASCII text produce a valid
query string.
src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java (1)

12-16: 💤 Low value

Consider sharing MatchedKeywordUsers with KoreatechArticleKeywordEvent if the shape is identical.

Per the PR description, both events carry a Map<String, List<Integer>> userIdsByKeyword. If KoreatechArticleKeywordEvent defines its own nested MatchedKeywordUsers, the duplication will drift over time. Hoisting the value object to in.koreatech.koin.common.event (or a sibling shared type) keeps both events aligned and lets cross-cutting concerns (validation, defensive copy) live in one place.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java`
around lines 12 - 16, The nested record MatchedKeywordUsers in
LostItemKeywordEvent duplicates the same shape used by
KoreatechArticleKeywordEvent; hoist MatchedKeywordUsers out of the nested class
into a shared top-level type in package in.koreatech.koin.common.event (or a
sibling shared package), remove the nested declarations from
LostItemKeywordEvent and KoreatechArticleKeywordEvent, update both classes to
import and reference the new shared MatchedKeywordUsers type, and consolidate
any validation or defensive-copy logic into that shared record so both events
reuse the single canonical implementation.
src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java (1)

31-43: ⚡ Quick win

Method name embeds "ArticleKeyword" but the query is reused for lost-item subscribes.

The query is fully parameterized over subscribeType, so it serves both ARTICLE_KEYWORD and LOST_ITEM_KEYWORD flows. Naming it findArticleKeywordSubscribesByUserIdIn will mislead future readers and grep-based audits when the lost-item service calls the same method.

♻️ Suggested rename
-    List<NotificationSubscribe> findArticleKeywordSubscribesByUserIdIn(
+    List<NotificationSubscribe> findActiveSubscribesByTypeAndUserIdIn(
         `@Param`("subscribeType") NotificationSubscribeType subscribeType,
         `@Param`("userIds") List<Integer> userIds
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java`
around lines 31 - 43, The repository method
NotificationSubscribeRepository.findArticleKeywordSubscribesByUserIdIn is
misnamed because the JPQL is generic over subscribeType; rename the method to a
neutral, accurate name like findKeywordSubscribesByUserIdIn or
findBySubscribeTypeAndUserIds (keeping the same parameters
`@Param`("subscribeType") NotificationSubscribeType subscribeType and
`@Param`("userIds") List<Integer> userIds) and update all call sites that
reference findArticleKeywordSubscribesByUserIdIn (e.g., services handling
ARTICLE_KEYWORD and LOST_ITEM_KEYWORD flows) to the new method name so behavior
and query remain unchanged but the name reflects its generic use.
src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java (2)

29-42: 💤 Low value

Pagination index can be simplified.

PageRequest.of(offset / KEYWORD_BATCH_SIZE, KEYWORD_BATCH_SIZE) re-derives a page number from a manual offset counter. A direct int page = 0; ... page++; is clearer and avoids the implicit invariant that offset must always be a multiple of KEYWORD_BATCH_SIZE.

♻️ Suggested simplification
-        int offset = 0;
+        int page = 0;

         while (true) {
-            Pageable pageable = PageRequest.of(offset / KEYWORD_BATCH_SIZE, KEYWORD_BATCH_SIZE);
+            Pageable pageable = PageRequest.of(page, KEYWORD_BATCH_SIZE);
             List<ArticleKeyword> keywords = articleKeywordRepository.findAllByCategory(category, pageable);

             if (keywords.isEmpty()) {
                 break;
             }
             for (ArticleKeyword keyword : keywords) {
                 if (title.contains(keyword.getKeyword())) {
                     matchedKeywords.add(keyword.getKeyword());
                 }
             }
-            offset += KEYWORD_BATCH_SIZE;
+            page++;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java`
around lines 29 - 42, The pagination loop in KeywordExtractor uses an offset
integer and computes the page as PageRequest.of(offset / KEYWORD_BATCH_SIZE,
KEYWORD_BATCH_SIZE), which is confusing and fragile; change the loop to use an
explicit page counter (e.g., int page = 0) and create the pageable with
PageRequest.of(page, KEYWORD_BATCH_SIZE), incrementing page++ each iteration and
removing the offset arithmetic; keep the same stop condition on
keywords.isEmpty() and leave KEYWORD_BATCH_SIZE and
articleKeywordRepository.findAllByCategory intact.

25-45: 💤 Low value

Guard against null title.

title.contains(...) will NPE if a caller ever passes a null title. Adding a defensive early-return keeps this utility robust against upstream changes (e.g., a new article publisher path) and avoids paginating the keyword table just to throw.

🛡️ Suggested guard
     public List<String> matchKeywords(String title, KeywordCategory category) {
+        if (title == null || title.isBlank()) {
+            return List.of();
+        }
         List<String> matchedKeywords = new ArrayList<>();
         int offset = 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java`
around lines 25 - 45, In matchKeywords (KeywordExtractor) add a defensive
null-check for the title parameter and return an empty List<String> immediately
if title is null (to avoid NPE from title.contains(...)); locate the
matchKeywords method and put the guard before any pagination or repository calls
(before PageRequest.of(...) / articleKeywordRepository.findAllByCategory(...))
so the method exits early when title is null and avoids unnecessary DB paging
using the existing matchedKeywords empty list as the return.
src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java (1)

67-69: ⚡ Quick win

Build the dedupe pattern from KEYWORD, not a string literal.

createNotification() derives the target URI from KEYWORD, but this lookup hardcodes "keyword". If those ever diverge, duplicate suppression breaks and users can receive repeated notifications for the same article.

Suggested change
     private Set<Integer> getAlreadyNotifiedUserIds(Integer articleId, List<Integer> userIds) {
         return new HashSet<>(notificationRepository
-            .findUserIdsBySchemeUriLikeAndUserIdIn("keyword?id=%d&%%".formatted(articleId), userIds));
+            .findUserIdsBySchemeUriLikeAndUserIdIn(
+                "%s?id=%d&%%".formatted(KEYWORD.getPath(), articleId),
+                userIds
+            ));
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java`
around lines 67 - 69, getAlreadyNotifiedUserIds currently hardcodes "keyword"
when building the scheme-uri LIKE pattern which can diverge from the KEYWORD
used in createNotification; change the pattern construction to use the KEYWORD
constant (e.g., build "%s?id=%d&%%" with KEYWORD and articleId) so the call to
notificationRepository.findUserIdsBySchemeUriLikeAndUserIdIn uses the same
source of truth as createNotification, preventing duplicate notifications.
src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java (1)

24-28: ⚡ Quick win

Snapshot the matched users before publishing the async event.

MatchedKeywordUsers currently wraps the original mutable Map<String, List<Integer>> by reference. Because the listener runs AFTER_COMMIT and @Async, later mutations can leak into delivery or trigger concurrent modification issues. Copy the map and inner lists when constructing the event.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java`
around lines 24 - 28, The event is passing the original mutable Map into
MatchedKeywordUsers by reference, risking concurrent mutation during async
AFTER_COMMIT delivery; update the construction so MatchedKeywordUsers receives a
defensive deep copy (copy the outer Map and for each entry copy the inner List)
instead of the original reference—either modify where new
MatchedKeywordUsers(userIdsByKeyword) is called in KoreatechArticleKeywordEvent
or add copying logic inside MatchedKeywordUsers' constructor to produce an
immutable/independent Map<String,List<Integer>> snapshot.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java`:
- Around line 6-10: KoreatechArticleKeywordEvent is missing authorId so
ArticleKeywordNotificationService cannot exclude notifying the post author; add
an Integer authorId field to the KoreatechArticleKeywordEvent record (same
pattern as LostItemKeywordEvent), update all places that construct the
KoreatechArticleKeywordEvent to pass the article's authorId, and update any
consumers (e.g., ArticleKeywordNotificationService) to read event.authorId() to
preserve the "exclude self" logic; ensure compilation by adjusting imports and
tests that construct or pattern-match the record.

In
`@src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java`:
- Around line 13-15: The LostItemKeywordEventListener class is always active and
must be disabled for the test profile; add the same profile restriction used on
ArticleKeywordEventListener by annotating the LostItemKeywordEventListener class
with `@Profile`("!test") (and import
org.springframework.context.annotation.Profile) so the async AFTER_COMMIT
listener is not active during tests.

---

Nitpick comments:
In
`@src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java`:
- Around line 24-28: The event is passing the original mutable Map into
MatchedKeywordUsers by reference, risking concurrent mutation during async
AFTER_COMMIT delivery; update the construction so MatchedKeywordUsers receives a
defensive deep copy (copy the outer Map and for each entry copy the inner List)
instead of the original reference—either modify where new
MatchedKeywordUsers(userIdsByKeyword) is called in KoreatechArticleKeywordEvent
or add copying logic inside MatchedKeywordUsers' constructor to produce an
immutable/independent Map<String,List<Integer>> snapshot.

In `@src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java`:
- Around line 12-16: The nested record MatchedKeywordUsers in
LostItemKeywordEvent duplicates the same shape used by
KoreatechArticleKeywordEvent; hoist MatchedKeywordUsers out of the nested class
into a shared top-level type in package in.koreatech.koin.common.event (or a
sibling shared package), remove the nested declarations from
LostItemKeywordEvent and KoreatechArticleKeywordEvent, update both classes to
import and reference the new shared MatchedKeywordUsers type, and consolidate
any validation or defensive-copy logic into that shared record so both events
reuse the single canonical implementation.

In
`@src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java`:
- Around line 24-49: Add a short-circuit at the start of
findUserIdsByMatchedKeyword: if the matchedKeywords list is null or empty,
return an empty LinkedHashMap immediately to avoid calling
articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn
with an empty collection; ensure you check the matchedKeywords parameter (and
treat null as empty) before any repository call so the method returns quickly
when there's nothing to query.
- Around line 29-49: KeywordExtractor.matchKeywords currently returns
List<String> causing ArticleKeyword rows to be refetched via
articleKeywordUserMapRepository.findAllByArticleKeywordCategoryAndArticleKeywordKeywordIn;
change the extractor to return List<ArticleKeyword> (or List<Long> ids) so the
service can use those directly and avoid the redundant lookup, then update the
repository call to use articleKeyword IDs (e.g.,
findAllByArticleKeywordCategoryAndArticleKeywordIdIn) or compare ArticleKeyword
entities directly (findAllByArticleKeywordCategoryAndArticleKeywordIn) and adapt
the loop that builds keywordByUserId to use ArticleKeyword objects instead of
re-querying by keyword text.

In `@src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java`:
- Around line 29-42: The pagination loop in KeywordExtractor uses an offset
integer and computes the page as PageRequest.of(offset / KEYWORD_BATCH_SIZE,
KEYWORD_BATCH_SIZE), which is confusing and fragile; change the loop to use an
explicit page counter (e.g., int page = 0) and create the pageable with
PageRequest.of(page, KEYWORD_BATCH_SIZE), incrementing page++ each iteration and
removing the offset arithmetic; keep the same stop condition on
keywords.isEmpty() and leave KEYWORD_BATCH_SIZE and
articleKeywordRepository.findAllByCategory intact.
- Around line 25-45: In matchKeywords (KeywordExtractor) add a defensive
null-check for the title parameter and return an empty List<String> immediately
if title is null (to avoid NPE from title.contains(...)); locate the
matchKeywords method and put the guard before any pagination or repository calls
(before PageRequest.of(...) / articleKeywordRepository.findAllByCategory(...))
so the method exits early when title is null and avoids unnecessary DB paging
using the existing matchedKeywords empty list as the return.

In
`@src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java`:
- Around line 235-240: The methods generateLostItemKeywordSchemeUri and
generateKeywordSchemeUri interpolate raw keyword into the deep-link query and
must URL-encode keyword before building the scheme URI; update both functions to
return generateSchemeUri(path,eventId) when keyword is null, otherwise encode
the keyword using UTF-8 (e.g., via URLEncoder.encode(keyword,
StandardCharsets.UTF_8.name()) or equivalent) and use the encoded value in the
String.format call so reserved characters and non-ASCII text produce a valid
query string.

In
`@src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java`:
- Around line 31-43: The repository method
NotificationSubscribeRepository.findArticleKeywordSubscribesByUserIdIn is
misnamed because the JPQL is generic over subscribeType; rename the method to a
neutral, accurate name like findKeywordSubscribesByUserIdIn or
findBySubscribeTypeAndUserIds (keeping the same parameters
`@Param`("subscribeType") NotificationSubscribeType subscribeType and
`@Param`("userIds") List<Integer> userIds) and update all call sites that
reference findArticleKeywordSubscribesByUserIdIn (e.g., services handling
ARTICLE_KEYWORD and LOST_ITEM_KEYWORD flows) to the new method name so behavior
and query remain unchanged but the name reflects its generic use.

In
`@src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java`:
- Around line 67-69: getAlreadyNotifiedUserIds currently hardcodes "keyword"
when building the scheme-uri LIKE pattern which can diverge from the KEYWORD
used in createNotification; change the pattern construction to use the KEYWORD
constant (e.g., build "%s?id=%d&%%" with KEYWORD and articleId) so the call to
notificationRepository.findUserIdsBySchemeUriLikeAndUserIdIn uses the same
source of truth as createNotification, preventing duplicate notifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 441d18df-9053-4a73-95db-3417d872b94e

📥 Commits

Reviewing files that changed from the base of the PR and between ff6fb80 and ec1927f.

📒 Files selected for processing (22)
  • src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java
  • src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java
  • src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java
  • src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java
  • src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java
  • src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java
  • src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java
  • src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java
  • src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java
  • src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java
  • src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java
  • src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java
  • src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java
  • src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java
  • src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java
  • src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java
💤 Files with no reviewable changes (5)
  • src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java
  • src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java
  • src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java
  • src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java
  • src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

키워드 알림 처리에서 EventListener에 섞여 있던 비즈니스 로직(구독 조회/중복 제거/기발송 제외/알림 생성)을 역할별 서비스로 분리하고, 공지(KOREATECH)와 분실물(LOST_ITEM) 키워드 알림 흐름을 이벤트/서비스 단위로 분리한 PR입니다.

Changes:

  • 게시글(공지) 키워드 알림: KoreatechArticleKeywordEvent + ArticleKeywordNotificationService로 책임 분리
  • 분실물 키워드 알림: LostItemKeywordEvent + LostItemKeywordNotificationService 및 전용 리스너 추가
  • 키워드 매칭/구독 유저 매핑 로직 재구성(KeywordExtractor 단순화, ArticleKeywordUserMatcher 도입) 및 관련 Repository 쿼리 추가

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/test/java/in/koreatech/koin/unit/domain/notification/eventlistener/ArticleKeywordEventListenerTest.java 기존 리스너 중심 단위 테스트 삭제
src/test/java/in/koreatech/koin/unit/domain/community/util/KeywordExtractorTest.java 기존 KeywordExtractor 이벤트 생성 테스트 삭제
src/test/java/in/koreatech/koin/unit/domain/community/keyword/service/KeywordServiceTest.java 기존 KeywordService 이벤트 발행/이력 저장 테스트 삭제
src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java 분실물 키워드 알림 비즈니스 로직 서비스로 분리
src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java 공지(게시글) 키워드 알림 비즈니스 로직 서비스로 분리
src/main/java/in/koreatech/koin/domain/notification/repository/NotificationSubscribeRepository.java 매칭 유저 ID 기반 구독 조회 쿼리 추가
src/main/java/in/koreatech/koin/domain/notification/repository/NotificationRepository.java schemeUri LIKE 기반 기발송 사용자 조회 쿼리 추가
src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java 키워드 알림 생성 API 변경 및 분실물 전용 생성 메서드 추가
src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java 분실물 키워드 이벤트 리스너 신규 추가
src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java 리스너를 단순 위임 구조로 리팩터링 및 이벤트 타입 분리
src/main/java/in/koreatech/koin/domain/community/util/KeywordExtractor.java 이벤트 생성 책임 제거, 제목 기반 매칭 키워드 리스트 반환으로 단순화
src/main/java/in/koreatech/koin/domain/community/keyword/service/KeywordService.java 공지 키워드 알림 이벤트 발행 흐름을 신규 구성 요소에 맞게 변경
src/main/java/in/koreatech/koin/domain/community/keyword/service/ArticleKeywordUserMatcher.java 매칭 키워드에 대한 구독 유저 매핑/우선순위(더 긴 키워드) 선정 로직 분리
src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordUserMapRepository.java 카테고리/키워드 IN 기반 유저 매핑 조회 쿼리 추가 및 미사용 메서드 제거
src/main/java/in/koreatech/koin/domain/community/keyword/repository/ArticleKeywordRepository.java 미사용 deleteById 제거
src/main/java/in/koreatech/koin/domain/community/keyword/model/ArticleKeyword.java “더 긴 키워드 우선” 비교 유틸 메서드 추가
src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java updateNotification 타입을 List → Set으로 변경
src/main/java/in/koreatech/koin/domain/community/article/service/LostItemArticleService.java 분실물 키워드 이벤트 발행 흐름을 신규 구성 요소에 맞게 변경
src/main/java/in/koreatech/koin/domain/community/article/repository/ArticleRepository.java findAllByIdIn 파라미터를 Collection으로 확장
src/main/java/in/koreatech/koin/common/event/LostItemKeywordEvent.java 분실물 키워드 이벤트 신규 추가
src/main/java/in/koreatech/koin/common/event/KoreatechArticleKeywordEvent.java 공지(게시글) 키워드 이벤트 신규 추가
src/main/java/in/koreatech/koin/common/event/ArticleKeywordEvent.java 기존 통합 이벤트 삭제

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Soundbar91 and others added 2 commits May 9, 2026 07:57
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@Soundbar91 Soundbar91 requested a review from taejinn May 20, 2026 10:29
Copy link
Copy Markdown
Collaborator

@BaeJinho4028 BaeJinho4028 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ArticleKeywordNotificationService와 LostItemKeywordNotificationService의 흐름이 상당히 유사해 보여, 중복 로직을 공통화할 수 있을지 고민해봐도 좋을 것 같습니다.

@Query("""
SELECT DISTINCT n.user.id
FROM Notification n
WHERE n.schemeUri LIKE :schemeUriPattern
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위험해보이긴합니담. schemeUri 문자열 파싱 대신 다른 방식이 있나 고려해보시면 좋을 것 같습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배치 코드를 확인했는데, 새롭게 생성된 게시글의 ID값들을 요청값으로 보내고 수정/삭제된 게시글에 대해서는 보내지 않고 있음을 확인했습니다. 해당 로직이 필요없을 거 같아요 !

schemeUri 문자열 파싱이 위험하다가 생각하신 이유가 궁금합니당

제 생각은 서버에서 schemeUri를 관리하고 있기 때문에 DB에서 직접 수정을 가하는 것 아닌이상 문제가 될일은 없다고 생각했습니다. user_notification_status 테이블의 값을 직접 수정하는 거랑 동일한 맥락으로 확인했습니다.

별도의 방법을 더 고민을 했는데 마땅히 방법이 없다고 결론을 내렸습니다. Notification 도메인에서 Keyword 모델인 UserNotificationStatus를 참조하는 것을 풀고 싶었는데 방법이 마땅히 없었습니다. Keyword 도메인에서 구독자를 필터링을 하자니 그럼 Keyword 도메인에서 Notification 도메인을 의존하는 상황이 발생하게 되고, 이벤트 처리로 풀어볼려 했으나 이 또한 방법이 잘 안보였습니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java`:
- Around line 38-39: The call to createNotifications(event, userIdsByKeyword,
subscribesByUserId) no longer filters out users who have already been notified
for the same article; restore the prior "already-notified" filtering by
computing the set/map of previously sent notifications (from notification
history) and pass that into createNotifications (or reintroduce an overloaded
createNotifications signature) so it excludes those userIds before building
Notification objects; update createNotifications (and its callers) to accept and
use that notified set/map (referencing createNotifications, userIdsByKeyword,
subscribesByUserId) to suppress duplicates.

In
`@src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java`:
- Around line 39-40: The notification assembly in
LostItemKeywordNotificationService now calls createNotifications(event,
userIdsByKeyword, subscribesByUserId) without filtering out items users were
already notified about, which can cause duplicate pushes; restore
duplicate-history suppression by modifying createNotifications (or its caller)
to accept and use the already-notified tracking (e.g., a Set or Map of
previously notified lostItem IDs per user) and filter out any lost-item entries
present in that structure before creating Notification objects; update the
createNotifications signature and its internal filtering logic (referencing
createNotifications, event, userIdsByKeyword, subscribesByUserId) and ensure the
caller populates the already-notified data from the notification history lookup
used elsewhere in LostItemKeywordNotificationService.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76446264-69fe-4965-b4f1-b897f9bd194c

📥 Commits

Reviewing files that changed from the base of the PR and between 9179ca1 and 031f4ae.

📒 Files selected for processing (2)
  • src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java
  • src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java

Comment on lines +38 to +39
List<Notification> notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Restore already-notified filtering before building notifications.

Line 38 now calls createNotifications(...) without notification-history filtering, and Line 66 removed that input entirely. This regresses duplicate suppression for the same article and can resend pushes on retries/replays.

💡 Suggested direction
+ private final NotificationRepository notificationRepository;

 public void notifyArticleKeyword(KoreatechArticleKeywordEvent event) {
     Map<String, List<Integer>> userIdsByKeyword = event.matchedKeywordUsers().userIdsByKeyword();
     List<Integer> matchedUserIds = getMatchedUserIds(userIdsByKeyword);
     if (matchedUserIds.isEmpty()) {
         return;
     }

     Map<Integer, NotificationSubscribe> subscribesByUserId = findSubscribesByUserId(matchedUserIds);
-    List<Notification> notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId);
+    Set<Integer> alreadyNotifiedUserIds = getAlreadyNotifiedUserIds(event.articleId(), matchedUserIds);
+    List<Notification> notifications = createNotifications(
+        event, userIdsByKeyword, subscribesByUserId, alreadyNotifiedUserIds
+    );

     notificationService.pushNotificationsWithResult(notifications);
 }

 private List<Notification> createNotifications(
     KoreatechArticleKeywordEvent event,
     Map<String, List<Integer>> userIdsByKeyword,
-    Map<Integer, NotificationSubscribe> subscribesByUserId
+    Map<Integer, NotificationSubscribe> subscribesByUserId,
+    Set<Integer> alreadyNotifiedUserIds
 ) {
     List<Notification> notifications = new ArrayList<>();
     for (Map.Entry<String, List<Integer>> entry : userIdsByKeyword.entrySet()) {
         String keyword = entry.getKey();
         for (Integer userId : entry.getValue()) {
+            if (alreadyNotifiedUserIds.contains(userId)) {
+                continue;
+            }
             NotificationSubscribe subscribe = subscribesByUserId.get(userId);
             if (subscribe == null) {
                 continue;
             }
             notifications.add(createNotification(event, keyword, subscribe));
         }
     }
     return notifications;
 }

Also applies to: 63-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java`
around lines 38 - 39, The call to createNotifications(event, userIdsByKeyword,
subscribesByUserId) no longer filters out users who have already been notified
for the same article; restore the prior "already-notified" filtering by
computing the set/map of previously sent notifications (from notification
history) and pass that into createNotifications (or reintroduce an overloaded
createNotifications signature) so it excludes those userIds before building
Notification objects; update createNotifications (and its callers) to accept and
use that notified set/map (referencing createNotifications, userIdsByKeyword,
subscribesByUserId) to suppress duplicates.

Comment on lines +39 to +40
List<Notification> notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Reintroduce duplicate-history filtering in lost-item notification assembly.

Line 39 now builds notifications without prior “already notified” filtering, and Line 67 removed that parameter from createNotifications(...). Author filtering still exists, but duplicate suppression is missing, so repeated processing can resend the same lost-item keyword push.

💡 Suggested direction
+ private final NotificationRepository notificationRepository;

 public void notifyLostItemKeyword(LostItemKeywordEvent event) {
     Map<String, List<Integer>> userIdsByKeyword = event.matchedKeywordUsers().userIdsByKeyword();
     List<Integer> matchedUserIds = getMatchedUserIds(userIdsByKeyword);
     if (matchedUserIds.isEmpty()) {
         return;
     }

     Map<Integer, NotificationSubscribe> subscribesByUserId = findSubscribesByUserId(matchedUserIds);
-    List<Notification> notifications = createNotifications(event, userIdsByKeyword, subscribesByUserId);
+    Set<Integer> alreadyNotifiedUserIds = getAlreadyNotifiedUserIds(event.articleId(), matchedUserIds);
+    List<Notification> notifications = createNotifications(
+        event, userIdsByKeyword, subscribesByUserId, alreadyNotifiedUserIds
+    );

     notificationService.pushNotificationsWithResult(notifications);
 }

 private List<Notification> createNotifications(
     LostItemKeywordEvent event,
     Map<String, List<Integer>> userIdsByKeyword,
-    Map<Integer, NotificationSubscribe> subscribesByUserId
+    Map<Integer, NotificationSubscribe> subscribesByUserId,
+    Set<Integer> alreadyNotifiedUserIds
 ) {
     List<Notification> notifications = new ArrayList<>();
     for (Map.Entry<String, List<Integer>> entry : userIdsByKeyword.entrySet()) {
         String keyword = entry.getKey();
         for (Integer userId : entry.getValue()) {
             if (isMyArticle(event, userId)) {
                 continue;
             }
+            if (alreadyNotifiedUserIds.contains(userId)) {
+                continue;
+            }

             NotificationSubscribe subscribe = subscribesByUserId.get(userId);
             if (subscribe == null) {
                 continue;
             }

             notifications.add(createNotification(event, keyword, subscribe));
         }
     }
     return notifications;
 }

Also applies to: 64-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/in/koreatech/koin/domain/notification/service/LostItemKeywordNotificationService.java`
around lines 39 - 40, The notification assembly in
LostItemKeywordNotificationService now calls createNotifications(event,
userIdsByKeyword, subscribesByUserId) without filtering out items users were
already notified about, which can cause duplicate pushes; restore
duplicate-history suppression by modifying createNotifications (or its caller)
to accept and use the already-notified tracking (e.g., a Set or Map of
previously notified lostItem IDs per user) and filter out any lost-item entries
present in that structure before creating Notification objects; update the
createNotifications signature and its internal filtering logic (referencing
createNotifications, event, userIdsByKeyword, subscribesByUserId) and ensure the
caller populates the already-notified data from the notification history lookup
used elsewhere in LostItemKeywordNotificationService.

@Soundbar91 Soundbar91 merged commit 872c03b into develop May 25, 2026
6 checks passed
@Soundbar91 Soundbar91 deleted the refactor/2219-article-keyword-event-listener branch May 25, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

리팩터링 리팩터링을 위한 이슈입니다

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[공통] 키워드 알림 EventListener 책임 분리

3 participants