푸시알림 구현 실습 📲🔔

dev-jjun·2024년 1월 30일
2

Server

목록 보기
33/33
post-thumbnail
👾 실습 흐름은 크게 아래와 같이 정리할 수 있습니다.

1️⃣ 파이어베이스 서버와 스프링부트 서버 연결

2️⃣ 파이어베이스에 보낼 메시지 형식을 DTO로 만들어서 커스텀하고 관리하자

3️⃣ 메시지 만들고, 파이어베이스 서버로 전송! → 이걸 호출하는 시점이 푸시알림을 보내고 싶은 서비스 로직 상에 있으면 됨

기본 세팅

[전제] User 테이블 안에 있는 1명의 유저를 대상으로 푸시 알림 테스트를 진행한다.

  1. 스프링부트 프로젝트에 미리 ‘fcm_token’ 필드를 가지고 있는 User 테이블을 생성해두었습니다! 저는 회원가입 시 전달받은 FCM 토큰을 유저 DB에 한번에 저장해두고 유저 아이디와 각각 매핑되는 방식으로 구현해보겠습니다

  2. Firebase Console > ‘프로젝트 만들기’

    그냥 ‘계속’ 을 쭉쭉 누르시고 생성하세요~

    그럼 이렇게 내가 지정한 프로젝트명의 콘솔로 들어오게 됩니다

    ✅ 여기서 ‘엡에 Firebase를 추가하여 시작하기’ 부분이 클라분들(안드,아요)이 등록해주셔야 하는 부분입니다!!

  3. 왼쪽 상단에 톱니바퀴를 누르고, [프로젝트 설정]을 클릭

  4. [서비스 계정] 탭 > [새 비공개 키 생성] 버튼 클릭

비공개 키(*.json)를 다운 받은 후, 프로젝트의 src/main/resources 디렉토리 안에 파일을 넣어줍시다!

🚨 .gitignore에 미리 파일명을 추가해두고, 절대절대절대 Github에 올리지 않도록 주의합니다. (저는 잘못 올렸다가 커밋 기록 삭제해달라고 깃헙에 문의드린 경험이 있습니다 ,,허허)

*파일명이 엄청 길텐데 얘는 그냥 임의로 바꿔주셔도 됩니다!

