spring

Spring RabbitListener에서 예외가 발생하면? feat. 무한 루프

e4g3r 2025. 2. 7. 15:34
 

예외 처리의 예외 처리 (feat. RabbitMQ 지연 큐)

결제 로직 에서 발생하는 예외 처리하기(feat.트랜젝션 의존 줄이기)현재 진행중인 사이드 프로젝트 내에서 아이템을 구매할 때에는 서비스 재화인 포인트가 사용됩니다. 포인트는 게임 캐시처

e4g3r.tistory.com


이전 포스팅에서는 포인트 충전 예외 처리 로직에서 또 예외 처리가 발생했을 때 지연 큐에 메시지를 발행하여 일정시간 이후
재시도 처리하도록 구성했었습니다.

 

현재 진행중인 프로젝트에서는 포인트로 문제를 구매하는 기능도 있기 때문에 문제 구매 예외 처리 로직에서도 똑같이 지연 큐를 도입하게 되었습니다.

문제 발생 - Infinite Loop in RabbitListener

@RabbitListener(id = "fail-question-payment", queues = "fail-question-payment")
@Transactional
public void handler(FailQuestionPaymentMessage message) {
    try {
        QuestionPayment questionPayment = message.getQuestionPayment();
        questionPayment.fail();
        questionPaymentRepository.save(questionPayment);

        rollbackPoint(questionPayment.getUserId(), questionPayment.getAmount());
        
        // 테스트를 위한 임의 예외 발생
        if (true) {
            throw new RuntimeException("e");
        }
            
        rollbackCoupon(questionPayment.getQuestionPaymentCoupon().getUserCouponId());
    } catch (Exception e) {
        message.increaseFailCount();
        // 지연 큐 메시지 발행
        messageSender.sendDelayMessage(MessageType.FAIL_QUESTION_PAYMENT, message, message.getFailCount());
        
        // 롤백 처리를 위해 예외 그대로 던져주기
        throw e;
    }
}

 

포인트 결제와 유사하게 문제 결제 도중 예외가 발생하면 FailQuestionPaymentMessage를 발행하여 롤백 처리를 하도록 하였는데요.

문제 결제는 포인트쿠폰을 사용하기 때문에 지불한 포인트를 다시 돌려주고, 사용처리 된 쿠폰을 미사용으로 변경해야합니다.

만약 포인트 롤백은 완료되었지만 쿠폰 롤백에서 문제가 발생해 재시도가 된다면 포인트 롤백이 2번 되는 상황이 발생 할수 있습니다.

따라서 포인트 롤백, 쿠폰 롤백은 하나의 트랜젝션으로 처리하기 위해 @Transactional 어노테이션을 사용하였습니다.

 

트랜젝션의 롤백을 위해서는 예외가 발생해야 하기 때문에 지연 큐에 메시지를 발행 후 발생한 예외를 다시 던져주어 트랜젝션이
수행되도록 하였습니다.

 

테스트를 위해 포인트 롤백 이후 임의로 예외를 발생시켜 동작을 확인해보았습니다.

 

그런데 테스트 도중 문제가 발생하였는데요. 

현재 지연 큐 발행은 10초마다 진행되기 때문에 10초에 한번 씩 재시도 로그가 콘솔에 떠야합니다.

 

하지만 너무 많은 로그가 발생되어 확인해보니 큐에는 2565개의 메시지가 쌓여있었고 해당 메시지를 계속 수신하면서
재시도를 처리하고 있었습니다.

문제 원인 - Requeued when message is rejected

공식 문서

리스너가 예외를 발생시키면 해당 예외는 ListenerExecutionFailedException으로 래핑됩니다.
일반적으로 메시지는 브로커에 의해 거부되고 다시 큐에 들어갑니다. 하지만 defaultRequeueRejected를 false로 설정하면 메시지가 폐기되거나(혹은 데드 레터 익스체인지로 라우팅됨) 처리됩니다.

https://docs.spring.io/spring-amqp/reference/amqp/exception-handling.html#

 

공식문서를 확인해보니 문제의 원인은 catch에서 발생한 예외를 그대로 다시 던져주는 것이였습니다.

일반적으로 리스너에서 예외가 발생하면 메시지는 Reject되었다고 판단되어 다시 큐에 들어가기 때문에 catch에서 다시 예외를 던져주면
메시지가 바로 바로 재시도가 되기 때문에 반복되는 만큼 지연 큐에 메시지가 발행되고 무한 루프에 빠지게 되었던 것입니다.

 

하지만 트랜젝션의 롤백을 위해서는 예외를 발생시켜야 했기 때문에 방법을 찾아보았습니다.

해결 방안

AmqpRejectAndDontRequeueException

공식 문서

AmqpRejectAndDontRequeueException을 발생시킬 수도 있습니다.
이렇게 하면 defaultRequeueRejected 속성의 설정과 관계없이 메시지가 다시 큐에 들어가는 것을 방지할 수 있습니다.

https://docs.spring.io/spring-amqp/reference/amqp/resilience-recovering-from-errors-and-broker-failures.html#async-listeners

 

공식문서에 따르면 예외를 던질 때 AmqpRejectAndDontRequeueException로 던지는 경우 Requeue가 되지 않는다고 합니다.

@RabbitListener(id = "fail-question-payment", queues = "fail-question-payment")
@Transactional
public void handler(FailQuestionPaymentMessage message) {
    try {
        QuestionPayment questionPayment = message.getQuestionPayment();
        questionPayment.fail();
        questionPaymentRepository.save(questionPayment);

        rollbackPoint(questionPayment.getUserId(), questionPayment.getAmount());
        rollbackCoupon(questionPayment.getQuestionPaymentCoupon().getUserCouponId());
    } catch (Exception e) {
        message.increaseFailCount();
        messageSender.sendDelayMessage(MessageType.FAIL_QUESTION_PAYMENT, message, message.getFailCount());

        // 발생한 e를 AmqpRejectAndDontRequeueException로 감싸서 던짐
        throw new AmqpRejectAndDontRequeueException(e);
    }
}

 

catch에서 바로 던지던 Eeception e를 AmqpRejectAndDontRequeueException로 감싸서 던지도록 수정하였습니다.

큐에는 지연 큐에서 발행 된 1개의 메시지만 존재

 

테스트 결과 예외가 발생해도 무한 루프에 빠지지 않고 지연 큐에 1번만 발행이 되어 10초마다 재시도가 되었습니다.

이외 방법 - 프로퍼티 설정

spring.rabbitmq.listener.simple.default-requeue-rejected=false

 

properties 혹은 yml에 해당 프로퍼티를 설정하면 기본적으로 Listener에서 예외가 발생했을 때 Requeue가 되지 않도록 설정할 수
있습니다. 하지만 모든 Listener에 일괄적용 되기 때문에 Listener마다 다르게 적용하고 싶다면 다른 방법을 사용해야 할 것 같습니다.

마무리

RabbitMQ Listener의 기본 정책인 Requeue로 인해 발생되었던 문제를 해결해보았습니다.
아직까지는 RabbitMQ를 메시지 큐로 활용하기 위해 간단하게 사용법만 숙지한 채로 사용했는데요.
Listener의 예외처리를 시작으로 좀 더 사용법과 문서를 읽어보면서 기본 지식을 습득해야겠습니다.