~☆~ 우하하!!~ 개발블로그

[SpringBoot] FCM 메시지 발송 본문

개발환경

[SpringBoot] FCM 메시지 발송

iwoohaha 2024. 11. 5. 09:29
반응형

Firebase 를 이용하여 앱에 푸시메시지를 발송하는 기능의 웹 프로젝트이다.

전제조건

Firebase 에 회원가입된 상태여야 한다.

Firebase 에 프로젝트를 생성하고, 해당 프로젝트의 모바일 앱(iOS 또는 Android) 이 개발된 상태여야 한다.

모바일 앱은 FCM 푸시 메시지 수신 기능이 구현되어 있어야 한다.

 

프로젝트 설정으로 이동한다.

 

서비스 계정 탭으로 이동한다.

 

Firebase Admin SDK 항목을 선택한다.

 

"새 비공개 키 생성" 버튼을 클릭하여 비공개 키를 생성한다.

 

"키 생성" 버튼을 클릭하면 파일이 다운로드된다.

 

다운로드된 파일은 learnfirebase-9fed8-firebase-adminsdk-6x1xl-c8415d6104.json 이다.

 

SpringBoot 프로젝트의 resources 디렉토리 아래에 firebase 라는 이름으로 디렉토리를 생성한 뒤에 이곳에 넣어주되, 파일의 이름은 learnfirebase-adminsdk-key.json 정도로 단순하게 변경해준다.

 

build.gradle 에 의존성 패키지를 추가한다.

 

위 정보는 Firebase Admin SDK 화면의 링크에서 얻었다.

 

우선 푸시메시지 데이터 객체부터 설계하자.

데이터 객체 클래스명은 MessageDto 로 한다.

 

푸시메시지를 위한 데이터 항목으로는 대상기기의 푸시토큰값, 푸시메시지의 제목과 본문내용 등 3가지 항목이다.

package com.woohahaapps.example.dto;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class MessageDto {
    private String pushToken;
    private String title;
    private String body;
}

 

푸시메시지를 발송하는 서비스 클래스를 구현한다.

package com.woohahaapps.example.service;

import com.woohahaapps.example.dto.MessageDto;
import org.springframework.stereotype.Service;

@Service
public class FcmPushService {

    public void sendMessage(MessageDto messageDto) {
        
    }
}

 

SpringBoot 프로그램에서 Firebase 를 연동하여 푸시메시지를 발송하기 위해서는 Firebase 가 제공하는 api 를 이용해야 한다.

FCM(Firebase Cloud Messaging) API 는 아래 링크에서 소개되고 있다.

https://firebase.google.com/docs/reference/fcm/rest?hl=ko

 

Firebase Cloud Messaging API  |  Firebase Cloud Messaging REST API

Firebase Cloud Messaging (FCM) is a cross-platform messaging solution that lets you reliably send messages at no cost.

firebase.google.com

 

서비스 엔드포인트는 https://fcm.googleapis.com 이다.