본격적인 실습 GOGOGO 🏃🏻‍♀️

  1. build.gradle에 FCM 설정에 필요한 의존성 라이브러리 추가

    // FCM
    implementation 'com.google.firebase:firebase-admin:9.1.1'
    implementation 'com.squareup.okhttp3:okhttp:4.10.0'  // Firebase 서버로 푸시 메시지 전송 시 필요
  2. application.yml 설정 추가

    fcm:
      key:
        path: firebase-adminsdk.json
        scope: https://www.googleapis.com/auth/cloud-platform
    • path : 위에서 추가한 비공개 키 파일의 경로와 파일명을 명시 *지금은 같은 resources/ 하위에 있으므로 그냥 파일명만 적어주자!
    • scope : 권한의 범위를 설정 → Google Cloud 데이터 확인, 수정, 구성, 삭제 및 Google 계정 이메일 주소 확인 에 대한 권한을 부여하도록 명시 *참고 - https://developers.google.com/identity/protocols/oauth2/scopes?hl=ko#fcm
  3. FCMConfig 에서 기본 설정에 대한 내용 추가

    // common/config/FCMConfig.java
    @Slf4j
    @Configuration
    public class FCMConfig {
    
        @Value("${fcm.key.path}")
        private String SERViCE_ACCOUNT_JSON;
    
        @PostConstruct
        public void init() {
            try {
                ClassPathResource resource = new ClassPathResource(SERViCE_ACCOUNT_JSON);
                InputStream serviceAccount = resource.getInputStream();
    
                FirebaseOptions options = FirebaseOptions.builder()
                        .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                        .build();
    
                FirebaseApp.initializeApp(options);
    						log.info("파이어베이스 서버와의 연결에 성공했습니다.");
            } catch (IOException e) {
                log.error("파이어베이스 서버와의 연결에 실패했습니다.");
            }
        }
        
    }
    • @@Value 어노테이션으로 application.yml 에서 지정해준 파일명을 SERVICE_ACCOUNT_JSON 변수에 넣어준다.
    • 파이어베이스 서버와의 연결을 위해서 키의 내용이 필요한데, 파일경로+이름을 통해 해당 파일로 접근해 InputStream 클래스의 fromStream() 메서드를 이용해 파일의 내용을 읽는다.
    • 키를 통해 FirebaseApp의 설정정보로 연결을 시도하고 IOException이 발생하지 않았다면 연결 성공이다!
    • Android, iOS 플랫폼 별 설정을 각각 지정해주려면? → 플랫폼마다 별도의 설정이 필요한 경우에 사용한다.
      // Android
      public AndroidConfig TokenAndroidConfig(FCMPushRequestDto request) {
          return AndroidConfig.builder()
                  .setCollapseKey(request.getCollapseKey())
                  .setNotification(AndroidNotification.builder()
                          .setTitle(request.getTitle())
                          .setBody(request.getBody())
                          .build())
                  .build();
      }
      
      // Apple
      public ApnsConfig TokenApnsConfig(FCMPushRequestDto request) {
          return ApnsConfig.builder()
                  .setAps(Aps.builder()
                          .setAlert(
                                  ApsAlert.builder()
                                          .setTitle(request.getTitle())
                                          .setBody(request.getBody())
                                          .setLaunchImage(request.getImgUrl())
                                          .build()
                          )
                          .setCategory(request.getCollapseKey())
                          .setSound("default")
                          .build())
                  .build();
      }
  4. 공식 문서와 동일한 메시지 형식에 따라 FCMMessage 클래스 생성

    @Builder
    @Getter
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public class FCMMessage {
    
        **private boolean validateOnly;
        private Message message;**
    
        @Builder
        @AllArgsConstructor
        @Getter
        public static class Message {
            **private Notification notification;**   // 모든 모바일 OS에 통합으로 사용할 수 있는 Notification
            private String token;   // 특정 디바이스(클라이언트)에 알림을 보내기 위한 토큰
            private String topic;   // 주제 구독 시 사용
        }
    
        @Builder
        @AllArgsConstructor
        @Getter
        **public static class Notification {
            private String title;
            private String body;
            private String image;
        }**
    
    }
    • 공식 문서
       * - Request
       * {
       *   **"validate_only": boolean,
       *   "message": {
       *     object (Message)
       *   }**
       * }
       *
       *
       * - Message
       * {
       *   **"name": string,
       *   "data": {
       *     string: string,
       *     ...
       *   },
       *   "notification": {   ✅모든 플랫폼에서 사용할 기본 알림 템플릿
       *     object (Notification)
       *   },**
       *   "android": {    FCM 연결 서버를 통해 전송된 메시지에 대한 Android 전용 옵션 
       *     object (AndroidConfig)
       *   },
       *   "webpush": {   Web 푸시 알림을 위한 webpush 프로토콘 옵션
       *     object (WebpushConfig)
       *   },
       *   "apns": {      Apple 푸시 알림 서비스 특정 옵션  
       *     object (ApnsConfig)
       *   },
       *   "fcm_options": {  모든 플랫폼에서 사용할 FCM SDK 기능 옵션용 템플릿
       *     object (FcmOptions)
       *   },
       *
       *   // Union field target can be only one of the following:
       *   "token": string,    메시지를 보낼 등록 토큰 (특정 클라이언트 대상)
       *   "topic": string,    Topic 발행의 경우, 사용
       *   "condition": string
       *   // End of list of possible types for union field target.
       * }
      → 우리는 Android, iOS 공통으로 사용할 것이므로 Notification 부분만 참고하면 된다!
  5. 커스텀 알림 DTO 생성

    → title, body로 구성

    → title, body, image로 구성

    앱에서 수신하는 푸시알림의 형태를 살펴보면 다음과 같이 제목, 바디의 형태로 구성되어 있다는 공통점을 발견할 수 있는데요, (앱의 아이콘과 시간 정보는 저희 서버에서 처리해주는 것이 아닙니다!)
    이를 커스텀하기 위한 DTO를 만들어서 알림을 보내는 서비스 로직에서 공통적으로 적용시켜줍시다

    • title
    • body
    • image
    • targetToken

    → 이 DTO 필드의 값들은 위 Notification 클래스의 title, body, Message 클래스의 token에 매핑됩니다.

    @Slf4j
    @Getter
    @Builder
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    @NoArgsConstructor(access = AccessLevel.PRIVATE)
    public class FCMPushRequestDto {
    
        private String targetToken;
    
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private String title;
    
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private String body;
    }
    • @JsonInclude(JsonInclude.Include.NON_NULL) JSON 문자열로 요청, 응답값이 보내질 때 null 인 경우에 해당 필드는 아예 제외하고 반환하게 하는 어노테이션입니다. *이 어노테이션이 없다면 값이 null인 경우에 아래와 같이 표시되지만, 이를 아예 제외시켜버리기 위해서 해당 어노테이션을 붙여주었습니다!
      {
          "targetToken" : "fcm-token-unique-value",
      	"title": null,
      	"body": "제목이 없는 푸시알림이군요"
      }
  6. application.yml 내용 추가

    특정 파이어베이스 서버(=우리가 생성한 프로젝트)에 전송하기 위해 API 엔드포인트를 추가해줘야 하는데요, API 엔드포인트 형식은 공식문서에 따르면 아래와 같습니다.

    POST https://fcm.googleapis.com/v1/projects/{firebase-project-ID}/messages:send

    fcm:
      key:
        path: firebase-adminsdk.json
        scope: https://www.googleapis.com/auth/cloud-platform
      api:
        url: **https://fcm.googleapis.com/v1/projects/{fcm-project-id}/messages:send**
      topic:
        "sopt-topic"
    • IntelliJ에서 환경변수 관리하기

      [Edit] > [Modify Options] > [Environment variables]

      key=value 의 형태로 작성하고(따옴표, 괄호, 공백 없음), 코드에서 환경변수를 사용할 때는 키 값으로 적어주면 됩니다!

  7. 이제 FCMService에서 Firebase에 보낼 메시지 객체를 생성하고 보내봅시다!

    먼저 의존성 주입은 얘 정도만.. DTO → json 문자열로 매핑해서 String 형태로 반환하기 위해 사용할 거예요

    // service/FCMService.java
    private final ObjectMapper objectMapper;  // FCM의 body 형태에 따라 생성한 값을 문자열로 저장하기 위한 Mapper 클래스

    위 설정파일에 내용을 추가해줬으니, 어딘가 사용이 되겠죠?

    바로 지금입니다.

    // service/FCMService.java
    @Value("${fcm.key.path}")
    private String SERVICE_ACCOUNT_JSON;
    @Value("${fcm.api.url}")
    private String FCM_API_URL;
    
    @Value("${fcm.topic}")
    private String topic;   

    @Value 어노테이션으로 application.yml의 내용을 가져와서 변수에 값을 넣어준다.

    • topic : 어플리케이션 내에서 관리하는 주제 (유저들이 구독 가능한)를 가져오면, 해당 주제를 구독하는 유저에게 일괄적으로 푸시 메시지 전송 가능

    • SERVICE_ACCOUNT_JSON : 초기 설정과 마찬가지로 해당 파일 안에 있는 비공개 키를 읽어서 헤더에 실어야 요청을 정상적으로 보낼 수 있다!

    • FCM_API_URL : 요청을 어디에 보낼 것인지 지정 → 파이어베이스 특정 서버 (미리 생성해둔 파이어베이스 프로젝트가 되겠쥬?)

      얘네들은 어디서 쓰이냐구요?

      // 실제 파이어베이스 서버로 푸시 메시지를 전송하는 메서드
      private void sendPushMessage(String message) {
      
          try {
              OkHttpClient client = new OkHttpClient();
              RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8"));
              Request httpRequest = new Request.Builder()
      		            .url(**FCM_API_URL**)  // 요청을 보낼 위치 (to 파이어베이스 서버)
                      .post(requestBody)
                      .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken())
                      .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8")
                      .build();
      
              Response response = client.newCall(httpRequest).execute();
      
              log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully");
              log.info("알림 전송: {}", response.body().string());
          } catch (IOException e) {
              throw new IllegalArgumentException("파일을 읽는 데 실패했습니다.");
          }
      }
      
      // Firebase에서 Access Token 가져오기
      private String getAccessToken() {
      
          try {
              GoogleCredentials googleCredentials = GoogleCredentials
      		            .fromStream(new ClassPathResource(**SERVICE_ACCOUNT_JSON**).getInputStream())
                      .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));
              googleCredentials.refreshIfExpired();
              log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue());
      
              return googleCredentials.getAccessToken().getTokenValue();
          } catch (IOException e) {
              throw new IllegalArgumentException("파일을 읽는 데 실패했습니다.");
          }
      }

      그리구, 요청 바디에는 정해진 형식대로 반드시 지켜서 보내야 한다고 했었죠!

      json 문자열을 만들어주기 위해 아까 만들어둔 FCMMessage 클래스를 이용할 것입니다

      // service/FCMService.java
      
      // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기]
      private String makeSingleMessage(FCMPushRequestDto request) throws JsonProcessingException {
      
          try {
              FCMMessage fcmMessage = FCMMessage.builder()
                      .message(FCMMessage.Message.builder()
                              .token(request.getTargetToken())   // 1:1 전송 시 반드시 필요한 대상 토큰 설정
                              .notification(FCMMessage.Notification.builder()
                                      .title(request.getTitle())
                                      .body(request.getBody())
                                      .image(request.getImage())
                                      .build())
                              .build()
                      ).validateOnly(false)
                      .build();
      
              return objectMapper.writeValueAsString(fcmMessage);
          } catch (JsonProcessingException e) {
              throw new IllegalArgumentException("JSON 처리 도중에 예외가 발생했습니다.");
          }
      }
      
      // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [주제 구독]
      private String makeTopicMessage(FCMPushRequestDto request) throws JsonProcessingException {
          try {
              FCMMessage fcmMessage = FCMMessage.builder()
                      .message(FCMMessage.Message.builder()
                                      .topic(topic)   // 토픽 구독에서 반드시 필요한 설정 (token 지정 x)
                                      .notification(FCMMessage.Notification.builder()
                                              .title(request.getTitle())
                                              .body(request.getBody())
                                              .image(request.getImage())
                                              .build())
                                      .build()
                      ).validateOnly(false)
                      .build();
      
              return objectMapper.writeValueAsString(fcmMessage);
          } catch (JsonProcessingException e) {
              throw new IllegalArgumentException("JSON 처리 도중에 예외가 발생했습니다.");
          }
      }
      
      // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [다수 기기]
      private static MulticastMessage makeMultipleMessage(FCMPushRequestDto request, List<String> tokenList) {
          MulticastMessage message = MulticastMessage.builder()
                  .setNotification(Notification.builder()
                          .setTitle(request.getTitle())
                          .setBody(request.getBody())
                          .setImage(request.getImage())
                          .build())
                  .addAllTokens(tokenList)
                  .build();
      
          log.info("message: {}", request.getTitle() +" "+ request.getBody());
          return message;
      }

      타겟팅에 따라 3가지 방식으로 구현이 가능하다고 했었는데요..! 3가지 방식의 작동하는 구현을 모두 소개하고, 실제 테스트는 단일 기기만 해보겠습니다 (시험 끝나고 또 나머지도 추가할게요,,)

      → [참고][**타겟팅에 따른 구현**](https://www.notion.so/2fdec67c96a145db9ea914b8ae73998b?pvs=21)

      단일 기기 (특정 이벤트 발생 시, 1명의 기기 대상)

      /**
    • 단일 기기

      • Firebase에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기)
        */
        @Transactional
        public String pushAlarm(FCMPushRequestDto request) throws IOException {

        String message = **makeSingleMessage**(request);
        sendPushMessage(message);
        return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken();

        }

      
      ### 다수 기기 (특정 이벤트 발생 시, 2명 이상의 기기 대상)
      
      ```java
      /**
    • 다수 기기

      • Firebase에 메시지를 수신하는 함수 (동일한 메시지를 2명 이상의 유저에게 발송)
        */
        public String multipleSendByToken(FCMPushRequestDto request, List userList) {

        // User 리스트에서 FCM 토큰만 꺼내와서 리스트로 저장
        List<String> tokenList = userList.stream()
                .map(User::getFcmToken).toList();
        
        // 2명만 있다고 가정
        log.info("tokenList: {}🌈,  {}🌈",tokenList.get(0), tokenList.get(1));
        
        MulticastMessage message = makeMultipleMessage(request, tokenList);
        
        try {
            BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
            log.info("다수 기기 알림 전송 성공 ! successCount: " + response.getSuccessCount() + " messages were sent successfully");
            log.info("알림 전송: {}", response.getResponses().toString());
        
            return "알림을 성공적으로 전송했습니다. \ntargetUserId = 1." + tokenList.get(0) + ", \n\n2." + tokenList.get(1);
        } catch (FirebaseMessagingException e) {
            log.error("다수기기 푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage());
            throw new IllegalArgumentException(ErrorType.FAIL_TO_SEND_PUSH_ALARM.getMessage());
        }

        }

      
      ### 주제(topic) 구독 (유저의 이벤트와 무관하게 사전에 구독한 알림 일괄 발송)
      
      ```java
      /**
    • 주제 구독 등록 및 취소

      • 특정 타깃 토큰 없이 해당 주제를 구독한 모든 유저에 푸시 전송
        */
        @Transactional
        public String pushTopicAlarm(FCMPushRequestDto request) throws IOException {
          String message = **makeTopicMessage(request)**;
          sendPushMessage(message);
          return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken();
        }
        
        주제를 구독한 유저들이 존재하려면, 구독을 등록하거나 취소하는 로직도 필요하겠죠!
        
        ```java
        // Topic 구독 설정 - application.yml에서 topic명 관리
        // 단일 요청으로 최대 1000개의 기기를 Topic에 구독 등록 및 취소할 수 있다.
        public void subscribe() throws FirebaseMessagingException {
            // These registration tokens come from the client FCM SDKs.
            List<String> registrationTokens = Arrays.asList(
                    "YOUR_REGISTRATION_TOKEN_1",
                    // ...
                    "YOUR_REGISTRATION_TOKEN_n"
            );
        
            // Subscribe the devices corresponding to the registration tokens to the topic.
            TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic(
                    registrationTokens, topic);
        
            log.info(response.getSuccessCount() + " tokens were subscribed successfully");
        }
        
        // Topic 구독 취소
        public void unsubscribe() throws FirebaseMessagingException {
            // These registration tokens come from the client FCM SDKs.
            List<String> registrationTokens = Arrays.asList(
                    "YOUR_REGISTRATION_TOKEN_1",
                    // ...
                    "YOUR_REGISTRATION_TOKEN_n"
            );
        
            // Unsubscribe the devices corresponding to the registration tokens from the topic.
            TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic(
                    registrationTokens, topic);
        
            log.info(response.getSuccessCount() + " tokens were unsubscribed successfully");
        }

        ✨ 최종 FCMService 클래스 코드

          ```java
          package jjun.server.pushalarm.service;
          
          import com.fasterxml.jackson.core.JsonProcessingException;
          import com.fasterxml.jackson.databind.ObjectMapper;
          import com.google.auth.oauth2.GoogleCredentials;
          import com.google.firebase.messaging.BatchResponse;
          import com.google.firebase.messaging.FirebaseMessaging;
          import com.google.firebase.messaging.FirebaseMessagingException;
          import com.google.firebase.messaging.MulticastMessage;
          import com.google.firebase.messaging.Notification;
          import com.google.firebase.messaging.TopicManagementResponse;
          import java.io.IOException;
          import java.util.Arrays;
          import java.util.List;
          import jjun.server.pushalarm.common.exception.ErrorType;
          import jjun.server.pushalarm.domain.User;
          import jjun.server.pushalarm.dto.fcm.FCMMessage;
          import jjun.server.pushalarm.dto.fcm.FCMPushRequestDto;
          import jjun.server.pushalarm.repository.UserRepository;
          import lombok.RequiredArgsConstructor;
          import lombok.extern.slf4j.Slf4j;
          import okhttp3.MediaType;
          import okhttp3.OkHttpClient;
          import okhttp3.Request;
          import okhttp3.RequestBody;
          import okhttp3.Response;
          import org.springframework.beans.factory.annotation.Value;
          import org.springframework.core.io.ClassPathResource;
          import org.springframework.http.HttpHeaders;
          import org.springframework.stereotype.Service;
          import org.springframework.transaction.annotation.Transactional;
          
          @Slf4j
          @Service
          @RequiredArgsConstructor
          public class FCMService {
          
              private final ObjectMapper objectMapper;  // FCM의 body 형태에 따라 생성한 값을 문자열로 저장하기 위한 Mapper 클래스
          
              @Value("${fcm.key.path}")
              private String SERVICE_ACCOUNT_JSON;
              @Value("${fcm.api.url}")
              private String FCM_API_URL;
              @Value("${fcm.topic}")
              private String topic;
          
              /**
               * 단일 기기
               * - Firebase에 메시지를 수신하는 함수 (헤더와 바디 직접 만들기)
               */
              @Transactional
              public String pushAlarm(FCMPushRequestDto request) throws IOException {
          
                  String message = makeSingleMessage(request);
                  sendPushMessage(message);
                  return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken();
              }
          
              /**
               * 다수 기기
               * - Firebase에 메시지를 수신하는 함수 (동일한 메시지를 2명 이상의 유저에게 발송)
               */
              public String multipleSendByToken(FCMPushRequestDto request, List<User> userList) {
          
                  // User 리스트에서 FCM 토큰만 꺼내와서 리스트로 저장
                  List<String> tokenList = userList.stream()
                          .map(User::getFcmToken).toList();
          
                  // 2명만 있다고 가정
                  log.info("tokenList: {}🌈,  {}🌈",tokenList.get(0), tokenList.get(1));
          
                  MulticastMessage message = makeMultipleMessage(request, tokenList);
          
                  try {
                      BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(message);
                      log.info("다수 기기 알림 전송 성공 ! successCount: " + response.getSuccessCount() + " messages were sent successfully");
                      log.info("알림 전송: {}", response.getResponses().toString());
          
                      return "알림을 성공적으로 전송했습니다. \ntargetUserId = 1." + tokenList.get(0) + ", \n\n2." + tokenList.get(1);
                  } catch (FirebaseMessagingException e) {
                      log.error("다수기기 푸시메시지 전송 실패 - FirebaseMessagingException: {}", e.getMessage());
                      throw new IllegalArgumentException(ErrorType.FAIL_TO_SEND_PUSH_ALARM.getMessage());
                  }
              }
          
              /**
               * 주제 구독 등록 및 취소
               * - 특정 타깃 토큰 없이 해당 주제를 구독한 모든 유저에 푸시 전송
               */
              @Transactional
              public String pushTopicAlarm(FCMPushRequestDto request) throws IOException {
          
                  String message = makeTopicMessage(request);
                  sendPushMessage(message);
                  return "알림을 성공적으로 전송했습니다. targetUserId = " + request.getTargetToken();
              }
          
              // Topic 구독 설정 - application.yml에서 topic명 관리
              // 단일 요청으로 최대 1000개의 기기를 Topic에 구독 등록 및 취소할 수 있다.
          
              public void subscribe() throws FirebaseMessagingException {
                  // These registration tokens come from the client FCM SDKs.
                  List<String> registrationTokens = Arrays.asList(
                          "YOUR_REGISTRATION_TOKEN_1",
                          // ...
                          "YOUR_REGISTRATION_TOKEN_n"
                  );
          
                  // Subscribe the devices corresponding to the registration tokens to the topic.
                  TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopic(
                          registrationTokens, topic);
          
                  log.info(response.getSuccessCount() + " tokens were subscribed successfully");
              }
          
              // Topic 구독 취소
              public void unsubscribe() throws FirebaseMessagingException {
                  // These registration tokens come from the client FCM SDKs.
                  List<String> registrationTokens = Arrays.asList(
                          "YOUR_REGISTRATION_TOKEN_1",
                          // ...
                          "YOUR_REGISTRATION_TOKEN_n"
                  );
          
                  // Unsubscribe the devices corresponding to the registration tokens from the topic.
                  TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopic(
                          registrationTokens, topic);
          
                  log.info(response.getSuccessCount() + " tokens were unsubscribed successfully");
              }
          
              // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [단일 기기]
          
              private String makeSingleMessage(FCMPushRequestDto request) throws JsonProcessingException {
          
                  FCMMessage fcmMessage = FCMMessage.builder()
                          .message(FCMMessage.Message.builder()
                                  .token(request.getTargetToken())   // 1:1 전송 시 반드시 필요한 대상 토큰 설정
                                  .notification(FCMMessage.Notification.builder()
                                          .title(request.getTitle())
                                          .body(request.getBody())
                                          .image(request.getImage())
                                          .build())
                                  .build()
                          ).validateOnly(false)
                          .build();
          
                  return objectMapper.writeValueAsString(fcmMessage);
              }
          
              // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [주제 구독]
              private String makeTopicMessage(FCMPushRequestDto request) throws JsonProcessingException {
          
                  FCMMessage fcmMessage = FCMMessage.builder()
                          .message(FCMMessage.Message.builder()
                                          .topic(topic)   // 토픽 구독에서 반드시 필요한 설정 (token 지정 x)
                                          .notification(FCMMessage.Notification.builder()
                                                  .title(request.getTitle())
                                                  .body(request.getBody())
                                                  .image(request.getImage())
                                                  .build())
                                          .build()
                          ).validateOnly(false)
                          .build();
          
                  return objectMapper.writeValueAsString(fcmMessage);
              }
          
              // 요청 파라미터를 FCM의 body 형태로 만들어주는 메서드 [다수 기기]
              private static MulticastMessage makeMultipleMessage(FCMPushRequestDto request, List<String> tokenList) {
                  MulticastMessage message = MulticastMessage.builder()
                          .setNotification(Notification.builder()
                                  .setTitle(request.getTitle())
                                  .setBody(request.getBody())
                                  .setImage(request.getImage())
                                  .build())
                          .addAllTokens(tokenList)
                          .build();
          
                  log.info("message: {}", request.getTitle() +" "+ request.getBody());
                  return message;
              }
          
              // 실제 파이어베이스 서버로 푸시 메시지를 전송하는 메서드
              private void sendPushMessage(String message) throws IOException {
          
                  OkHttpClient client = new OkHttpClient();
                  RequestBody requestBody = RequestBody.create(message, MediaType.get("application/json; charset=utf-8"));
                  Request httpRequest = new Request.Builder()
                          .url(FCM_API_URL)
                          .post(requestBody)
                          .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken())
                          .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8")
                          .build();
          
                  Response response = client.newCall(httpRequest).execute();
          
                  log.info("단일 기기 알림 전송 성공 ! successCount: 1 messages were sent successfully");
                  log.info("알림 전송: {}", response.body().string());
              }
          
              // Firebase에서 Access Token 가져오기
              private String getAccessToken() throws IOException {
          
                  GoogleCredentials googleCredentials = GoogleCredentials
                          .fromStream(new ClassPathResource(SERVICE_ACCOUNT_JSON).getInputStream())
                          .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));
                  googleCredentials.refreshIfExpired();
                  log.info("getAccessToken() - googleCredentials: {} ", googleCredentials.getAccessToken().getTokenValue());
          
                  return googleCredentials.getAccessToken().getTokenValue();
              }
          }
          ```
          
        ⭐ 우리가 서비스에 위 로직을 적용하려면, ①특정 이벤트가 발생한 경우 또는 ②주기적으로 알림을 보낼 경우 등을 생각해볼 수 있겠죠!? 푸시알림을 이때 보내야겠다! 하는 그 시점에서 위 메서드를 호출하는 거라고 이해하시면 됩니다!
  8. 자 이제 파이어베이스에 보내는 부분까지 구현했으니, 테스트를 해볼까요?

    • [👨‍👩‍👧‍👦엄빠도 어렸다] 실제 적용 사례

      SOPT 32기 앱잼 ‘엄빠도 어렸다’에서는 위 알림 템플릿을 Enum 으로 관리하여 적용했습니다!

      public static FCMPushRequestDto sendTodayQna(String targetToken, String section, String topic) {
      
          return FCMPushRequestDto.builder()
                  .type(MessageType.FIREBASE)
                  .targetToken(targetToken)
                  .title("📞" + section + PushMessage.TODAY_QNA.getTitle())
                  .body("'" + topic + PushMessage.TODAY_QNA.getBody())
                  .build();
      }
      @Getter
      @AllArgsConstructor(access = AccessLevel.PRIVATE)
      public enum PushMessage {
      
          // 새로운 주제가 도착했을 때
          TODAY_QNA("로부터 교신이 도착했어요",
                  "'에 대한 질문에 답변하고 추억을 나눠보세요 ☺️(수신거부 : 설정 - 푸시알림 off)"),
      
          private String title;
          private String body;
      }

      그래서 ! 우리는 간단한 테스트를 해볼 거니까 테스트용으로 만들어 보겠습니다

    FCMPushRequestDto 클래스에 static 메서드를 정의해서 푸시알림을 보내는 시점에 바로 메시지가 들어간 객체로 메서드 호출을 할 수 있도록 합시다! (ENUM은 따로 구현하지 않겠습니다!)

    public static FCMPushRequestDto sendTestPush(String targetToken) {
    
        return FCMPushRequestDto.builder()
                .targetToken(targetToken)
                .title("💚DO SOPT SERVER💚")
                .body("서팟 앱잼 화이팅! FCM 화이팅!")
                .build();
    }

    API 호출을 통해 테스트하기 위해서, FCMController 클래스를 생성해줍시다!

    @RestController
    @RequestMapping("/alarm")
    @RequiredArgsConstructor
    public class FCMController {
    
        private final FCMService fcmService;
    
        /**
         * 헤더와 바디를 직접 만들어 알림을 전송하는 테스트용 API (상대 답변 알람 전송에 사용)
         */
        @PostMapping
        @ResponseStatus(HttpStatus.OK)
        public ResponseEntity<String> sendNotificationByToken(@RequestBody FCMPushRequestDto request) throws IOException {
    
            fcmService.pushAlarm(request);
            return ResponseEntity.ok().body("푸시알림 전송에 성공했습니다!");
        }
    }

    또, 테스트용으로 만들어둔 유저 조회 API 호출 시마다 위 Push 메시지를 전송하는 코드를 추가해보겠습니다!

    @Service
    @Transactional(readOnly = true)
    @RequiredArgsConstructor
    public class UserService {
    
        private final UserRepository userRepository;
    
        // 푸시알림 활성화가 필요한 로직이라면, FCMService를 주입!
        private final FCMService fcmService;
    
        public GetUserResponse getUserById(Long userId) {
            User user = userRepository.findById(userId).orElseThrow(
                    () -> new EntityNotFoundException("존재하지 않는 회원의 아이디입니다.")
            );
            //== User 조회 API 호출 시 푸시 알림 전송! ==//
            fcmService.pushAlarm(FCMPushRequestDto.sendTestPush(user.getFcmToken()));
            return GetUserResponse.of(user);
        }
    }

    PostMan에서 POST http://localhost:8080/alarm 로 테스트해보면, 아래와 같이 전송에 성공했다는 로그를 볼 수 있어요! 클라이언트 측에서 앱을 해당 파이어베이스 프로젝트에 등록한다면 실제 기기에서 테스트 결과를 확인할 수 있습니다 !!!

    → 이 부분은 FCM 토큰 값이 유효하지 않기 때문에(아직 유저가 앱을 설치할 수 없으니까요!) 발생하는 문제이고, 여기까지 나오면 서버 측에서는 성공입니다 😁

전체 실습코드는 제 깃헙에 올려뒀으니 참고해주실 분들은 참고해주세요 !!!

https://github.com/jun02160/fcm-practice.git

위 실습은 정말 일부분만 다루며 , 더 다양하고 좋은 레퍼런스들이 많아요..! 처음 접하시는 분들을 위해 최대한 빠르고 쉽게 구현할 수 있도록 준비한 자료이니 많은 도움 되셨으면 좋겠습니다 :>

궁금한 점, 오류사항 지적, 같이 토론하고 싶은 부분 모두 다 대 환 영 입니다 !!!!

profile
서버 개발자를 꿈꾸며 성장하는 쭌입니다 😽

1개의 댓글

comment-user-thumbnail
2024년 10월 29일

아웅 야무져~

답글 달기