Skip to content

Conversation

@jjaeroong
Copy link
Contributor

@jjaeroong jjaeroong commented Jan 9, 2026

🔀 변경 내용

  • 학생회 게시글 작성 시, 학생에게 푸시알림 기능 구현

✅ 작업 항목

  • firebase 설정
  • topic 발행
  • 테스트 완료 여부

알림 처리 방식

  • 학생회가 게시글을 등록하면,
  • 해당 학생회 범위(학교/단과대/전공)에 속한 학생들에게 푸시 알림 -> 게시글 등록이 느려지지 않게 비동기 처리

"Topic 기반 발행"

  • 프론트 쪽에서 학생이 본인의 학교/단과대/전공 학생회 토픽을 구독한다.
  • 서버는 학생회 게시글 등록 시, 해당 학생회 범위에 매칭되는 topic으로 FCM 메시지를 전송한다.
  • 푸시 발송 대상 그룹은 CouncilType.topic 으로 토픽 문자열의 생성을 통해 정해진다.

CouncilPostPushListener

@TransactionalEventListener(phase = AFTER_COMMIT)
DB 커밋까지 성공해야만 푸시 보냄
-> 푸시 유실 가능성이 있어서, 서비스가 커지면 outbox 고려 예정 (결제 같은 중요 알림이 아니어서 유실해도 큰 문제는 아니라고 생각)

@Async("fcmTaskExecutor")
게시글 등록이 느려지지 않게 스레드풀에서 비동기 발송
푸시알림 느릴 수 있으니 요청 스레드를 안 잡아먹게 전용 스레드풀로 분리함.

FirebaseCloudMessageService

토픽 대상 지정하고 메세지 내용 정하는 등, fcm 코드가 담긴 클래스
푸시가 밀려서, 즉 큐가 쌓여있는지 보기 위한 execSnapshot 로그 메서드

📸 스크린샷 (선택)

image ## 📎 참고 이슈 관련 이슈 번호 #45

Summary by CodeRabbit

릴리스 노트

  • New Features

    • 동아리 게시물 작성 시 푸시 알림 기능 추가
  • Chores

    • Firebase Admin SDK 의존성 추가
    • Firebase 클라우드 메시징 서비스 구성

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 9, 2026

Walkthrough

Firebase Cloud Messaging (FCM) 시스템을 도입하여 학생회 게시물 생성 시 푸시 알림을 전송합니다. 이벤트 기반 아키텍처를 추가하고, 비동기 작업 처리를 위한 스레드 풀을 구성하며, Firebase 초기화 및 토픽 기반 메시징 기능을 구현합니다.

Changes

Cohort / File(s) 변경 사항
Firebase 의존성 및 설정
build.gradle, src/main/resources/application-*.yml, src/test/resources/application-test.yml
Firebase Admin SDK 9.7.0 추가 및 개발/프로덕션 환경별 자격증명 경로 설정 (dev/local/prod에서 firebase.credentials.path 추가, test에서 firebase.enabled: false로 비활성화)
Firebase 인프라 구조
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java, src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java, src/main/java/com/campus/campus/global/firebase/exception/*
Firebase 초기화 로직, FCM 토픽별 메시지 전송 서비스, 에러 코드 정의(FIREBASE_INITIALIZATION_FAILED, FCM_TOPIC_SEND_FAILED), 커스텀 예외 클래스 추가
비동기 작업 처리
src/main/java/com/campus/campus/global/config/AsyncConfig.java
FCM 타스크용 스레드 풀 설정 (코어 풀 5, 최대 풀 10, 큐 용량 1000) 및 @EnableAsync 활성화
FCM 데이터 전송 객체
src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java
FCM 메시지 요청 DTO 추가 (userId, title, body)
학생회 게시물 이벤트 시스템
src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java, src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java, src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java, src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java
게시물 생성 이벤트 레코드 정의, 비동기 이벤트 리스너 추가 (카테고리별 메시지 매핑), StudentCouncilPostService에서 이벤트 발행 로직 추가, 매퍼에 createPostCreatedEvent() 메서드 추가
도메인 모델 확장
src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
추상 메서드 topic(StudentCouncil writer) 추가 (SCHOOL_COUNCIL, COLLEGE_COUNCIL, MAJOR_COUNCIL별로 타입별 토픽 문자열 생성)
정리 및 최소 수정
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java, src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java, src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java
미사용 import 정리, SavedPlaceInfo의 coordinate 필드에서 @NotBlank 제거

Sequence Diagram(s)

sequenceDiagram
    participant User as Client
    participant Service as StudentCouncilPostService
    participant Publisher as ApplicationEventPublisher
    participant Listener as CouncilPostPushListener
    participant Executor as ThreadPoolTaskExecutor
    participant FCMService as FirebaseCloudMessageService
    participant Firebase as Firebase Cloud Messaging
    
    User->>Service: createPost(request)
    Service->>Service: Save post to repository
    Service->>Publisher: publishEvent(CouncilPostCreatedEvent)
    Publisher->>Listener: handleCouncilPostCreatedEvent(event)
    Listener->>Listener: Resolve title & body from event
    Listener->>Executor: Submit async task
    Executor->>FCMService: sendToTopic(topic, title, body, data)
    FCMService->>Firebase: Send Message to topic
    Firebase-->>FCMService: messageId (success) or exception
    FCMService-->>Executor: Log result/error
    Executor-->>User: Return response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✨Feat, ⚙️Chore

Suggested reviewers

  • 1224kang
  • 1winhyun

Poem

🐰 Firebase의 새 길을 닦아,
토끼가 메시지를 달려가,
학생회 소식은 스르륵 날아,
알림이 쏘옥 도착하네요,
이제 모두 소식을 놓치지 않아! 🚀✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목이 변경 사항의 핵심을 명확하게 반영하고 있습니다. 학생회 게시글 작성 시 학생들에게 푸시알림을 구현하는 것이 이 PR의 주요 목표이며, 제목이 이를 정확히 설명합니다.

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

✨ Finishing touches
  • 📝 Generate docstrings

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

@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: 9

🤖 Fix all issues with AI agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java:
- Around line 33-35: The topic method in CouncilType (topic(StudentCouncil
writer)) calls writer.getMajor().getMajorId() without null checks which can NPE;
update topic to first check for null writer.getMajor() (and null majorId) and
return a safe fallback (e.g., "major_unknown" or an empty topic) when null, and
apply the same null-safety fixes to the SCHOOL_COUNCIL and COLLEGE_COUNCIL
branches so none of them dereference getMajor() or getMajorId() without guards.
- Around line 23-25: CouncilType.topic 메서드에서 writer.getCollege()가 null일 경우 NPE가
발생하므로 writer.getCollege()와 관련된 null 체크를 추가해 SCHOOL_COUNCIL과 동일한 방식으로 처리하세요; 즉
CouncilType.topic(…)에서 writer.getCollege()가 null이면 적절한 예외(예:
IllegalStateException/IllegalArgumentException) 또는 기본 토픽 문자열을 반환하도록 방어 코드를 넣고
로그/메시지에 writer 식별자와 함께 상태를 명시하도록 수정하세요.
- Around line 13-15: The topic() implementations for COLLEGE_COUNCIL and
MAJOR_COUNCIL access StudentCouncil.getCollege() and getMajor() without null
checks; add explicit null-safety in each CouncilType.topic(StudentCouncil
writer) (and mirror the defensive style of hasAccess()) by validating writer and
the specific field (college/major) before using them — if null, either throw a
clear IllegalStateException/IllegalArgumentException with context (e.g.,
"College is null for council topic") or return a sensible fallback topic string;
ensure SCHOOL_COUNCIL/topic still assumes non-null school but optionally add an
assertion for consistency.

In
@src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java:
- Around line 28-48: handleCouncilPostCreatedEvent currently runs async and lets
firebaseCloudMessageService.sendToTopic throw FcmTopicSendFailedException
uncaught; wrap the sendToTopic call in a try-catch that catches
FcmTopicSendFailedException (and optionally Exception), log the failure with
context (topic, postId, category) using the existing log instance, and ensure
any necessary compensating actions or rethrows are handled per project
convention so failures are not silently ignored in the @Async method.

In @src/main/java/com/campus/campus/global/config/AsyncConfig.java:
- Around line 6-9: The current AsyncConfig enabling @EnableAsync leaves Spring's
default executor with an effectively unbounded queue, risking OOM under load;
replace the empty config by defining a named ThreadPoolTaskExecutor bean with
explicit corePoolSize, maxPoolSize and queueCapacity (bounded queue) and
register it as the AsyncConfigurer/TaskExecutor for @Async, and also provide an
AsyncUncaughtExceptionHandler implementation to handle exceptions from void
@Async methods; update references to the executor bean name in any @Async
annotations if needed.

In
@src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java:
- Around line 6-16: FcmMessageRequestDto currently lacks validation and has an
incorrect Schema for title; annotate the record components with validation
annotations (e.g., annotate userId with @NotNull, title and body with @NotBlank)
and update the @Schema descriptions to match each field's meaning (change
title's description from "메시지 발송인" to something like "메시지 제목", keep body as
message content, and document userId as required). Ensure you import the correct
validation package (jakarta.validation or javax.validation depending on project)
and confirm callers (controllers) validate the DTO (use @Valid) so invalid
requests are rejected before reaching Firebase.
🧹 Nitpick comments (4)
src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java (1)

3-8: categoryString 대신 PostCategory 타입으로 이벤트를 구성하는 게 안전합니다.
이벤트는 내부 도메인 경계에서 많이 재사용되니, 문자열은 오타/케이스/값 변경에 취약합니다.

Proposed diff
 package com.campus.campus.domain.councilpost.application.dto.request;

+import com.campus.campus.domain.councilpost.domain.entity.PostCategory;
+
 public record CouncilPostCreatedEvent(
 	Long postId,
 	String councilName,
-	String category,
+	PostCategory category,
 	String topic
 ) {}
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java (1)

32-34: 예외 처리 개선이 필요합니다.

현재 구현의 문제점:

  1. 원본 예외(IOException)가 cause로 전달되지 않아 디버깅이 어렵습니다.
  2. 예외 발생 시 로그가 없어 프로덕션 환경에서 문제 파악이 힘듭니다.
♻️ 개선된 예외 처리

FirebaseInitializationFailedException에 cause 지원 추가:

public class FirebaseInitializationFailedException extends RuntimeException {
    public FirebaseInitializationFailedException(Throwable cause) {
        super(ErrorCode.FIREBASE_INITIALIZATION_FAILED.getMessage(), cause);
    }
}

FirebaseInitializer에서 로깅 및 cause 전달:

 } catch (IOException e) {
+  log.error("Firebase 초기화 실패: {}", e.getMessage(), e);
-  throw new FirebaseInitializationFailedException();
+  throw new FirebaseInitializationFailedException(e);
 }
src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java (1)

3-6: 예외 cause 전달을 지원하도록 개선이 권장됩니다.

현재 생성자는 원본 예외(cause)를 받지 않아 스택 트레이스가 손실됩니다. FirebaseInitializer에서 IOException을 래핑할 때 원본 예외 정보를 보존하려면 cause를 받는 생성자가 필요합니다.

♻️ cause를 지원하는 생성자 추가
 public class FirebaseInitializationFailedException extends RuntimeException {
   public FirebaseInitializationFailedException() {
     super(ErrorCode.FIREBASE_INITIALIZATION_FAILED.getMessage());
   }
+
+  public FirebaseInitializationFailedException(Throwable cause) {
+    super(ErrorCode.FIREBASE_INITIALIZATION_FAILED.getMessage(), cause);
+  }
 }
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (1)

21-31: 입력 파라미터 검증을 추가하는 것이 좋습니다.

현재 topic, title, body에 대한 null/빈 문자열 검증이 없습니다. Firebase API 호출 시 모호한 에러가 발생할 수 있으며, 의도하지 않은 빈 알림이 전송될 위험이 있습니다.

♻️ 입력 검증 추가
 public void sendToTopic(String topic, String title, String body, Map<String, String> data) {
+  if (topic == null || topic.isBlank()) {
+    throw new IllegalArgumentException("Topic cannot be null or blank");
+  }
+  if (title == null || title.isBlank()) {
+    throw new IllegalArgumentException("Title cannot be null or blank");
+  }
+  if (body == null || body.isBlank()) {
+    throw new IllegalArgumentException("Body cannot be null or blank");
+  }
+
   Message.Builder builder = Message.builder()
     .setTopic(topic)
     .setNotification(Notification.builder()
       .setTitle(title)
       .setBody(body)
       .build());
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a9494fb and f7c875e.

📒 Files selected for processing (15)
  • build.gradle
  • src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
  • src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java
  • src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java
  • src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java
  • src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java
  • src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java
  • src/main/java/com/campus/campus/global/config/AsyncConfig.java
  • src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java
  • src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java
  • src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java
  • src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java
💤 Files with no reviewable changes (1)
  • src/main/java/com/campus/campus/domain/councilpost/domain/repository/LikePostRepository.java
🧰 Additional context used
🧬 Code graph analysis (6)
src/main/java/com/campus/campus/global/config/AsyncConfig.java (1)
src/main/java/com/campus/campus/global/config/executor/PlaceSearchExecutorConfig.java (1)
  • Configuration (9-15)
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java (2)
src/main/java/com/campus/campus/global/firebase/exception/FirebaseInitializationFailedException.java (1)
  • FirebaseInitializationFailedException (3-7)
src/main/java/com/campus/campus/CampusApplication.java (1)
  • SpringBootApplication (8-17)
src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java (10)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilChangePasswordRequest.java (1)
  • StudentCouncilChangePasswordRequest (9-24)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilChangeEmailRequest.java (1)
  • StudentCouncilChangeEmailRequest (6-11)
src/main/java/com/campus/campus/domain/councilnotice/application/dto/request/NoticeRequest.java (1)
  • NoticeRequest (5-10)
src/main/java/com/campus/campus/domain/user/application/dto/request/CampusNicknameUpdateRequest.java (1)
  • CampusNicknameUpdateRequest (6-11)
src/main/java/com/campus/campus/domain/user/application/dto/request/UserProfileRequest.java (1)
  • UserProfileRequest (5-12)
src/main/java/com/campus/campus/domain/mail/application/dto/request/EmailVerificationRequest.java (1)
  • EmailVerificationRequest (6-11)
src/main/java/com/campus/campus/global/util/jwt/application/dto/request/TokenReissueRequest.java (1)
  • TokenReissueRequest (6-11)
src/main/java/com/campus/campus/domain/manager/application/dto/response/CouncilApproveOrDenyResponse.java (1)
  • CouncilApproveOrDenyResponse (5-12)
src/main/java/com/campus/campus/domain/council/application/dto/request/StudentCouncilSignUpRequest.java (1)
  • StudentCouncilSignUpRequest (11-44)
src/main/java/com/campus/campus/domain/manager/application/dto/response/CertifyRequestCouncilResponse.java (1)
  • CertifyRequestCouncilResponse (5-15)
src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java (2)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)
  • Slf4j (40-279)
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (1)
  • Slf4j (16-53)
src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java (3)
src/main/java/com/campus/campus/global/common/exception/ErrorCodeInterface.java (3)
  • ErrorCodeInterface (5-11)
  • getStatus (8-8)
  • getCode (6-6)
src/main/java/com/campus/campus/domain/user/application/exception/ErrorCode.java (1)
  • Getter (10-22)
src/main/java/com/campus/campus/domain/councilpost/application/exception/ErrorCode.java (1)
  • Getter (8-34)
src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java (4)
src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetUpcomingEventListForCouncilResponse.java (1)
  • GetUpcomingEventListForCouncilResponse (10-29)
src/main/java/com/campus/campus/domain/councilpost/application/dto/request/PostRequest.java (1)
  • PostRequest (14-42)
src/main/java/com/campus/campus/domain/councilpost/application/dto/response/GetPostListForCouncilResponse.java (1)
  • GetPostListForCouncilResponse (10-32)
src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java (1)
  • Entity (25-98)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (14)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostForUserService.java (1)

20-20: LGTM! 사용하지 않는 import 제거 및 정리

GetPostForUserResponse import 순서 조정 및 사용하지 않는 java.util.Optional import 제거는 적절한 코드 정리입니다.

다만, PR 목표는 푸시 알림 구현인데 이 파일에는 import 변경만 있습니다. 실제 푸시 알림 구현은 다른 파일(예: 게시글 작성 서비스)에 있을 것으로 보이는데, 해당 파일들이 리뷰에 포함되지 않았습니다.

build.gradle (1)

60-62: Firebase Admin SDK 9.7.0은 현재 최신 버전이며 Java 21 + Spring Boot 3.5.7과 호환됩니다.

검증 결과:

  • Firebase Admin SDK 9.7.0은 Maven Central에 존재하는 최신 버전입니다 (2025년 9월 24일 릴리스)
  • Java 8 이상을 지원하므로 Java 21과 완전히 호환됩니다
  • Spring Boot 3.5.7은 Java 17+ 이상을 지원하며 Java 21과 호환됩니다

현재 의존성은 적절하며 별도의 버전 업데이트가 필요하지 않습니다.

src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java (1)

19-23: 초기화 중복 방지 로직은 적절합니다.

Spring의 @PostConstruct는 빈 초기화 시 단일 스레드로 실행되므로 동시성 문제가 발생할 가능성은 낮습니다. 다만, 테스트 환경이나 여러 애플리케이션 컨텍스트가 로드되는 경우를 대비하여 이 체크가 존재하는 것은 좋은 방어 코드입니다.

src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (3)

50-50: 의존성 주입이 올바르게 구현되었습니다.

Spring의 ApplicationEventPublisher@RequiredArgsConstructor를 통해 주입받는 것은 표준적이고 권장되는 방식입니다.


75-75: 저장된 엔티티를 변수에 할당하는 것이 적절합니다.

생성된 ID를 이벤트에 전달하기 위해 저장 결과를 변수에 할당하는 것은 올바른 접근입니다.


83-91: 이 리뷰 의견은 유효하지 않습니다. 코드는 이미 권장된 패턴을 구현하고 있습니다.

CouncilPostPushListener는 다음과 같이 올바르게 구성되어 있습니다:

  • @Async: 비동기 처리
  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT): 트랜잭션 커밋 후 실행

더불어 AsyncConfig에서 @EnableAsync가 설정되어 있습니다. 따라서 이벤트 리스너의 예외는 게시글 생성 트랜잭션에 영향을 주지 않으며, 푸시 알림 전송도 비동기로 처리되어 성능 문제가 없습니다.

Likely an incorrect or invalid review comment.

src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java (1)

3-6: 예외 처리가 올바르게 구현되었습니다.

Throwable cause를 받아 상위 예외로 전달하므로, 원본 FirebaseMessagingException의 스택 트레이스가 보존됩니다. 이는 디버깅에 매우 유용합니다.

src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (1)

33-42: 성공 로깅이 잘 구현되었습니다.

전송된 메시지의 ID, 토픽, 제목, 본문, 데이터 키를 모두 로깅하여 추적 및 디버깅이 용이합니다.

src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java (1)

39-39: LGTM!

추상 메서드 선언이 적절하며, hasAccess() 메서드와 일관된 패턴을 따르고 있습니다.

src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java (3)

21-24: LGTM!

매직 스트링 대신 상수를 사용한 것은 좋은 관행입니다. 명명 규칙도 적절합니다.


16-19: LGTM!

클래스 구조와 어노테이션이 적절합니다. Spring 이벤트 리스너로서 필요한 구성이 잘 갖춰져 있습니다.


50-56: 제휴 게시글과 행사글 처리 완료됨

PostCategory enum에는 EVENT와 PARTNERSHIP 두 개의 값만 존재하며, 두 값 모두 resolveBody 메서드에서 명시적으로 처리되고 있습니다. 카테고리가 PostCategory enum의 name() 메서드를 통해 전달되므로 이 두 값 이외의 카테고리가 들어올 가능성은 없으며, default 케이스가 예상치 못한 값에 대한 안전장치 역할을 합니다. 별도의 타입 안전성 개선이나 추가 카테고리 매핑은 필요하지 않습니다.

Likely an incorrect or invalid review comment.

src/main/java/com/campus/campus/global/firebase/exception/ErrorCode.java (2)

10-20: LGTM!

ErrorCode enum 구조가 기존 패턴(user, councilpost 도메인의 ErrorCode)과 일관성 있게 구현되었습니다. ErrorCodeInterface 구현과 Lombok 어노테이션 사용도 적절합니다.


14-15: Firebase 에러 코드(6201-6202)는 OCI 에러 코드(6100-6101)와 겹치지 않으며, 6xxx 범위가 외부 서비스 인프라 에러를 위해 적절히 할당되어 있습니다. 코드 범위 충돌 없이 일관된 패턴을 따르고 있어 별도의 변경이 필요하지 않습니다.

Comment on lines +13 to +15
@Override public String topic(StudentCouncil writer) {
return "school_" + writer.getSchool().getSchoolId();
}
Copy link

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:

# Find StudentCouncil entity file
fd -t f "StudentCouncil.java" --type f

Repository: our-campUS/our-campUS-BE

Length of output: 148


🏁 Script executed:

# Find and examine CouncilType.java to get full context
fd -t f "CouncilType.java" --type f

Repository: our-campUS/our-campUS-BE

Length of output: 145


🏁 Script executed:

# Search for StudentCouncil class definition and its fields
rg -nP --type=java "class\s+StudentCouncil" -A 30

Repository: our-campUS/our-campUS-BE

Length of output: 50380


🏁 Script executed:

cat -n src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java

Repository: our-campUS/our-campUS-BE

Length of output: 1615


🏁 Script executed:

# Search for hasAccess method to see how it handles null checks
rg -nP "hasAccess" --type=java -B2 -A5

Repository: our-campUS/our-campUS-BE

Length of output: 4230


🏁 Script executed:

# Check if @ManyToOne on school field has explicit optional parameter
rg -nP "@ManyToOne|@JoinColumn.*school" src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java -A1

Repository: our-campUS/our-campUS-BE

Length of output: 266


🏁 Script executed:

# Find where topic() method is called
rg -nP "\.topic\(" --type=java -B2 -A2

Repository: our-campUS/our-campUS-BE

Length of output: 1682


🏁 Script executed:

# Check StudentCouncil entity to see if college and major have nullable constraints
rg -nP "@JoinColumn.*college|@JoinColumn.*major" src/main/java/com/campus/campus/domain/council/domain/entity/StudentCouncil.java -B1 -A1

Repository: our-campUS/our-campUS-BE

Length of output: 273


필드의 null 안전성 검증 필요 (college, major의 경우)

StudentCouncil의 school 필드는 데이터베이스 레벨에서 nullable = false 제약이 있어 SCHOOL_COUNCIL의 topic() 메서드는 즉시적인 NPE 위험이 낮습니다. 하지만 collegemajor 필드는 nullable이 기본값이므로 COLLEGE_COUNCIL과 MAJOR_COUNCIL의 topic() 메서드는 null 체크 없이 이들 필드에 접근하고 있습니다.

또한 hasAccess() 메서드는 user의 필드만 null 검증하고 writer의 필드는 검증하지 않으므로, 방어적 프로그래밍 관점에서 topic() 메서드에서도 일관되게 null 체크를 수행하는 것이 좋습니다. 특히 lazy-loading 환경에서 예상치 못한 상황이 발생할 수 있습니다.

모든 council type의 topic() 메서드에 명시적인 null 체크를 추가하여 일관성과 안정성을 확보하기를 권장합니다.

🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 13 - 15, The topic() implementations for COLLEGE_COUNCIL and
MAJOR_COUNCIL access StudentCouncil.getCollege() and getMajor() without null
checks; add explicit null-safety in each CouncilType.topic(StudentCouncil
writer) (and mirror the defensive style of hasAccess()) by validating writer and
the specific field (college/major) before using them — if null, either throw a
clear IllegalStateException/IllegalArgumentException with context (e.g.,
"College is null for council topic") or return a sensible fallback topic string;
ensure SCHOOL_COUNCIL/topic still assumes non-null school but optionally add an
assertion for consistency.

Comment on lines +23 to +25
@Override public String topic(StudentCouncil writer) {
return "college_" + writer.getCollege().getCollegeId();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Null 안전성 검증 필요

writer.getCollege()이 null일 경우 NPE가 발생할 수 있습니다. SCHOOL_COUNCIL과 동일한 null 안전성 문제가 있습니다.

🛡️ Null 안전성을 위한 수정안
 @Override public String topic(StudentCouncil writer) {
+	if (writer.getCollege() == null) {
+		throw new IllegalStateException("COLLEGE_COUNCIL writer must have a college");
+	}
 	return "college_" + writer.getCollege().getCollegeId();
 }
📝 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
@Override public String topic(StudentCouncil writer) {
return "college_" + writer.getCollege().getCollegeId();
}
@Override public String topic(StudentCouncil writer) {
if (writer.getCollege() == null) {
throw new IllegalStateException("COLLEGE_COUNCIL writer must have a college");
}
return "college_" + writer.getCollege().getCollegeId();
}
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 23 - 25, CouncilType.topic 메서드에서 writer.getCollege()가 null일 경우 NPE가
발생하므로 writer.getCollege()와 관련된 null 체크를 추가해 SCHOOL_COUNCIL과 동일한 방식으로 처리하세요; 즉
CouncilType.topic(…)에서 writer.getCollege()가 null이면 적절한 예외(예:
IllegalStateException/IllegalArgumentException) 또는 기본 토픽 문자열을 반환하도록 방어 코드를 넣고
로그/메시지에 writer 식별자와 함께 상태를 명시하도록 수정하세요.

Comment on lines +33 to +35
@Override public String topic(StudentCouncil writer) {
return "major_" + writer.getMajor().getMajorId();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Null 안전성 검증 필요

writer.getMajor()가 null일 경우 NPE가 발생할 수 있습니다. SCHOOL_COUNCIL, COLLEGE_COUNCIL과 동일한 null 안전성 문제가 있습니다.

🛡️ Null 안전성을 위한 수정안
 @Override public String topic(StudentCouncil writer) {
+	if (writer.getMajor() == null) {
+		throw new IllegalStateException("MAJOR_COUNCIL writer must have a major");
+	}
 	return "major_" + writer.getMajor().getMajorId();
 }
📝 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
@Override public String topic(StudentCouncil writer) {
return "major_" + writer.getMajor().getMajorId();
}
@Override public String topic(StudentCouncil writer) {
if (writer.getMajor() == null) {
throw new IllegalStateException("MAJOR_COUNCIL writer must have a major");
}
return "major_" + writer.getMajor().getMajorId();
}
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/domain/council/domain/entity/CouncilType.java
around lines 33 - 35, The topic method in CouncilType (topic(StudentCouncil
writer)) calls writer.getMajor().getMajorId() without null checks which can NPE;
update topic to first check for null writer.getMajor() (and null majorId) and
return a safe fallback (e.g., "major_unknown" or an empty topic) when null, and
apply the same null-safety fixes to the SCHOOL_COUNCIL and COLLEGE_COUNCIL
branches so none of them dereference getMajor() or getMajorId() without guards.

Comment on lines 6 to 9
@Configuration
@EnableAsync
public class AsyncConfig {
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Spring Boot 3.5 Spring Framework 6 @EnableAsync default executor behavior and best practices

💡 Result:

Short summary

  • Default in Spring Boot 3.5 / Spring Framework 6: Boot auto-configures an AsyncTaskExecutor for @EnableAsync. If virtual threads are enabled (Java 21+ with spring.threads.virtual.enabled=true) the auto-configured executor is a SimpleAsyncTaskExecutor that creates virtual threads; otherwise Boot configures a ThreadPoolTaskExecutor with “sensible defaults.” The auto-configured executor is used for @EnableAsync unless you register your own Executor/AsyncTaskExecutor or AsyncConfigurer. [1][2][3]

What @EnableAsync looks up

  • Spring’s @EnableAsync will use, in order of precedence:
    1. an AsyncConfigurer (if you implement it and provide getAsyncExecutor()), or
    2. a single TaskExecutor/AsyncTaskExecutor bean in the context (or an Executor bean when Boot’s auto-config is active), or
    3. fall back to a SimpleAsyncTaskExecutor (framework default). [4][5]

Key Boot behavior notes

  • Boot’s TaskExecutionAutoConfiguration will back off if you define custom Executor beans. Boot also documents property-based configuration via spring.task.execution.* to tune the auto-configured ThreadPoolTaskExecutor (core/max/queue/keep-alive, name prefix). Web integrations (MVC/WebFlux/GraphQL/WebSocket, JPA bootstrap, etc.) may require an AsyncTaskExecutor bean named applicationTaskExecutor for all pieces to use it. [1][2][6]

Recommended best practices

  • Always explicitly configure an Executor for production instead of relying on the silent fallback:
    • Use a ThreadPoolTaskExecutor (or a java.util.concurrent.Executor adapted) with explicit corePoolSize, maxPoolSize, queueCapacity, keepAlive and thread-name-prefix. Bound the queue to avoid unbounded memory growth. [1][6]
    • Choose rejection policy appropriate for your app (CallerRunsPolicy, abort, etc.) and consider back-pressure.
    • Provide an AsyncUncaughtExceptionHandler (via AsyncConfigurer) to handle exceptions from void @async methods.
    • Name the bean appropriately (consider applicationTaskExecutor when you need MVC/WebFlux integration) and/or expose a single TaskExecutor bean so @EnableAsync picks it up.
    • Monitor threads, queue length, latency and adapt sizes; avoid very small pools for blocking workloads.
    • If running on Java 21+ and your workload is heavily blocking IO, evaluate virtual threads (spring.threads.virtual.enabled=true) — they can simplify sizing but test for your workload. [1][4][7][8]
    • Prefer non-blocking/reactive designs for highly concurrent services where appropriate.

Quick config examples

  • application.properties (use Boot’s properties to tune auto executor):
    • spring.task.execution.pool.max-size=16
    • spring.task.execution.pool.queue-capacity=100
    • spring.task.execution.pool.keep-alive=10s
    • spring.threads.virtual.enabled=true (only on Java 21+ to opt into virtual-thread SimpleAsyncTaskExecutor). [1][6][7]

References

  1. Spring Boot — Task Execution and Scheduling (auto-config behavior, properties, virtual threads). [Doc]. [4]
  2. Spring Boot Reference — TaskExecutionAutoConfiguration / defaults discussion. [Doc]. [3]
  3. Spring Boot docs (3.2/3.1 pages confirming ThreadPoolTaskExecutor vs SimpleAsyncTaskExecutor with virtual threads). [7][2]
  4. Spring Framework — @EnableAsync / AsyncConfigurer / SimpleAsyncTaskExecutor behavior. [9][8]
  5. Spring Framework scheduling & @async docs (executor selection and @async("beanName")). [10]

If you want, I can: (a) paste a recommended ThreadPoolTaskExecutor @bean + AsyncConfigurer example, or (b) produce tuned sizing guidance based on your app’s CPU vs blocking characteristics — tell me your expected concurrency and type of tasks.


🌐 Web query:

Spring @Async default ThreadPoolExecutor configuration risks unbounded queue

💡 Result:

Short answer: yes — by default Spring's ThreadPoolTaskExecutor uses an effectively unbounded queue (queueCapacity = Integer.MAX_VALUE), and Spring Boot's auto-configured @async executor uses that queue with a core size (default 8). That combination means the queue can grow without bound (risking OOM) and prevents the pool from expanding beyond the core (max-size is ignored while the queue is unbounded). [1][2][3]

What to do (brief):

  • Set a finite queue (spring.task.execution.pool.queue-capacity) and a sensible max-size (spring.task.execution.pool.max-size).
  • Or provide your own Executor bean/ThreadPoolTaskExecutor configured with bounded queue and rejection policy. [2][3]

Sources:

  • Spring Framework (ThreadPoolTaskExecutor defaults and queue behavior). [1]
  • Spring Framework task-execution docs (warning about unbounded queues/ OOM and interaction with maxPoolSize). [2]
  • Spring Boot task-execution auto-config (defaults: core-size=8 and how to configure queue-capacity/max-size). [3]

🌐 Web query:

Spring Boot AsyncConfigurer TaskExecutor recommended settings production

💡 Result:

Short recommended production settings & rules-of-thumb for Spring Boot AsyncConfigurer / ThreadPoolTaskExecutor:

  • Use a bounded queue (not Integer.MAX_VALUE). Configure spring.task.execution.pool.queue-capacity to a finite value (e.g. 50–500) so maxPoolSize is honored.
  • Size threads by workload type using the usual formula: threads ≈ CPU_cores * (1 + wait_time / service_time). For CPU-bound tasks set ≈ #cores; for IO-bound allow many more (example: cores * (1 + blockingCoefficient)). Start with theory then load-test/monitor. (engineering.zalando.com)
  • Example starter values (tune to your app & host):
    • corePoolSize: = #cores (or slightly higher for light IO).
    • maxPoolSize: = corePoolSize * 2–10 (or set to cores*(1+W/S) if known).
    • queueCapacity: 50–200 for web backends; smaller (0–10) for latency-sensitive pipelines to avoid large queuing.
    • keepAlive: 10–60s; consider allowCoreThreadTimeOut=true for elastic usage.
    • rejectedExecutionHandler: CallerRunsPolicy (simple back-pressure) or a custom handler that logs/metrics.
    • waitForTasksToCompleteOnShutdown=true and set an awaitTerminationPeriod. (docs.spring.io)
  • Observe these metrics in production and adjust: CPU, GC, thread counts, queue length, task latency and rejection rate. Use load tests to find sweet spot. (foojay.io)

If you want, tell me: (1) your host #CPU cores, (2) whether tasks are CPU- or IO-bound, and (3) an expected concurrency/throughput—I’ll propose concrete numbers and example config/YAML.

Citations:


@EnableAsync 단독 구성 시 기본 Executor의 무제한 큐로 인한 운영 리스크 해결 필요.

Spring Boot 3.5에서는 @EnableAsync 시 자동으로 ThreadPoolTaskExecutor를 구성하지만, 기본값으로 큐 용량이 Integer.MAX_VALUE(사실상 무제한)로 설정됩니다. 이로 인해 트래픽 급증 시 메모리 무제한 증가와 스레드 풀 확장 불능이 발생할 수 있어, 운영 환경에서는 다음과 같이 명시적으로 유계 TaskExecutor를 빈으로 등록하거나 설정 프로퍼티를 통해 큐를 제한해야 합니다.

푸시 발송 같은 백그라운드 작업이 몰릴 경우 메모리 부족(OOM)으로 이어질 수 있으므로, 적절한 corePoolSize, maxPoolSize, queueCapacity를 지정한 전용 Executor 구성을 권장하며, 함께 AsyncUncaughtExceptionHandler를 정의하여 void @async 메서드 예외도 처리하면 좋습니다.

권장 설정 예시 (유계 executor + 예외 처리)
 package com.campus.campus.global.config;

+import java.util.concurrent.Executor;
+
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.context.annotation.Bean;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;

 @Configuration
 @EnableAsync
-public class AsyncConfig {
+public class AsyncConfig implements AsyncConfigurer {
+
+	private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
+
+	@Override
+	@Bean(name = "taskExecutor")
+	public Executor getAsyncExecutor() {
+		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+		executor.setCorePoolSize(4);
+		executor.setMaxPoolSize(16);
+		executor.setQueueCapacity(100);
+		executor.setThreadNamePrefix("async-");
+		executor.setWaitForTasksToCompleteOnShutdown(true);
+		executor.setAwaitTerminationSeconds(30);
+		executor.initialize();
+		return executor;
+	}
+
+	@Override
+	public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
+		return (ex, method, params) -> 
+			log.error("Uncaught async exception in {}.{}()", 
+				method.getDeclaringClass().getSimpleName(), method.getName(), ex);
+	}
 }
📝 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
@Configuration
@EnableAsync
public class AsyncConfig {
}
package com.campus.campus.global.config;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
private static final Logger log = LoggerFactory.getLogger(AsyncConfig.class);
@Override
@Bean(name = "taskExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Uncaught async exception in {}.{}()",
method.getDeclaringClass().getSimpleName(), method.getName(), ex);
}
}
🤖 Prompt for AI Agents
In @src/main/java/com/campus/campus/global/config/AsyncConfig.java around lines
6 - 9, The current AsyncConfig enabling @EnableAsync leaves Spring's default
executor with an effectively unbounded queue, risking OOM under load; replace
the empty config by defining a named ThreadPoolTaskExecutor bean with explicit
corePoolSize, maxPoolSize and queueCapacity (bounded queue) and register it as
the AsyncConfigurer/TaskExecutor for @Async, and also provide an
AsyncUncaughtExceptionHandler implementation to handle exceptions from void
@Async methods; update references to the executor bean name in any @Async
annotations if needed.

Comment on lines +6 to +16
public record FcmMessageRequestDto(

@Schema(description = "유저ID", example = "1")
Long userId,

@Schema(description = "메시지 발송인", example = "시스템")
String title,

@Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.")
String body
) {
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

요청 DTO에 Validation(@NotNull/@notblank) 추가 + Schema 설명 정합성 맞추기 권장.
지금 상태면 API 계층에서 null/blank가 그대로 들어와 downstream(Firebase)에서 실패/예외가 날 수 있습니다. 또한 title 설명이 “메시지 발송인”으로 되어 있어 필드 의미와 어긋나 보입니다.

Proposed diff
 package com.campus.campus.global.firebase.application.dto;

 import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;

 public record FcmMessageRequestDto(

 	@Schema(description = "유저ID", example = "1")
+	@NotNull
 	Long userId,

-	@Schema(description = "메시지 발송인", example = "시스템")
+	@Schema(description = "메시지 제목", example = "시스템")
+	@NotBlank
 	String title,

 	@Schema(description = "메시지 내용", example = "이제 서연님에게 화이팅을 할 수 있어요.")
+	@NotBlank
 	String body
 ) {
 }
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/global/firebase/application/dto/FcmMessageRequestDto.java
around lines 6 - 16, FcmMessageRequestDto currently lacks validation and has an
incorrect Schema for title; annotate the record components with validation
annotations (e.g., annotate userId with @NotNull, title and body with @NotBlank)
and update the @Schema descriptions to match each field's meaning (change
title's description from "메시지 발송인" to something like "메시지 제목", keep body as
message content, and document userId as required). Ensure you import the correct
validation package (jakarta.validation or javax.validation depending on project)
and confirm callers (controllers) validate the DTO (use @Valid) so invalid
requests are rejected before reaching Firebase.

Comment on lines 43 to 51
} catch (FirebaseMessagingException e) {
log.error("[FCM] send failed. topic={}, errorCode={}, message={}",
topic,
e.getErrorCode(),
e.getMessage(),
e
);
throw new FcmTopicSendFailedException(e);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find usages of FirebaseCloudMessageService and sendToTopic
rg -n "FirebaseCloudMessageService|sendToTopic" --type java -A 3 -B 3

Repository: our-campUS/our-campUS-BE

Length of output: 7147


🏁 Script executed:

# Check for @Async annotations in the codebase
rg -n "@Async" --type java -A 2 -B 2

Repository: our-campUS/our-campUS-BE

Length of output: 767


🏁 Script executed:

# Look for CouncilPostPushListener specifically
fd "CouncilPostPushListener" --type f

Repository: our-campUS/our-campUS-BE

Length of output: 159


🏁 Script executed:

# Check for retry mechanisms (Spring Retry, Resilience4j, etc.)
rg -n "Retry|@Retryable|RetryTemplate" --type java -A 2

Repository: our-campUS/our-campUS-BE

Length of output: 50


일시적 Firebase 오류에 대한 재시도 메커니즘을 추가하세요.

현재 상태:

  • CouncilPostPushListener는 이미 @Async로 비동기 처리됩니다.
  • 하지만 sendToTopic() 메서드 자체는 동기 메서드이며, 네트워크 오류나 Firebase 서비스 일시적 문제 시 즉시 실패합니다.
  • StudentCouncilPostController에서 직접 호출하는 경우 비동기 보호가 없습니다.

권장 사항:

  • Spring Retry 또는 유사한 재시도 메커니즘 적용
  • Firebase 오류 코드별 재시도 가능 여부 판단 (예: UNAVAILABLE, INTERNAL은 재시도 대상)
  • 최소 지수 백오프를 사용하여 일시적 장애에 대한 탄력성 향상

Copy link

@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

🤖 Fix all issues with AI agents
In
@src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java:
- Around line 192-200: The POST test endpoint sendToTopic in
StudentCouncilPostController uses hardcoded test messages, returns void, lacks
@Operation, validation, and error handling and must not be in production; remove
this endpoint from the controller (preferred) and rely on the existing
event-driven flow (CouncilPostCreatedEvent and CouncilPostPushListener) to send
push notifications, or if you must keep it only for non-prod testing: annotate
the method/class with @Profile("!prod"), change the signature to return
CommonResponse<Void>, add @Operation docs, validate the @PathVariable topic
(e.g., non-blank/regex), call firebaseCloudMessageService with proper try/catch
to handle and log/send error responses via CommonResponse, and ensure it uses
real payload parameters rather than hardcoded strings.
- Around line 3-4: The new test endpoint in StudentCouncilPostController (lines
~192-200) should not be a void, undocumented, hardcoded-production API: add an
@Operation(...) annotation like the other endpoints, change the method signature
to return CommonResponse<Void> (or CommonResponse<Object> if you prefer payload)
and wrap the result in your CommonResponse.success(...) return wrapper, and
remove the hardcoded test payloads (or gate the endpoint behind a non-production
activation such as @Profile("dev") or a feature flag/@ConditionalOnProperty) so
it’s either deleted from production or only enabled in dev/testing environments.
🧹 Nitpick comments (1)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)

18-20: 중복 import 제거 필요

PostRequest가 19번과 20번 라인에 중복으로 import되고 있습니다. 중복 import를 제거해주세요.

♻️ 제안된 수정
 import com.campus.campus.domain.councilpost.application.dto.request.CouncilPostCreatedEvent;
 import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
-import com.campus.campus.domain.councilpost.application.dto.request.PostRequest;
 import com.campus.campus.domain.councilpost.application.dto.response.GetPostListForCouncilResponse;
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f7c875e and 4cd891a.

📒 Files selected for processing (2)
  • src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (2)
src/main/java/com/campus/campus/domain/councilpost/domain/entity/StudentCouncilPost.java (2)
  • Entity (26-101)
  • update (61-83)
src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java (1)
  • Component (27-174)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)

85-85: 이벤트 기반 푸시 알림 구현이 올바르게 구성되었습니다

CouncilPostPushListener@Async@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) 어노테이션으로 올바르게 구성되어 있습니다. 이를 통해 이벤트는 트랜잭션 커밋 이후에 비동기적으로 실행되므로, 리스너에서 발생하는 예외가 게시글 생성 트랜잭션에 영향을 주지 않습니다.

또한 CouncilType 열거형의 topic() 메서드가 모든 상수(GENERAL, CLUB, ACTIVITY)에서 올바르게 구현되어 있고, 이벤트에 필요한 모든 데이터(postId, councilName, category, topic)가 정확하게 전달되고 있습니다.

src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java (2)

43-43: 의존성 주입 패턴 적절함

FirebaseCloudMessageServiceprivate final 필드로 선언하고 @RequiredArgsConstructor를 통해 주입받는 방식은 기존 코드 스타일과 일관되며 적절합니다.


192-200: 이벤트 기반 푸시 알림 구현이 정상적으로 작동 중입니다

검증 결과, PR 목적에 따른 이벤트 기반 자동 푸시 알림 기능이 완전히 구현되어 있습니다:

  • StudentCouncilPostService.create() 메서드에서 게시글 저장 후 CouncilPostCreatedEvent를 발행
  • CouncilPostPushListener@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)@Async로 설정되어 데이터 커밋 후 비동기로 푸시 전송
  • 카테고리에 따라 적절한 메시지 본문 구성 (PARTNERSHIP, EVENT 등)

리뷰 코드의 테스트 엔드포인트(POST /topic/{topic})는 수동 테스트용 도구이며, 실제 푸시 알림은 게시글 생성 시점에 자동으로 전송됩니다.

Copy link

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)

54-101: FCM topic이 Firebase 명명 규칙을 준수하는지 중앙에서 보장이 필요합니다.

이벤트 리스너는 이미 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)로 올바르게 구성되어 있어 첫 번째 concerns는 해결됨을 확인했습니다.

다만 CouncilType.topic()에서 생성되는 topic 값("school_" + schoolId 등)은 ID 값에 대한 검증이 없어, schoolId/collegeId/majorId가 FCM 규칙을 위반하는 문자나 공백을 포함할 수 있습니다. Firebase topic 명명 규칙(문자/숫자/-_.~%만 허용, 최대 256자, 공백 불허)을 보장하도록 topic 생성 로직에 검증을 추가하거나 ID 자체가 이미 검증되었다면 문서화가 필요합니다.

🤖 Fix all issues with AI agents
In
@src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java:
- Around line 18-20: The file StudentCouncilPostService.java has a duplicate
import of PostRequest (two identical import lines for
com.campus.campus.domain.councilpost.application.dto.request.PostRequest);
remove the redundant import so that PostRequest is imported only once and keep
the other imports (e.g., CouncilPostCreatedEvent) unchanged to resolve the
duplicate-import warning.

In @src/main/java/com/campus/campus/global/config/AsyncConfig.java:
- Line 25: Update the executor rejection policy in AsyncConfig to avoid dropped
push notifications: locate where executor.setRejectedExecutionHandler(new
ThreadPoolExecutor.AbortPolicy()) is called (in the executor configuration
method in class AsyncConfig) and replace AbortPolicy with CallerRunsPolicy so
that when the queue is full the calling thread runs the task (providing
back-pressure) instead of throwing RejectedExecutionException.
🧹 Nitpick comments (4)
src/main/java/com/campus/campus/global/config/AsyncConfig.java (2)

18-21: 스레드 풀 설정값을 외부 설정으로 관리하는 것을 고려해보세요.

현재 스레드 풀 크기와 큐 용량이 하드코딩되어 있어 환경별로 조정하기 어렵습니다. application.properties 또는 application.yml에서 @Value를 통해 주입받는 방식으로 변경하면 운영 환경에서 알림 볼륨에 따라 유연하게 조정할 수 있습니다.

♻️ 설정 외부화 제안

application.yml에 추가:

fcm:
  executor:
    core-pool-size: 5
    max-pool-size: 10
    queue-capacity: 1000

코드 수정:

+import org.springframework.beans.factory.annotation.Value;
+
 @Configuration
 @EnableAsync
 public class AsyncConfig {
 
+	@Value("${fcm.executor.core-pool-size:5}")
+	private int corePoolSize;
+	
+	@Value("${fcm.executor.max-pool-size:10}")
+	private int maxPoolSize;
+	
+	@Value("${fcm.executor.queue-capacity:1000}")
+	private int queueCapacity;
+
 	@Bean(name = "fcmTaskExecutor")
 	public ThreadPoolTaskExecutor fcmTaskExecutor() {
 		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
 
-		executor.setCorePoolSize(5);
-		executor.setMaxPoolSize(10);
+		executor.setCorePoolSize(corePoolSize);
+		executor.setMaxPoolSize(maxPoolSize);
 
-		executor.setQueueCapacity(1000);
+		executor.setQueueCapacity(queueCapacity);
 
 		executor.setThreadNamePrefix("fcm-");

14-29: 애플리케이션 종료 시 graceful shutdown 설정 추가를 고려해보세요.

현재 설정은 정상적으로 동작하지만, 애플리케이션 종료 시 진행 중인 FCM 알림 작업이 완료될 수 있도록 graceful shutdown 설정을 추가하면 더욱 안정적입니다.

♻️ Graceful shutdown 설정 제안
 		executor.setThreadNamePrefix("fcm-");
 
 		executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
+		
+		executor.setWaitForTasksToCompleteOnShutdown(true);
+		executor.setAwaitTerminationSeconds(60);
 
 		executor.initialize();
 		return executor;
src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java (1)

1-10: 이벤트 타입인데 dto.request 패키지에 위치한 점은 혼동 소지가 있어요.

Line 1의 패키지 경로가 “요청 DTO” 의미로 읽혀서, 내부 이벤트(예: ...application.event / ...application.dto.event)로 분리하는 편이 유지보수에 유리합니다.
(현재 코드 자체는 record로 깔끔합니다.)

src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)

85-101: savedpost를 혼용 중이라, 하나로 통일하면 안전/가독성이 좋아져요.

Line 85에서 saved를 받은 뒤 이벤트에는 saved.getId()/saved.getCategory()를 쓰는데, 이미지 저장/조회 및 응답 변환에는 post를 계속 사용합니다. JPA 관점에선 보통 동일 엔티티로 동작하겠지만, 혼용은 오해를 부르니 saved로 통일하는 쪽을 권장합니다.

제안 diff
-        StudentCouncilPost saved = postRepository.save(post);
+        StudentCouncilPost saved = postRepository.save(post);

         if (dto.imageUrls() != null) {
             for (String imageUrl : dto.imageUrls()) {
-                postImageRepository.save(studentCouncilPostMapper.createPostImage(post, imageUrl));
+                postImageRepository.save(studentCouncilPostMapper.createPostImage(saved, imageUrl));
             }
         }

         List<String> imageUrls = postImageRepository
-            .findAllByPostOrderByIdAsc(post)
+            .findAllByPostOrderByIdAsc(saved)
             .stream()
             .map(PostImage::getImageUrl)
             .toList();

-        return studentCouncilPostMapper.toGetPostResponse(post, imageUrls, councilId);
+        return studentCouncilPostMapper.toGetPostResponse(saved, imageUrls, councilId);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4cd891a and 604495c.

📒 Files selected for processing (5)
  • src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java
  • src/main/java/com/campus/campus/domain/councilpost/application/dto/request/CouncilPostCreatedEvent.java
  • src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/global/config/AsyncConfig.java
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java
  • src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java
🔇 Additional comments (1)
src/main/java/com/campus/campus/global/config/AsyncConfig.java (1)

10-12: 비동기 처리를 위한 설정 클래스 구성이 적절합니다.

@EnableAsync를 통해 Spring의 비동기 처리가 활성화되었고, FCM 푸시 알림을 위한 전용 executor 설정이 적절합니다.

Copy link

@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

🤖 Fix all issues with AI agents
In
@src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java:
- Around line 25-26: Remove the unused FirebaseCloudMessageService dependency
from StudentCouncilPostController: delete the import for
com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService
and remove the unused field named firebaseCloudMessageService from the
StudentCouncilPostController class so all FCM concerns remain handled
asynchronously via events and postService; ensure no remaining references to
firebaseCloudMessageService exist in any controller methods.
- Around line 3-4: Remove the unused import of java.util.Map from the
StudentCouncilPostController class; locate the import line "import
java.util.Map;" at the top of StudentCouncilPostController and delete it so the
class no longer imports Map (ensure no other Map usages exist in methods like
any controller handlers before committing).
🧹 Nitpick comments (1)
src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java (1)

42-44: firebaseCloudMessageService 필드가 미사용이면 DI 제거 권장(컨트롤러 책임 축소)

Line 43에서 주입만 하고 사용하지 않으면 “왜 컨트롤러에 FCM이 있지?”라는 혼란을 주고, 규칙에 따라 경고/실패 포인트가 될 수 있습니다. 푸시 트리거가 StudentCouncilPostService의 이벤트 발행으로 충분하다면 이 필드는 제거하는 쪽을 권장합니다.

제거 방향(diff)
-import java.util.Map;
-
@@
-import com.campus.campus.global.firebase.application.service.FirebaseCloudMessageService;
@@
 	private final StudentCouncilPostService postService;
-	private final FirebaseCloudMessageService firebaseCloudMessageService;
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 604495c and cf57384.

📒 Files selected for processing (1)
  • src/main/java/com/campus/campus/domain/councilpost/presentation/StudentCouncilPostController.java

Copy link
Member

@1winhyun 1winhyun left a comment

Choose a reason for hiding this comment

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

수고하셨습니다!! 리뷰 한번 확인 부탁드려요~!!

Copy link

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (1)

197-197: 상수를 일관되게 사용하세요.

Line 61에서는 MAX_IMAGE_COUNT 상수를 사용하지만, Line 197에서는 여전히 하드코딩된 10을 사용하고 있습니다. 일관성을 위해 상수를 사용해야 합니다.

🐛 수정 제안
-	if (dto.imageUrls() != null && dto.imageUrls().size() > 10) {
+	if (dto.imageUrls() != null && dto.imageUrls().size() > MAX_IMAGE_COUNT) {
🤖 Fix all issues with AI agents
In
@src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java:
- Line 51: The log format in FirebaseCloudMessageService is wrong: replace the
malformed placeholder "exec{}" with "exec={}" in the log.error call so the
message template has correct key=value placeholders; locate the log.error(...)
invocation (the send/failure logging in FirebaseCloudMessageService) and update
the format string to use "exec={}" ensuring number of placeholders matches
provided arguments.

In @src/main/resources/application-local.yml:
- Around line 70-72: Document that firebase.credentials.path in
application-local.yml reads the FIREBASE_CREDENTIALS_PATH environment variable
and add explicit instructions to the project README or environment setup guide
explaining how to set FIREBASE_CREDENTIALS_PATH for local development (e.g.,
where to place the Firebase credentials JSON file and how to export the env var
or add it to a .env file), and also add a short note clarifying why
application-dev.yml/application-prod.yml use encrypted/managed secrets while
application-local.yml uses a local env var for developer convenience.
🧹 Nitpick comments (2)
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (1)

25-35: 필수 파라미터에 대한 유효성 검증을 추가하세요.

topic, title, body 파라미터가 null이거나 빈 문자열인 경우를 검증하지 않습니다. 이는 FCM 전송 실패나 NPE로 이어질 수 있습니다.

♻️ 제안하는 수정안
 public void sendToTopic(String topic, String title, String body, Map<String, String> data) {
+	if (topic == null || topic.isBlank()) {
+		throw new IllegalArgumentException("Topic must not be null or empty");
+	}
+	if (title == null || title.isBlank()) {
+		throw new IllegalArgumentException("Title must not be null or empty");
+	}
+	if (body == null || body.isBlank()) {
+		throw new IllegalArgumentException("Body must not be null or empty");
+	}
+
 	Message.Builder builder = Message.builder()
src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java (1)

176-185: 방어적 프로그래밍을 위해 null 체크를 고려하세요.

현재 호출 컨텍스트에서는 postwriter가 null이 아니지만, 매퍼는 재사용 가능한 컴포넌트이므로 파라미터 검증을 추가하면 더 견고해집니다. 특히 writer.getCouncilType()이 null을 반환할 경우 NPE가 발생합니다.

♻️ 제안하는 수정안
 public CouncilPostCreatedEvent createPostCreatedEvent(StudentCouncilPost post, StudentCouncil writer) {
+	if (post == null || writer == null) {
+		throw new IllegalArgumentException("Post and writer must not be null");
+	}
+	if (writer.getCouncilType() == null) {
+		throw new IllegalStateException("CouncilType must not be null");
+	}
+
 	String topic = writer.getCouncilType().topic(writer);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 541c15a and 812256c.

📒 Files selected for processing (9)
  • src/main/java/com/campus/campus/domain/councilpost/application/mapper/StudentCouncilPostMapper.java
  • src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java
  • src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java
  • src/main/resources/application-dev.yml
  • src/main/resources/application-local.yml
  • src/main/resources/application-prod.yml
  • src/test/resources/application-test.yml
💤 Files with no reviewable changes (1)
  • src/main/java/com/campus/campus/domain/place/application/dto/response/SavedPlaceInfo.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/campus/campus/global/firebase/application/service/FirebaseInitializer.java
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-11-18T09:42:14.128Z
Learnt from: 1winhyun
Repo: our-campUS/our-campUS-BE PR: 6
File: src/test/resources/application-test.yml:15-16
Timestamp: 2025-11-18T09:42:14.128Z
Learning: In the our-campUS-BE project, the test configuration file (src/test/resources/application-test.yml) uses hardcoded dummy values for OAuth and JWT secrets, as these are arbitrary test values and not real credentials.

Applied to files:

  • src/main/resources/application-prod.yml
  • src/test/resources/application-test.yml
🧬 Code graph analysis (1)
src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (2)
src/main/java/com/campus/campus/global/firebase/exception/FcmTopicSendFailedException.java (1)
  • FcmTopicSendFailedException (3-7)
src/main/java/com/campus/campus/domain/councilpost/application/CouncilPostPushListener.java (1)
  • Slf4j (17-57)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (6)
src/main/resources/application-dev.yml (1)

68-71: firebase.credentials.path 키 바인딩/경로 타입(classpath vs filesystem) 일치 여부 확인 필요

firebase.credentials.path가 코드에서 기대하는 프로퍼티명(prefix 포함)과 정확히 일치하는지, 그리고 값이 “classpath 리소스 경로”인지 “서버 파일시스템 경로”인지(예: resource:/절대경로 등) Firebase 초기화 로직과 일관되게 맞는지 확인해주세요. 또한 server-uri/firebase 같은 커스텀 설정이 루트에 있어 충돌 여지가 있으니(장기적으로) campus.* 같은 전용 prefix로 묶는 것도 고려할 만합니다.

src/test/resources/application-test.yml (1)

72-75: 테스트 프로파일에서 Firebase 비활성화는 적절하게 구현됨

FirebaseInitializer.java@ConditionalOnProperty(prefix = "firebase", name = "enabled", havingValue = "true", matchIfMissing = true) 어노테이션으로 Firebase 빈 초기화가 firebase.enabled 플래그로 올바르게 가드됩니다. CampusApplicationTests.java에서 @ActiveProfiles("test")가 활성화되어 있으므로, application-test.ymlfirebase.enabled: false 설정이 테스트 실행 시 적용되어 Firebase 초기화를 제대로 비활성화합니다.

src/main/resources/application-prod.yml (1)

69-71: Firebase 설정은 다른 환경과 일치하며 올바릅니다.

검증 결과, application-prod.yml의 Firebase 설정은 application-dev.ymlapplication-local.yml과 동일하게 firebase.credentials.path만 포함하고 있습니다. firebase.enabled 플래그는 테스트 환경에서만 필요하며(firebase.enabled: false), 프로덕션, 개발, 로컬 환경에서는 일관되게 자격증명 경로만 구성하는 것이 올바른 설계입니다. 추가 설정(타임아웃, 재시도 정책 등)도 필요하지 않습니다.

Likely an incorrect or invalid review comment.

src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java (1)

62-70: LGTM!

스레드 풀 모니터링을 위한 유용한 헬퍼 메서드입니다. 로그를 통해 풀 상태를 추적할 수 있어 운영 시 디버깅에 도움이 됩니다.

src/main/java/com/campus/campus/domain/councilpost/application/service/StudentCouncilPostService.java (2)

47-48: LGTM!

상수를 도입하여 매직 넘버를 제거한 것은 좋은 개선입니다. 코드 가독성과 유지보수성이 향상됩니다.


82-90: LGTM!

게시글 저장 후 이벤트를 발행하는 패턴이 올바르게 구현되었습니다. @TransactionalEventListener(phase = AFTER_COMMIT)를 사용하는 리스너와 함께 사용하면, 트랜잭션 커밋 후에만 FCM 푸시가 전송되어 데이터 일관성이 보장됩니다.

);

} catch (FirebaseMessagingException e) {
log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec{}",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

로그 포맷 오류를 수정하세요.

Line 51의 로그 메시지에서 exec{}exec={}여야 합니다. 등호가 누락되어 로그 파싱에 문제가 발생할 수 있습니다.

🐛 수정 제안
-		log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec{}",
+		log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec={}",
📝 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
log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec{}",
log.error("[FCM] send failed. topic={}, errorCode={}, message={}, exec={}",
🤖 Prompt for AI Agents
In
@src/main/java/com/campus/campus/global/firebase/application/service/FirebaseCloudMessageService.java
at line 51, The log format in FirebaseCloudMessageService is wrong: replace the
malformed placeholder "exec{}" with "exec={}" in the log.error call so the
message template has correct key=value placeholders; locate the log.error(...)
invocation (the send/failure logging in FirebaseCloudMessageService) and update
the format string to use "exec={}" ensuring number of placeholders matches
provided arguments.

Comment on lines +70 to +72
firebase:
credentials:
path: ${FIREBASE_CREDENTIALS_PATH}
Copy link

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
# Step 1: List all environment configuration files in the resources directory
echo "=== Environment Configuration Files ==="
fd -e yml -e yaml "" src/main/resources --type f | sort

Repository: our-campUS/our-campUS-BE

Length of output: 227


🏁 Script executed:

#!/bin/bash
# Step 2: Check firebase configuration in all environment files
echo "=== Firebase Configuration in All Environments ==="
fd -e yml -e yaml "^application" src/main/resources --exec sh -c 'echo "=== {} ===" && grep -A 3 "firebase:" "$1" || echo "No firebase config found"' _ {} \;

Repository: our-campUS/our-campUS-BE

Length of output: 536


🏁 Script executed:

#!/bin/bash
# Step 3: Search for Firebase initialization and FIREBASE_CREDENTIALS_PATH usage in Java code
echo "=== Firebase Initialization Code ==="
rg -t java "firebase|Firebase|FIREBASE_CREDENTIALS" -A 3 -B 3

Repository: our-campUS/our-campUS-BE

Length of output: 15439


🏁 Script executed:

#!/bin/bash
# Step 4: Check for documentation about environment variables
echo "=== Documentation Files About Environment Setup ==="
fd -e md -e txt "README|SETUP|ENV|CONFIG|INSTALL" . --type f | head -20

Repository: our-campUS/our-campUS-BE

Length of output: 131


🏁 Script executed:

#!/bin/bash
# Step 5: Check if README or setup documentation mentions FIREBASE_CREDENTIALS_PATH
echo "=== Checking README and Documentation for FIREBASE_CREDENTIALS_PATH ==="
fd -e md "" . --type f --exec grep -l "FIREBASE_CREDENTIALS_PATH\|firebase" {} \; | head -10

Repository: our-campUS/our-campUS-BE

Length of output: 139


🏁 Script executed:

#!/bin/bash
# Check README content for environment setup instructions
head -100 README.md

Repository: our-campUS/our-campUS-BE

Length of output: 105


🏁 Script executed:

#!/bin/bash
# Search for any setup/installation guides in the repository
fd -e md "" . --type f | xargs grep -l "FIREBASE\|환경\|setup\|설정" | head -10

Repository: our-campUS/our-campUS-BE

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check if there's a separate environment setup guide or .env.example file
fd -e example -e sample -e template "" . --type f | head -10

Repository: our-campUS/our-campUS-BE

Length of output: 50


로컬 개발 환경용 Firebase 자격 증명 경로 문서화 필요

firebase.credentials.path${FIREBASE_CREDENTIALS_PATH} 환경 변수에 의존하고 있으나, application-dev.ymlapplication-prod.yml과 달리 암호화된 값 대신 환경 변수를 사용하고 있습니다. 로컬 개발 환경에서 이 변수를 설정하는 방법을 README 또는 환경 설정 가이드에 문서화해주세요.

또한 각 환경 간의 구성 방식이 다른 이유(dev/prod는 암호화된 값, local은 환경 변수)를 명확히 하고, 로컬 개발 시 Firebase 자격 증명 파일의 위치를 지정하는 방법을 개발자가 쉽게 이해할 수 있도록 안내해주시길 권장합니다.

🤖 Prompt for AI Agents
In @src/main/resources/application-local.yml around lines 70 - 72, Document that
firebase.credentials.path in application-local.yml reads the
FIREBASE_CREDENTIALS_PATH environment variable and add explicit instructions to
the project README or environment setup guide explaining how to set
FIREBASE_CREDENTIALS_PATH for local development (e.g., where to place the
Firebase credentials JSON file and how to export the env var or add it to a .env
file), and also add a short note clarifying why
application-dev.yml/application-prod.yml use encrypted/managed secrets while
application-local.yml uses a local env var for developer convenience.

@jjaeroong jjaeroong merged commit a77ce30 into dev Jan 11, 2026
2 checks passed
@1winhyun 1winhyun added ✨Feat 새로운 기능 개발 ⚙️Chore 인프라 labels Jan 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚙️Chore 인프라 ✨Feat 새로운 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants