Home Spring Boot FCM 적용 삽질 일기
Post
Cancel

Spring Boot FCM 적용 삽질 일기

  • FCM 이란?

  • FCM 적용 방법


FCM 이란?

  • Firebase 클라우드 메시징(FCM)은 비용 없이 안정적으로 메시지를 보낼 수 있는 플랫폼 간 메시징 솔루션입니다.

라고 공식문서에서는 말한다. 좀 딱딱해 보이니 더 간단하게 한 줄로 요약하면

Web, Android, iOS 등 여러 플랫폼에 메시지 보내주는 서비스이다.

FCM-Architecture

FCM의 구조는 다음과 같다.

저 그림이 좀 쉽게 이해가 되지 않아서 글로 간단하게 나타내자면 다음과 같다.

  • Web, Android, iOS (이하 클라이언트)에서 Backend와 소통하는 중에 Backend에게 본인이 원하는 대상들에게 어떤 메세지를 보내고 싶다고 요청을한다.

  • Backend는 이 요청을 내부적으로 처리하고 FCM에게 요청을 이관한다.

  • FCM 은 등록된 클라이언트에게 Backend에게 이관받은 메세지를 뿌려준다.

그러면 메세지는 어떻게 생겼는가?

아래와 같은 json형식으로 메세지를 만들면된다.

1
2
3
4
5
6
7
8
9
{
    "message": {
        "token": "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1...",
        "notification": {
            "title": "Portugal vs. Denmark",
            "body": "great match!"
        }
    }
}

여기서 token은 클라이언트 플랫폼에서 각 기기마다의 정보를 바탕으로 고유한 token을 발급받아야 하는 것이다.

notification은 title, body로 이루어져 있어서 말 그대로 메세지의 제목, 내용을 담아 보내면 되는 것이다.


FCM 토큰 사용하기

기본 모범 사례

  • 서버에 등록 토큰을 저장한다.

  • 서버의 역할은 각 클라이언트의 토큰을 추적하고 업데이트된 활성 토큰 목록을 유지하는 것이다. (DB에 담거나 캐시에 담거나 등 서버에서 토큰을 관리해야한다.)

  • 코드와 서버에 토큰 타임스탬프를 구현하고 정기적으로 이 타임스탬프를 업데이트하는 것이 좋다.

  • 오래되어 저장된 토큰을 제거해야한다.

  • 잘못된 토큰 응답의 명백한 경우 토큰을 제거하는 것 외에, 토큰이 만료된 상황 또한 고려해야한다.

등록된 토큰 검색 및 저장

  • 앱을 처음 시작할 때 FCM SDK는 클라이언트 앱 인스턴스에 대한 등록 토큰을 생성한다.

  • 이는 API의 대상 전송 요청에 포함하거나 주제 대상 지정을 위해 주제 구독에 추가해야 하는 AccessToken 이다.

  • 앱은 초기 시작 시 이 토큰을 검색하고 타임스탬프와 함께 앱 서버에 저장해야 한다.

  • 이 타임스탬프는 FCM SDK에서 제공하지 않으므로 코드와 서버에서 구현해야 한다.

  • 또한 토큰을 서버에 저장하고 다음과 같이 변경될 때마다 타임스탬프를 업데이트해야한다.

    • 앱이 새 기기에서 복원되는 상황
    • 사용자가 앱을 제거 -> 재설치 하는 상황
    • 사용자가 앱 데이터를 지우는 상황

코드로 적용해보기

0. FCM 전역 설정정보 추가

YML 파일 추가

1
2
3
fcm:
  key: firebase_key.json
  project-id: ~~~
1
2
3
4
5
6
7
spring:
  profiles:
    include:
      - jpa
      - jwt
      - aws
      - fcm

resourced 폴더에 FCM 사용자 key file 넣어주기

key file은 json 타입으로 다음과 유사하게 생긴 내용이 담긴 파일이다.

1
2
3
4
5
6
7
8
9
10
11
12
{
  "type": "service_account",
  "project_id": "~~~",
  "private_key_id": "~~~",
  "private_key": "~~~",
  "client_email": "~~~",
  "client_id": "~~~",
  "auth_uri": "~~~",
  "token_uri": "~~~",
  "auth_provider_x509_cert_url": "~~~",
  "client_x509_cert_url": "~~~"
}

1. 의존성 추가

1
2
3
4
5
6
7
8
9
10
dependencies {

    ...

	implementation 'com.google.firebase:firebase-admin:6.8.1'
	implementation group: 'com.squareup.okhttp3', name: 'okhttp', version: '4.2.2'

    ...
}

2. Fcm 설정정보 등록하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import java.io.IOException;
import java.io.InputStream;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

@Configuration
public class FcmConfig {

    @Value("${fcm.key}")
    private String secretKey;

    @Value("${fcm.project-id}")
    private String projectId;

    @Bean
    public FirebaseApp firebaseApp() {
        final ClassPathResource resource = new ClassPathResource(secretKey);
        try (InputStream stream = resource.getInputStream()) {
            final FirebaseOptions firebaseOptions = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(stream))
                .setProjectId(projectId)
                .build();
            return FirebaseApp.initializeApp(firebaseOptions);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

3. FCM 메세지 폼 지정하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.usw.sugo.domain.user.user.User;
import lombok.Getter;

@Getter
public class FcmMessage {

    private final User user;
    private final String title;
    private final String body;

    public FcmMessage(User user, String title, String body) {
        this.user = user;
        this.title = title;
        this.body = body;
    }
}

4. FCM 푸쉬 서비스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import static com.usw.sugo.global.exception.ExceptionType.INTERNAL_PUSH_SERVER_EXCEPTION;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.messaging.FirebaseMessaging;
import com.google.firebase.messaging.FirebaseMessagingException;
import com.google.firebase.messaging.MulticastMessage;
import com.google.firebase.messaging.Notification;
import com.usw.sugo.domain.user.user.User;
import com.usw.sugo.global.exception.CustomException;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class FcmPushService {

    private final FirebaseApp firebaseApp;

    @Value("${fcm.key}")
    private String secretKey;

    @Value("${fcm.project-id}")
    private String projectId;

    private final String CONFIG_PATH = secretKey; // 토큰 발급 URL
    private final String AUTH_URL = "https://www.googleapis.com/auth/cloud-platform"; // 엔드포인트 URL
    private final String SEND_URL =
        "https://fcm.googleapis.com/v1/projects/" + projectId + "/messages:send";

    private String getAccessToken() throws IOException {
        GoogleCredentials googleCredentials = GoogleCredentials.fromStream(
            new ClassPathResource(CONFIG_PATH).getInputStream()).createScoped(List.of(AUTH_URL));
        googleCredentials.refreshIfExpired();
        return googleCredentials.getAccessToken().getTokenValue();
    }

    @Async
    public void sendPushNotification(FcmMessage fcmMessage) {
        try {
            extractUserTokenByPushAlarmAllowed(fcmMessage.getUser());
        } catch (NullPointerException exception) {
            return;
            // throw new CustomException(INTERNAL_PUSH_SERVER_EXCEPTION_BY_TOKEN_NOT_ACCESSIBLE);
        }
        MulticastMessage multicastMessage = MulticastMessage.builder()
            .setNotification(new Notification(fcmMessage.getTitle(), fcmMessage.getBody()))
            .addAllTokens(
                Collections.singletonList(extractUserTokenByPushAlarmAllowed(fcmMessage.getUser())))
            .build();
        try {
            FirebaseMessaging.getInstance(firebaseApp).sendMulticast(multicastMessage);
        } catch (FirebaseMessagingException e) {
            throw new CustomException(INTERNAL_PUSH_SERVER_EXCEPTION);
        }
    }

    private String extractUserTokenByPushAlarmAllowed(User user) {
        if (user.getPushAlarmStatus()) {
            return user.getFcmToken();
        }
        return null;
    }
}

5. PushService 적용하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    @Transactional
    public String executeSendNoteContent(
        Note note, String message, Long senderId, Long receiverId) {

        User sender = userServiceUtility.loadUserById(senderId);
        User receiver = userServiceUtility.loadUserById(receiverId);

        saveNoteContentByText(note, message, sender, receiver);
        note.updateRecentContent(message);
        note.updateUserUnreadCountBySendMessage(sender);

        fcmPushService.sendPushNotification(new FcmMessage(receiver, fixedPushAlarmTitle, message));
        return message;
    }

임시로 완성되지 않은 iOS 애뮬레이터에서 AccessToken을 발급받고 DB에 삽입한 후

위 코드를 실행시키는 API를 요청했을 때 정상적으로 push 알림이 가는 것을 확인했다.

이제 남은 것은 클라이언트에게 각 유저마다의 FCM AccessToken을 발급받아 서버에서 관리하는 내용만 만들면 된다.

이 부분은 아직 클라이언트 팀원들과 다루지 않은 내용이므로 실제로 적용하는 방법을 추가할 예정이다.

This post is licensed under CC BY 4.0 by the author.