Skip to content

[NO-ISSUE] FCM 푸시 알림 기능 develop 이식 및 회원 탈퇴 시 토큰 정리#266

Merged
Develop-KIM merged 2 commits into
developfrom
feat/NO-ISSUE-push-notification
Jun 13, 2026
Merged

[NO-ISSUE] FCM 푸시 알림 기능 develop 이식 및 회원 탈퇴 시 토큰 정리#266
Develop-KIM merged 2 commits into
developfrom
feat/NO-ISSUE-push-notification

Conversation

@Develop-KIM

@Develop-KIM Develop-KIM commented Jun 13, 2026

Copy link
Copy Markdown
Member

개요

롤백(SOU-638)으로 develop에서 제외됐던 FCM 푸시 알림 기능을 develop의 새 패키지 구조(shared, auth.application)에 맞춰 재이식합니다. 더불어 회원 탈퇴 시 FCM 토큰이 정리되지 않던 문제도 함께 해결합니다.

주요 변경

기능

  • FCM 토큰 등록/해제 API (FcmTokenController, FcmTokenCommandService)
  • Firebase Admin 연동 (shared.config.FirebaseConfig/FirebaseProperties)
  • 단일/사용자/브로드캐스트 전송 (FcmNotificationService)
  • 관리자 전체 브로드캐스트 API 및 발송 이력 (AdminPushApi, PushBroadcastHistory)
  • 회원 탈퇴 시 활성 FCM 토큰 일괄 비활성화 (UserService.withdrawdeactivateAllByUserId)

인프라/매핑

  • ErrorCode.FCM_SEND_FAILED 추가
  • orm.xmlFcmToken, PushBroadcastHistory 엔티티 매핑 추가
  • V16__create_push_broadcast_history 마이그레이션 추가 (develop의 V14 fcm_tokens와 정합)
  • build.gradlefirebase-admin 의존성 추가

테스트/문서

  • 도메인/애플리케이션/컨트롤러 테스트 추가 (RestDocs 포함)
  • app/notification.adoc, admin/push.adoc API 문서 추가 및 index include
  • 전체 테스트 404개 통과 (pre-push 훅 검증 완료)

패키지 이식 매핑

기존 develop
global.exception shared.exception
global.common shared.common
global.config (Firebase) shared.config
global.security.annotation auth.adapter.security.annotation
domain.shared.BaseEntity shared.domain.BaseEntity

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Firebase Cloud Messaging(FCM) 푸시 알림 기능 추가
    • 관리자 전체 기기 푸시 브로드캐스트 전송 API
    • 푸시 발송 이력 조회 API
    • 사용자 기기별 FCM 토큰 등록/갱신 및 비활성화 기능
  • Dependencies

    • Firebase Admin SDK 9.4.3 추가
  • Documentation

    • 관리자 푸시 브로드캐스트 REST API 문서
    • FCM 토큰 관리 API 문서

롤백(SOU-638)으로 제외됐던 FCM 푸시 알림 모듈을 develop의 새 패키지
구조(shared/auth.application)에 맞춰 재이식합니다.

주요 변경
- FCM 토큰 등록/해제 API (FcmTokenController, FcmTokenCommandService)
- Firebase Admin 연동 (shared.config.FirebaseConfig/Properties)
- 단일/사용자/브로드캐스트 전송 (FcmNotificationService)
- 관리자 브로드캐스트 API 및 발송 이력 (AdminPushApi, PushBroadcastHistory)
- 회원 탈퇴 시 활성 FCM 토큰 일괄 비활성화 (UserService.withdraw)

인프라/매핑
- ErrorCode.FCM_SEND_FAILED 추가
- orm.xml 에 FcmToken, PushBroadcastHistory 엔티티 매핑 추가
- V16__create_push_broadcast_history 마이그레이션 추가
- build.gradle firebase-admin 의존성 추가

테스트/문서
- 도메인/애플리케이션/컨트롤러 테스트 추가 (RestDocs 포함)
- app/notification.adoc, admin/push.adoc API 문서 추가
- 전체 테스트 404개 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Develop-KIM, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 29 minutes and 40 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 2200ea61-6f69-4e96-8bb9-937427b70e9c

📥 Commits

Reviewing files that changed from the base of the PR and between b503edc and 7e5e5f0.

📒 Files selected for processing (7)
  • src/main/java/com/souzip/application/notification/FcmNotificationService.java
  • src/main/java/com/souzip/application/notification/FcmSendException.java
  • src/main/java/com/souzip/application/notification/FcmTokenCommandService.java
  • src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java
  • src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java
  • src/test/java/com/souzip/application/notification/FcmNotificationServiceTest.java
  • src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java

Walkthrough

Firebase Cloud Messaging(FCM) 라이브러리를 통합하고, 사용자별 FCM 토큰 관리 및 전체 기기 대상 푸시 브로드캐스트 시스템을 추가합니다. Firebase 설정, FCM 토큰 및 브로드캐스트 히스토리 도메인 엔티티, 토큰 등록/갱신/조회 서비스, 푸시 발송 및 이력 기록 서비스, 사용자/관리자 REST API 엔드포인트, DB 스키마, 포괄적 테스트 및 API 문서를 한 번에 추가합니다.

