Skip to content

Conversation

@slg1119
Copy link
Contributor

@slg1119 slg1119 commented Nov 26, 2025

작업 내용 ⚒️

  • FCM 메시징 구현 완료

리뷰어 참고 사항 🤔

  • 없음

DDL

ALTER TABLE MEMBER ADD COLUMN FCM_TOKEN VARCHAR(255);

@slg1119 slg1119 self-assigned this Nov 26, 2025
@slg1119 slg1119 added the ✨ Feat 기능 개발 label Nov 26, 2025
@gemini-code-assist
Copy link

Summary of Changes

Hello @slg1119, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 Firebase Cloud Messaging (FCM)을 활용하여 백엔드에 푸시 알림 기능을 전면적으로 도입합니다. 주요 변경 사항은 FCM 설정, 회원별 FCM 토큰 관리, 친구의 다음 연락 예정일 및 기념일에 맞춰 알림을 발송하는 스케줄러 구현, 그리고 알림 테스트 및 강제 전송을 위한 API 엔드포인트 추가입니다. 이를 통해 사용자에게 시기적절한 알림을 제공하여 서비스 참여도를 높이고, 친구 관계 관리를 더욱 효과적으로 지원할 수 있게 됩니다.

Highlights

  • FCM 메시징 시스템 도입: Firebase Cloud Messaging(FCM)을 백엔드에 통합하여 푸시 알림 기능을 구현했습니다.
  • FCM 설정 및 초기화: 새로운 설정 클래스(FcmConfig, FcmProperties)를 통해 FCM 서비스 계정 자격 증명 및 활성화 여부를 설정하고 초기화합니다.
  • 회원 FCM 토큰 관리: Member 엔티티에 fcmToken 필드를 추가하고, 기기 등록/해제 서비스에서 토큰을 업데이트하도록 했습니다.
  • 알림 스케줄러 구현: NotificationScheduler를 통해 매일 오전 9시에 친구의 다음 연락 예정일 및 기념일을 확인하여 알림을 발송합니다.
  • 알림 전송 API 추가: 테스트 및 강제 알림 전송을 위한 NotificationController 엔드포인트를 추가했습니다.
  • FCM 서비스 추상화: FcmService 인터페이스와 FcmServiceImpl 구현체를 통해 FCM 메시지 전송 로직을 캡슐화했습니다.
  • 예외 처리 개선: GlobalExceptionHandlerIllegalStateException 처리를 추가했습니다.
  • 데이터베이스 쿼리 추가: 친구 기념일 및 다음 연락 예정일을 조회하는 새로운 레포지토리 메서드를 추가했습니다.
  • 테스트 코드 작성: FCM 알림 기능의 컨트롤러, 스케줄러, 서비스에 대한 단위 및 통합 테스트를 추가했습니다.
Ignored Files
  • Ignored by pattern: .github/workflows/** (3)
    • .github/workflows/deploy-dev.yml
    • .github/workflows/deploy-prod.yml
    • .github/workflows/test.yml
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

FCM 메시징 기능 구현을 위한 전반적인 변경 사항을 확인했습니다. 기능 구현 자체는 잘 이루어졌으나, 몇 가지 중요한 개선점이 필요해 보입니다. 특히, 관리자용 API의 접근 제어 누락은 심각한 보안 문제이므로 반드시 수정이 필요합니다. 또한, 스케줄러에서 발생하는 N+1 쿼리 문제와 FCM 초기화 시 리소스 누수 등 성능 및 안정성과 관련된 부분을 중점적으로 검토하고 수정을 제안했습니다. 이 외에도 FCM 전송 실패 처리, APNS 배지 설정, 코드 가독성 향상 등에 대한 의견을 포함했습니다. 추가된 테스트 코드는 다양한 시나리오를 잘 پوشش하고 있어 기능의 안정성을 높이는 데 큰 도움이 될 것입니다.

* 특정 사용자에게 강제로 FCM 알림을 전송하는 엔드포인트 (관리자용).
* memberId를 직접 지정하여 해당 사용자에게 푸시 알림을 전송합니다.
*/
@PostMapping("/force/send")

Choose a reason for hiding this comment

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

critical

