Skip to content

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

Open
Soundbar91 wants to merge 26 commits into
developfrom
refactor/2219-article-keyword-event-listener
Open

refactor: 키워드 알림 EventListener 책임 분리#2237
Soundbar91 wants to merge 26 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

  • Refactor

    • Keyword notifications split into separate flows for regular articles and lost-item posts for more reliable delivery.
    • Matching now extracts keywords from titles and selects best matches per user to improve relevance.
    • Notification dispatch deduplicates recipients and avoids re-sending for the same article.
  • Bug Fixes

    • Skip notifying the article author and users already notified to reduce redundant alerts.
  • Style

    • Notification messages use a consistent, templated format.

@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

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29e11740-fccd-460f-8c69-a67af5b83e66

📥 Commits

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

📒 Files selected for processing (3)
  • 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/service/ArticleKeywordNotificationService.java
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/main/java/in/koreatech/koin/domain/notification/eventlistener/LostItemKeywordEventListener.java
  • src/main/java/in/koreatech/koin/domain/notification/service/ArticleKeywordNotificationService.java
  • src/main/java/in/koreatech/koin/domain/notification/eventlistener/ArticleKeywordEventListener.java

📝 Walkthrough

Walkthrough

Refactors keyword notification: replaces per-keyword event with per-article and lost-item events, simplifies keyword extraction to title matching, introduces ArticleKeywordUserMatcher, adds dedicated notification services and listener delegation, updates repository queries, and removes related 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 Matching & 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 strings; new ArticleKeywordUserMatcher resolves keyword strings to user ID lists, selecting 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 returns join-fetched entities for keyword/user; NotificationRepository and NotificationSubscribeRepository add JPQL methods for deduplication and subscription lookups.
Notification Services
src/main/java/in/koreatech/koin/domain/notification/service/*, src/main/java/in/koreatech/koin/domain/notification/model/NotificationFactory.java
Added ArticleKeywordNotificationService and LostItemKeywordNotificationService to assemble, filter (already-notified, author), and push notifications; NotificationFactory split article vs lost-item notification builders and URI helpers.
Event Listeners
src/main/java/in/koreatech/koin/domain/notification/eventlistener/*
ArticleKeywordEventListener simplified to delegate KoreatechArticleKeywordEvent to service; new LostItemKeywordEventListener added to handle LostItemKeywordEvent; both are async AFTER_COMMIT.
Service Integration
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 and ArticleKeywordUserMatcher; removed transactional notification-status upsert.
Model & DTO Updates
src/main/java/in/koreatech/koin/domain/community/keyword/dto/KeywordNotificationRequest.java
updateNotification field type changed from List<Integer> to Set<Integer> for deduplication.
Test Removal
src/test/java/...
Removed unit tests for prior 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
  • ImTotem

Poem

🐰 A listener once heavy with burden and care,
Now hops to services, tidy and fair.
Keywords found in titles, users gently matched,
Events split and routed, notifications dispatched.
The rabbit nods — refactor done with flair.

🚥 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 title clearly summarizes the main refactoring change: separating responsibilities in the keyword notification EventListener.
Linked Issues check ✅ Passed All primary objectives from issue #2219 are met: ArticleKeywordEventListener is simplified to ~30 lines delegating to services; keyword notification logic is separated into ArticleKeywordNotificationService and LostItemKeywordNotificationService; and listener-based business logic testing is removed.
Out of Scope Changes check ✅ Passed All changes directly support the refactoring goal. New event classes (KoreatechArticleKeywordEvent, LostItemKeywordEvent) and services (ArticleKeywordUserMatcher, ArticleKeywordNotificationService, LostItemKeywordNotificationService) enable responsibility separation as intended.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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 9179ca1.

♻️ 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

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.

Comment on lines +18 to +30
public static KoreatechArticleKeywordEvent of(
Integer articleId,
Integer boardId,
String articleTitle,
Map<String, List<Integer>> userIdsByKeyword
) {
return new KoreatechArticleKeywordEvent(
articleId,
boardId,
articleTitle,
new MatchedKeywordUsers(userIdsByKeyword)
);
}
Comment on lines +18 to +30
public static LostItemKeywordEvent of(
Integer articleId,
String articleTitle,
Integer authorId,
Map<String, List<Integer>> userIdsByKeyword
) {
return new LostItemKeywordEvent(
articleId,
articleTitle,
authorId,
new MatchedKeywordUsers(userIdsByKeyword)
);
}
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>
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 책임 분리

2 participants