Changes

Firebase Cloud Messaging 푸시 알림 기능

Layer / File(s) Summary
Firebase 설정 및 의존성
build.gradle, src/main/java/com/souzip/shared/config/Firebase*.java
Firebase Admin SDK 9.4.3 의존성, 조건부 @Configuration 설정 클래스, FirebaseProperties 바인딩을 통해 Firebase 초기화 및 FirebaseMessaging 빈 제공을 구성합니다.
도메인 모델: FCM 토큰 및 브로드캐스트 히스토리
src/main/java/com/souzip/domain/notification/DeviceType.java, FcmTokenRegisterRequest.java, FcmToken.java, FcmTokenNotFoundException.java, PushBroadcastHistory.java
DeviceType 열거형, FCM 토큰 요청 DTO(컴팩트 생성자로 null/공백/길이 검증), FcmToken 엔티티(토큰·사용자·디바이스 정보 관리 메서드), 히스토리 엔티티(카운트/플래그 필드)를 정의합니다.
데이터 접근층: 리포지토리 및 인터페이스
src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java, PushBroadcastHistoryRepository.java, provided/FcmTokenFinder.java
Spring Data 리포지토리 인터페이스와 공개 쿼리 인터페이스를 통해 토큰·히스토리 조회/저장/삭제 계약을 정의합니다.
애플리케이션 서비스: 토큰 관리 및 푸시 발송
src/main/java/com/souzip/application/notification/FcmTokenCommandService.java, FcmTokenQueryService.java, FcmNotificationService.java, PushBroadcastHistoryCommandService.java, PushBroadcastHistoryQueryService.java
토큰 등록/갱신(기존 토큰 우선 → 디바이스 일치 → 신규 생성), 비활성화, Firebase 통합 개별/브로드캐스트 발송(성공/실패 카운트), 브로드캐스트 히스토리 기록 및 페이지 조회 서비스를 구현합니다.
DTO 및 응답 객체
src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java, src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java, PushBroadcastHistoryResponse.java, src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java
푸시 브로드캐스트 요청, 발송 결과(총 타깃/성공/실패/Firebase 설정 여부), 히스토리 및 토큰 응답 DTO를 정의합니다.
Web API 컨트롤러: 사용자 및 관리자 엔드포인트
src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java, src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java
사용자용 POST /api/users/me/fcm-tokens 등록/갱신, DELETE 비활성화; 관리자용 POST /api/admin/push/broadcast 전체 기기 발송, GET /api/admin/push/broadcast/history 이력 조회 엔드포인트를 구현합니다.
UserService 통합: 탈퇴 시 FCM 토큰 비활성화
src/main/java/com/souzip/domain/user/service/UserService.java
탈퇴 흐름에 FcmTokenCommandService.deactivateAllByUserId() 호출을 추가하여 사용자의 모든 FCM 토큰을 비활성화합니다.
데이터베이스 스키마 및 ORM 매핑
src/main/resources/META-INF/orm.xml, src/main/resources/db/migration/V16__create_push_broadcast_history.sql
FcmToken, PushBroadcastHistory 엔티티 매핑 및 push_broadcast_histories 테이블(컬럼, PK, created_at 내림차순 인덱스) 마이그레이션을 추가합니다.
에러 코드
src/main/java/com/souzip/shared/exception/ErrorCode.java
FCM 푸시 전송 실패 시 FCM_SEND_FAILED(500) 에러 코드를 추가합니다.
도메인 모델 테스트
src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java, FcmTokenTest.java, PushBroadcastHistoryTest.java
요청 객체 생성/검증/트림, 토큰 엔티티 메서드(연결/해제/갱신/활성), 히스토리 기록 및 필드 검증 테스트를 작성합니다.
애플리케이션 서비스 및 컨트롤러 테스트
src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java, FcmNotificationServiceTest.java, src/test/java/com/souzip/domain/notification/controller/FcmTokenControllerTest.java, src/test/java/com/souzip/adapter/webapi/admin/AdminPushApiTest.java
등록 분기 흐름, 브로드캐스트 성공/실패/미설정 시나리오, API 응답 및 RestDocs 문서화 테스트를 작성합니다.
기존 통합 테스트 업데이트
src/test/java/com/souzip/domain/user/service/UserServiceTest.java
사용자 탈퇴 테스트에 FcmTokenCommandService.deactivateAllByUserId() 호출 검증을 추가합니다.
API 문서
src/docs/asciidoc/admin/index.adoc, admin/push.adoc, app/index.adoc, app/notification.adoc
관리자 푸시 브로드캐스트/이력 조회 API 및 사용자 FCM 토큰 등록/비활성화 API 문서를 추가합니다.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • souzip/souzip-api#255: 이 PR에서 추가한 AdminPushApi.broadcast()FcmNotificationService.broadcastToAllActiveTokens()에 대해 검색된 PR이 마케팅 동의자만 발송하도록 수정하는 변경이어서 동일 메서드/클래스 수준에서 직접 관련됩니다.
  • souzip/souzip-api#227: 이 PR이 추가한 FcmToken, FcmTokenRegisterRequest, FcmTokenFinder, FcmTokenQueryService, FcmTokenRepository 등 FCM 토큰 도메인 및 구조를 동일하게 포함하고 있어 코드 단계에서 강하게 연관됩니다.
  • souzip/souzip-api#240: 이 PR의 AdminPushApi, PushBroadcastHistory, 커맨드/쿼리 서비스, 리포지토리, 응답 DTO 등이 검색된 PR과 동일한 코드 경로 및 클래스들을 공유하여 직접적으로 맞물려 있습니다.