REST API 주소는 /v1/{parent=projects/*}/messages:send 이다.

데이터 전송 방식은 POST 이다.

 

REST API 리소스의 send 함수 링크를 클릭하면 {parent=projects/*} 를 어떻게 구성해야 하는지에 대한 방법이 있다.

https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send?hl=ko&_gl=1*15dp1es*_up*MQ..*_ga*OTE2MDM1NTE2LjE3MzA3Njk3Njg.*_ga_CW55HF8NVT*MTczMDc2OTc2OC4xLjAuMTczMDc2OTc2OC4wLjAuMA..

REST API 주소에서 {parent=projects/*} 부분은 Firebase Console 의 프로젝트 설정 - 일반 에서 구할 수 있다.

 

최종적인 REST API 주소는 https://fcm.googleapis.com/v1/projects/learnfirebase-9fed8/messages:send 이다.

요청 본문 데이터 구조는 해당 문서 아래쪽에 다음과 같이 설명되고 있다.

 

RestTemplate 를 이용한 동기식 연동 방법

RestTemplate 은 Spring에서 제공하는 HTTP 요청/응답을 처리하는 데 사용되는 클래스입니다. 주로 RESTful 웹 서비스와 상호작용하기 위해 사용되며, 외부 API와 통신하거나 서버 간 데이터를 주고받는 데 적합한 도구이다. RestTemplate을 사용하면 HTTP GET, POST, PUT, DELETE 요청을 쉽게 보낼 수 있고, JSON이나 XML과 같은 응답을 객체로 변환하여 받을 수 있다.

 

RestTemplate 의 여러 메소드 중에서 exchange 를 사용해서 요청을 보내고 ResponseEntity 로 응답을 받을 수 있다. HttpHeaders 와 HttpEntity 를 사용해 요청 헤더를 추가할 수 있어 좀 더 유연하게 사용할 수 있다.

exchange(String url, HttpMethod method, HttpEntity<?> requestEntity, Class<T> responseType, Object... uriVariables)

 

아래 코드는 RestTemplate 의 exchange 메소드를 사용하여 FCM 발송 API 에 접속하여 요청을 보내고 응답을 받는 내용의 코드이다.

    public void sendMessage(MessageDto messageDto) throws IOException {
        RestTemplate restTemplate = new RestTemplate();

        String message = makeMessage(messageDto);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + getAccessToken());

        HttpEntity<String> entity = new HttpEntity<>(message, headers);

        String API_URL = "https://fcm.googleapis.com/v1/projects/learnfirebase-9fed8/messages:send";
        ResponseEntity<String> response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class);

        System.out.println(response.getStatusCode());
    }

 

sendMessage 가 파라미터로 받는 MessageDto 는 대상 기기의 FCM 토큰, 푸시메시지의 제목과 본문 으로 구성된 데이터형이다.

makeMessage 함수를 이용하여 FCM 메시지 API 용 데이터 구조로 변환을 처리하고 있다.

HttpHeaders 를 이용하여 FCM 메시지 API 를 사용하는데 필요한 인증 절차를 처리하고 있다.

makeMessage 함수와 getAccessToken 함수의 내용은 다음과 같다.

makeMessage 함수에서는 Message 데이터형식의 데이터를 만들어내고 있는데,

이 데이터형식은 FcmMessageDto 클래스로 아래와 같이 먼저 정의해둔다.

package com.woohahaapps.example.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class FcmMessageDto {
    private boolean validateOnly;
    private FcmMessageDto.Message message;

    @Builder
    @AllArgsConstructor
    @Getter
    public static class Message {
        private FcmMessageDto.Notification notification;
        private String token;
    }

    @Builder
    @AllArgsConstructor
    @Getter
    public static class Notification {
        private String title;
        private String body;
        private String image;
    }
}

    /**
     * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다.
     *
     * @return Bearer token
     */
    private String getAccessToken() throws IOException {
        String firebaseConfigPath = "firebase/learnfirebase-adminsdk-key.json";

        GoogleCredentials googleCredentials = GoogleCredentials
                .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream())
                .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));

        googleCredentials.refreshIfExpired();
        return googleCredentials.getAccessToken().getTokenValue();
    }

    /**
     * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String)
     *
     * @param messageDto MessageDto
     * @return String
     */
    private String makeMessage(MessageDto messageDto) throws JsonProcessingException {

        ObjectMapper om = new ObjectMapper();
        FcmMessageDto fcmMessageDto = FcmMessageDto.builder()
                .message(FcmMessageDto.Message.builder()
                        .token(messageDto.getPushToken())
                        .notification(FcmMessageDto.Notification.builder()
                                .title(messageDto.getTitle())
                                .body(messageDto.getBody())
                                .image(null)
                                .build()
                        ).build()).validateOnly(false).build();

        return om.writeValueAsString(fcmMessageDto);
    }

 

FcmPushService 클래스의 전체 소스코드는 다음과 같다.

package com.woohahaapps.example.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.auth.oauth2.GoogleCredentials;
import com.woohahaapps.example.dto.FcmMessageDto;
import com.woohahaapps.example.dto.MessageDto;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.util.List;


@Service
public class FcmPushService {

    public void sendMessage(MessageDto messageDto) throws IOException {
        RestTemplate restTemplate = new RestTemplate();

        String message = makeMessage(messageDto);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + getAccessToken());

        HttpEntity<String> entity = new HttpEntity<>(message, headers);

        String API_URL = "https://fcm.googleapis.com/v1/projects/learnfirebase-9fed8/messages:send";
        ResponseEntity<String> response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class);

        System.out.println(response.getStatusCode());
    }

    /**
     * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다.
     *
     * @return Bearer token
     */
    private String getAccessToken() throws IOException {
        String firebaseConfigPath = "firebase/learnfirebase-adminsdk-key.json";

        GoogleCredentials googleCredentials = GoogleCredentials
                .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream())
                .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));

        googleCredentials.refreshIfExpired();
        return googleCredentials.getAccessToken().getTokenValue();
    }

    /**
     * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String)
     *
     * @param messageDto MessageDto
     * @return String
     */
    private String makeMessage(MessageDto messageDto) throws JsonProcessingException {

        ObjectMapper om = new ObjectMapper();
        FcmMessageDto fcmMessageDto = FcmMessageDto.builder()
                .message(FcmMessageDto.Message.builder()
                        .token(messageDto.getPushToken())
                        .notification(FcmMessageDto.Notification.builder()
                                .title(messageDto.getTitle())
                                .body(messageDto.getBody())
                                .image(null)
                                .build()
                        ).build()).validateOnly(false).build();

        return om.writeValueAsString(fcmMessageDto);
    }
}

 

FcmPushService 클래스의 테스트를 만들어서 푸시 메시지 발송을 테스트해보자.

package com.woohahaapps.example.service;

import com.woohahaapps.example.dto.FcmMessageDto;
import com.woohahaapps.example.dto.MessageDto;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
class FcmPushServiceTest {

    @Autowired
    private FcmPushService fcmPushService;

    @Test
    void sendMessage() throws IOException {
        MessageDto messageDto = MessageDto.builder()
                .pushToken("dnChR27YIEaSgLuGt59dsZ:APA91bF67edTpQ5o6h0ujg_kSQUzDPQvlMT8AZxcMo0MZIxsxY58M5kC4pqvWFPd_EXkGz8WY3_eC7LszbwRobWvTVf8nft8IqvF7-H2u1PfTy2LXnUQWGg")
                .title("테스트 메시지 제목입니다.")
                .body("테스트로 발송하는 메시지입니다.")
                .build();

        fcmPushService.sendMessage(messageDto);
    }
}

 

위와 같은 테스트코드로 기기에 설치한 앱에 푸시메시지가 정상적으로 들어오는 것을 확인하였다.

 

FcmPushService 에 작성한 또 다른 푸시메시지 발송 함수를 소개한다.

    public void sendNotification(MessageDto messageDto) {
        Notification notification = Notification.builder()
                .setTitle(messageDto.getTitle())
                .setBody(messageDto.getBody())
                .build();

        Message message = Message.builder()
                .setToken(messageDto.getPushToken())
                .setNotification(notification)
                .build();

        try {
            String response = FirebaseMessaging.getInstance().send(message);
            logger.info("Successfully sent message: {}", response);
        } catch (Exception e) {
            logger.error("An error occurred", e);
            logger.error("Failed to send message");
        }
    }

    public CompletableFuture<String> sendNotificationAsync(MessageDto messageDto) {
        Notification notification = Notification.builder()
                .setTitle(messageDto.getTitle())
                .setBody(messageDto.getBody())
                .build();

        Message message = Message.builder()
                .setToken(messageDto.getPushToken())
                .setNotification(notification)
                .build();

        // ApiFuture 를 CompletableFuture 로 변환
        return apiFutureToCompletable(FirebaseMessaging.getInstance().sendAsync(message))
                .thenApply(response -> "Successfully sent message: " + response)
                .exceptionally(e -> {
                    logger.error("An error occurred", e);
                    return "Failed to send message";
                });
    }

    // ApiFuture 를 CompletableFuture 로 변환하는 메서드
    private <T> CompletableFuture<T> apiFutureToCompletable(ApiFuture<T> apiFuture) {
        CompletableFuture<T> completableFuture = new CompletableFuture<>();
        apiFuture.addListener(() -> {
            try {
                completableFuture.complete(apiFuture.get());
            } catch (Exception e) {
                completableFuture.completeExceptionally(e);
            }
        }, Runnable::run);
        return completableFuture;
    }

 

FcmPushService 의 Full Source 는 아래와 같다.

package com.woohahaapps.example.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.api.core.ApiFuture;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.google.firebase.messaging.Notification;
import com.woohahaapps.example.dto.FcmMessageDto;
import com.woohahaapps.example.dto.MessageDto;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.io.IOException;
import java.util.List;
import java.util.concurrent.CompletableFuture;

@Slf4j
@Service
public class FcmPushService {

    private static final Logger logger = LoggerFactory.getLogger(FcmPushService.class);

    public void sendMessage(MessageDto messageDto) throws IOException {
        RestTemplate restTemplate = new RestTemplate();

        String message = makeMessage(messageDto);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + getAccessToken());

        HttpEntity<String> entity = new HttpEntity<>(message, headers);

        String API_URL = "https://fcm.googleapis.com/v1/projects/learnfirebase-9fed8/messages:send";
        ResponseEntity<String> response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class);

        System.out.println(response.getStatusCode());
    }

    /**
     * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다.
     *
     * @return Bearer token
     */
    private String getAccessToken() throws IOException {
        String firebaseConfigPath = "firebase/learnfirebase-adminsdk-key.json";

        GoogleCredentials googleCredentials = GoogleCredentials
                .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream())
                .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform"));

        googleCredentials.refreshIfExpired();
        return googleCredentials.getAccessToken().getTokenValue();
    }

    /**
     * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String)
     *
     * @param messageDto MessageDto
     * @return String
     */
    private String makeMessage(MessageDto messageDto) throws JsonProcessingException {

        ObjectMapper om = new ObjectMapper();
        FcmMessageDto fcmMessageDto = FcmMessageDto.builder()
                .message(FcmMessageDto.Message.builder()
                        .token(messageDto.getPushToken())
                        .notification(FcmMessageDto.Notification.builder()
                                .title(messageDto.getTitle())
                                .body(messageDto.getBody())
                                .image(null)
                                .build()
                        ).build()).validateOnly(false).build();

        return om.writeValueAsString(fcmMessageDto);
    }

    public void sendNotification(MessageDto messageDto) {
        Notification notification = Notification.builder()
                .setTitle(messageDto.getTitle())
                .setBody(messageDto.getBody())
                .build();

        Message message = Message.builder()
                .setToken(messageDto.getPushToken())
                .setNotification(notification)
                .build();

        try {
            String response = FirebaseMessaging.getInstance().send(message);
            logger.info("Successfully sent message: {}", response);
        } catch (Exception e) {
            logger.error("An error occurred", e);
            logger.error("Failed to send message");
        }
    }

    public CompletableFuture<String> sendNotificationAsync(MessageDto messageDto) {
        Notification notification = Notification.builder()
                .setTitle(messageDto.getTitle())
                .setBody(messageDto.getBody())
                .build();

        Message message = Message.builder()
                .setToken(messageDto.getPushToken())
                .setNotification(notification)
                .build();

        // ApiFuture 를 CompletableFuture 로 변환
        return apiFutureToCompletable(FirebaseMessaging.getInstance().sendAsync(message))
                .thenApply(response -> "Successfully sent message: " + response)
                .exceptionally(e -> {
                    logger.error("An error occurred", e);
                    return "Failed to send message";
                });
    }

    // ApiFuture 를 CompletableFuture 로 변환하는 메서드
    private <T> CompletableFuture<T> apiFutureToCompletable(ApiFuture<T> apiFuture) {
        CompletableFuture<T> completableFuture = new CompletableFuture<>();
        apiFuture.addListener(() -> {
            try {
                completableFuture.complete(apiFuture.get());
            } catch (Exception e) {
                completableFuture.completeExceptionally(e);
            }
        }, Runnable::run);
        return completableFuture;
    }
}

 

FcmPushService 의 함수를 테스트하는 코드를 아래와 같이 작성해서 모두 푸시메시지가 잘 들어오는 것도 확인하였다.

package com.woohahaapps.example.service;

import com.google.api.core.ApiFuture;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.Message;
import com.woohahaapps.example.dto.FcmMessageDto;
import com.woohahaapps.example.dto.MessageDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@SpringBootTest
class FcmPushServiceTest {

    @Autowired
    private FcmPushService fcmPushService;

    @Test
    void sendMessage() throws IOException {
        MessageDto messageDto = MessageDto.builder()
                .pushToken("dnChR27YIEaSgLuGt59dsZ:APA91bF67edTpQ5o6h0ujg_kSQUzDPQvlMT8AZxcMo0MZIxsxY58M5kC4pqvWFPd_EXkGz8WY3_eC7LszbwRobWvTVf8nft8IqvF7-H2u1PfTy2LXnUQWGg")
                .title("테스트 메시지 제목입니다.")
                .body("테스트로 발송하는 메시지입니다.")
                .build();

        fcmPushService.sendMessage(messageDto);
    }

    @Test
    void sendNotification() {
        MessageDto messageDto = MessageDto.builder()
                .pushToken("dnChR27YIEaSgLuGt59dsZ:APA91bF67edTpQ5o6h0ujg_kSQUzDPQvlMT8AZxcMo0MZIxsxY58M5kC4pqvWFPd_EXkGz8WY3_eC7LszbwRobWvTVf8nft8IqvF7-H2u1PfTy2LXnUQWGg")
                .title("테스트 메시지 제목입니다2222.")
                .body("테스트로 발송하는 메시지입니다2222.")
                .build();

        fcmPushService.sendNotification(messageDto);
    }

    @Test
    void sendNotificationAsync() throws ExecutionException, InterruptedException {
        // Arrange
        MessageDto messageDto = MessageDto.builder()
                .pushToken("dnChR27YIEaSgLuGt59dsZ:APA91bF67edTpQ5o6h0ujg_kSQUzDPQvlMT8AZxcMo0MZIxsxY58M5kC4pqvWFPd_EXkGz8WY3_eC7LszbwRobWvTVf8nft8IqvF7-H2u1PfTy2LXnUQWGg")
                .title("테스트 메시지 제목입니다3333.")
                .body("테스트로 발송하는 메시지입니다3333.")
                .build();

        // Act
        CompletableFuture<String> resultFuture = fcmPushService.sendNotificationAsync(messageDto);
        String result = resultFuture.get();  // Blocking get() for test simplicity
    }
}

 

반응형