Skip to content

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

Open
Soundbar91 wants to merge 5 commits into
developfrom
feat/2262-add-multicast-message
Open

feat: FCM 병렬 동기 전송 메소드 추가#2263
Soundbar91 wants to merge 5 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

📝 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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

Unit Test Results

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

Results for commit 96c31a1.

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 병렬 동기 전송 메소드 추가

1 participant