서론
https://kangwook.tistory.com/44
지난 글에 FCM을 도입해,
매장 찜을 통한 구독 등록/취소 과정과 매장의 신규 쿠폰 발행 시 알림 메세지를 발송 기능을 구현했다.
하지만 비동기 처리를 함으로 써 생기는 문제점과 단점들이 드러나게되었다.
이벤트 기반 비동기 처리로의 변경 이유
현재 FCM 알림 발송 코드는 다음과 같다.
ApiFuture<String> apiFuture= firebaseMessaging.sendAsync(message);
apiFuture.addListener(() ->{
try{
String response = apiFuture.get();
log.info("토픽 구독 성공: {}", response);
} catch (ExecutionException | InterruptedException e) {
log.error("토픽 구독 관련 예외 발생: {}", e.getMessage());
throw new CustomException(StatusCode.FCM_SEND_FAIL);
}
}, asyncConfig.getFcmExecutor());
문제는 이 코드를 비지니스 로직에서 호출한다는 점이다.

이렇게 함으로 써 생기는 문제점은
트랜잭션이 롤백되어도 FCM 알림이 전송이 된다는 것이다.
따라서, 비즈니스 로직과 외부 API의 호출을 분리해 의존성을 제거하고, 순서를 확실하게 하기 위해 이벤트 처리를 하게되었다.
과정
우선 ApplicationEventPublisher를 통해 이벤트를 발생시키고 브로드캐스트한다.
@Transactional
public CouponRes createCoupon(CouponReq couponReq, Long marketId) {
Market market = findMarketById(marketId);
Coupon coupon = couponRepository.save(couponReq.ofCreate(market));
eventPublisher.publishEvent(new SendNewCouponFcmEvent(market,coupon));
return CouponRes.toDto(coupon);
}
그다음 이벤트를 구독하고 처리하는 @EventListener을 붙인 메서드를 통해 비동기로 이벤트를 처리한다.
@Async("FcmExecutor")
@TransactionalEventListener(phase = AFTER_COMMIT)
public void sendNewCouponFcmToSubscriber(SendNewCouponFcmEvent event){
Message message = Message.builder()
.setNotification(
Notification.builder()
.setTitle(event.getCoupon().getName())
.setBody(event.getCoupon().getDescription())
.build()
)
.setTopic("market-"+ event.getMarket().getId())
.build();
try{
firebaseMessaging.send(message);
} catch (FirebaseMessagingException e){
log.error("예외 발생: {}", e.getErrorCode());
throw new CustomException(StatusCode.FCM_SEND_FAIL);
}
}
@EventListener는 리스너의 작업이 발행자의 트랜잭션과 독립적으로 실행된다. 따라서 비즈니스 로직이 롤백이 되어도 리스너의 작업은 무조건 실행된다는 뜻이다. 이렇게 되면 이벤트 처리로 변경하는 이유가 없다!!
@TransactionalEventListener는 원하는 트랜잭션의 진행 상황에 따라 이벤트를 처리하는 메서드를 호출할 수 있다.
따라서 AFTER_COMIT을 적용해 트랜잭션이 무사히 커밋된 이후 메서드를 호출하도록 하였다.
또한, @EventListener와 @TransactionalEventListener는 기본적으로 동기로 작동하기 때문에, @Async를 붙여 비동기 방식으로 작동하도록하였다. 또한 FcmExecutor로 FCM만 처리하는 스레드풀을 적용시켰다.
메서드가 비동기 스레드에서 처리되기 때문에 기존 FCM API 호출 메서드는 sendAsync가 아닌 send로 변경했다.
테스트
테스트할 내용은 트랜잭션이 커밋된 후 이벤트가 발행되는지, 롤백 시 이벤트 발행이 안되는지 확인하는 것이다.

테스트 코드를 통해 위와 같이 예상한대로 동작하는 것을 확인할 수 있었다.
예외는 어떻게 처리할 것인가?
REST API에서 비동기의 단점은 메인 스레드에서 실행하지 않기 때문에, 예외가 나와도 클라이언트에게 전해줄 수 없다는 것이다.
따라서 최대한 비동기 작업이 무사히 실행되도록 보장하거나, 예외처리가 중요하다.
스프링에서 제공하는 @Retryable를 통해 예외가 나올 경우 재시도를 함으로 써 최대한 실행을 보장하고, @Recover로 끝내 처리하지 못할 경우에 예외처리를 구현해보겠다.
@Retryable 사용 준비
먼저 라이브러리를 추가한 뒤,
implementation 'org.springframework.retry:spring-retry'
@EnableRetry를 통해 활성화해야한다.
@Configuration
@EnableRetry
public class RetryConfig {
}
@Retryable 설정
이제 재시도를 할 메서드에 @Retryable을 써주면 된다.
@Retryable(
recover = "recoverSendNewCouponFcmToSubscriber",
retryFor = FirebaseMessagingException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
retryFor은 해당 예외가 나올 시 재시도한다는 뜻이다.
maxAttempts는 재시도할 최대 횟수이다.
backoff는 재시도 할 때의 딜레이를 정할 수 있다. 난 1초의 딜레이를 정했고, 재시도할 때마다 이전 딜레이의 2를 곱해 지수적으로 증가시켰다.
recover은 최대 재시도 후 실행할 메서드 이름을 지정하는 것이다.
@Recover 설정
우선 알림 발송 이벤트에 대한 recover은 예외를 던지기만 했다.
@Recover
public void recoverSendNewCouponFcmToSubscriber(FirebaseMessagingException e, SendNewCouponFcmEvent event){
throw new FcmException(StatusCode.FCM_SEND_FAIL);
}
알림 발송 이벤트는 매장의 신규 쿠폰 발행할 때 생성되는데, 쿠폰 발행은 직접적인 비지니스와 연결돼있기 때문에 철회에 대한 리스크가 크다.
구독/구독취소는 해당 매장을 찜할 때 생겨나는 이벤트로 리스크가 적을 것으로 생각해,
구독/구독취소에 대한 Recover은 매장 찜에 대한 철회를 진행시킬 것이다.
테스트 진행
지정한 예외가 발생했을 때,
Retry요청이 정상적으로 동작하는지 테스트를 진행했다.
@Retryable은 AOP를 이용한 프록시 호출이기 때문에 스프링 컨텍스트에서 관리되는 빈이 존재해야 작동한다.
따라서 필요한 빈만 로드하여 AOP 프록시가 작동하도록 했다. mock만 사용하면 재시도를 하지 않는다.
@ExtendWith(SpringExtension.class)
@ContextConfiguration(classes = {FcmService.class, RetryConfig.class}) // 필요한 Bean만 로드
예외 발생 시, 최대 3번 재시도하는가?
@Test
@DisplayName("FCM 서버 에러 시 최대 3번 재시도")
void testRetryAndRecover() throws FirebaseMessagingException {
// given
given(firebaseMessagingException.getMessagingErrorCode()).willReturn(MessagingErrorCode.INTERNAL);
given(firebaseMessaging.send(any(Message.class))).willThrow(firebaseMessagingException);
when(sendNewCouponFcmEvent.getCoupon()).thenReturn(coupon);
when(coupon.getName()).thenReturn("New Coupon");
when(coupon.getDescription()).thenReturn("Special Discount Coupon");
when(sendNewCouponFcmEvent.getMarket()).thenReturn(market);
when(market.getId()).thenReturn(1L);
// when
assertThatThrownBy(() ->
fcmService.sendNewCouponFcmToSubscriber(sendNewCouponFcmEvent))
.isInstanceOf(FirebaseMessagingException.class);
// then
// FirebaseMessaging.send() 메서드가 3번 호출되었는지 검증
verify(firebaseMessaging, times(3)).send(any(Message.class));
}

재시도 중 정상 응답이 오면 멈추는 가?
@Test
@DisplayName("재시도 중 정상 응답이 오면 재시도하지 않는다")
void testRetryWithSuccessfulResponse() throws FirebaseMessagingException {
// given
given(firebaseMessagingException.getMessagingErrorCode()).willReturn(MessagingErrorCode.INTERNAL);
// 첫 번째 호출에서는 예외를 던짐
given(firebaseMessaging.send(any(Message.class))).willThrow(firebaseMessagingException)
// 두 번째 호출에서는 정상 응답을 반환
.willReturn("Success response");
when(sendNewCouponFcmEvent.getCoupon()).thenReturn(coupon);
when(coupon.getName()).thenReturn("New Coupon");
when(coupon.getDescription()).thenReturn("Special Discount Coupon");
when(sendNewCouponFcmEvent.getMarket()).thenReturn(market);
when(market.getId()).thenReturn(1L);
// when
fcmService.sendNewCouponFcmToSubscriber(sendNewCouponFcmEvent);
// then
// 정상 응답이 들어오면 재시도가 멈추어야 하므로 총 두 번 호출됨
verify(firebaseMessaging, times(2)).send(any(Message.class));
}

14:44:00.649 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- FCM 신규 쿠폰 알림 발송 실패
14:44:01.671 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- 메세지 발송 성공: Success response
최대 재시도 후 Recover 메서드가 실행되는가?
14:54:02.515 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- FCM 신규 쿠폰 알림 발송 실패
14:54:03.534 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- FCM 신규 쿠폰 알림 발송 실패
14:54:05.546 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- FCM 신규 쿠폰 알림 발송 실패
14:54:05.549 [Test worker] INFO com.appcenter.marketplace.global.fcm.FcmService -- FCM 신규 쿠폰 알림 발송 3번 재시도 후 Recover 메서드 실행

결과
테스트 결과 모두 올바르게 작동하는 것을 확인했다.
이로써 외부 API 요청을 비지니스 로직과 분리하고, 비동기 작업을 최대한 보장, 예외처리를 구현했다.
프로젝트에 이미지 저장 서비스를 의존하는 서비스들이 있는데 , 이 또한 이벤트처리로 전환하는 리팩토링을 진행해봐야겠다.
'쿠러미' 카테고리의 다른 글
| [쿠러미] Redis 분산락 구현, 비관적락 성능 비교 (0) | 2025.05.14 |
|---|---|
| [쿠러미] 쿠폰 발급 시 (동시성 제어 + 중복 체크) 문제해결 (0) | 2025.05.11 |
| [쿠러미] FCM 구독, 알림 기능 개발 & 비동기 전환 (0) | 2025.03.31 |
| [쿠러미] Springboot +프로메테우스 + 그라파나 연동 (0) | 2025.03.19 |
| Mysql 프로시저로 더미데이터 생성 (0) | 2025.03.18 |