본문 바로가기

Server/Spring

[Spring] Spring Boot에서 FCM으로 푸시알림 기능 구현하기

반응형
SMALL

 

글을 시작하기 전에

이번 포스팅에서는 진행 중인 프로젝트에서 푸시알림을 전송하기 위한 API/기능을 구현했던 과정을 소개하려고 한다.

개발을 막 시작했을 즈음, 주변에서 푸시알림은 너무 어렵다는 말을 적지 않게 들어와서, 나도 모르게 긴장을 좀 하고 있었던 것 같다. 다른 몇몇 분들도 푸시알람은 어렵다는 생각을 하고 있을지도 모른다. 하지만 워낙 관련 자료들도 많고, 활용 서비스로 선택한 Firebase의 공식 문서도 잘 되어있으니, 일단 해보니 크게 어렵지 않았다. 그럼 이제 시작해보자!

 


FCM

푸시알림을 전송하기 위해 FCM(Firebase Cloud Messaging) 서비스를 활용하기로 결정했다.

무료로 메시지를 전송할 수 있고, 많은 사용자들이 사용하고 있어 그 안정성이 보장되었을 거라 생각했기 때문이다. 많은 사용자들이 사용하고 있다면 관련 자료들도 많을 것이라 사용하지 않을 이유가 없다고 생각했다.

 

푸시알림 전송 흐름

푸시알림의 전송 흐름을 나타낸 이미지이다.

- 사용자가 (서비스에 가입하면) 디바이스 토큰을 얻어온다.

- 얻은 사용자의 디바이스 토큰을 통해 BE 쪽에서 FCM으로 푸시알림 전송을 요청한다.

- 전송 요청을 받은 FCM, 즉 알림 제공자는 디바이스 토큰이 가리키는 기기로 푸시알림을 전송하게 된다. (단, 모바일 앱 개발자가 미리 푸시알림을 받을 수 있도록 개발해주는 것이 필요하다.)

 

그렇다면, 백엔드의 기능에 집중해보자.

사용자의 디바이스 토큰을 가지고, FCM에게 푸시알림 전송 요청을 보내는 것이다.

서버 측에서는 해당 기능만 구현해주면 된다.

 

프로젝트 설정

먼저 프로젝트 설정과 FCM 관련 정보들이 필요하다.

 

Firebase 프로젝트 콘솔에 접속하여 새로운 프로젝트를 생성해준다. 프로젝트 추가를 눌러 설정해주면 추가된 프로젝트를 확인할 수 있다. 이미지에는 예전에 연습용으로 만든 프로젝트가 남아있다.

 

추가한 프로젝트로 접속하고, 왼쪽에 프로젝트 개요프로젝트 설정으로 들어가준다.

 

프로젝트 설정으로 들어오면, 정보들을 확인할 수 있다. 우리는 프로젝트 ID가 필요하니 복사하던가 해서 잘 기억해두자!

 

마찬가지로 프로젝트 설정에서 서비스 계정 탭으로 들어간다. 언어는 활용하는 자바를 선택했고, 새 비공개 키 생성을 누르면 필요한 json 파일이 발급된다. 잘 가지고 있도록 하자.

 

Firebase에서 프로젝트 설정은 끝났다.

 


Spring Boot

이제 본격적으로 FCM을 활용하여 푸시알림을 전송하는 기능을 구현할 차례이다.

프로젝트 초기에는 RestClient를 활용하여 FCM의 API를 호출하는 방식을 사용했다. 여기서 RestClient는 Spring Boot 기준 3.2.0 버전 이상부터 사용가능하고, RestTemplate을 사용해도 별 문제는 없다. (스프링 공식 문서에서 RestTemplate을 사용하지 않는 추세라 RestClient를 사용했을 뿐) 이후 리팩토링 과정에서 FCM 자체에서 제공하는 기능이 있어, 이를 활용하는 방식으로 변경했다.

포스팅에서는 2가지 방식을 모두 소개하고 있으니, 2가지 중 적절한 한 방식을 선택해주면 되겠다.

 

프로젝트 설정

먼저 의존성, 파일 배치, DTO 등 설정해줘야 한다.

 

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

필요한 의존성을 추가해준다.

 

다음으로 발급받은 json 파일을 프로젝트 내부에 위치시킨다. resources/firebase에 위치시켰다. 당연하지만, 해당 json 파일은 외부로 유출되면 안되니 잘 숨기도록 하자. 프로젝트에서는 .gitignore로 유출되지 않도록 하고, 외부 서비스에 저장시켜 배포 단계에서 파일을 새로 받아 배포하도록 했다.

 