Suggested labels

feat

Suggested reviewers

  • miller198
  • bum0w0
  • gnoes-ios
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.48% 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.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed 제목이 PR의 주요 변경사항인 FCM 푸시 알림 기능 재이식과 회원 탈퇴 시 토큰 정리를 정확히 요약하고 있습니다.

✏️ 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/NO-ISSUE-push-notification

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b503edc74b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +28 to +29
existing.linkUser(userId);
existing.syncDeviceIdentity(request.deviceType(), request.deviceId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 계정 전환 전에 기존 device row를 정리하세요

medium: 같은 물리 기기에서 다른 계정으로 전환할 때 새 요청의 FCM token이 이미 다른 사용자 row에 있고 현재 사용자에게 동일 deviceId의 기존 row가 남아 있으면, 여기서 기존 token row를 현재 userId/deviceId로 바꾸는 순간 V14__create_fcm_token_tablel.sql(user_id, device_id) unique index와 충돌해 등록 API가 500으로 실패합니다. token 기준 row와 현재 user/device row를 모두 조회한 뒤 기존 device row를 비활성화/삭제하거나 merge한 후 저장하세요.

Useful? React with 👍 / 👎.

Comment on lines +52 to +54
} catch (FirebaseMessagingException e) {
log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), e);
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, e.getMessage());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 영구 실패 토큰은 비활성화할 수 있게 구분하세요

medium: 앱 삭제/재설치 등으로 FCM이 UNREGISTERED 같은 영구 실패를 반환해도 여기서 모든 FirebaseMessagingException을 동일한 FCM_SEND_FAILED로 감싸 호출부는 failCount만 올립니다. 그 token은 active=true로 남아 findAllByActiveTrue()/findByUserIdAndActiveTrue()에 계속 포함되므로 이후 broadcast마다 반복 실패하고, 사용자별 알림은 stale token만 있을 때 계속 실패할 수 있습니다. MessagingErrorCode를 보존해 영구 실패 시 해당 FcmToken을 비활성화/삭제하도록 처리하세요.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (3)
src/main/java/com/souzip/shared/config/FirebaseConfig.java (1)

23-36: 💤 Low value

파일 존재 여부를 명시적으로 검증하면 더 명확한 에러 메시지를 제공할 수 있습니다.

현재 구현은 FileInputStream 생성 시 파일이 없으면 FileNotFoundException이 발생하지만, 명시적으로 파일 존재를 체크하면 사용자에게 더 명확한 에러 메시지를 제공할 수 있습니다.

