FCM이란 firebase cloud messaging의 줄임말로 메세지를 안정적으로 무료 전송할 수 있는 크로스 플랫폼 메시징 솔루션이다. 크로스 플랫폼 메시징이므로 플랫폼에 종속되지 않고 메세지를 전송할 수 있다.

 

메세지 종류로는 알림 메세지와 데이터 메세지가 있다.

알림 메세지 양식 - data 부분은 선택사항

{
  "message":{
    "token":"bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
    "notification":{
      "title":"Portugal vs. Denmark",
      "body":"great match!"
    },
    "data" : {
      "Nick" : "Mario",
      "Room" : "PortugalVSDenmark"
    }
  }
}

백그라운드 상태인 경우 알림 페이로드가 앱의 알림 목록에 수신되며 사용자가 알림을 탭한 경우에만 앱에서 데이터 페이로드를 처리한다. 포그라운드 상태인 경우 앱에서 페이로드가 둘 다 제공되는 메시지 객체를 수신한다.

 

데이터 메세지 양식

{
  "to": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
  "notification": {
    "body": "great match!",
    "title": "Portugal vs. Denmark",
    "icon": "myicon"
  }
}

 

카카오톡의 경우 대화창이 활성화 되어있는 Foreground 상태일 때는 FCM을 사용하지 않고 client와 서버간에 소켓 통신을 한다. 하지만 Background상태일 때는 FCM을 사용하여 디바이스에 메세지를 대신 전달해준다.

 

흐름도

1. 클라이언트에서 FCM에 등록 요청.

2. FCM 서버는 클라이언트에게 token 발급.

3. 클라이언트는 App Server(백엔드 서버)에 자신의 토큰과 topic을 전달하고 서버는 topic에 대해 클라이언트를 등록.

4. 다른 클라이언트에 대해 메세지가 전송되어 프론트에서 요청이 들어오면 App Server에서 이에 대해 FCM 서버에 전송.

5. FCM 서버는 AppServer에서 받은 요청에서 topic 안에 있는 클라이언트의 token으로 하여금 클라이언트를 식별하여 알림 전송.

여기서 주의할 점으로는 FCM에서 발급해주는 token은 디바이스에 대해 발급해주는 것이 아니라, 앱마다 발급해주는 것이므로 한 디바이스가 여러가지 token을 가질 수 있다. 이 token을 가지고 FCM 서버에서 앱을 식별한다.

cf)topic은 stomp에서의 topic과 같은 역할이다.

 

FCM 서버와 통신을 위한 신뢰할 수 있는 서버 환경의 요구 사항 

1. FCM에서 지정한 형식의 메시지 요청을 보낼 수 있어야 한다. token 대신 topic으로 대신 설정 가능

2. 지수 백오프를 사용하여 요청을 처리하고 다시 보낼 수 있어야 한다.

cf)지수 백오프 : 요청이 실패할 때마다 다음 요청까지의 유효시간 간격을 n배씩 늘리면서 재요청을 지연시키는 알고리즘. 연쇄 충돌을 방지하기 위함

3. 사용자의 인증 정보(인증된 서버임을 증명)와 클라이언트의 등록 토큰을 안전하게 저장할 수 있어야 한다.

 

FCM 서버와 상호 작용할 방법으로는 Firebase Admin SKD, FCM HTTP v1 APIm, 기존 HTTP 프로토콜, XMPP 서버 프로토콜와 같이 있으며, 여기서는 Firebase Admin SDK를 사용한다.

Firebase Admin SDK는 기기에서 topic 구독 및 구독 취소가 가능하고, 다양한 타겟 플랫폼에 맞는 메세지 페이로드를 구성할 수 있다. 또한 나머지 옵션과는 다르게, 초기화 작업만 잘 진행하면 구글과의 인증 처리를 자동으로 수행한다. 따라서 FCM에서 가장 권장하는 옵션이다.

 

이제 FCM을 구현해보겠다.

먼저 의존성을 추가해줘야하는데,

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

이 문구를 복사해서 넣어주면 된다.

 

Firebase Admin SDK를 사용하므로 Firebase 프로젝트를 생성 후 비공개 키를 생성해야 한다. 이 키를 resources 디렉토리에 저장한 이후 application-secrets.properties에 파일 위치를 저장 후 @Value를 통해 파일의 내용 불러와준다.

 

지수 백오프 사용을 위해 @EnableRetey 어노테이션 application 클래스에 추가

@EnableRetry
@SpringBootApplication
public class FcmstudyApplication {

    public static void main(String[] args) {
        SpringApplication.run(FcmstudyApplication.class, args);
    }

}

 

Firebase SDK를 초기화하여 Firebase 프로젝트와 연결하기

@Component
@PropertySource("classpath:application.properties")
public class FcmComponent {
    @Value("${fcm.key}")
    private String googleCredentials;

    //Bean 객체가 생성되고 의존성 주입이 완료된 후에 이 어노테이션이 붙어있는 메소드 실행
    //이 메소드가 애플리케이션 실행 시점에 실행되도록 보장하기 위한 것
    @PostConstruct
    public void init() throws IOException{
        //해당 파일 위치에 있는 파일의 내용 불러옴
        ClassPathResource resource = new ClassPathResource(googleCredentials);
        try(InputStream in = resource.getInputStream()){
            FirebaseOptions options = FirebaseOptions.builder()
                                //구글 API 사용하기 위한 구글 계정 인증 처리
                    .setCredentials(GoogleCredentials.fromStream(in))
                    .build();
            if(FirebaseApp.getApps().isEmpty()){
                //Firebase SDK 초기화하여 클라우드 메세징 기능 사용 활성화하기
                //FirebaseOptions-Credentials의 firebase 비공개 계정 키를 통해 firebase 프로젝트와 연결
                FirebaseApp.initializeApp(options);
            }
        }
    }
}

 

controller

@RestController
@RequiredArgsConstructor
public class FcmController {
    private final FcmService fcmService;

    @GetMapping("/test")
    public String test() {
        return "ok";
    }
    @PostMapping("/subscribe")
    public String subscribe(@RequestBody RequestSubscribe requestSubscribe) {
        fcmService.subscribeTopic(requestSubscribe);
        return "ok";
    }

    @DeleteMapping("/unsubscribe")
    public String unsubscribe(@RequestBody RequestUnSubscribe requestUnSubscribe) {
        fcmService.unsubscribeTopic(requestUnSubscribe);
        return "ok";
    }

    @PostMapping("/sendAlarm")
    public String sendalarm(@RequestBody RequestChatMessage requestChatMessage){
        fcmService.sendalarm(requestChatMessage);
        return "ok";
    }
}

 

service

@Service
public class FcmService {
    //recover는 최대 시도횟수를 지났을 때 실행할 메소드를 설정해주는 것이고
    //maxAttempts는 최대 시도횟수를 의미한다
    //마지막으로 backoff에서 delay는 지연시간을 의미하고 multipliter는 재시도마다 delay*multiplire의 값을 지연시간에 추가시켜준다.
    @Retryable(recover = "recover", maxAttempts = 5, backoff = @Backoff(delay = 1000, multiplierExpression = "T(java.lang.Math).pow(2, #currentRetryContext.retryCount) * 1000"))
    public void subscribeTopic(RequestSubscribe requestSubscribe) {
        List<String> tokens = Arrays.asList(requestSubscribe.getToken());
        try {
            //FirebaseApp을 통해 Messaging 기능 사용
            FirebaseMessaging.getInstance().subscribeToTopic(tokens, requestSubscribe.getTopic());
        } catch (FirebaseMessagingException e) {
            throw new RuntimeException(e);
        }
    }
    @Retryable(recover = "recover", maxAttempts = 5, backoff = @Backoff(delay = 1000,multiplierExpression = "T(java.lang.Math).pow(2, #currentRetryContext.retryCount) * 1000"))
    public void unsubscribeTopic(RequestUnSubscribe requestUnSubscribe) {
        List<String> tokens = Arrays.asList(requestUnSubscribe.getToken());
        try {
            FirebaseMessaging.getInstance().unsubscribeFromTopic(tokens, requestUnSubscribe.getTopic());
        } catch (FirebaseMessagingException e) {
            throw new RuntimeException(e);
        }
    }
    @Retryable(recover = "recover", maxAttempts = 5, backoff = @Backoff(delay = 1000,multiplierExpression = "T(java.lang.Math).pow(2, #currentRetryContext.retryCount) * 1000"))
    public void sendalarm(RequestChatMessage requestChatMessage) {
        //알림 메세지 형식
        Message message = Message.builder()
                .setNotification(Notification.builder()
                        .setTitle(requestChatMessage.getRoomName())
                        .setBody(requestChatMessage.getUserName()+":"+requestChatMessage.getChatMessage())
                        .build())
                .setTopic(requestChatMessage.getTopic())
                .build();
        send(message);
    }

    private void send(Message message) {
        FirebaseMessaging.getInstance().sendAsync(message);
    }
    
    @Recover
    private void recover() {
        System.out.println("실패");
    }
}

 

 

+ Recent posts