/force/send 엔드포인트는 특정 사용자에게 강제로 알림을 보낼 수 있는 강력한 기능이지만, 현재 아무런 인증/인가 제어가 없어 누구나 이 API를 호출할 수 있는 심각한 보안 취약점이 존재합니다. 관리자만 이 기능을 사용할 수 있도록 접근을 제한해야 합니다. 예를 들어, Spring Security의 메서드 시큐리티를 활성화(@EnableMethodSecurity)하고 해당 메서드에 @PreAuthorize("hasRole('ADMIN')") 어노테이션을 추가하는 방법을 사용할 수 있습니다.

    @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
    @PostMapping("/force/send")

build.gradle Outdated
implementation 'org.springframework.boot:spring-boot-configuration-processor'
implementation 'software.amazon.awssdk:s3:2.31.16'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.google.firebase:firebase-admin:9.4.2'

Choose a reason for hiding this comment

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

high

firebase-admin 라이브러리의 9.4.2 버전은 존재하지 않는 버전으로 보입니다. Maven Central 저장소에서 확인할 수 있는 최신 안정 버전은 9.3.0입니다. 잘못된 버전 정보는 빌드 실패를 유발할 수 있으므로, 올바른 버전으로 수정하는 것이 좋습니다.

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

Comment on lines 42 to 58
InputStream serviceAccount;
String credentialsPath = fcmProperties.getCredentialsPath();

// classpath 또는 파일 시스템 경로 지원
if (credentialsPath.startsWith("classpath:")) {
String path = credentialsPath.substring("classpath:".length());
serviceAccount = new ClassPathResource(path).getInputStream();
} else {
serviceAccount = new FileInputStream(credentialsPath);
}

FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

FirebaseApp.initializeApp(options);
log.info("Firebase application has been initialized successfully.");

Choose a reason for hiding this comment

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

high

FileInputStreamClassPathResource.getInputStream()으로 생성된 InputStream이 닫히지 않아 리소스 누수(resource leak)가 발생할 수 있습니다. try-with-resources 구문을 사용하여 스트림이 자동으로 닫히도록 하는 것이 안전합니다.

                String credentialsPath = fcmProperties.getCredentialsPath();

                // classpath 또는 파일 시스템 경로 지원
                try (InputStream serviceAccount = credentialsPath.startsWith("classpath:")
                        ? new ClassPathResource(credentialsPath.substring("classpath:".length())).getInputStream()
                        : new FileInputStream(credentialsPath)) {

                    FirebaseOptions options = FirebaseOptions.builder()
                            .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                            .build();

                    FirebaseApp.initializeApp(options);
                    log.info("Firebase application has been initialized successfully.");
                }

Comment on lines 37 to 80
public void sendDailyFriendReminders() {
log.info("Starting daily friend reminder notifications...");

LocalDate today = LocalDate.now();
Set<UUID> processedFriendIds = new HashSet<>();

// 1. nextContactAt이 오늘인 친구들 처리
List<Friend> friendsToContact = friendRepository.findAllByNextContactAt(today);
log.info("Found {} friends to contact today based on nextContactAt",
friendsToContact.size());

for (Friend friend : friendsToContact) {
sendReminderForFriend(friend, "연락 예정일입니다.");
processedFriendIds.add(friend.getFriendId());
}

// 2. 오늘이 기념일인 친구들 처리
int month = today.getMonthValue();
int day = today.getDayOfMonth();
List<FriendAnniversary> todayAnniversaries =
friendAnniversaryRepository.findAllByMonthAndDay(month, day);
log.info("Found {} anniversaries today", todayAnniversaries.size());

for (FriendAnniversary anniversary : todayAnniversaries) {
UUID friendId = anniversary.getFriendId();

// 이미 연락 예정일로 알림을 보낸 친구는 스킵
if (processedFriendIds.contains(friendId)) {
continue;
}

friendRepository.findById(friendId).ifPresent(friend -> {
String reason = "오늘은 " + anniversary.getTitle() + "입니다.";
sendReminderForFriend(friend, reason);
processedFriendIds.add(friendId);
});
}

log.info("Daily friend reminder notifications completed. Total notifications sent: {}",
processedFriendIds.size());
}

Choose a reason for hiding this comment

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

high

