Spring/Spring Boot

[Spring] FCM 푸시 알림 연동하기 (AOS, IOS)

민돌v 2024. 1. 7. 22:44

오늘은 현재 진행중인 '채팅 서비스' 사이드 프로젝트에 적용할 목적으로
💡 Spring 기반의 Server 에서 푸시알림을 전송하는 방법에 대해 공부해보고자 합니다.

 

우리가 흔히 아는 어플리케이션 Push 기능을 쉽게 구현하기 위해서 시중에 나와있는 서드파티 솔루션들을 이용할 수 있는데
가볍게 찾아보았을때 가장 많이 나오는 솔루션을 2가지로 추릴 수 있었습니다.

  1. Amozon SNS (Simple Notification Service)
  2. FCM (Firebase Cloud Messaging)

 

위의 2가지 솔루션 모두 믿음직스러운(?) 대기업에서 제공하는 기술이지만

  1. FCM 이 조금 더 적용하기 쉬워보였고 (공식문서가 잘나와있음)
  2. 또, '무.료.' 라는 점 (아마존은 알림 1백만 개당 0.50 USD)
  3. 안드로이드와 ios 개발자분들이 친숙하다는 점

이 2가지 장점으로 FCM을 적용하기로 결정했습니다.


✔️ FCM 이란

  • Firebase 클라우드 메시징의 줄임말입니다.
  • FCM 은 메세지를 안정적이고 무료로 전송할 수 있는 크로스 플랫폼 메시징 솔루션입니다.
  • Firebase는 2014년에 구글에 인수된 모바일 및 웹 어플리케이션 개발 플랫폼 서비스 회사입니다. (즉. 믿을만하다!)

 


✔️ FCM 푸시 동작과정

사실 공식문서에 너무 잘나와있습니다. 쉽게쉽게 정리만 해보겠습니다. (FCM 아키텍처 개요)

fcm 아키텍처

  1. Firebase 에서 제공하는 GUI 혹은 신뢰할 수 있는 서버 환경에서 Firebase Admin Sdk 나 FCM 서버 프로토콜을 사용하여 메세지를 FCM Backend 서버에 전송합니다. 
  2. FCM Backend 서버에서는 메세지 요청이 들어오면 메시지 요청 수락, 메시지 메터데이터 생성 등 실질적인 Push 메세지 포맷팅을 해줍니다.
  3. 그런 다음 aos, ios, web 등의 풀랫폼 수준의 전송 레이어 기기로 메시지를 라우팅합니다.
  4. 사용자 기기의 FCM SDK 에서 알림이 표시됩니다.

 

👏🏻 여기서 사실상 저희가 push 알림을 위해 해줘야할 것은 "(1) → (2)" 으로 가는 메세지 요청을 생성해주는 것 뿐입니다.

FCM 에서 플랫폼 레이어에 따라 알아서 메세지를 적용해주기 때문에 개발자 입장에서는 매우 편해진 것이죠

 


✔️ FCM 메시지 유형 

FCM 공식문서에 따르면 2가지 유형의 메시지 타입을 사용하여, 클라이언트에게 전송할 수 있습니다.

  1. 알림메세지
    • 종종 '표시 메시지'로 간주
    • 앱이 백그라운드로 실행 시 FCM SDK에서 자동 처리, 포그라운드에서 실행 중이면 앱의 코드에 따라 동작이 결정됩니다.
    • 그렇기 때문인지 사용자에게 표시되는 키 모음이 정의되어있습니다.
  2. 데이터 메세지
    • 클라이언트앱에서 처리
    • 커스텀한 키-값 쌍만 포함됩니다.

→ 현재는 채팅 알림이 목적이므로, "알림메세지" 유형의 메세지 데이터타입을 사용하는게 좋아보입니다.


알림메세지 유형

{
  "message":{
    "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
    "notification":{
      "title":"Portugal vs. Denmark",
      "body":"great match!",
      "image" : "image-url"
    },
    //메세지 생명주기 defalut 는 4주
    "apns":{	//ios
      "headers":{
        "apns-expiration":"1604750400"
      }
    },
    "android":{
      "ttl":"4500s"
    },
    "webpush":{
      "headers":{
        "TTL":"4500"
      }
    }
  }
}

