Skip to content

feat: FCM 병렬 동기 전송 메소드 추가#2263

Merged
Soundbar91 merged 7 commits into
developfrom
feat/2262-add-multicast-message
May 24, 2026
Merged

feat: FCM 병렬 동기 전송 메소드 추가#2263
Soundbar91 merged 7 commits into
developfrom
feat/2262-add-multicast-message

Conversation

@Soundbar91
Copy link
Copy Markdown
Collaborator

@Soundbar91 Soundbar91 commented May 22, 2026

🔍 개요

image
  • 키워드 알림 이벤트 처리는 @Async를 통해 별도 스레드에서 병렬로 동작하고 있으나, APM 확인 결과 각 이벤트 처리에 수 초가 소요되는 문제가 있었습니다.

  • 두 작업이 순차 및 동기적으로 진행되어 병목이 발생함을 확인했습니다.

    • FCM 알림을 순차적으로 전송
    • 각 전송이 끝날 때마다 알림 이력을 DB에 저장
  • 게시물 키워드 알림 기능의 경우 속도가 핵심인 기능이라고 생각했습니다.

    • 선착순 마감과 같은 게시글이 올라올 경우, 알림이 몇 초만 늦어도 사용자가 참여 기회를 놓칠 수 있는 상황이 발생할 수 있다고 생각했습니다.
  • 해당 상황을 예방하고자, FCM 전송 로직을 개선하는 작업을 진행합니다.

  • close [공통] FCM 병렬 동기 전송 메소드 추가 #2262


🚀 주요 변경 내용

FCM 병렬 동기 전송 메소드 추가

  • FCM 라이브러리에서 제공하는 sendEach 메소드를 활용하는 메소드를 추가했습니다.
  • 해당 메소드는 FCM 서버에서 메시지를 최대 500개를 병렬로 전송하도록 요청하는 메소드입니다.
  • 키워드 알림 메시지를 병렬 및 동기적으로 전송하고자 해당 메소드를 추가했습니다.
  • 토픽 기능 마이그레이션을 생각했지만 다음과 같은 이유로 적용하지 않았습니다.
    • 게시물 키워드 알림은 한 번 알림을 보낸 게시글에 대해서 중복해서 보내지 않는 비즈니스 로직이 존재합니다.
    • 토픽을 활용할 경우 개개인의 알림 전송 성공 여부를 확인할 수 없기 때문에, 중복 방지 로직에서 문제가 발생합니다.

💬 참고 사항


✅ Checklist (완료 조건)

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

@Soundbar91 Soundbar91 self-assigned this May 22, 2026
@github-actions github-actions Bot added the 공통 백엔드 공통으로 작업할 이슈입니다. label May 22, 2026
@github-actions github-actions Bot requested review from BaeJinho4028 and taejinn May 22, 2026 07:35
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 22, 2026

Warning

Review limit reached

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

Your plan currently allows 1 review/hour. Refill in 28 minutes and 54 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: a3334c80-adc9-4d1b-abe7-4d4edf4c8cbd

📥 Commits

Reviewing files that changed from the base of the PR and between a09422f and eb11ed3.

📒 Files selected for processing (1)
  • src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java
📝 Walkthrough

Walkthrough

This PR introduces batch Firebase Cloud Messaging support by adding a FcmSendRequest record to encapsulate notification payloads and a new sendMessages() method to FcmClient that processes multiple requests concurrently using Firebase's sendEach() API, with exception handling and logging.

Changes

Batch FCM notification sending

Layer / File(s) Summary
FCM request payload contract
src/main/java/in/koreatech/koin/infrastructure/fcm/FcmSendRequest.java
FcmSendRequest record captures device token, notification content (title, content, image URL), navigation path, and deep-link metadata with a static factory method.
Batch message sending implementation
src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java
sendMessages() method converts each FcmSendRequest into a Firebase Message with per-platform APNs/Android configs, batches them, and dispatches via sendEach() with error logging.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Suggested labels

성능개선

Suggested reviewers

  • dh2906
  • kih1015
  • ImTotem

Poem

🐰 A bundle of messages flies so fast,
Batched through Firebase, no task's the last,
APNs and Android dance in sync,
Parallel notifications link by link! ✨

🚥 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
Title check ✅ Passed The PR title 'feat: FCM 병렬 동기 전송 메소드 추가' accurately reflects the main change: adding a parallel synchronous FCM message sending method.
Linked Issues check ✅ Passed The PR implements the objective from issue #2262 by adding a sendMessages method using Firebase's sendEach API for parallel FCM message transmission to improve notification latency.
Out of Scope Changes check ✅ Passed All changes are within scope: FcmSendRequest record and sendMessages method directly implement the parallel FCM sending requirement without extraneous modifications.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 feat/2262-add-multicast-message

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.

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: 1

🤖 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/infrastructure/fcm/FcmClient.java`:
- Around line 74-101: The sendMessages method currently calls
FirebaseMessaging.getInstance().sendEach(messages) which hides per-message
failures and also builds messages from requests without filtering blank/invalid
targetDeviceToken(); update sendMessages to first filter out requests with
blank/empty request.targetDeviceToken(), then build Message objects (keeping a
parallel list mapping back to original request/token), call
FirebaseMessaging.getInstance().sendAll(messages) to obtain a BatchResponse,
iterate BatchResponse.getResponses() to detect failures and log each failed
index/token/request.id with the specific error, and optionally retry or collect
failed tokens for further handling; ensure the catch still logs unexpected
exceptions but per-message failures are handled via the BatchResponse rather
than swallowed.
🪄 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: 457e575a-cfcf-4157-8e47-0e32d901acea

📥 Commits

Reviewing files that changed from the base of the PR and between 6493d93 and a09422f.

📒 Files selected for processing (2)
  • src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java
  • src/main/java/in/koreatech/koin/infrastructure/fcm/FcmSendRequest.java

Comment on lines +74 to +101
public void sendMessages(List<FcmSendRequest> requests) {
try {
List<Message> messages = requests.stream()
.map(request -> Message.builder()
.setToken(request.targetDeviceToken())
.setApnsConfig(generateAppleConfig(
request.title(),
request.content(),
request.imageUrl(),
request.path(),
request.type(),
request.schemeUri()
))
.setAndroidConfig(generateAndroidConfig(
request.title(),
request.content(),
request.imageUrl(),
request.schemeUri(),
request.type()
))
.build()
)
.toList();

FirebaseMessaging.getInstance().sendEach(messages);
} catch (Exception e) {
log.warn("FCM 알림 전송 실패", e);
}
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 | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Firebase Admin Java SDK, for FirebaseMessaging.sendEach(List), do individual message failures throw an exception or only appear in BatchResponse? Also confirm recommended failure-handling patterns.

💡 Result:

In the Firebase Admin Java SDK, the FirebaseMessaging.sendEach() method does not throw an exception for individual message failures [1]. Instead, it processes the entire batch and returns a BatchResponse object, which contains the results for each individual message [1][2]. If a specific message fails, that failure is encapsulated within the BatchResponse [1]. You must inspect the BatchResponse to determine the outcome of each message in the batch [2]. Recommended failure-handling patterns include: 1. Inspect the BatchResponse: Always check BatchResponse.getFailureCount() to see if any messages failed [2]. 2. Iterate through Responses: Use BatchResponse.getResponses() to iterate through the list of SendResponse objects [2]. Note that the order of these responses corresponds exactly to the order of the messages provided in the input list [2]. 3. Identify and Handle Failures: For each SendResponse, call isSuccessful(). If it returns false, use getException() to retrieve the specific error details [1][3]. 4. Token Management: Common errors, such as messaging/registration-token-not-registered or messaging/invalid-argument, often indicate that a token is stale or invalid [4][5][6]. When these errors are identified in the SendResponse, you should remove the associated token from your database to prevent future failed attempts [3][4][6]. 5. Retry Strategy: For errors indicating temporary issues (e.g., messaging/server-unavailable or messaging/internal-error), implement an exponential backoff retry strategy [5]. Do not retry messages that failed due to permanent client-side errors [3][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java"

echo "=== File exists? ==="
ls -l "$FILE" || exit 1

echo "=== Method: sendMessages (around lines 60-130) ==="
nl -ba "$FILE" | sed -n '50,140p'

echo "=== Search: sendMessageWithResult ==="
rg -n "sendMessageWithResult" "$FILE"

echo "=== Context around sendMessageWithResult and any hasText guards ==="
# show around all hasText occurrences
rg -n "hasText" "$FILE" || true
for m in $(rg -n "sendMessageWithResult" "$FILE" | cut -d: -f1); do
  start=$((m-40))
  end=$((m+80))
  echo "--- Around sendMessageWithResult at line $m ---"
  nl -ba "$FILE" | sed -n "${start},${end}p"
done

Repository: BCSDLab/KOIN_API_V2

Length of output: 312


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java"

echo "=== Method: sendMessages (around lines 60-130) ==="
sed -n '60,140p' "$FILE" | cat -n

echo "=== Search: sendMessageWithResult ==="
rg -n "sendMessageWithResult" "$FILE" || true

echo "=== Search: hasText usage ==="
rg -n "hasText" "$FILE" || true

echo "=== Search: token usage in sendMessages ==="
rg -n "targetDeviceToken|registration-token|token" "$FILE" | head -n 50

Repository: BCSDLab/KOIN_API_V2

Length of output: 3970


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java"

echo "=== File imports (top) ==="
sed -n '1,120p' "$FILE" | cat -n

echo "=== sendMessageWithResult block (full) ==="
# find start/end by searching for method signature and next method
python3 - <<'PY'
import re, itertools, pathlib
p = pathlib.Path("src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java")
lines = p.read_text().splitlines()
start = next(i for i,l in enumerate(lines) if "public boolean sendMessageWithResult" in l)
# end at next "private " or "public void sendMessages" or EOF
end = next((j for j in range(start+1,len(lines)) if re.search(r'public void sendMessages|private ApnsConfig|public void|private ', lines[j]) ), len(lines))
for k in range(start, min(end, start+140)):
    print(f"{k+1:4d}  {lines[k]}")
PY

echo "=== sendMessages exact block ==="
python3 - <<'PY'
import pathlib, re
p = pathlib.Path("src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java")
lines = p.read_text().splitlines()
start = next(i for i,l in enumerate(lines) if "public void sendMessages(List<FcmSendRequest> requests)" in l)
end = next((j for j in range(start+1,len(lines)) if re.search(r'\n\s*(private|public)\s', "\n"+ "\n".join(lines[j:])),), len(lines))
# fallback simple: print 60 lines
for k in range(start, min(start+60, len(lines))):
    print(f"{k+1:4d}  {lines[k]}")
PY

Repository: BCSDLab/KOIN_API_V2

Length of output: 6534


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/main/java/in/koreatech/koin/infrastructure/fcm/FcmClient.java"

echo "=== Search for existing var usage in this file ==="
rg -n "\bvar\b" "$FILE" || true

echo "=== Search for BatchResponse usage in repo ==="
rg -n "BatchResponse|SendResponse" . || true

echo "=== Search for Java version / sourceCompatibility hints ==="
rg -n "sourceCompatibility|maven-compiler-plugin|toolchain|java\.version" -S . || true

Repository: BCSDLab/KOIN_API_V2

Length of output: 270


Handle per-message failures in FcmClient.sendMessages and filter blank tokens

  • FirebaseMessaging.getInstance().sendEach(messages) does not throw per-message errors; it returns a BatchResponse that must be inspected, so current code hides partial failures.
  • Unlike sendMessageWithResult, sendMessages doesn’t guard against blank/invalid request.targetDeviceToken(), so invalid tokens can degrade the whole batch.
Proposed fix
 public void sendMessages(List<FcmSendRequest> requests) {
+    if (requests == null || requests.isEmpty()) {
+        return;
+    }
     try {
-        List<Message> messages = requests.stream()
+        List<FcmSendRequest> validRequests = requests.stream()
+            .filter(request -> request != null && StringUtils.hasText(request.targetDeviceToken()))
+            .toList();
+
+        if (validRequests.isEmpty()) {
+            return;
+        }
+
+        List<Message> messages = validRequests.stream()
             .map(request -> Message.builder()
                 .setToken(request.targetDeviceToken())
                 .setApnsConfig(generateAppleConfig(
                     request.title(),
                     request.content(),
@@
                 .build()
             )
             .toList();
 
-        FirebaseMessaging.getInstance().sendEach(messages);
+        var batchResponse = FirebaseMessaging.getInstance().sendEach(messages);
+        if (batchResponse.getFailureCount() > 0) {
+            for (int i = 0; i < batchResponse.getResponses().size(); i++) {
+                var response = batchResponse.getResponses().get(i);
+                if (!response.isSuccessful()) {
+                    log.warn("FCM batch send failed. token: {}, error: {}",
+                        validRequests.get(i).targetDeviceToken(),
+                        response.getException() != null ? response.getException().getMessage() : "unknown");
+                }
+            }
+        }
     } catch (Exception e) {
         log.warn("FCM 알림 전송 실패", e);
     }
 }
🤖 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/infrastructure/fcm/FcmClient.java` around
lines 74 - 101, The sendMessages method currently calls
FirebaseMessaging.getInstance().sendEach(messages) which hides per-message
failures and also builds messages from requests without filtering blank/invalid
targetDeviceToken(); update sendMessages to first filter out requests with
blank/empty request.targetDeviceToken(), then build Message objects (keeping a
parallel list mapping back to original request/token), call
FirebaseMessaging.getInstance().sendAll(messages) to obtain a BatchResponse,
iterate BatchResponse.getResponses() to detect failures and log each failed
index/token/request.id with the specific error, and optionally retry or collect
failed tokens for further handling; ensure the catch still logs unexpected
exceptions but per-message failures are handled via the BatchResponse rather
than swallowed.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 22, 2026

Unit Test Results

672 tests   669 ✔️  1m 22s ⏱️
166 suites      3 💤
166 files        0

Results for commit eb11ed3.

♻️ This comment has been updated with latest results.


List<Message> messages = requests.stream()
.map(request -> Message.builder()
.setToken(request.targetDeviceToken())
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.

device 토큰을 여기서 검사해서 공통화 시키면 되겠네요

Comment on lines +78 to +81
if (requests.size() > FCM_MESSAGE_BATCH_SIZE) {
log.warn("FCM 전송 최대 개수를 초과했습니다.");
return ;
}
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.

바로 return하는것보다 500개씩 끊어보내는 것도 방법일 것 같아요

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.

좋은 방법이라고 생각합니다 ! 92347bd 수정했습니다

Comment on lines +106 to +108
} catch (Exception e) {
log.warn("FCM 알림 전송 실패", e);
}
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.

개별 실패를 체크하면 좋긴할텐데 로그 노이즈가 많이 발생할거 같으니,
한번의 시퀀스에 실패한 것들 리스트화 해두면 좋을 것 같아요

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.

제가 PR에 설명이 부족했던 거 같습니다 😢

배치 전송을 하게 되면 BatchResponse 응답을 받게 되고 내부에는 SendResponse이라는 클래스가 존재합니다. 내부에는 사진과 같은 필드가 존재합니다.
image
여기서 각 단건에 대해서 에러를 받을 수 있습니다 ! 해당 코드의 에러 캐칭은 배치 전송 과정 자체에서 발생하는 에러를 캐치하는 것으로 이해하시면 될 거 같습니다.

말씀해주신 작업은 향후 작업으로 진행할 예정입니다 !

@Soundbar91 Soundbar91 merged commit e4c5122 into develop May 24, 2026
6 checks passed
@Soundbar91 Soundbar91 deleted the feat/2262-add-multicast-message branch May 24, 2026 08:12
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.

[공통] FCM 병렬 동기 전송 메소드 추가

2 participants