sendDailyFriendReminders 메서드 내에서 루프를 돌며 memberRepository.findById()friendRepository.findById()를 호출하고 있어 N+1 쿼리 문제가 발생합니다. 알림 대상이 많아질 경우 심각한 성능 저하를 유발할 수 있습니다.

개선 방안:

  1. friendsToContacttodayAnniversaries에서 필요한 모든 memberIdfriendId를 수집합니다.
  2. findAllById와 같은 메서드를 사용하여 관련된 MemberFriend 객체들을 한 번의 쿼리로 모두 조회합니다.
  3. 조회한 객체들을 Map에 담아두고, 루프 내에서는 DB 조회 없이 Map에서 가져와 사용합니다.

이렇게 수정하면 DB 호출 횟수를 크게 줄여 성능을 개선할 수 있습니다.

Comment on lines +73 to +75
} catch (FirebaseMessagingException e) {
log.error("Failed to send FCM notification to token: {}", request.getFcmToken(), e);
}

Choose a reason for hiding this comment

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

high

FirebaseMessagingException이 발생했을 때 로그만 남기고 예외를 처리하지 않고 있습니다. 이 경우 알림 전송이 실패해도 호출한 쪽에서는 성공한 것으로 간주하게 됩니다. 특히, 만료되었거나 유효하지 않은 FCM 토큰으로 인해 예외가 발생했을 때 이를 감지하고 해당 토큰을 DB에서 삭제하는 등의 후속 조치를 할 수 없습니다. 예외를 다시 던지거나, 전송 결과를 반환하여 호출 측에서 실패를 인지하고 적절히 대응할 수 있도록 수정하는 것이 좋습니다.

Comment on lines +32 to +38
String[] activeProfiles = environment.getActiveProfiles();
for (String profile : activeProfiles) {
if ("test".equals(profile)) {
log.info("Test profile detected. Skipping FCM initialization.");
return;
}
}

Choose a reason for hiding this comment

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

medium

for 루프를 사용하여 활성 프로필을 확인하는 대신, java.util.Arrays.streamanyMatch를 사용하면 코드를 더 간결하게 작성할 수 있습니다.

        if (java.util.Arrays.stream(environment.getActiveProfiles()).anyMatch("test"::equals)) {
            log.info("Test profile detected. Skipping FCM initialization.");
            return;
        }

Comment on lines +37 to +42
@Query("""
SELECT fa
FROM FriendAnniversary fa
WHERE MONTH(fa.date) = :month AND DAY(fa.date) = :day
""")
List<FriendAnniversary> findAllByMonthAndDay(@Param("month") int month, @Param("day") int day);

Choose a reason for hiding this comment

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

medium

WHERE 절에서 MONTH(fa.date)DAY(fa.date) 같은 함수를 사용하면 date 컬럼의 인덱스를 활용하지 못하고 풀 테이블 스캔(full table scan)을 유발할 수 있습니다. FriendAnniversary 테이블의 데이터가 많아질 경우 성능 저하의 원인이 될 수 있습니다. 성능 개선이 필요하다면, 월(month)과 일(day)을 별도의 컬럼으로 저장하고 인덱스를 생성하는 방법을 고려해볼 수 있습니다.

ApnsConfig apnsConfig = ApnsConfig.builder()
.setAps(Aps.builder()
.setSound("default")
.setBadge(1)

Choose a reason for hiding this comment

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

medium

iOS 푸시 알림의 배지(badge) 숫자를 1로 하드코딩하고 있습니다. 이 경우 새로운 알림이 올 때마다 앱 아이콘의 배지 숫자가 항상 1로 설정됩니다. 사용자가 읽지 않은 알림의 총 개수를 배지로 보여주는 것이 일반적인 UX입니다. 현재 정책이 배지를 1로 고정하는 것이 아니라면, 이 부분은 클라이언트에서 직접 관리하거나 서버에서 읽지 않은 알림 수를 계산하여 동적으로 설정하는 방식으로 변경하는 것을 고려해 보세요.

@slg1119 slg1119 force-pushed the feat/messaging branch 3 times, most recently from b095379 to 2b23cf1 Compare November 26, 2025 14:31
@slg1119 slg1119 merged commit 28bad14 into dev Nov 26, 2025
4 checks passed
@slg1119 slg1119 deleted the feat/messaging branch November 26, 2025 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feat 기능 개발

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants