본문 바로가기

Spring

FCM 알람 전송 재시도 구현하기

Docconneting프로젝트에서 알람 전송기능을 모두 구현했다

하지만 알람을 전송하고 실패하는 경우가 분명히 발생하고, 이 경우에 사용자가 알람을 받지 못한다면 이건 꽤나 큰 문제라는 생각이 들었다

그래서 이를 해결하기 위해서 알람 재시도를 구현해야겠다고 마음을 먹었다!

문제 상황

@Async("fcmExecutor")
    public void sendMulticastAlarm(List<String> fcmTokenBatche, String content) {
        try {
            MulticastMessage message = MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .addAllTokens(fcmTokenBatche)
                    .build();

            BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

            int successCount = response.getSuccessCount();
            int failureCount = response.getFailureCount();

            log.info("알림 전송 완료 - 성공횟수: {}, 실패횟수: {}", successCount, failureCount);
        } catch (FirebaseMessagingException e) {
            throw new RuntimeException(e);
        }
    }
  • 기존 코드에서는 알람 전송시에 재시도 로직이 존재하지 않아, 알람을 1만건 보냈을 때 성공한 횟수, 실패한 횟수만 볼 수 있었음
  • 알람 전송 실패에 대한 여러 케이스들에 대한 처리가 되어있지 않아 이를 해결할 수 있는 방법이 없었음

 

가장 먼저 FCM에서 알람 전송에 실패하는 경우 에러 코드를 보자

FCM 에러 종류

코드 의미 처리 전략
INTERNAL (HTTP 500) FCM 서버 내부 오류 재시도 권장
UNAVAILABLE (HTTP 503) FCM 서버 일시적 장애 재시도 권장
INVALID_ARGUMENT (HTTP 400) 메시지 내용/형식 또는 토큰이 잘못됨 해당 토큰 제거
UNREGISTERED (HTTP 404) 토큰이 만료됐거나 앱 삭제됨 해당 토큰 제거
QUOTA_EXCEEDED (HTTP 429) 메시지 전송 한도 초과 일반적으로 재시도 가능, but 조심
SENDER_ID_MISMATCH (HTTP 403) 토큰 발급 시 사용된 sender ID와 현재 sender ID가 다름 서버 설정 문제 (보통 Firebase 프로젝트 설정 오류)
THIRD_PARTY_AUTH_ERROR (HTTP 401) APNs 키 or 웹푸시 키 오류 서버 인증서/키 설정 확인 필요

 

여기서 오류 코드에 따라서 처리하는 경우를 크게 3가지로 분리했다

 

INTERNAL , UNAVAILABLE

  • FCM 서버 내부 오류와 일시적 장애이기 때문에 지수 백오프 전략을 이용하여 1초, 2초, 4초의 딜레이를 주며 최대 3회까지 FCM 알람 서버로 알람 전송 재시도 수행
  • FCM 공식 문서의 경우에도 해당 에러는 지수 백오프 전략을 사용하여 재시도를 구현하라고 적혀 있었음

FCM 공식 문서

@Retryable(
            retryFor = ServerException.class,
            maxAttempts = 3,
            backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public void sendAlarm(String fcmToken, String content) {
        try {
            Message message = Message.builder()
                    .setToken(fcmToken)
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .build();

            String response = FirebaseMessaging.getInstance().send(message);
            log.info("알림 전송 완료 - 메시지 ID: {}", response);
        } catch (FirebaseMessagingException exception) {
            MessagingErrorCode errorCode = exception.getMessagingErrorCode();

            if (errorCode.equals(INTERNAL) || errorCode.equals(UNAVAILABLE)) {
                log.error("FCM 서버 내부 오류 발생 - 알람 전송 재시도");
                throw new ServerException(ErrorCode.FCM_SEND_FAILED);
            }
            
            // 나머지 오류 처리들...
          }
    }

 @Recover
 public void recover(ServerException exception, String fcmToken, String content) {
       log.info("FCM 알림 재시도 3회 실패 - token : {}, content : {}", fcmToken, content);
  }

 

여기서 잠깐!

지수 백오프란?

  • 재시도 할 때마다 대기 시간을 지수적으로 늘려서 요청하는 전략
  • 점점 천천히 시도해서 서버 부하를 줄이고 실패 상황에서 자원 낭비를 방지하는 전략
  • 지수적으로 늘리는 이유 => 네트워크 오류, 서버 다운 같은 일시적인 장애는 시간이 지나면 회복될 가능성이 있음

 

INVALID_ARGUMENT, UNREGISTERED

  • 메시지 내용이나 토큰자체가 잘못됐을 때 발생하는 에러이기 때문에 토큰을 삭제
  • 로그로 에러 상황 기록
if (errorCode.equals(INVALID_ARGUMENT) || errorCode.equals(UNREGISTERED)) {
                log.error("FCM 토큰 이상 발생 - 토큰 제거");
                fcmTokenService.deleteFcmToken(fcmToken);
            }

 

THIRD_PARTY_AUTH_ERROR, SENDER_ID_MISMATCH

  • 서버의 인증서나 설정과 관련된 에러이기 때문에 서버의 코드를 직접확인해야 하므로 로그로 에러 상황 기록
if (errorCode.equals(THIRD_PARTY_AUTH_ERROR) || errorCode.equals(SENDER_ID_MISMATCH)) {
                log.error("서버 설정/인증서 문제 발생 - 서버 확인 필요");
            }

 

위의 3가지의 경우로 에러 처리를 해주었다

그런데 여기서 로그를 찍을 때 log.info가 아니라 에러가 나는 경우에 log.error을 왜 써줘야 하는지 의문이 들었다

 

로그 레벨

찾아보니 실제 운영 환경에서는 로그를 기록하고 수집하면서 서버에 대한 모니터링을 수행한다고 하는데, 로그를 저장할 때 설정파일에서 지정한 특정 레벨까지만 저장하고 하위 레벨은 로그를 저장하지 않는다고 한다

에러가 발생한 경우 무조건 log.error()로 지정하고, 이렇게 지정한 로그는 항상 기록하고 모니터링 해야하므로 로그를 레벨별로 저장하는것이 중요하다!

  • 실제 운영 환경에서는 로그를 레벨별로 수집하고 모니터링하기 때문에 레벨별로 지정해야 함
메서드 로그 레벨 의미
log.trace() TRACE 가장 상세한 디버깅 로그
log.debug() DEBUG 개발자용 디버깅 정보
log.info() INFO 일반적인 정보 로그 (성공 메시지 등)
log.warn() WARN 경고 (문제 될 수 있음)
log.error() ERROR 실제 예외, 오류 상황 (운영 이슈)

 

다건 알람 재전송 로직에서의 고민 (sendEachForMulticast)

다건의 경우, 실패한 토큰 리스트만 가지고 재시도를 해야 할 텐데, 그러면 @Retryable과 @Recover을 가지고 재시도를 할 수는 없을 것 같다는 생각이 들었다

그래서, while문을 만들고 최대 재시도 할 수 있는 횟수를 정해서 실패한 토큰 리스트를 뽑아서 계속 반복하는 식으로 구현했다

public void sendMulticastAlarm(List<String> fcmTokenBatche, String content) {
        List<String> targets = new ArrayList<>(fcmTokenBatche);
        int maxAttempts = 3;
        int attempt = 1;

        while (attempt <= maxAttempts && !targets.isEmpty()) {
            MulticastMessage message = MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .addAllTokens(targets)
                    .build();

            try {
                BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

                List<String> failedTokens = new ArrayList<>();
                List<SendResponse> responses = response.getResponses();

                for (int i = 0; i < responses.size(); i++) {
                    SendResponse sendResponse = responses.get(i);
                    String fcmToken = targets.get(i);

                    if (!sendResponse.isSuccessful()) {
                        FirebaseMessagingException exception = (FirebaseMessagingException) sendResponse.getException();
                        MessagingErrorCode errorCode = exception.getMessagingErrorCode();

                        if (errorCode.equals(INTERNAL) || errorCode.equals(UNAVAILABLE)) {
                            log.error("FCM 서버 내부 오류 발생 - 알람 전송 재시도 리스트에 추가");
                            failedTokens.add(fcmToken);
                        }

                        if (errorCode.equals(INVALID_ARGUMENT) || errorCode.equals(UNREGISTERED)) {
                            log.error("FCM 토큰 이상 발생 - 토큰 제거");
                            fcmTokenService.deleteFcmToken(fcmToken);
                        }

                        if (errorCode.equals(THIRD_PARTY_AUTH_ERROR) || errorCode.equals(SENDER_ID_MISMATCH)) {
                            log.error("서버 설정/인증서 문제 발생 - 서버 확인 필요");
                        }
                    }
                }

                if (failedTokens.isEmpty()) {
                    log.info("알림 전송 완료 - 성공횟수 : {}, 실패횟수 : {}", response.getSuccessCount(), response.getFailureCount());
                    return;
                }

                targets = failedTokens;
                attempt++;

            } catch (FirebaseMessagingException e) {
                log.error("알람 전체 전송 실패 - {}", e.getMessagingErrorCode());
                return;
            }

        }

        if (!targets.isEmpty()) {
            log.error("알람 전송 최종 실패 명수 - {}", targets.size());
        }
    }

 

도입 전후 비교

  • 시나리오 : 1만건의 알람을 sendEachForMulticast 메서드를 사용하여 100개씩 Batch 전송한다고 가정

📌 도입 전 성능 테스트 결과

 

☑️ 알람 전송 초반에 실패하는 경우 발생

 

📌  도입 후 성능 테스트 결과

 

☑️ 알람 전송 초반에도 재시도 로직을 통해서 무사히 누락되는 알람 없이 잘 전송됨

 

1만건의 알람을 sendEachForMulticast 메서드를 통해서 보냈을 때, 오류율이 3.01% 였으나, 재시도 로직을 도입하고 오류율을 0%로 감소했다!! 🥳