♻️ 제안하는 개선안
 `@Bean`
 public FirebaseApp firebaseApp() throws IOException {
     if (!StringUtils.hasText(firebaseProperties.getCredentialsPath())) {
         throw new IllegalStateException("firebase.enabled=true 일 때 firebase.credentials-path 가 필요합니다.");
     }
+    
+    File credentialsFile = new File(firebaseProperties.getCredentialsPath());
+    if (!credentialsFile.exists()) {
+        throw new IllegalStateException(
+            String.format("Firebase credentials 파일을 찾을 수 없습니다: %s", firebaseProperties.getCredentialsPath())
+        );
+    }
+    
-    try (FileInputStream serviceAccount = new FileInputStream(firebaseProperties.getCredentialsPath())) {
+    try (FileInputStream serviceAccount = new FileInputStream(credentialsFile)) {
         FirebaseOptions options = FirebaseOptions.builder()
                 .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                 .build();
🤖 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/com/souzip/shared/config/FirebaseConfig.java` around lines 23 -
36, In FirebaseConfig.firebaseApp(), add an explicit file-existence check for
the path returned by firebaseProperties.getCredentialsPath() before opening
FileInputStream: verify the path is non-empty (you already check) and that new
File(path).exists() and isFile(); if not, throw a clear IllegalStateException
(e.g. "Firebase credentials file not found at: <path>") so callers get a
descriptive error instead of a FileNotFoundException; keep the existing
try-with-resources FileInputStream block and rest of initialization
(FirebaseOptions.builder(), FirebaseApp.initializeApp/getInstance()) unchanged.
src/main/java/com/souzip/domain/notification/FcmToken.java (1)

44-44: 타임존 기준 명확화(일관성은 유지, 범위 큰 전환은 신중히)

  • FcmToken.lastUsedAtLocalDateTime.now()로 갱신되며(예: FcmToken.java의 각 메서드에서 lastUsedAt = LocalDateTime.now()), BaseEntitycreatedAt/updatedAt도 동일하게 LocalDateTime이라 현재 도메인 타임스탬프 방식은 일관적입니다.
  • 다만 다중 타임존 환경을 고려하면 LocalDateTime 저장의 기준 타임존(UTC vs Asia/Seoul 등)을 명확히(문서화/표준화)하고, 필요 시 Instant/ZonedDateTime 또는 공통 DateTimeProvider 기반의 점진적 전환을 검토하는 편이 안전합니다.
🤖 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/com/souzip/domain/notification/FcmToken.java` at line 44, The
review points out inconsistent timezone semantics: update FcmToken.lastUsedAt
(and related timestamps like BaseEntity.createdAt/updatedAt) to use a clear
timezone strategy rather than calling LocalDateTime.now() directly; pick and
document a project-wide approach (e.g., use Instant.now() for UTC storage or
ZonedDateTime with a defined zone) or introduce a centralized
DateTimeProvider/Clock to obtain timestamps, then replace occurrences of
LocalDateTime.now() in FcmToken (lastUsedAt assignments) and the BaseEntity
timestamp setters to call that provider (or Instant.now()) so all domain
timestamps share the same, explicit timezone source.
src/main/java/com/souzip/application/notification/FcmTokenCommandService.java (1)

60-66: ⚡ Quick win

반복 저장 대신 벌크 업데이트 고려.

현재 구현은 각 토큰을 개별적으로 deactivate()하고 저장합니다. 사용자가 다수의 활성 토큰을 보유한 경우 성능에 영향을 줄 수 있습니다. JPA의 @Modifying 쿼리를 사용한 벌크 업데이트를 고려해보세요.

♻️ 벌크 업데이트 예시

FcmTokenRepository에 커스텀 쿼리 추가:

`@Modifying`
`@Query`("UPDATE FcmToken f SET f.active = false WHERE f.userId = :userId AND f.active = true")
int deactivateAllByUserId(`@Param`("userId") Long userId);

서비스 메서드 변경:

 public void deactivateAllByUserId(Long userId) {
-    List<FcmToken> tokens = fcmTokenRepository.findByUserIdAndActiveTrue(userId);
-    tokens.forEach(token -> {
-        token.deactivate();
-        fcmTokenRepository.save(token);
-    });
+    fcmTokenRepository.deactivateAllByUserId(userId);
 }
🤖 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/com/souzip/application/notification/FcmTokenCommandService.java`
around lines 60 - 66, The current deactivateAllByUserId in
FcmTokenCommandService iterates and saves each FcmToken causing N updates;
replace with a bulk update: add a method to FcmTokenRepository such as
`@Modifying` `@Query`("UPDATE FcmToken f SET f.active = false WHERE f.userId =
:userId AND f.active = true") int deactivateAllByUserId(`@Param`("userId") Long
userId) and then change FcmTokenCommandService.deactivateAllByUserId to call
that repository method (ensure the service method or repository call is executed
within a `@Transactional` context and, if using Spring Data JPA, add
`@Transactional` and `@Modifying` imports where needed).
🤖 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 `@build.gradle`:
- Line 63: The build declares an outdated Firebase Admin dependency
("com.google.firebase:firebase-admin:9.4.3") which should be reviewed and
updated; change the version to 9.9.0 (or later) in the implementation
coordinate, then run a Gradle dependency report (e.g., ./gradlew dependencies
--configuration compileClasspath and runtimeClasspath or dependencyInsight) to
verify transitive vulnerabilities and compatibility, and if vulnerable
transitive artifacts are found add dependency constraints or
force/implementation overrides in build.gradle to pin safe patch versions and
run your test suite to confirm no regressions.

In
`@src/main/java/com/souzip/application/notification/FcmNotificationService.java`:
- Around line 59-86: The logs in sendToUser leak a user identifier (userId)
which is PII; remove or mask userId in all log.warn calls inside sendToUser and
instead log only the non-PII token id (token.getId()) and error info
(e.getErrorCode()), or move detailed user-linked logs to a debug-only message.
Concretely, update the catch block that calls log.warn("FCM 전송 실패... userId={},
fcmTokenId={}, errorCode={}") to omit or mask userId and keep fcmTokenId and
errorCode, and update the later log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}")
to remove or mask userId (e.g., log only success/fail counts or use a
debug-level message that includes a masked userId). Ensure these changes are
applied in the sendToUser method and that references to FcmToken.getId(),
sendToToken, and BusinessException handling remain intact.
- Around line 34-56: The catch in sendToToken currently passes e.getMessage()
into the BusinessException which may expose sensitive Firebase details; change
the catch to avoid forwarding raw exception text: keep the detailed log (using
maskToken(registrationToken) and include e for internal traces), but throw new
BusinessException(ErrorCode.FCM_SEND_FAILED, "FCM 전송 실패") or another
non-sensitive, localized message instead of e.getMessage(); do not include the
raw FirebaseMessagingException text in the BusinessException. Also ensure the
catch still logs e (as it does) so internal diagnostics remain available while
client-facing messages are sanitized.

In `@src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java`:
- Around line 15-19: The FCM token is being included in the exception message in
FcmTokenNotFoundException.byToken(String token), which exposes sensitive
credentials; change byToken to return a generic message that does not include
the token (e.g., "FCM 토큰을 찾을 수 없습니다.") and remove any direct token interpolation
in the String.format call; if you need to retain the token for internal tracing,
store it in a private/non-logged field on the exception rather than embedding it
in the message, but do not log or expose the raw token.
- Around line 9-13: The exception factory FcmTokenNotFoundException.byDeviceId
currently embeds the raw deviceId in the message (risking PII leakage); change
it to return a non-sensitive message (for example "FCM token for the specified
device was not found.") and remove the raw deviceId from the exception text, or
if you must include an identifier, call a dedicated masker (e.g.,
maskDeviceId(deviceId)) to redact most characters before inserting; update
FcmTokenNotFoundException.byDeviceId and add or reuse a maskDeviceId utility
where needed, and ensure any logging that needs the full deviceId uses
secure/debug-only logs rather than the exception message.

In `@src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java`:
- Around line 29-47: In PushBroadcastHistory.record, add defensive validation
that the integer count fields totalTargets, successCount, and failCount are
non-negative: if any is < 0 throw an IllegalArgumentException (or use
Objects.checkIndex-style validation) with a clear message (e.g., "totalTargets
must be non-negative"), then proceed to assign the fields as before; keep the
existing requireNonNull checks for adminId/title/body and update only the record
method to enforce these non-negative checks for totalTargets, successCount and
failCount.

---

Nitpick comments:
In
`@src/main/java/com/souzip/application/notification/FcmTokenCommandService.java`:
- Around line 60-66: The current deactivateAllByUserId in FcmTokenCommandService
iterates and saves each FcmToken causing N updates; replace with a bulk update:
add a method to FcmTokenRepository such as `@Modifying` `@Query`("UPDATE FcmToken f
SET f.active = false WHERE f.userId = :userId AND f.active = true") int
deactivateAllByUserId(`@Param`("userId") Long userId) and then change
FcmTokenCommandService.deactivateAllByUserId to call that repository method
(ensure the service method or repository call is executed within a
`@Transactional` context and, if using Spring Data JPA, add `@Transactional` and
`@Modifying` imports where needed).

In `@src/main/java/com/souzip/domain/notification/FcmToken.java`:
- Line 44: The review points out inconsistent timezone semantics: update
FcmToken.lastUsedAt (and related timestamps like BaseEntity.createdAt/updatedAt)
to use a clear timezone strategy rather than calling LocalDateTime.now()
directly; pick and document a project-wide approach (e.g., use Instant.now() for
UTC storage or ZonedDateTime with a defined zone) or introduce a centralized
DateTimeProvider/Clock to obtain timestamps, then replace occurrences of
LocalDateTime.now() in FcmToken (lastUsedAt assignments) and the BaseEntity
timestamp setters to call that provider (or Instant.now()) so all domain
timestamps share the same, explicit timezone source.

In `@src/main/java/com/souzip/shared/config/FirebaseConfig.java`:
- Around line 23-36: In FirebaseConfig.firebaseApp(), add an explicit
file-existence check for the path returned by
firebaseProperties.getCredentialsPath() before opening FileInputStream: verify
the path is non-empty (you already check) and that new File(path).exists() and
isFile(); if not, throw a clear IllegalStateException (e.g. "Firebase
credentials file not found at: <path>") so callers get a descriptive error
instead of a FileNotFoundException; keep the existing try-with-resources
FileInputStream block and rest of initialization (FirebaseOptions.builder(),
FirebaseApp.initializeApp/getInstance()) unchanged.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 9bdfb804-6f25-4164-9fe9-85f71998b46a

📥 Commits

Reviewing files that changed from the base of the PR and between 883e7ce and b503edc.

📒 Files selected for processing (39)
  • build.gradle
  • src/docs/asciidoc/admin/index.adoc
  • src/docs/asciidoc/admin/push.adoc
  • src/docs/asciidoc/app/index.adoc
  • src/docs/asciidoc/app/notification.adoc
  • src/main/java/com/souzip/adapter/webapi/admin/AdminPushApi.java
  • src/main/java/com/souzip/adapter/webapi/admin/dto/PushBroadcastRequest.java
  • src/main/java/com/souzip/application/notification/FcmNotificationService.java
  • src/main/java/com/souzip/application/notification/FcmTokenCommandService.java
  • src/main/java/com/souzip/application/notification/FcmTokenQueryService.java
  • src/main/java/com/souzip/application/notification/PushBroadcastHistoryCommandService.java
  • src/main/java/com/souzip/application/notification/PushBroadcastHistoryQueryService.java
  • src/main/java/com/souzip/application/notification/dto/PushBroadcastHistoryResponse.java
  • src/main/java/com/souzip/application/notification/dto/PushBroadcastResult.java
  • src/main/java/com/souzip/application/notification/provided/FcmTokenFinder.java
  • src/main/java/com/souzip/application/notification/required/FcmTokenRepository.java
  • src/main/java/com/souzip/application/notification/required/PushBroadcastHistoryRepository.java
  • src/main/java/com/souzip/domain/notification/DeviceType.java
  • src/main/java/com/souzip/domain/notification/FcmToken.java
  • src/main/java/com/souzip/domain/notification/FcmTokenNotFoundException.java
  • src/main/java/com/souzip/domain/notification/FcmTokenRegisterRequest.java
  • src/main/java/com/souzip/domain/notification/PushBroadcastHistory.java
  • src/main/java/com/souzip/domain/notification/controller/FcmTokenController.java
  • src/main/java/com/souzip/domain/notification/dto/FcmTokenResponse.java
  • src/main/java/com/souzip/domain/user/service/UserService.java
  • src/main/java/com/souzip/shared/config/FirebaseConfig.java
  • src/main/java/com/souzip/shared/config/FirebaseProperties.java
  • src/main/java/com/souzip/shared/config/FirebasePropertiesConfiguration.java
  • src/main/java/com/souzip/shared/exception/ErrorCode.java
  • src/main/resources/META-INF/orm.xml
  • src/main/resources/db/migration/V16__create_push_broadcast_history.sql
  • src/test/java/com/souzip/adapter/webapi/admin/AdminPushApiTest.java
  • src/test/java/com/souzip/application/notification/FcmNotificationServiceTest.java
  • src/test/java/com/souzip/application/notification/FcmTokenCommandServiceTest.java
  • src/test/java/com/souzip/domain/notification/FcmTokenRegisterRequestTest.java
  • src/test/java/com/souzip/domain/notification/FcmTokenTest.java
  • src/test/java/com/souzip/domain/notification/PushBroadcastHistoryTest.java
  • src/test/java/com/souzip/domain/notification/controller/FcmTokenControllerTest.java
  • src/test/java/com/souzip/domain/user/service/UserServiceTest.java

Comment thread build.gradle

implementation 'com.amazonaws:aws-java-sdk-s3:1.12.529'

implementation 'com.google.firebase:firebase-admin:9.4.3'

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Firebase Admin SDK 최신 버전 확인
curl -s https://repo1.maven.org/maven2/com/google/firebase/firebase-admin/maven-metadata.xml | grep -o '<latest>[^<]*</latest>' | sed 's/<[^>]*>//g'

# GitHub Security Advisories 확인
gh api graphql -f query='
{
  securityVulnerabilities(first: 5, ecosystem: MAVEN, package: "com.google.firebase:firebase-admin") {
    nodes {
      advisory {
        summary
        severity
        publishedAt
      }
      vulnerableVersionRange
      firstPatchedVersion {
        identifier
      }
    }
  }
}'

Repository: souzip/souzip-api

Length of output: 244


firebase-admin 9.4.3 버전(보안 권고/취약점) 확인 및 필요 시 업그레이드 권장 (build.gradle 63행)

  • 현재 Gradle에 com.google.firebase:firebase-admin:9.4.3을 사용 중이며, Maven 메타데이터 기준 최신은 9.9.0입니다. 취약점 스캔/호환성 검토 후 9.9.0(또는 그 이후)로 업그레이드를 검토하세요.
  • GitHub Security Advisories는 API 호출이 인증 필요(HTTP 401)로 현재 상태에서 직접 확인이 어렵습니다. 대신 Gradle 의존성 트리(compileClasspath/runtimeClasspath) 기반 SCA로 실제 취약 컴포넌트(트랜지티브 포함) 유무를 확인하고, 필요 시 dependency constraints/override로 패치 버전을 적용하세요.
🤖 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 `@build.gradle` at line 63, The build declares an outdated Firebase Admin
dependency ("com.google.firebase:firebase-admin:9.4.3") which should be reviewed
and updated; change the version to 9.9.0 (or later) in the implementation
coordinate, then run a Gradle dependency report (e.g., ./gradlew dependencies
--configuration compileClasspath and runtimeClasspath or dependencyInsight) to
verify transitive vulnerabilities and compatibility, and if vulnerable
transitive artifacts are found add dependency constraints or
force/implementation overrides in build.gradle to pin safe patch versions and
run your test suite to confirm no regressions.

Comment on lines +59 to +86
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (BusinessException e) {
failCount++;
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
userId,
token.getId(),
e.getErrorCode()
);
}
}
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
}
}

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 | 🟡 Minor | ⚡ Quick win

userId 로깅은 PII 유출에 해당할 수 있음.

Lines 72-77, 84에서 userId를 로그에 기록하고 있습니다. 코딩 가이드라인에 따르면 "Never log PII (email, phone, tokens, access keys)"이며, 사용자 식별자(userId)는 PII로 간주될 수 있습니다. 운영 환경에서 민감 정보 유출을 방지하기 위해 userId 로깅을 제거하거나 마스킹하는 것을 권장합니다.

디버깅 목적이라면 fcmTokenId만 로깅하거나, 필요시 별도의 디버그 레벨 로그로 분리하는 방안을 고려하세요.

🔒 PII 로깅 제거 제안
                 failCount++;
                 log.warn(
-                        "FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
-                        userId,
+                        "FCM 전송 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
                         token.getId(),
                         e.getErrorCode()
                 );
         if (failCount > 0) {
-            log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
+            log.warn("FCM 일부 토큰 실패 성공={}, 실패={}", successCount, failCount);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (BusinessException e) {
failCount++;
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) userId={}, fcmTokenId={}, errorCode={}",
userId,
token.getId(),
e.getErrorCode()
);
}
}
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}", userId, successCount, failCount);
}
}
public void sendToUser(Long userId, String title, String body) {
List<FcmToken> tokens = fcmTokenFinder.getActiveTokensByUserId(userId);
if (tokens.isEmpty()) {
return;
}
int successCount = 0;
int failCount = 0;
for (FcmToken token : tokens) {
try {
sendToToken(token.getToken(), title, body);
successCount++;
} catch (BusinessException e) {
failCount++;
log.warn(
"FCM 전송 실패(다음 토큰으로 계속) fcmTokenId={}, errorCode={}",
token.getId(),
e.getErrorCode()
);
}
}
if (failCount > 0 && successCount == 0) {
throw new BusinessException(ErrorCode.FCM_SEND_FAILED, "활성 토큰 전송이 모두 실패했습니다.");
}
if (failCount > 0) {
log.warn("FCM 일부 토큰 실패 성공={}, 실패={}", successCount, failCount);
}
}
🤖 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/com/souzip/application/notification/FcmNotificationService.java`
around lines 59 - 86, The logs in sendToUser leak a user identifier (userId)
which is PII; remove or mask userId in all log.warn calls inside sendToUser and
instead log only the non-PII token id (token.getId()) and error info
(e.getErrorCode()), or move detailed user-linked logs to a debug-only message.
Concretely, update the catch block that calls log.warn("FCM 전송 실패... userId={},
fcmTokenId={}, errorCode={}") to omit or mask userId and keep fcmTokenId and
errorCode, and update the later log.warn("FCM 일부 토큰 실패 userId={}, 성공={}, 실패={}")
to remove or mask userId (e.g., log only success/fail counts or use a
debug-level message that includes a masked userId). Ensure these changes are
applied in the sendToUser method and that references to FcmToken.getId(),
sendToToken, and BusinessException handling remain intact.

Source: Coding guidelines

Comment on lines +29 to +47
public static PushBroadcastHistory record(
UUID adminId,
String title,
String body,
int totalTargets,
int successCount,
int failCount,
boolean firebaseConfigured
) {
PushBroadcastHistory row = new PushBroadcastHistory();
row.adminId = requireNonNull(adminId, "관리자 ID는 필수입니다.");
row.title = requireNonNull(title, "제목은 필수입니다.");
row.body = requireNonNull(body, "본문은 필수입니다.");
row.totalTargets = totalTargets;
row.successCount = successCount;
row.failCount = failCount;
row.firebaseConfigured = firebaseConfigured;
return row;
}

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 | 🟡 Minor | ⚡ Quick win

카운트 필드에 대한 음수 검증을 추가하세요.

totalTargets, successCount, failCount 필드는 음수가 될 수 없는 비즈니스 의미를 가지지만, 현재 record 메서드에서 이에 대한 검증이 없습니다. 음수 값이 저장되면 데이터 무결성 문제가 발생할 수 있습니다. 도메인 모델 레벨에서 방어적으로 검증하는 것이 좋습니다.

🛡️ 제안: 카운트 필드 음수 검증 추가
 public static PushBroadcastHistory record(
         UUID adminId,
         String title,
         String body,
         int totalTargets,
         int successCount,
         int failCount,
         boolean firebaseConfigured
 ) {
     PushBroadcastHistory row = new PushBroadcastHistory();
     row.adminId = requireNonNull(adminId, "관리자 ID는 필수입니다.");
     row.title = requireNonNull(title, "제목은 필수입니다.");
     row.body = requireNonNull(body, "본문은 필수입니다.");
+    if (totalTargets < 0) {
+        throw new IllegalArgumentException("전체 타겟 수는 0 이상이어야 합니다.");
+    }
+    if (successCount < 0) {
+        throw new IllegalArgumentException("성공 카운트는 0 이상이어야 합니다.");
+    }
+    if (failCount < 0) {
+        throw new IllegalArgumentException("실패 카운트는 0 이상이어야 합니다.");
+    }
     row.totalTargets = totalTargets;
     row.successCount = successCount;
     row.failCount = failCount;
     row.firebaseConfigured = firebaseConfigured;
     return row;
 }
🤖 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/com/souzip/domain/notification/PushBroadcastHistory.java`
around lines 29 - 47, In PushBroadcastHistory.record, add defensive validation
that the integer count fields totalTargets, successCount, and failCount are
non-negative: if any is < 0 throw an IllegalArgumentException (or use
Objects.checkIndex-style validation) with a clear message (e.g., "totalTargets
must be non-negative"), then proceed to assign the fields as before; keep the
existing requireNonNull checks for adminId/title/body and update only the record
method to enforce these non-negative checks for totalTargets, successCount and
failCount.

@Develop-KIM Develop-KIM changed the title feat: FCM 푸시 알림 기능 develop 이식 및 회원 탈퇴 시 토큰 정리 [NO-ISSUE] FCM 푸시 알림 기능 develop 이식 및 회원 탈퇴 시 토큰 정리 Jun 13, 2026
- FcmTokenNotFoundException: 예외 메시지에서 토큰/디바이스 ID 제거 (민감정보 노출 방지)
- FcmNotificationService: FirebaseMessagingException 메시지를 그대로 전파하지 않고
  FcmSendException(오류 코드만 보존)으로 감싸 민감정보 누출 차단
- 영구 실패(UNREGISTERED/INVALID_ARGUMENT/SENDER_ID_MISMATCH) 토큰은 전송 후
  비활성화하여 이후 broadcast/사용자 알림에서 반복 실패하지 않도록 처리
- FcmTokenCommandService.deactivateByIds, FcmTokenRepository.findAllByIdIn 추가
- 관련 단위 테스트 추가 (전체 408개 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Develop-KIM

Copy link
Copy Markdown
Member Author

리뷰 반영 (7e5e5f0)

✅ 적용

  • (Critical/Major) 예외 메시지 민감정보 제거FcmTokenNotFoundException.byToken/byDeviceId에서 토큰·디바이스 ID 값 제거
  • (Minor) 예외 메시지 누출 차단FcmNotificationServiceFirebaseMessagingException.getMessage()를 그대로 전파하던 것을 FcmSendException(오류 코드만 보존)으로 교체
  • (P2) 영구 실패 토큰 자동 비활성화UNREGISTERED/INVALID_ARGUMENT/SENDER_ID_MISMATCH 응답 시 해당 토큰을 전송 후 비활성화하여 broadcast·사용자 알림에서 반복 실패하지 않도록 처리 (deactivateByIds 추가)
  • 관련 단위 테스트 추가, 전체 408개 통과

⏭️ 별도 PR로 분리 권장

  • (P2) 계정 전환 시 (user_id, device_id) unique 충돌 — 같은 기기에서 토큰이 다른 계정으로 재할당될 때의 edge case입니다. 올바른 수정은 충돌 row 삭제를 update보다 먼저 flush해야 하는 Hibernate 순서 처리가 필요해, 이 포팅 PR 범위와 분리해 별도로 다루는 것이 안전하다고 판단했습니다. (해당 로직은 원본 기능에서 그대로 이식된 부분으로 본 PR에서 신규 도입한 것은 아닙니다.)

🙅 미적용 (사유)

  • userId 로깅 — userId는 내부 surrogate key(Long)로 이메일/토큰 같은 PII가 아니며, 운영 디버깅에 필요해 유지
  • firebase-admin 버전업 — 현재 9.4.3 정상 동작, 불필요한 변경 위험 회피
  • 카운트 음수 검증total/success/failCount는 내부 계산값으로 음수가 될 수 없어 과방어로 판단

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e5e5f0686

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
messaging.send(messageBuilder.build());
} catch (FirebaseMessagingException e) {
log.error("FCM 전송 실패 tokenPrefix={} error={}", maskToken(registrationToken), e.getMessagingErrorCode(), 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.

P2 Badge Firebase 예외 원문을 로그에 남기지 마세요

medium: 아래 주석처럼 FirebaseMessagingException 메시지에는 원본 registration token 같은 민감 정보가 섞일 수 있는데, log.error(..., e)로 throwable을 그대로 넘기면 stack trace의 Exception.toString()에 그 메시지가 다시 출력됩니다. FCM이 잘못된 토큰 값을 메시지에 포함하는 실패 케이스에서는 maskToken()과 무관하게 운영 로그에 토큰이 남을 수 있으니, error code/마스킹 값만 로깅하거나 throwable 메시지를 sanitize해 주세요.

Useful? React with 👍 / 👎.

Comment on lines +23 to +26
public boolean isPermanentFailure() {
return messagingErrorCode == MessagingErrorCode.UNREGISTERED
|| messagingErrorCode == MessagingErrorCode.INVALID_ARGUMENT
|| messagingErrorCode == MessagingErrorCode.SENDER_ID_MISMATCH;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge INVALID_ARGUMENT만으로 토큰을 비활성화하지 마세요

medium: INVALID_ARGUMENT는 stale token뿐 아니라 잘못된 message/data payload에도 반환될 수 있습니다. 예를 들어 sendToToken(..., data) 호출에서 지원하지 않는 data key/값 때문에 FCM이 INVALID_ARGUMENT를 반환하면, 사용자 전송이나 broadcast 루프가 유효한 토큰들을 모두 영구 실패로 분류해 active=false로 바꿀 수 있습니다. 토큰 자체가 무효인 경우만 비활성화하도록 오류 세부 원인이나 토큰 검증 경로를 분리해 주세요.

Useful? React with 👍 / 👎.

@ViewerAccess
@GetMapping("/broadcast/history")
public SuccessResponse<PaginationResponse<PushBroadcastHistoryResponse>> broadcastHistory(
@ModelAttribute PaginationRequest paginationRequest

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 문서화한 페이지 파라미터를 실제로 바인딩하세요

medium: 신규 history API의 RestDocs는 page/size를 노출하지만, 여기서 받는 PaginationRequest는 기존 컨벤션처럼 pageNo/pageSize 생성자 파라미터만 바인딩합니다. 문서대로 ?page=2&size=20을 보내는 클라이언트는 값이 무시되어 기본값(1, 10)으로만 조회하게 되므로, 문서/테스트를 pageNo/pageSize로 맞추거나 이 endpoint에서 @RequestParam page/size를 명시적으로 받아 변환해 주세요.

Useful? React with 👍 / 👎.

@Develop-KIM Develop-KIM merged commit 2d468f2 into develop Jun 13, 2026
4 checks passed
@Develop-KIM Develop-KIM deleted the feat/NO-ISSUE-push-notification branch June 13, 2026 11:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant