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 공식 문서의 경우에도 해당 에러는 지수 백오프 전략을 사용하여 재시도를 구현하라고 적혀 있었음
@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%로 감소했다!! 🥳
'Spring' 카테고리의 다른 글
컨트롤러 테스트에 인증/인가가 들어있는 경우 (0) | 2025.05.12 |
---|---|
FCM의 구조와 이를 구현하기 위한 고민(Feat : FCM토큰관리와 전송 메서드) (1) | 2025.05.12 |
알람 전송 구현 기술의 고민 (0) | 2025.05.12 |
동적쿼리와 인덱싱 적용하여 성능 개선하기 (+ Like 검색은?) (0) | 2025.04.07 |
컨트롤러 테스트 작성시 주의할 점 (1) | 2025.04.07 |