알림메세지 + 데이터메세지 타입 함께 사용하기

  • 처음에는 위와같이 알림메세지 타입으로만 메세지를 전송하려했지만,
  • IOS 에서는 notification 타입에 정의도어있는 key-value 값 중에 icon 이미지를 변경할만한 정의된 키 값을 찾지못하여,
  • 데이터 메세지 타입을 함께 사용해주어 필요한 값을 같이 전송해주려고 합니다.

이러한 경우, [백그라운드 상태] 일 경우 notification 필드만 수신되어 사용자가 알림을 탭한 경우에만 앱에서 데이터 페이로드를 처리한다고 합니다.

{
  "message":{
    "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
    "notification":{
      "title":"Portugal vs. Denmark",
      "body":"great match!",
      "image" : "image-url"
    },
    "data" : {
      //커스텀한 데이터 값
      "custom-key" : "value"
    },
    //메세지 생명주기 defalut 는 4주
    "apns":{	//ios
      "headers":{
        "apns-expiration":"1604750400"
      }
    },
    "android":{
      "ttl":"4500s"
    },
    "webpush":{
      "headers":{
        "TTL":"4500"
      }
    }
  }
}

 


✔️ Spring 서버에 FCM 적용하기

결국 서버에서 구현해줘야할 부분은 FCM Backend 에 메세지를 요청하는 부분입니다.
또한, 그러기 위해서는 FCM 서버(Backend) 와 상호작용하는 방법을 결정해야합니다.

 

🔍 FCM Backend 서버와 통신을 위한 앱 서버의 옵션 선택사항

1. Firebase Admin SDK (👍)

  • Node.js, Java, Python, C#, Go 등의 프로그래밍 언어 지원.
  • 기기에서 주제 구독 및 구독 취소가 가능하고, 다양한 타켓 플랫폼에 맞는 메세지 페이로드 구성.
  • 나머지 옵션과는 다르게, 초기화 작업만 잘 진행하면 인증 처리를 자동으로 수행한다.

2. FCM HTTP v1 API 

  • 가장 최신 프로토콜로서 보다 안전한 승인과 유연한 교차 플래폼 메시징 기능 제공
    (Firebase Admin SDK는 이 프로토콜을 기반으로 하며 모든 고유 이점을 제공함)
  • 23.12 기준 새 기능은 해당 API를 사용하는걸 권장한다고 합니다.

3. 기존의 HTTP 프로토콜, XMPP 서버 프로토콜 → (원시 프로토콜들)

 

→ 그러니 FCM HTTP v1 API를 기반으로 "Firebase Admin SDK" 를 사용하면 됩니다

 


👏🏻 코드 적용

1) 종속성 추가

gradle

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

maven

<dependency>
  <groupId>com.google.firebase</groupId>
  <artifactId>firebase-admin</artifactId>
  <version>9.2.0</version>
</dependency>

2) Firebase 프로젝트 생성

  • Firebase Admin SDK 를 사용하기 위해서는 Firebase 프로젝트를 생성해 주어야 합니다. 
  • FCM 을 사용하는 서비스를 시작할 떄 SDK를 초기화하는 것을 권장하는데, Google 환경 (Google cloud 등)이 아니라면 만들어준 Firebase 프로젝트의 서비스 계정 키 파일을 이용해야합니다.

[프로젝트 생성] → [프로젝트 개요] → [서비스 계정] → [새 비공개 키 생성]

👏🏻 비공개키는 Json 파일이고, 공개적인 레포지토리에 올려서는 안됩니다.


3) FCM Admin SDK 초기화

  • Firebase Admin SDK 초기화를 위해 config class 를 Bean으로 올리고
    @PostConstruct를 이용하여 1회만 호출되도록 설정해주었습니다.
  • 📌 공식문서에서는 'FileInputStream' 을 사용하지만, 저는 배포환경에서 Jar 로 어플리케이션을 실행시키기 때문에 ClassResourceLoader 를 사용하였습니다.
    • FileInputStream 사용시 상대경로가 jar 의 실행위치에 따라서 달라지기 때문에 "FileNotFound"를 뱉습니다
    • ClassResourceLoader 를 사용해야 ClassPath 기준으로 파일을 찾습니다.
@Configuration
public class FCMConfig {

    @PostConstruct
    private void init() throws IOException {
        //FileInputStream serviceAccount = new FileInputStream("resources/security/Server-Security/fcm/tht-fcm-firebase-adminsdk-waqsh-7f9e6071b2.json");

        String fileResourceURL = "security/Server-Security/fcm/tht-push-fcm-firebase-adminsdk-secretkey.json";
        ClassPathResource resource = new ClassPathResource(fileResourceURL);

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

        FirebaseApp.initializeApp(options);
    }
}

4) 비지니스 로직 작성

  • 완벽한 코드는 아니지만,  FCM push 역할을 하는 Util 클래스를 따로 추출하여 비지니스 로직에대한 책임을 전가해주었습니다.
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class FcmUtils {

    private static final int FCM_PUSH_LIMIT_SIZE = 500;
    private static final long ONE_WEEK = (long) 60 * 60 * 24 * 7;
    private static final long EXPIRED_TIME_FOR_UNIX = new Date(new Date().getTime() + ONE_WEEK).getTime();

    public static void broadCast(final List<String> registrationTokens) {

        //limit 500
        limitSizeValidate(registrationTokens);

        MulticastMessage message = MulticastMessage.builder()
                .setNotification(Notification.builder()
                        .setTitle("이름")
                        .setBody("대화내용")
                        .build())
                .putData("senderThumbnail", "보내는 사람 프로필 이미지")
                .setAndroidConfig(AndroidConfig.builder()
                        .setTtl(ONE_WEEK)
                        .setNotification(AndroidNotification.builder()
                                .setIcon("보내는사람 프로필 이미지")
                                .build())
                        .build())
                 .setApnsConfig(
                        ApnsConfig.builder()
                                .setAps(Aps.builder().build())
                        .putHeader("apns-expiration", Long.toString(EXPIRED_TIME_FOR_UNIX))
                        .build())
                .addAllTokens(registrationTokens)
                .build();

        BatchResponse response = null;

        try {
            response = FirebaseMessaging.getInstance().sendEachForMulticast(message);
        } catch (FirebaseMessagingException e) {
            //todo
            //전송 실패 exception
        } finally {
            assert response != null;
            pushSuccessValidate(registrationTokens, response);
        }

    }

    private static void limitSizeValidate(final List<String> registrationTokens) {
        if (registrationTokens.size() > FCM_PUSH_LIMIT_SIZE) {
            //todo exception
        }
    }

    private static void pushSuccessValidate(final List<String> registrationTokens, final BatchResponse response) {

        // See the BatchResponse reference documentation
        // for the contents of response.
        System.out.println(response.getSuccessCount() + " messages were sent successfully");
    }
}

 

→ 테스트를 하기위해서는, fcm-token이 필요한데 이 token 안드로이드난 IOS 클라이언트 코드로 구성되어있어, 서버에서는 자체적으로 테스트하기가 곤란합니다,,

참고 : https://firebase.google.com/docs/cloud-messaging/android/first-message?hl=ko&authuser=0#java_1


📌 고민되는 점

1. 처리지연(latency)

  • 지금 구조라면 실제 채팅환경에서 채팅이 쳐질때마다 fcm push 가 동작할텐데 처리지연이 걸리지는 않을지, 클라이언트 입장에서 과부하가 되는건 아닐지 고민이 되는 부분입니다.. 이에 대해서는 클라이언트분들과 이야기를 해봐야 될 것 같습니다.

2. 속도

  • 처리지연과 같은 맥락의 고민이지만, 현재구조상 db에서 fcm token을 조회하는 1차적인 과정이 있기 때문에, 실제 사용자가 조금만 생겨도 많은 양의 조회성 쿼리가 날아갈 수 도있겠다는 생각이 들었습니다
  • 흠.. 이런 부분은 아무래도 같은 값을 불필요하게 조회는 부분이기 때문에 현재로서는 캐시를 적용하면 좋을 부분이라고 생각되어집니다.

 

 

일단은,,끝!

 

 


*참고