본문 바로가기
Backend/Spring

[Spring/Java] FCM을 통해 Push 알림 보내기

by persi0815 2024. 7. 8.

프로젝트 마지막 즈음에 push 알림 기능을 추가했다. 기획 의도와 잘 맞아서 좋았지만, 개발 기간이 짧아서 다양한 방법들 중에 fcm을 선택했다. 

 

FCM이란? 

Firebase Cloud Messaging은 Google의 Firebase 플랫폼에서 제공하는 클라우드 메시징 서비스로, 개발자는 이를 통해 간편히 안드로이드, iOS, 웹 애플리케이션에 푸시 알림을 보낼 수 있다. 

장점
- Firebase 플랫폼과 통합되어 있어 설정과 사용이 간편하다. Firebase 콘솔에서 설정을 쉽게 관리할 수 있다. 
- 안드로이드, iOS, 웹을 포함한 여러 플랫폼에서 사용 가능하다. 
- Google의 인프라를 기반으로 하여, 대량의 메시지를 안정적으로 처리할 수 있다. 
- FCM은 무료로 제공되며, 추가 비용 없이 사용할 수 있다. 
- 주제 기반 메시징, 메시지 우선순위 설정, 다양한 메시지 타입 지원(알림 메시지, 데이터 메시지) 등 다양한 기능을 제공한다. 

단점
- 메시지 전송 및 전달 상태에 대한 세부적인 제어가 제한적이다. ex) 재시도 로직에 대한 상세한 제어가 어렵다. 
- FCM은 Google 서비스에 의존적이므로, Google 서비스가 제한된 지역에서는 사용이 어려울 수 있다. 
- 사용자 기기에서 푸시 알림을 비활성화하면, 메시지를 전달할 수 없다. 
- 고급 기능을 구현하기 위해 서버 측에서 추가적인 로직을 처리해야 할 수 있다. 

 

공식문서

https://firebase.google.com/docs/cloud-messaging?hl=ko

 

Firebase 클라우드 메시징

Firebase 클라우드 메시징(FCM)은 무료로 메시지를 안정적으로 전송할 수 있는 크로스 플랫폼 메시징 솔루션입니다.

firebase.google.com


FCM 초기 세팅

https://velog.io/@dionisos198/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%91%B8%EC%8B%9C-%EC%95%8C%EB%A6%BC-%EA%B5%AC%ED%98%84%EC%98%88%EC%A0%9CFCM-%EC%82%AC%EC%9A%A9

 

스프링- 푸시 알림 구현예제(FCM 사용)

나는 푸시 알림 구현을 공부하기 전에는 왜 필요한지 이해하지 못했다.아니 내가 8시마다 똑같은 공지를 받는 것을 서버가 뭘 해줘야 해?그냥 클라이언트 분들이 알아서 8시 되면 공지를 띄우면

velog.io

여기에 너무 잘 정리되어있다! 따라가보자~


FCM 알림 서버 작동 방식

https://maejing.tistory.com/entry/Android-FCM%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-Push-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

 

 

1. 앱 등록 및 토큰 발급: 앱이 처음 실행될 때 FCM SDK는 디바이스를 FCM 서버에 등록하고 고유한 등록 토큰을 발급받는다. 이 토큰은 디바이스를 식별하는 데 사용된다. 

* 프론트엔드에서 삽입했던 아래와 같은 google-services.json 파일(안드로이드) 때문에 위와 같은 과정이 가능하다. 

 

2. 서버에 토큰 저장: 앱 서버는 디바이스 토큰을 수집하여 저장해야 한다. 우리 서비스의 경우, 한 사용자당 한 디바이스를 등록할 수 있었기에 회원가입 완료 시 fcm 토큰을 프론트엔드에게 받아 user DB에 저장하여 사용을 용이하게 했다. 해당 토큰을 통해 특정 디바이스에 푸시 알림을 보낼 수 있다.

 

3. 서버에서 FCM 서버로 메시지 전송: 요청이 들어오면 앱 서버는 다음 과정을 거쳐 FCM 서버에 메시지를 전송한다. 

첫째, 서버는 대상 디바이스의 토큰과 메시지 데이터를 포함한 메시지를 구성한다

둘째, 서버는 Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급받는다. 

셋째, 발급받은 Bearer 토큰을 사용하여 FCM 서버에 인증된 요청을 보낸다. 

 

* Bearer 토큰은 Firebase 프로젝트의 서버 키(=비공개 키)로부터 생성된 JWT(Json Web Token)로, HTTP 헤더에 포함된다.

* 백엔드에서 삽입했던 아래와 같은 json 형식의  Firebase Admin SDK의 비공개 키가 여기서 사용되는 것이다. 

* 강조하지만, 프론트의 json 파일과 백엔드의 json 파일은 다르다!

 

4. FCM 서버에서 디바이스로 메시지 전송: FCM 서버는 전달받은 메시지를 해당 디바이스로 전송한다. 디바이스가 온라인 상태라면 즉시 메시지가 전달되고, 오프라인 상태라면 디바이스가 온라인이 되었을 때 메시지가 전달된다.

 

5. 디바이스에서 메시지 수신: 디바이스는 FCM SDK를 통해 푸시 알림을 수신하고, 이를 사용자에게 표시한다. 메시지는 앱의 백그라운드 또는 포그라운드 상태에 따라 다르게 처리될 수 있다.

 


기본 작동 로직

기본 로직에서는 request body가 아래 형식으로 요청을 받았을 때 알람이 보내지도록 controller를 만들었는데, 다른 로직에 푸시 알람 기능을 적용할 때에는 따로 컨트롤러가 필요 없다. 이에 대한 예시는 아래에 적어두었다. 

* 디바이스 토큰은 요청으로 일일이 받는 것이 아닌 user DB에 넣어두었고, title과 body도 미리 설정해두었다. 

{
  "deviceToken": "string",
  "title": "string",
  "body": "string"
}

Controller

@RestController
@RequiredArgsConstructor
@RequestMapping("/fcm")
public class FcmController {
    private final FcmService fcmService;

    // 1. client가 server로 알림 생성 요청
    @PostMapping("/pushMessage")
    public ApiResponse<String> pushMessage(@RequestBody FcmRequestDto requestDTO) throws IOException {
        System.out.println(requestDTO.getDeviceToken() + " "
                +requestDTO.getTitle() + " " + requestDTO.getBody());
        fcmService.sendMessageTo(
                requestDTO.getDeviceToken(),
                requestDTO.getTitle(),
                requestDTO.getBody());
        return ApiResponse.onSuccess(SuccessCode.FCM_SEND_SUCCESS, "fcm alarm success");
    }
}

FcmMessage

/*
메시지 구조
image: 선택사항
validateOnly: 메시지를 실제로 보내는 대신, 유효성 검사만 실행하려면 true로 설정 -> 보통 false로 고정함
*/

@Builder
@AllArgsConstructor
@Getter
public class FcmMessage {

    private boolean validateOnly;
    private Message message;
    @Builder
    @AllArgsConstructor
    @Getter
    public static class Message {
        private Notification notification;
        private String token;
    }
    @Builder
    @AllArgsConstructor
    @Getter
    public static class Notification {
        private String title;
        private String body;
        private String image;
    }
}

RequestDto

/*
프론트엔드가 디바이스 토큰과 title과 body를 넘겨 주는 걸로 되어있는데, 
실제로는 미리 토큰을 db 컬럼에 저장해두었다. 
*/
@Getter
@Setter
public class FcmRequestDto {
    private String deviceToken;
    private String title;
    private String body;
}

Service - 가장 중요한 부분

1. makeMessage(): 서버는 대상 디바이스의 토큰과 메시지 데이터를 포함한 메시지를 구성한다

2. getAccessToken(): 서버는 Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급받는다. 

3. sendMessageTo(): 발급받은 Bearer 토큰을 사용하여 FCM 서버에 인증된 요청을 보낸다. 

 

API_URL은 메세지 전송을 위해 요청하는 주소이다.

"https://fcm.googleapis.com/v1/projects/{프로젝트 ID}/messages:send"

 

@Slf4j
@Service
@RequiredArgsConstructor
public class FcmService {
    private final String API_URL = "https://fcm.googleapis.com/v1/projects/reborn-bbf41/messages:send";
    private final ObjectMapper objectMapper;

    // 메시지를 구성하고 토큰을 받아서 FCM으로 메시지를 처리한다.
    public void sendMessageTo(String targetToken, String title, String body) throws IOException {
        String message = makeMessage(targetToken, title, body);

        OkHttpClient client = new OkHttpClient();
        RequestBody requestBody = RequestBody.create(message, // 만든 message body에 넣기
                MediaType.get("application/json; charset=utf-8"));

        Request request = new Request.Builder()
                .url(API_URL)
                .post(requestBody)
                .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + getAccessToken()) // header에 포함
                .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8")
                .build();
        Response response = client.newCall(request).execute(); // 요청 보냄

        System.out.println(response.body().string());
    }

    // FCM 전송 정보를 기반으로 메시지를 구성한다. (Object -> String)
    private String makeMessage(String targetToken, String title, String body) throws com.fasterxml.jackson.core.JsonProcessingException  { // JsonParseException, JsonProcessingException
        FcmMessage fcmMessage = FcmMessage.builder()
                .message(FcmMessage.Message.builder()
                        .token(targetToken)
                        .notification(FcmMessage.Notification.builder()
                                .title(title)
                                .body(body)
                                .image(null)
                                .build()
                        ).build()).validateOnly(false).build();
        return objectMapper.writeValueAsString(fcmMessage);
    }

    // Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받는다.
    private String getAccessToken() throws IOException {
        final String firebaseConfigPath = "firebase/serviceAccountKey.json";

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

            googleCredentials.refreshIfExpired();
            log.info("access token: {}",googleCredentials.getAccessToken());
            return googleCredentials.getAccessToken().getTokenValue();

        } catch (IOException e) {
            throw new GeneralException(ErrorCode.GOOGLE_REQUEST_TOKEN_ERROR);
        }
    }
}

 


 

만들었던 Service의 함수들을 가지고 아래와 같이 다양한 곳에서 알람 기능을 사용할 수 있다.

 

1. 댓글 달릴 때마다 게시물 작성자에게 알람

@PostMapping("/{board-id}/create")
public ApiResponse<Long> create(
        @PathVariable(name = "board-id") Long boardId,
        @RequestBody CommentRequestDto.CommentDto commentDto,
        @AuthenticationPrincipal CustomUserDetails customUserDetails
) throws IOException {
    User user = userService.findUserByUserName(customUserDetails.getUsername());
    Long commentId = commentService.createComment(boardId, commentDto, user);
    Board board = boardService.findById(boardId);

    String title = String.valueOf(board.getBoardType());
    String body = "새로운 댓글: " + commentDto.getCommentContent();
    fcmService.sendMessageTo(board.getUser().getDeviceToken(), title, body); // 이렇게
    return ApiResponse.onSuccess(SuccessCode.COMMENT_CREATED,commentId);
}

 

2. 기념일 알람

@Scheduled(cron = "0 0 9 * * ?") // 매일 오전 9시에 실행
@Transactional
public void sendAnniversaryNotifications() {
    LocalDate today = LocalDate.now();
    List<Pet> pets = petRepository.findAll();

    for (Pet pet : pets) {
        if (today.equals(LocalDate.parse(pet.getAnniversary()))) {
            User user = pet.getUser();
            String token = user.getDeviceToken();
            String title = "Anniversary";
            String body = "오늘은 " + pet.getPetName() + "의 기일 입니다.";

            try {
                fcmService.sendMessageTo(token, title, body); // 이렇게
            } catch (IOException e) {
                log.error("Failed to send FCM notification", e);
            }
        }
    }
}

FCM Token Redis에 넣어보기 (DB에 넣는 로직 포함)

https://persi0815.tistory.com/32