FCM 푸시 알림 연동기 (2) – 백엔드(Backend) 전송 로직

지난 글에서 플러터 앱에서 토큰을 발급받아 서버로 던지는 것까지 처리했다. 이제 백엔드(Spring Boot)가 바통을 이어받을 차례다.

“그냥 라이브러리 쓰고 매세지 보내면 끝 아닌가?” 라고 생각했지만, 막상 해보니 채팅 알림과 일반 알림을 구분해야 하고, 유저가 알림을 껐는지 체크하는 등 신경 쓸 로직이 꽤 있었다.

오늘은 FCM 초기화부터 실제 전송, 그리고 알림 설정 처리까지의 과정을 정리해본다.

1. Firebase 초기화 (Configuration)

가장 먼저 firebase-service-account.json 파일을 내 프로젝트에 인식시켜야 한다. Spring Bean이 뜰 때(@PostConstruct) 딱 한 번만 초기화되도록 설정했다.

@Configuration
public class FirebaseConfig {

    @PostConstruct
    public void init() {
        try {
            // 이미 초기화되어 있으면 스킵 (중복 초기화 방지)
            if (FirebaseApp.getApps().isEmpty()) {
                InputStream serviceAccount = new ClassPathResource("firebase-service-account.json").getInputStream();
                FirebaseOptions options = FirebaseOptions.builder()
                        .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                        .build();
                FirebaseApp.initializeApp(options);
            }
        } catch (IOException e) {
            e.printStackTrace(); // 실제론 로그로 남기는게 좋다
        }
    }
}

TipClassPathResource를 쓰면 resources 폴더 안에 있는 키 파일을 경로 문제없이 잘 읽어온다. 로컬과 배포 환경 경로가 달라서 고생하는 일을 줄여준다.

2. 메시지 전송의 핵심 (FCMService)

메시지 전송 로직은 크게 두 가지로 나눴다.

  1. 일반 알림: 그냥 제목/내용만 감.
  2. 채팅 알림: 클릭하면 해당 채팅방으로 이동해야 함 + 우선순위 높음.

특히 채팅 알림 구현할 때 좀 헤맸는데, Android와 iOS(APNS) 설정을 각각 해줘야 제대로 동작한다.

// FCMService.java

public void sendChatNotification(String token, String title, String body, String roomId, String senderId) {
    if (token == null || token.isEmpty()) return;

    try {
        // Android용 설정 (High Priority)
        // 이게 없으면 절전 모드에서 알림이 늦게 올 수 있음
        AndroidConfig androidConfig = AndroidConfig.builder()
                .setPriority(AndroidConfig.Priority.HIGH)
                .setNotification(AndroidNotification.builder().setTag("chat_room_" + roomId).build())
                .build();

        // iOS용 설정
        ApnsConfig apnsConfig = ApnsConfig.builder()
                .setAps(Aps.builder().setContentAvailable(true).build())
                .build();

        // 메시지 구성
        Message message = Message.builder()
                .setToken(token)
                .setNotification(Notification.builder().setTitle(title).setBody(body).build())
                // [중요] 앱에서 이 데이터를 보고 채팅방으로 이동함
                .putData("type", "CHAT")
                .putData("roomId", roomId)
                .putData("senderId", senderId)
                .setAndroidConfig(androidConfig)
                .setApnsConfig(apnsConfig)
                .build();

        FirebaseMessaging.getInstance().send(message);
    } catch (Exception e) {
        log.error("채팅 알림 전송 실패", e);
    }
}

3. “알림 끄기 설정” (User Settings)

사용자가 “매치 알림은 받고 싶은데 채팅 알림은 싫어요”라고 할 수 있다. 그래서 DB에 Notification을 저장하기 전에, 유저의 설정값을 먼저 확인하는 shouldSendPush 로직을 태웠다.

// NotificationService.java

private boolean shouldSendPush(User user, NotificationType type) {
    switch (type) {
        case MATCH_INVITATION:
        case MATCH_ACCEPTED:
            // 매치 관련 설정 체크 (null이면 기본값 true)
            return user.getMatchNotification() == null || user.getMatchNotification();
            
        case CHAT_MESSAGE:
            // 채팅 알림 설정 체크
            return user.getChatNotification() == null || user.getChatNotification();
            
        default:
            return true; // 그 외 중요 알림은 무조건 전송
    }
}

이 로직을 createNotification 메서드 중간에 끼워넣어서, 알림 데이터는 DB에 쌓더라도 실제 푸시는 유저가 허용한 경우에만 발송되도록 처리했다.

4. 토큰 갱신 API

앱이 켜질 때마다(혹은 토큰이 바뀔 때마다) 서버로 토큰을 보내주는데, 이걸 받아주는 간단한 API도 필요하다. User 엔티티에 fcmToken 필드 하나 파두고 업데이트만 해주면 끝.

// UserController.java
@PostMapping("/fcm-token")
public ApiResponse<Void> updateFcmToken(@AuthenticationPrincipal String userId, @RequestBody FcmTokenRequest request) {
    userService.updateFcmToken(userId, request.getFcmToken());
    return ApiResponse.success("토큰 저장 완료");
}

마무리

백엔드 로직은 생각보다 심플했지만, 채팅 알림의 data 페이로드 처리와 플랫폼별(Android/iOS) config 설정이 디테일한 포인트였다. 이걸 제대로 안 해주면 앱에서 “알림은 왔는데 클릭해도 아무 일도 안 일어나는” 멍청한 상태가 된다.

댓글 남기기