티스토리 뷰

Firebase Cloud Messaging(FCM)은 백엔드 서비스나 서버간 통신을 위한 메시지 전달을 위해 Firebase에서 제공하는 무료 메시징 솔루션입니다. 이 글에서는 Spring Boot를 이용해서 FCM을 사용해 Android 앱에 푸시 알림을 보내는 방법에 대해 다루겠습니다.

 

왜 FCM을 사용했을까?
서버에서 어떤 데이터를 앱에 전달하려면 네트워크 연결은 필수입니다. 앱은 서버에서 데이터가 언제 송신될지 모르기 때문에 서비스 컴포넌트로 네트워크에 항상 연결되어 있어야 하는 것입니다. 문제는 백그라운드 제약 때문에 앱이 포그라운드 상황이 아니라면 서버와 연결을 지속할 수 없다는 것입니다.

하지만 클라우드 메시징은 서버의 데이터를 앱에 직접 전달하지 않고 FCM 서버를 거쳐서 앱에 전달하는 방식으로 서버와 앱이 네트워크 연결을 지속해서 유지하지 않아도 되며 앱이 포그라운드 상황이 아니라고 하더라도 데이터를 받을 수 있습니다.

* 포그라운드 상황: 앱이 켜저 있는 상태 즉 유저가 앱을 사용하고 있는 상태 *

 

먼저, Firebase 프로젝트를 생성하고 서비스 계정을 설정해야 합니다. 이 과정은 Firebase 공식 문서를 참조하세요. 설정이 완료되면, 다운로드 받은 JSON 키 파일을 프로젝트에 추가하고 경로를 저장해둡니다.

 

1. Firebase Configuration

✔️ 우선 application.yml에 다음을 추가해줍니다.

firebase:
  config:
    path: **firebasekey(json파일)위치**
    projectId: **firebase project id**

✔️ 아래는 Firebase 설정을 초기화하는 FcmConfig 클래스입니다.
firebaseApp 메서드에서 Firebase Options를 구성하고 FirebaseApp을 초기화합니다. 그 후 firebaseMessaging 빈을 반환하여 FCM 서비스를 사용할 수 있도록 합니다.

@Configuration
@Slf4j
public class FcmConfig {

    @Value("${firebase.config.path}")
    private String firebaseConfigPath;

    @Value("${firebase.config.projectId}")
    private String projectId;

    @Bean
    public FirebaseApp firebaseApp() {
        try {
            FileInputStream serviceAccount = new FileInputStream(firebaseConfigPath);

            FirebaseOptions options = FirebaseOptions.builder()
                    .setCredentials(GoogleCredentials.fromStream(serviceAccount))
                    .setProjectId(projectId)
                    .build();

            return FirebaseApp.initializeApp(options);
        } catch (FileNotFoundException e) {
            throw new IllegalStateException("파일을 찾을 수 없습니다."+e.getMessage());
        } catch (IOException e) {
            throw new InitializeException();
        }
    }

    @Bean
    public FirebaseMessaging firebaseMessaging() {
        try {
            return FirebaseMessaging.getInstance(firebaseApp());
        }catch (IllegalStateException e) {
            throw new MessagingException("FirebaseApp 초기화에 실패하였습니다." + e.getMessage());
        } catch (NullPointerException e) {
            throw new IllegalStateException("FirebaseApp을 불러오는데 실패하였습니다."+e.getMessage());
        } catch (Exception e) {
             throw new IllegalArgumentException("firebaseConfigPath를 읽어오는데 실패하였습니다."+e.getMessage());
        }
    }
}

 

2. DTO 

다음은 FCM에 메시지를 보내기 위한 DTO와 서비스 인터페이스입니다. FcmDto는 단일 기기나 다중 기기에 메시지를 전송하기 위한 요청 객체를 포함하고 있습니다.

public abstract class FcmDto {

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @ApiModel(description = "단일 기기 전송을 위한 요청 객체")
    public static class ToSingleRequest {
        @NotNull(message = "기기 등록 토큰을 입력해 주세요.")
        @ApiModelProperty(notes = "기기 등록 토큰을 입력해 주세요.")
        private String registrationToken;

        @NotNull(message = "알림 제목을 입력해 주세요.")
        @ApiModelProperty(notes = "알림 제목을 입력해 주세요.")
        private String title;

        @NotNull(message = "알림 내용을 입력해 주세요.")
        @ApiModelProperty(notes = "알림 내용을 입력해 주세요.")
        private String body;
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @ApiModel("여러 기기 전송을 위한 요청 객체")
    public static class ToMultiRequest {
        @NotNull(message = "기기 등록 토큰들을 입력해 주세요.")
        @ApiModelProperty(notes = "기기 등록 토큰들을 입력해 주세요.")
        private List<String> registrationToken;

        @NotNull(message = "알림 제목을 입력해 주세요.")
        @ApiModelProperty(notes = "알림 제목을 입력해 주세요.")
        private String title;

        @NotNull(message = "알림 내용을 입력해 주세요.")
        @ApiModelProperty(notes = "알림 내용을 입력해 주세요.")
        private String body;
    }

}

 

3. FCM Service 

서비스 인터페이스는 아래와 같습니다.

public interface FcmService {
    SingleFcmResponse sendSingleDevice(ToSingleRequest toSingleRequest);
    MultiFcmResponse sendMultipleDevices(ToMultiRequest toMultiRequest);
}

 

마지막으로, FCM 서비스 인터페이스의 구현체를 작성합니다. sendSingleDevice 메서드와 sendMultipleDevices 메서드는 각각 단일 기기와 다중 기기에 메시지를 전송합니다.

@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class FcmServiceImpl implements FcmService {

    private final FirebaseMessaging firebaseMessaging;

    @Override
    public SingleFcmResponse sendSingleDevice(ToSingleRequest toSingleRequest) {

        Message message = Message.builder()
                .setToken(toSingleRequest.getRegistrationToken())
                .setAndroidConfig(AndroidConfig.builder()
                        .setPriority(AndroidConfig.Priority.HIGH)
                        .setNotification(AndroidNotification.builder()
                                .setChannelId("FCM Channel")
                                .setTitle(toSingleRequest.getTitle())
                                .setBody(toSingleRequest.getBody())
                                .build())
                        .build())
                .build();

        try {
            String response = firebaseMessaging.send(message);
            return new SingleFcmResponse(response);
        } catch (FirebaseMessagingException e) {
            throw handleException(e);
        }
    }

    @Override
    public MultiFcmResponse sendMultipleDevices(ToMultiRequest toMultiRequest) {

        MulticastMessage message = MulticastMessage.builder()
                .addAllTokens(toMultiRequest.getRegistrationToken())
                .setAndroidConfig(AndroidConfig.builder()
                        .setPriority(AndroidConfig.Priority.HIGH)
                        .setNotification(AndroidNotification.builder()
                                .setChannelId("FCM Channel")
                                .setTitle(toMultiRequest.getTitle())
                                .setBody(toMultiRequest.getBody())
                                .build())
                        .build())
                .build();

        try {
            BatchResponse response = firebaseMessaging.sendMulticast(message);
            List<String> failedTokens = new ArrayList<>();

            if (response.getFailureCount() > 0) { // 실패한게 한개라도 있다면
                List<SendResponse> responses = response.getResponses();

                for (int i = 0; i < responses.size(); i++) {
                    if (!responses.get(i).isSuccessful()) {
                        //실패한 기기의 등록 토큰을 리스트에 추가
                        failedTokens.add(toMultiRequest.getRegistrationToken().get(i));
                    }
                }
            }

            String messageString = String.format("%d개의 메시지가 성공적으로 전송되었습니다.", response.getSuccessCount());

            return new MultiFcmResponse(messageString, failedTokens);

        } catch (FirebaseMessagingException e) {
            throw handleException(e);
        }
    }
 }

이 구현체는 FirebaseMessaging을 주입받아 메시지를 만들고, firebaseMessaging.send 메서드를 사용해 메시지를 전송합니다. 메시지 전송이 실패한 경우에는 handleException 메서드를 호출하여 예외를 처리합니다.

이러한 방식으로 Spring Boot에서 Firebase Cloud Messaging을 이용하여 Android 앱에 Push 알림을 보낼 수 있습니다.

 

❗️AndroidConfig를 이용해 Android에 특화된 설정을 추가했습니다.

안드로이드의 푸시 알림 시스템은 사용자의 환경(앱이 포그라운드에 있는지, 백그라운드에 있는지, 아니면 앱이 종료된 상태인지 등)에 따라 동작이 다르게 됩니다.

 

안드로이드의 AndroidConfig.Priority.HIGH 설정을 사용하면 알림의 우선순위를 높일 수 있습니다. 이는 시스템에 알림을 가능한 빠르게 전달하도록 지시하는 역할을 합니다. 이 우선순위가 높아질수록 알림은 배터리 사용량, 데이터 사용량, 시스템 자원에 더 많은 영향을 줄 수 있지만 사용자에게 더 빨리 도달하게 됩니다.

다음은 앱 상태별로 어떻게 동작하는지에 대한 간략한 설명입니다.

1️⃣ 포그라운드 상태의 앱 : 앱이 사용자에게 보이는 상태에서는 알림이 바로 앱 내부로 전달됩니다. 이 때, HIGH 우선순위 설정이 사용자에게 실시간으로 알림을 보여주는데 중요한 역할을 합니다. 앱이 이 알림을 받아 자체 UI를 통해 사용자에게 표시할 수 있습니다.

2️⃣ 백그라운드 상태의 앱 : 앱이 백그라운드 상태(사용자에게 보이지 않음)인 경우에도 HIGH 우선순위 알림은 시스템 UI를 통해 사용자에게 즉시 표시됩니다. 이는 일반적으로 상태 표시줄에 알림 아이콘이 표시되고, 알림 표시줄을 내리면 알림의 내용이 표시되는 방식으로 이루어집니다. 알림을 탭하면 앱이 포그라운드로 이동하게 됩니다.

3️⃣ 앱이 종료된 상태 : 앱이 완전히 종료된 상태에서도 HIGH 우선순위 알림은 시스템 UI를 통해 사용자에게 즉시 표시됩니다. 이는 백그라운드 상태의 앱과 동일하게 작동합니다.

 

알림 채널은 Android 8.0 이상에서 알림의 동작을 사용자가 더 세밀하게 조절할 수 있게 합니다. 사용자는 특정 채널의 알림을 전체적으로 끄거나, 알림의 중요도를 변경하거나, 알림 소리를 변경하는 등의 설정을 할 수 있습니다. 이러한 채널 설정은 앱별로, 심지어 앱 내의 특정 채널별로도 가능합니다. 이를 통해 사용자는 각 앱의 알림을 세밀하게 제어하며, 개발자는 사용자에게 제공할 알림을 더욱 세부적으로 분류하고 조절할 수 있게 됩니다.

이러한 특성들은 안드로이드의 푸시 알림 시스템이 강력하고 유연하게 동작할 수 있게 합니다. 이를 통해 개발자들은 사용자 경험을 향상시키며 앱의 기능을 최적화할 수 있습니다.

 

저는 구현해놓은 fcmService의 메서드를 호출해서 fcm push 알림을 구현했습니다!

공지를 생성할 때 '나와 같은 소모임 팀원에게' 알림을 보낼 때 이렇게 활용했습니다!

    @Override
    public void sendNewUploadNoticeAlarm(Notice notice, Long teamId, Long userId){
        Team team=teamValidationService.validateTeam(teamId);
        User loggedInUser=userRepository.findById(userId).orElseThrow(()->new NotFoundEmailException());

        //신규 업로드 알림이 true인지 확인
        if(loggedInUser.isNewUploadPush()){
            String title=team.getName()+" "+UPLOAD_NOTICE_NEW_TITLE.getTitle();
            String message=notice.getTitle();
            Optional<List<String>> fcmTokens=teamMemberService.getTeamMemberFcmToken(teamId, userId);
            if(fcmTokens.isPresent() && !fcmTokens.get().isEmpty()) {
                FcmDto.ToMultiRequest toMultiRequest = new FcmDto.ToMultiRequest(fcmTokens.get(), title, message);
                fcmService.sendMultipleDevices(toMultiRequest);
            }
        }
    }