이전에 실패한 알림을 재전송하는 스케줄러를 등록한 뒤 데드락이 걸리지 않도록 트랜잭션 범위를 수정했습니다.
그런데 해당 동작이 정상적으로 수행하는지를 확인하려고 실제 애플리케이션을 동작시켜 보니
(현재는 실제 FCM 토큰을 저장해 두지 않고 테스트용으로 저장해 둔 상태라 다시 실패하는 것이 정상입니다 ..)

이렇게 기존의 실패한 메시지를 ID로 찾고 다시 실패한 다음 새롭게 레코드를 만들어 저장하고 있었습니다.
@Scheduled(fixedDelay = 600000) // 10분마다 실행
@Transactional
public void retryFailedNotifications() {
List<FailedNotification> targets = failedNotificationRepository.findByRetryCountLessThan(MAX_RETRY_COUNT);
if (targets.isEmpty()) {
return;
}
log.info("알림 재처리 스케줄을 수행합니다. 대상 총 {}건.", targets.size());
List<CompletableFuture<Void>> futures = targets.stream()
.map(failedNotification -> CompletableFuture.runAsync(() -> {
// ID만 넘기거나, 엔티티를 넘겨서 별도 서비스에서 트랜잭션 처리
notificationRetryService.processSingleRetry(failedNotification.getId());
}, notificationExecutor))
.toList();
try {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("알림 재처리 스케줄이 성공적으로 완료되었습니다. 총 {}건 처리.", futures.size());
} catch (Exception e) {
log.error("알림 재처리 작업 중 일부에서 예외가 발생했습니다.", e);
}
}
해당 메서드에서는 이렇게 처리하고 있었습니다.
비동기 처리를 위해 새로운 스레드에게 알림 재전송 책임을 위임하고
전부 위임했다면 "알림 재처리 스케줄이 ..."라는 내용의 로그를 찍도록 해 뒀습니다.
그런데 실제 알림을 재전송하는 로직은 빠른 재처리를 위해 비동기 콜백 처리를 해 뒀던 게 문제였습니다.
크게 두 가지 문제가 있는데,
- 비동기 처리(Async)를 기다리지 않고 삭제해 버리는 코드
- 실패 시 무조건 새 객체를 만드는 콜백
문제 코드입니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processSingleRetry(Long id) {
failedNotificationRepository.findByIdWithLock(id).ifPresent(failedNotification -> {
try {
NotificationRequest event = NotificationRequest.fromFail(failedNotification);
// [문제 코드 1]
// 비동기 메서드입니다. 전송 요청만 하고 즉시 리턴됩니다.
// 즉, 성공/실패 여부를 아직 모르는 상태입니다.
fcmService.sendFcmNotification(event);
// [문제 코드 2]
// 위에서 에러가 안 났으니 "성공했네 ?"라고 착각하고 원본 데이터를 삭제합니다.
// 실제로는 아직 전송 중이거나 실패했을 수도 있습니다.
failedNotificationRepository.delete(failedNotification);
} catch (Exception e) {
// 여기는 sendFcmNotification 메소드 호출 자체에서 에러가 나야만 들어옵니다.
// 비동기 작업 내부의 실패(FCM 서버 응답 에러 등)는 여기로 오지 않습니다.
// ...
}
});
}
여기서 sendFcmNotification() 메서드는 다음과 같이 되어 있었습니다.
public void sendFcmNotification(NotificationRequest event) {
FcmToken fcmToken = fcmTokenRepository.getByMemberId(event.targetId());
if (fcmToken == null || fcmToken.getFcmToken() == null) {
log.warn("FCM 토큰이 존재하지 않아 전송에 실패했습니다. targetId: {}", event.targetId());
saveFailedNotification(event, FcmTokenExceptionCode.FCM_TOKEN_NOT_FOUND.getMessage());
return;
}
// 1. 메시지 전송 요청
ApiFuture<String> future = fcmMessageSender.sendFcmNotificationAsync(
fcmToken,
event.title(),
event.body()
);
// 2. 콜백 생성 및 등록
FcmApiFutureCallback callback = new FcmApiFutureCallback(
event,
fcmToken.getFcmToken(),
failedNotificationRepository
);
ApiFutures.addCallback(future, callback, MoreExecutors.directExecutor());
}
기존의 콜백 메서드를 등록한 Future를 재활용하고 있었던 것입니다.
그리고 제일 핵심 문제의 콜백 메서드입니다.
(실패한 알림 재처리에 대해서만 문제고 정상 전송에서는 문제 없습니다 ..)
@Override
public void onFailure(Throwable t) {
log.error("FCM 비동기 전송 실패 - target member Id: {}, token: {}, exception: {}",
event.targetId(), fcmToken, t.getMessage());
FailedNotification failedNotification = new FailedNotification(
event.senderId(),
event.targetId(),
event.title(),
event.body(),
NotificationType.FCM,
t.getMessage()
);
failedNotificationRepository.save(failedNotification);
log.info("실패한 FCM 알림을 DB에 저장했습니다. targetId: {}", event.targetId());
}
여기서 실패한 알림에 대한 메시지를 받은 다음
해당 내용을 통해 새롭게 DB에 저장하고 있었습니다.
그러니까
- 현재 로직은 "실패한 것을 수정(Update)"하는 것이 아니라,
- "실패한 것을 지우고(Delete) 새로운 실패 기록을 생성(Insert)"한다 ..
입니다.
구체적인 실행 과정은
- 스케줄러 실행
- 실패한 알림(ID: 49, 50)을 가져 옵니다.
- notificationRetryService.processSingleRetry(49)를 호출합니다.
- 재전송 시도 및 착각 (NotificationRetryService)
- fcmService.sendFcmNotification(event)를 호출합니다.
- 문제점은 이 메소드가 비동기(Async)라는 것입니다. 전송 요청만 보낸 뒤 결과를 기다리지 않고 리턴합니다.
- 따라서 서비스는 성공한 줄 알고 착각해 버린 뒤 repository.delete(49)를 실행해 ID 49번을 지워 버립니다. (DB의 deleted_date 업데이트)
- 진짜 실패 발생 및 신규 생성 (FcmApiFutureCallback)
- 잠시 후, 비동기로 실행된 FCM 전송이 실제로 실패합니다 (onFailure).
- FcmApiFutureCallback은 실패 시 repository.save(new FailedNotification(...))을 수행합니다.
- 결과적으로 새로운 ID(51번)가 생성됩니다.
요약:
- ID 49번 삭제됨 (성공한 줄 알고)
- ID 51번 생성됨 (콜백이 실패를 감지해서)
- 다음 스케줄: ID 51번을 가져와서 똑같은 짓을 반복 -> ID 53번 생성... (무한 반복)
해결 방법으로는 재시도 시에 'Callback'을 쓰지 않고 대기하는 것입니다.
스케줄러(재시도 로직)에서는 결과를 확실히 확인한 뒤 DB를 업데이트 합니다.
따라서 FcmService가 ApiFuture를 반환하도록 수정하고, 재시도 서비스에서 get()을 통해 대기합니다.
그런데 이렇게 해도 잘못하면 데드락에 걸릴 위험성은 다소 남아 있습니다 ..
그럴 경우 순차적으로 배치 처리(Sequential Batch Processing)의 방법을 생각해 볼 수 있을 거 같습니다.
조회 성능이 느릴 경우 현재는 실패 카운트를 기준으로 실패 알림을 조회하고 있으니
실패 카운트에 인덱스를 걸어 볼 수도 있을 거 같습니다.
아니면 차라리 그냥 스레드 풀 사이즈를 튜닝해 보는 것도 또 하나의 방법일 수 있을 거 같습니다.
## 왜 재처리에 순차 처리를 할까요 ? 알림 전송은 비동기로 하는 것이 맞지 않나요 ?
라고 하실 수도 있습니다. 그에 대한 제 생각은
- 이미 늦은 알림입니다: 재시도 로직은 이미 5분, 10분 전에 실패한 건을 다시 보내는 것입니다. 여기서 1초 더 빨리 보낸다고 사용자 경험이 크게 달라지지 않습니다.
- 서비스 보호: 비동기로 수백 개를 동시에 쏘다가 DB 커넥션이 말라버리면, 다른 멀쩡한 알림 서비스까지 다 죽어버립니다. (근데 이건 SSE도 분리하고 다른 것도 분리하고 전부 다 분리해 버리면 괜찮을 수도 ?)
- 혹시 모를 FCM 과부하: FCM 같은 외부 API에 과부하가 걸리면 혹시라도 실패할 수 있습니다.
어떻게 보면 단순한 불찰이면서도
비동기 처리에 대한 이해가 조금 부족했던 것일 수도 있습니다 ..
### 결론 ..
이번 버그를 계기로 비동기에 대한 이해가 조금 오른 것 같습니다.
다음부터는
- 비동기 처리 시 스레드 정보가 바뀌게 되고 기존의 트랜잭션과는 관계가 없어진다 ..
- 콜백 메서드 등록 시 기존 스레드가 재처리할 수도 있고 새로운 스레드가 재처리할 수도 있다 ..
- 기존의 메서드를 재활용하기 전에 한 번 더 확인한다 ..
에 대해서 조금 더 유념하면서 기능을 개발해야겠습니다 ..!
참고 링크: https://siyoon210.tistory.com/147
'SSA > Back' 카테고리의 다른 글
| [SSA] 실패한 알림에 대해서는 순차 처리 ? 병렬 처리 ? (0) | 2025.12.14 |
|---|---|
| [SSA] 실패한 알림 재전송 시 데드락 테스트 (0) | 2025.12.13 |
| [SSA] 스케줄러를 통해 Retry를 하게 될 경우 트랜잭션 처리 (0) | 2025.12.11 |
| [SSA] 카프카 메시지를 발행할 때 ZERO-PAYLOAD ? 아니면 Event-Carried State Transfer ? (0) | 2025.12.11 |
| [SSA] Redis의 특징을 활용한 동시성 제어 (Redis 분산 락 아님) (0) | 2025.09.13 |