fcm:
  file_path: ${json 파일 경로}
  url: https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send
  google_api: https://www.googleapis.com/auth/cloud-platform
  project_id: ${PROJECT_ID}

설정파일(application.yml)에는 다음과 같은 정보를 추가했다.

- file_path: json 파일이 위치한 경로이다.

- url: FCM으로 푸시알림 전송 요청을 보내기 위한 API URI이다. ${} 위치에 위에서 복사해둔 project_id를 넣어주면 된다.

- google_api: 발급받은 json을 통해 Access Token을 얻는 API URI이다.

- project_id: 위에서 복사한 project id 이다.

 

url과 google_api는 FCM의 API를 직접 호출하는 방식에서, project_id는 FCM 자체 기능을 활용하는 방식에서 사용하니 참고하자. (file_path는 둘 다 필요)

 

 


[1] FCM의 API 직접 호출하기

RestClient를 통해 FCM의 API를 직접 호출하는 방식을 적용해보겠다.

다시 말하지만, RestClient를 사용하기 위해서는 Spring Boot 기준 3.2.0 이상의 버전이 필요하고, 버전 업그레이드 없이 RestTemplate을 사용해도 무방하다.

 

DTO

데이터를 주고받기 위한 DTO가 필요하다.

 

@Builder(access = PRIVATE)
public record MessagePushServiceRequest(
        String targetToken,
        String title,
        String body
) {

    public static MessagePushServiceRequest of(String token, String title, String body) {
        return MessagePushServiceRequest.builder()
                .targetToken(token)
                .title(title)
                .body(body)
                .build();
    }
}

Service 계층에서 정보를 전달해야 하는 DTO이다.

사용자의 디바이스 토큰인 targetToken, 푸시알림 제목과 내용인 title, body를 담고있다.

 

@Builder(access = PRIVATE)
public record MessagePushRequest(
        boolean validateOnly,
        MessageRequest message
) {

    public static MessagePushRequest of(MessagePushServiceRequest request) {
        return MessagePushRequest.builder()
                .validateOnly(false)
                .message(MessageRequest.of(request))
                .build();
    }

    @Builder(access = PRIVATE)
    record MessageRequest(
            NotificationRequest notification,
            String token
    ) {

        private static MessageRequest of(MessagePushServiceRequest request) {
            return MessageRequest.builder()
                    .notification(NotificationRequest.of(request))
                    .token(request.targetToken())
                    .build();
        }
    }

    @Builder(access = PRIVATE)
    record NotificationRequest(
            String title,
            String body
    ) {

        private static NotificationRequest of(MessagePushServiceRequest request) {
            return NotificationRequest.builder()
                    .title(request.title())
                    .body(request.body())
                    .build();
        }
    }
}

FCM의 API로 요청을 전달할 DTO이다.

validate_only는 테스트 여부, message는 푸시알림의 정보이다.

MessageRequest로 내부에 record를 생성했다. notification은 푸시알림 전달 내용, token은 사용자의 디바이스 토큰이다.

NotificationRequest도 내부에 record로 생성했다. title은 푸시알림 제목, body는 푸시알림 내용 값이다.

더 자세한 API 내용은 FCM의 명세서를 확인하자.

 

Controller, Service

푸시알림 전송을 위한 프로젝트 내 REST API 틀을 마련해보자.

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/test")
public class TestController {
    private final TestService testService;

    @GetMapping("/alarm")
    public ResponseEntity<SuccessResponse<?>> alarmTest(Principal principal) {
        val memberId = getMemberId(principal);
        testService.pushTest(memberId);
        return ApiResponseUtil.success(SUCCESS_SEND_PUSH_ALARM);
    }
    
    private long getMemberId(Principal principal) {
    	if (principal == null || principal.getName() == null) {
        	throw new RuntimeException("유효하지 않은 토큰");
        }
        return Long.parseLong(principal.getName());
    }
}

 

Controller 계층이다. 프로젝트의 토큰 값을 받아 memberId 값을 Service 계층으로 넘긴다.

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TestService {

    private final MemberRepository memberRepository;
    private final FcmService fcmService;

    public void pushTest(long memberId) {
        val member = findMember(memberId);
        val title = "푸시알림 제목";
        val body = "푸시알림 내용";
        fcmService.pushMessage(MessagePushServiceRequest.of(member.getFcmToken(), title, body));
    }

    private Member findMember(long id) {
        return memberRepository.findById(id)
                .orElseThrow(() -> new MemberException(INVALID_MEMBER));
    }
}

Service 계층이다. 전달받은 memberId로 member 데이터를 조회하고, member의 디바이스 토큰과 푸시알림 제목, 내용을 위에서 만든 RequestDTO에 받아 fcmService로 전달한다. (해당 프로젝트는 DB의 member 테이블에 디바이스 토큰을 저장하고, FCM의 API를 호출하는 기능을 FcmService 파일을 별도로 만들어뒀다.)

 

FcmService

FCM의 REST API를 호출하는 기능을 가지고 있는 클래스이다.

 

@Service
@RequiredArgsConstructor
public class FcmService {

    private final ObjectMapper objectMapper;
    
    @Value("${fcm.file_path}")
    private String FIREBASE_CONFIG_PATH;
    
    @Value("${fcm.url}")
    private String FIREBASE_API_URI;
    
    @Value("${fcm.google_api}")
    private String GOOGLE_API_URI;

    public void pushMessage(final MessagePushServiceRequest request) { // 푸시알림 전송
        val restClient = RestClient.create();
        restClient.post()
                .uri(FIREBASE_API_URI) // 요청할 FCM의 REST API
                .contentType(APPLICATION_JSON)
                .body(makeMessage(request))
                .header(AUTHORIZATION, "Bearer " + getAccessToken())
                .header(ACCEPT, "application/json; UTF-8")
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (fcmRequest, fcmResponse) -> {
                    throw new FcmException(INVALID_REQUEST_MESSAGE, fcmResponse.getStatusCode());
                })
                .onStatus(HttpStatusCode::is5xxServerError, (fcmRequest, fcmResponse) -> {
                    throw new FcmException(INVALID_REQUEST_URI, fcmResponse.getStatusCode());
                })
                .toBodilessEntity();
    }

    private String makeMessage(MessagePushServiceRequest request) { // 푸시알림 요청 값 생성
        try {
            val message = MessagePushRequest.of(request);
            return objectMapper.writeValueAsString(message);
        } catch (JsonProcessingException exception) {
            throw new FcmException(INVALID_REQUEST_PATTERN);
        }
    }

    private String getAccessToken() { // FCM의 API에 요청하기 위한 Access Token 발급
        try {
            val googleCredentials = GoogleCredentials
                    .fromStream(new ClassPathResource(FIREBASE_CONFIG_PATH).getInputStream())
                    .createScoped(List.of(GOOGLE_API_URI));
            googleCredentials.refreshIfExpired();
            return googleCredentials.getAccessToken().getTokenValue();
        } catch (IOException exception) {
            throw new FcmException(INVALID_REQUEST_PATTERN);
        }
    }
}

해당 흐름은 다음과 같다.

FCM에서 제공하는 REST API에 Access Token을 활용하여 인증 받고, 요청 값을 전송하여 디바이스 토큰 대상으로 푸시알림이 전송되도록 한다. pushMessage에서 RestClient를 통해 API 요청을 보내고 있고, 그 과정에서 Request 값과 Access Token을 각각 만들고 발급받아 추가해주고 있다.

 

끝이다! 직접 테스트해보면 REST API 결과도 200으로 뜰 것이고, 앱 모바일 개발 설정만 잘 되어있다면 푸시알림도 잘 전송될 것이다.

 


[2] FirebaseMessaging 사용하기

푸시알림 전송의 다양한 기능을 제공하는 FCM에서 제공하는 클래스이다. 자세한 내용은 Firebase의 공식 문서를 참고하자.

Config(설정) 클래스 하나만 잘 만들어두면 편리하게 사용할 수 있다. 해당 프로젝트에서는 여러 개의 푸시알림을 전송해야 하는 기능도 필요했는데, 기존에는 위 방식을 사용하여 for문으로 전송해주고 있었다. 하지만 이렇게 가다보면 외부 API의 문제로 누락된 푸시알림도 발생할 수 있을 것이라 판단했다. 이를 위해 좀 더 찾아보다가 여러 개의 요청을 처리하는 기능을 포함한 FirebaseMessaging 클래스를 찾고만 것이다! 바로 적용해보자.

 

Config

@Configuration
public class FcmConfig {

    private final ClassPathResource firebaseResource;
    private final String projectId;

    public FcmConfig(
            @Value("${fcm.file_path}") String firebaseFilePath,
            @Value("${fcm.project_id}") String projectId
    ) {
        this.firebaseResource = new ClassPathResource(firebaseFilePath);
        this.projectId = projectId;
    }

    @PostConstruct
    public void init() throws IOException {
        val option = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(firebaseResource.getInputStream()))
                .setProjectId(projectId)
                .build();

        if (FirebaseApp.getApps().isEmpty()) {
            FirebaseApp.initializeApp(option);
        }
    }

    @Bean
    FirebaseMessaging firebaseMessaging() {
        return FirebaseMessaging.getInstance(firebaseApp());
    }

    @Bean
    FirebaseApp firebaseApp() {
        return FirebaseApp.getInstance();
    }
}

FirebaseMessaging을 활용하기 위한 Config 클래스를 먼저 생성해준다.

 

DTO

리팩토링을 병행했기에, DTO도 살짝 바꿔줬다. 좀 더 직관적인 네이밍을 위해 Notification 워딩을 사용했다.

 

public interface NotificationRequest {
    String title();
    String body();
    Notification toNotification();
}

푸시알림 요청을 단일로도, 다중으로도 보낼 수 있어야 하기 때문에 인터페이스를 사용했다.

 

@Builder(access = PRIVATE)
public record NotificationSingleRequest(
        @NonNull String targetToken,
        String title,
        String body
) implements NotificationRequest {

    public static NotificationSingleRequest of(String token, String title, String body) {
        return NotificationSingleRequest.builder()
                .targetToken(token)
                .title(title)
                .body(body)
                .build();
    }

    public Message.Builder buildMessage() {
        return Message.builder()
                .setToken(targetToken)
                .setNotification(toNotification());
    }

    public Notification toNotification() {
        return Notification.builder()
                .setTitle(title)
                .setBody(body)
                .build();
    }
}

단일 요청을 위한 DTO이다. 해당 내용은 위와 별반 다르지 않으므로 넘어가겠다.

 

@Builder(access = PRIVATE)
public record NotificationMulticastRequest(
        @NonNull List<String> targetTokens,
        String title,
        String body
) implements NotificationRequest {

    public static NotificationMulticastRequest of(List<String> tokens, String title, String body) {
        return NotificationMulticastRequest.builder()
                .targetTokens(tokens)
                .title(title)
                .body(body)
                .build();
    }

    public MulticastMessage.Builder buildSendMessage() {
        return MulticastMessage.builder()
                .setNotification(toNotification())
                .addAllTokens(targetTokens);
    }

    public Notification toNotification() {
        return Notification.builder()
                .setTitle(title)
                .setBody(body)
                .build();
    }
}

다중 요청을 위한 DTO이다. 다중으로 보내기 때문에 여러 개의 디바이스 토큰을 리스트 형태로 갖고 있다.

 

FcmNotificationService

위의 FcmService와 같은 역할의 클래스이다. 직관적인 네이밍을 위해 Notification을 추가했다.

 

@Service
@RequiredArgsConstructor
public class FcmNotificationService {

    private final FirebaseMessaging firebaseMessaging;

    public void sendMessage(final NotificationSingleRequest request) {
        try {
            val message = request.buildMessage().setApnsConfig(getApnsConfig(request)).build();
            firebaseMessaging.sendAsync(message);
        } catch (RuntimeException exception) {
            throw new FcmException(FCM_SERVICE_UNAVAILABLE, exception.getMessage());
        }
    }

    public void sendMessages(final NotificationMulticastRequest request) {
        try {
            val messages = request.buildSendMessage().setApnsConfig(getApnsConfig(request)).build();
            firebaseMessaging.sendMulticastAsync(messages);
        } catch (RuntimeException exception) {
            throw new FcmException(FCM_SERVICE_UNAVAILABLE, exception.getMessage());
        }
    }

    private ApnsConfig getApnsConfig(NotificationRequest request) {
        val alert = ApsAlert.builder().setTitle(request.title()).setBody(request.body()).build();
        val aps = Aps.builder().setAlert(alert).setSound("default").build();
        return ApnsConfig.builder().setAps(aps).build();
    }
}

FirebaseMessaging에서 제공하는 메서드를 활용함으로써 코드가 간결해졌다.

sendMessage 메서드에서는 FirebaseMessaging의 sendAsync 메서드를 통해 단일 전송을,

sendMessages 메서드에서는 FirebaseMessaging의 sendMulticastAsync 메서드를 통해 다중 전송을 보내도록 했다.

getApnsConfig 메서드를 통해 요청 메시지 값을 생성한다. 또한, 외부 API에서 발생한 예외는 파악할 수 있어야하므로 try-catch로 잡도록 했다.

 

 

추가로, 리팩토링한 Controller와 Service도 간략하게 소개하겠다.

 

Service

푸시알림을 테스트하기 위한 서비스 클래스이다.

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TestService {

    private final MemberRepository memberRepository;

    private final NotificationService notificationService;
    private final ValueConfig valueConfig;

    public void sendMessage(final TestPushAlarmServiceRequest request) {
        val member = findMember(request.memberId());
        val title = valueConfig.getNOTIFICATION_TITLE();
        val body = valueConfig.getNOTIFICATION_BODY();
        notificationService.sendMessage(NotificationSingleRequest.of(member.getFcmToken(), title, body));
    }

    private Member findMember(long id) {
        return memberRepository.findById(id)
                .orElseThrow(() -> new MemberException(INVALID_MEMBER));
    }
}

푸시알림의 제목, 내용 값은 ValueConfig라는 클래스에 모아두었다.

단일 전송이므로 sendMessage 메서드를 호출했다.

 

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v2/test")
public class TestApiController {

    private final TestService testService;

    @GetMapping("/alarm")
    public ResponseEntity<SuccessResponse<?>> sendMessage(Principal principal) {
        val memberId = PrincipalConverter.getMemberId(principal);
        testService.sendMessage(TestPushAlarmServiceRequest.of(memberId));
        return ApiResponseGenerator.success(SUCCESS_SEND_PUSH_ALARM);
    }
}

위와 거의 동일하다. 하나의 차이는, Controller와 Service 게층 간에 DTO를 하나 추가함으로써 계층의 분리를 강화시켰다. DTO로 한 번 더 감싸서 memberId를 넘겨주는 것으로 이해해주면 되겠다.

 


테스트

이렇게 해서 FCM을 활용한 푸시알림 전송 보내기 기능 구현이 완료되었다. API 테스트를 통해 잘 실행되는 지 확인하자.

현재 해당 프로젝트는 iOS 앱 쪽에서만 푸시알림이 정상적으로 실행되고 있고, 안드로이드 앱 쪽에서는 해당 관련하여 추가적인 개발이 필요한 상황이다. 나의 폰 기종은 안드로이드 폰이므로 당장 테스트할 수는 없다 ㅠㅠ (물론 배포 전에 iOS 개발자 팀원과 충분한 테스트는 마쳤다.) 안드로이드 이슈도 해결되면 후에 테스트한 결과를 추가하도록 하겠다.

 

역시 하면 된다는 것을 해당 과정을 통해 깨달을 수 있었고, 직접 API 호출부터 FirebaseMessaging 기능 활용까지 다양하게 접할 수 있어 좋았다. 리팩토링 과정을 통해 이를 포함한 다양한 경험이 가능했던 것 같다. 앞으로도 지속적으로 리팩토링 할 계획이다.

 

작업한 PR 및 프로젝트 레포지토리를 아래에 공유하면서 글을 마치도록 하겠다. 안뇽!

 

 

[REFACTOR] FCM 외부 API 호출 인터페이스 분리 및 기타 리팩토링 by thguss · Pull Request #239 · Team-Smeme/Sme

Related issue 🚀 closed #237 Work Description 💚 인터페이스 분리 FirebaseMessaging 기능 활용 (RestClient 미사용) 푸시알림 전송 Single, Multicast 분리 (비동기로 속도 향상) TrainingTimeRepository -> MemberRepository 쿼리

github.com

FCM 푸시알림 전송 기능 구현 PR

 

 

GitHub - Team-Smeme/Smeme-server-renewal: 스밈 서버에도 봄은 온다 🍃

스밈 서버에도 봄은 온다 🍃. Contribute to Team-Smeme/Smeme-server-renewal development by creating an account on GitHub.

github.com

프로젝트 스밈 Repository

 


References

https://seungwoolog.tistory.com/88

https://velog.io/@leesomyoung/Spring-Boot-FCM-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B02

https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages/send?hl=ko

https://firebase.google.com/docs/reference/android/com/google/firebase/messaging/FirebaseMessaging

https://okky.kr/articles/581650

https://musma.github.io/2023/09/06/FCM-알아보기.html

반응형
LIST