Spring Boot와 Firebase Cloud Messaging을 활용한 푸시 알림 시스템 구현

SUUUI·2025년 3월 21일
0

클라이언트: FCM 토큰을 생성하고 백엔드로 전송하는 모바일/웹 애플리케이션
백엔드 API: FCM 토큰을 업데이트하고 알림을 트리거하는 Spring Boot API
알림 서비스: 알림 유형별 비즈니스 로직을 처리하는 NotificationService
FCM 서비스: Firebase Admin SDK를 활용해 FCM 메시지를 전송하는 FCMService
알림 저장소: 알림 기록을 저장하고 조회하는 NotificationRepository

1. FCM(Firebase Cloud Messaging)이란?

FCM은 Google Firebase에서 제공하는 무료 크로스 플랫폼 메시징 솔루션이다. 모바일 앱(Android, iOS)과 웹 앱에 메시지를 안정적으로 전송할 수 있다.

2. FCM 작동 원리

FCM의 기본 동작 방식은 다음과 같다:

  1. 클라이언트 토큰 등록: 앱이 설치되거나 웹에서 FCM을 활성화하면, Firebase SDK가 각 기기/브라우저에 고유한 FCM 토큰을 생성
  2. 토큰 저장: 이 토큰을 서버에 전송하여 사용자와 연결(보통 데이터베이스에 저장)
  3. 메시지 요청: 애플리케이션 서버가 FCM 서버에 메시지 전송 요청
  4. 메시지 라우팅: FCM 서버가 해당 토큰을 가진 기기에 메시지 라우팅
  5. 알림 수신 및 처리: 클라이언트 앱이 메시지를 수신하고 적절히 처리

3. 데이터 흐름

  1. 사용자가 앱을 설치하거나 웹사이트에 접속하면 FCM 토큰이 생성됨
  2. 이 토큰이 백엔드 API를 통해 서버로 전송되어 사용자 계정과 연결됨
  3. 알림 이벤트(예: 새 메시지, 예약 확인 등)가 발생하면
  4. NotificationService가 호출됨
  5. NotificationService는 알림을 데이터베이스에 저장하고 FCMService를 통해 메시지 전송
  6. FCMService는 Firebase Admin SDK를 사용하여 FCM 서버에 메시지 전송 요청
  7. FCM 서버는 해당 토큰을 가진 기기로 메시지를 라우팅
    클라이언트에서 알림을 수신하고 표시

4. Spring Boot FCM 구현

  • FCM 토큰 관리
@PostMapping("/fcm-token")
public ResponseEntity<Void> updateFCMToken(
        @Parameter(description = "FCM 토큰 업데이트 요청 데이터", required = true)
        @RequestBody FcmTokenRequestDTO request
) {
    log.info("updateFCMToken request: {}", request);
    memberService.updateFCMToken(request.getEmail(), request.getFcmToken());
    return ResponseEntity.ok().build();
}

이 API는 클라이언트로부터 받은 FCM 토큰을 사용자 계정과 연결하는 역할을 한다. MemberService에서는 이 토큰을 데이터베이스에 저장한다.

  • FCM 서비스 구현
    FCM 메시지 전송을 담당하는 FCMService는 다음과 같이 구현되어 있다:
@Slf4j
@Service
@Transactional
public class FCMService {

    public void sendNotification(String targetEmail, String token, String title, String body, NotificationType type) {
        log.info("sendNotification token: {}, title: {}, body: {}, type: {}", token, title, body, type);
        Message message = Message.builder()
                .putData("type", type.name())
                .putData("targetEmail", targetEmail)
                .putData("title", title)
                .putData("body", body)
                .putData("timestamp", LocalDateTime.now().toString())
                .setToken(token)
                .build();

        try {
            String response = FirebaseMessaging.getInstance().send(message);
            log.info("Successfully sent notification: {}", response);
        } catch (Exception e) {
            log.error("Failed to send notification", e);
        }
    }

    // 단체 알림 발송
    public void sendMulticastNotification(List<String> tokenList, String title, String body, NotificationType type) {
        MulticastMessage message = MulticastMessage.builder()
                .setNotification(Notification.builder()
                        .setTitle(title)
                        .setBody(body)
                        .build())
                .putData("type", type.name())
                .putData("title", title)
                .putData("body", body)
                .putData("timestamp", LocalDateTime.now().toString())
                .addAllTokens(tokenList)
                .build();

        try {
            BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
            log.info("Multicast 메시지 전송 완료: 성공={}, 실패={}",
                    response.getSuccessCount(), response.getFailureCount());
        } catch (FirebaseMessagingException e) {
            log.error("FCM 멀티캐스트 메시지 전송 실패", e);
        }
    }
}

이 서비스는 두 가지 주요 기능을 제공한다:

개별 알림 전송: 단일 사용자에게 메시지 전송
다중 알림 전송: 여러 사용자에게 동시에 메시지 전송

  • 알림 서비스 구현
    NotificationService는 다양한 비즈니스 이벤트에 대한 알림 처리 로직을 구현한다:
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class NotificationService {

    private final NotificationRepository notificationRepository;
    private final FCMService fcmService;
    private final MemberRepository memberRepository;
    
    // 개인 알림 전송
    @Transactional
    public void sendNotificationToMember(Member member, String title, String body, NotificationType type) {
        log.info("sendNotificationToMember: member email {}", member.getEmail());
        Notification notification = Notification.of(
                type,
                member,
                title,
                body,
                member.getFcmToken()
        );
        notificationRepository.save(notification);
        this.fcmNotificationSender(member, title, body, type);
    }
    
    // 대량 알림 전송
    @Transactional
    public void sendBulkNotification(List<Member> memberList, String title, String body, NotificationType type) {
        log.info("sendBulkNotification 시작: {} 명의 회원에게 알림 전송", memberList.size());

        for (Member member : memberList) {
            if (member.getFcmToken() != null && !member.getFcmToken().isBlank()) {
                // DB에 알림 저장
                Notification notification = Notification.of(
                        type,
                        member,
                        title,
                        body,
                        member.getFcmToken()
                );
                notificationRepository.save(notification);

                // FCM 알림 전송
                fcmService.sendNotification(
                        member.getEmail(),
                        member.getFcmToken(),
                        title,
                        body,
                        type
                );
                log.info("알림 전송 완료: {}", member.getEmail());
            }
        }

        log.info("sendBulkNotification 종료됨");
    }
    
    // FCM 알림 전송 헬퍼 메서드
    private void fcmNotificationSender(Member member, String title, String body, NotificationType type) {
        if (member.getFcmToken() != null) {
            log.info("FCM 알림 전송됨! member.getFcmToken(): {}", member.getFcmToken());
            fcmService.sendNotification(
                    member.getEmail(),
                    member.getFcmToken(),
                    title,
                    body,
                    type
            );
        }
    }
}
  • 알림 모델
    알림 정보를 저장하는 엔티티는 다음과 같이 구성된다:
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Notification extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    private NotificationType type;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    private String title;
    private String body;
    private String token;
    private boolean isRead;

    // 정적 팩토리 메서드
    public static Notification of(NotificationType type, Member member, String title, String body, String token) {
        Notification notification = new Notification();
        notification.type = type;
        notification.member = member;
        notification.title = title;
        notification.body = body;
        notification.token = token;
        notification.isRead = false;
        return notification;
    }
}
  • 다양한 알림 유형과 구현 예시
    GymPT 프로젝트에서 구현한 다양한 알림 유형
public void sendChattingMessage(String targetEmail, ChatMessageDTO chatMessageDTO, Member member) {
    Member target = memberRepository.getWithRoles(targetEmail)
            .orElseThrow(() -> new IllegalArgumentException("해당 이메일을 가진 회원이 없습니다."));
    
    String title = member.getName() + "님 에게 새 메세지가 도착하였습니다!";
    String body = chatMessageDTO.getMessage();

    Notification notification = Notification.of(
            NotificationType.NEW_MESSAGE,
            target,
            title,
            body,
            target.getFcmToken()
    );
    notificationRepository.save(notification);
    this.fcmNotificationSender(target, title, body, NotificationType.NEW_MESSAGE);
}


// 역경매 관련 알림


public void sendOpenActionToTrainer(Long localId) {
    // 해당 지역에서 활동하는 트레이너 회원들을 조회
    List<Member> trainersInLocal = memberRepository.findMemberTrainerInLocal(localId);

    String title = "새로운 역경매 신청이 들어왔습니다";
    String body = "지금 입찰 신청을 해보세요";
    sendBulkNotification(trainersInLocal, title, body, NotificationType.OPEN_AUCTION);
}



// 예약 관련 알림
java복사public void bookingDayNotification(String targetEmail) {
    Member member = memberRepository.getWithRoles(targetEmail)
            .orElseThrow(() -> new IllegalArgumentException("해당 이메일을 가진 회원이 없습니다."));
    
    String title = "오늘은 예약 당일입니다";
    String body = "늦지 않게 방문해주세요";

    Notification notification = Notification.of(
            NotificationType.BOOKING_DAY,
            member,
            title,
            body,
            member.getFcmToken()
    );
    notificationRepository.save(notification);
    this.fcmNotificationSender(member, title, body, NotificationType.BOOKING_DAY);
}
profile
간단한 개발 기록

0개의 댓글