[Spring] FCM 푸시 알림 연동하기 (AOS, IOS)
오늘은 현재 진행중인 '채팅 서비스' 사이드 프로젝트에 적용할 목적으로
💡 Spring 기반의 Server 에서 푸시알림을 전송하는 방법에 대해 공부해보고자 합니다.
우리가 흔히 아는 어플리케이션 Push 기능을 쉽게 구현하기 위해서 시중에 나와있는 서드파티 솔루션들을 이용할 수 있는데
가볍게 찾아보았을때 가장 많이 나오는 솔루션을 2가지로 추릴 수 있었습니다.
- Amozon SNS (Simple Notification Service)
- FCM (Firebase Cloud Messaging)
위의 2가지 솔루션 모두 믿음직스러운(?) 대기업에서 제공하는 기술이지만
- FCM 이 조금 더 적용하기 쉬워보였고 (공식문서가 잘나와있음)
- 또, '무.료.' 라는 점 (아마존은 알림 1백만 개당 0.50 USD)
- 안드로이드와 ios 개발자분들이 친숙하다는 점
이 2가지 장점으로 FCM을 적용하기로 결정했습니다.
✔️ FCM 이란
- Firebase 클라우드 메시징의 줄임말입니다.
- FCM 은 메세지를 안정적이고 무료로 전송할 수 있는 크로스 플랫폼 메시징 솔루션입니다.
- Firebase는 2014년에 구글에 인수된 모바일 및 웹 어플리케이션 개발 플랫폼 서비스 회사입니다. (즉. 믿을만하다!)
✔️ FCM 푸시 동작과정
사실 공식문서에 너무 잘나와있습니다. 쉽게쉽게 정리만 해보겠습니다. (FCM 아키텍처 개요)
- Firebase 에서 제공하는 GUI 혹은 신뢰할 수 있는 서버 환경에서 Firebase Admin Sdk 나 FCM 서버 프로토콜을 사용하여 메세지를 FCM Backend 서버에 전송합니다.
- FCM Backend 서버에서는 메세지 요청이 들어오면 메시지 요청 수락, 메시지 메터데이터 생성 등 실질적인 Push 메세지 포맷팅을 해줍니다.
- 그런 다음 aos, ios, web 등의 풀랫폼 수준의 전송 레이어 기기로 메시지를 라우팅합니다.
- 사용자 기기의 FCM SDK 에서 알림이 표시됩니다.
👏🏻 여기서 사실상 저희가 push 알림을 위해 해줘야할 것은 "(1) → (2)" 으로 가는 메세지 요청을 생성해주는 것 뿐입니다.
FCM 에서 플랫폼 레이어에 따라 알아서 메세지를 적용해주기 때문에 개발자 입장에서는 매우 편해진 것이죠
✔️ FCM 메시지 유형
FCM 공식문서에 따르면 2가지 유형의 메시지 타입을 사용하여, 클라이언트에게 전송할 수 있습니다.
- 알림메세지
- 종종 '표시 메시지'로 간주
- 앱이 백그라운드로 실행 시 FCM SDK에서 자동 처리, 포그라운드에서 실행 중이면 앱의 코드에 따라 동작이 결정됩니다.
- 그렇기 때문인지 사용자에게 표시되는 키 모음이 정의되어있습니다.
- 데이터 메세지
- 클라이언트앱에서 처리
- 커스텀한 키-값 쌍만 포함됩니다.
→ 현재는 채팅 알림이 목적이므로, "알림메세지" 유형의 메세지 데이터타입을 사용하는게 좋아보입니다.
알림메세지 유형
- 정의된 키 모음 : https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages?hl=ko#Notification
{
"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 서버 프로토콜 → (원시 프로토콜들)
- 23.12 기준 HTTP v1 으로 마이그레이션 하는걸 권장한다고 합니다.
- 마이그레이션 참고 : https://firebase.google.com/docs/cloud-messaging/migrate-v1?hl=ko
→ 그러니 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차적인 과정이 있기 때문에, 실제 사용자가 조금만 생겨도 많은 양의 조회성 쿼리가 날아갈 수 도있겠다는 생각이 들었습니다
- 흠.. 이런 부분은 아무래도 같은 값을 불필요하게 조회는 부분이기 때문에 현재로서는 캐시를 적용하면 좋을 부분이라고 생각되어집니다.
일단은,,끝!
*참고
- 블로그 : https://kbwplace.tistory.com/179
- FCM 에서 권장하는 토큰 리프레시 모범사례 : https://firebase.google.com/docs/cloud-messaging/manage-tokens?hl=ko
- FCM 공식문서 : https://firebase.google.com/docs/cloud-messaging?hl=ko
- 아이폰 알림 (APNS) 정의된 키모음 : https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/generating_a_remote_notification