에러 핸들링 & 재시도 전략
메시지 소비 중 예외가 터졌는데, 같은 메시지가 끝없이 다시 들어온다면 어떻게 해야 할까?
에러 핸들링 & 재시도 전략
기본 동작의 문제 — 무한 재시도 루프
이전 글에서 @RabbitListener를 사용한 기본 연동을 다뤘습니다. 그런데 리스너 메서드에서 예외가 발생하면 어떻게 될까요?
Spring AMQP의 기본 설정에서는 예외 발생 시 메시지를 reject한 뒤 다시 큐에 넣습니다(requeue). 문제는 이 동작이 무한히 반복된다는 것입니다.
@RabbitListener(queues = "order.queue")
public void processOrder(OrderMessage message) {
// 외부 API 호출 실패 → 예외 발생
paymentService.charge(message.getOrderId()); // RuntimeException!
// → reject → requeue → 다시 consume → 또 예외 → reject → requeue → ...
}
이 상태가 되면 로그에 같은 에러가 초당 수백 번씩 쌓이고, CPU와 네트워크 자원을 낭비하게 됩니다. ACK/NACK 메커니즘에서 다뤘던 requeue=true의 부작용이 그대로 나타나는 것입니다.
재시도 자체가 문제가 아니라, ** 횟수 제한 없이** 재시도하는 것이 문제다. 일시적 장애인지, 영구적 장애인지를 구분하는 전략이 필요하다.
Spring AMQP 재시도 인프라
Spring AMQP는 spring-retry 라이브러리와 통합하여 체계적인 재시도 메커니즘을 제공합니다. 핵심 구성 요소는 세 가지입니다.
| 구성 요소 | 역할 |
|---|---|
| RetryTemplate | 재시도 실행의 핵심 엔진. 정책에 따라 재시도를 수행 |
| RetryPolicy | 재시도 ** 횟수 **를 결정 (SimpleRetryPolicy: 최대 N회) |
| BackOffPolicy | 재시도 ** 간격 **을 결정 (FixedBackOff, ExponentialBackOff 등) |
여기에 추가로 MessageRecoverer 가 있습니다. 모든 재시도가 실패한 뒤 최종적으로 메시지를 어떻게 처리할지 결정하는 역할입니다.
재시도 흐름
메시지 수신 → 처리 시도 → 예외 발생
↓
RetryPolicy 확인 (재시도 가능?)
├─ Yes → BackOffPolicy만큼 대기 → 재시도
└─ No → MessageRecoverer에 위임
├─ RejectAndDontRequeueRecoverer: reject (requeue=false)
└─ RepublishMessageRecoverer: DLQ로 재발행
RetryTemplate 설정
방법 1: application.yml 기반 (간단한 설정)
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 재시도 활성화 (기본값: false)
max-attempts: 3 # 최대 시도 횟수 (초기 시도 포함)
initial-interval: 1000 # 첫 재시도까지 대기 시간 (ms)
multiplier: 2.0 # 백오프 배수
max-interval: 10000 # 최대 대기 시간 (ms)
이 설정만으로 RetryTemplate이 자동 구성됩니다. 간단한 프로젝트라면 이것으로 충분합니다.
방법 2: @Configuration 기반 (세밀한 제어)
@Configuration
public class RabbitRetryConfig {
@Bean
public RetryOperationsInterceptor retryInterceptor() {
return RetryInterceptorBuilder.stateless()
.retryPolicy(new SimpleRetryPolicy(3)) // 최대 3회 시도
.backOffPolicy(exponentialBackOff())
.recoverer(republishRecoverer()) // 재시도 소진 후 DLQ로
.build();
}
private ExponentialBackOffPolicy exponentialBackOff() {
ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy();
policy.setInitialInterval(1000); // 첫 대기: 1초
policy.setMultiplier(2.0); // 배수: 2배씩 증가
policy.setMaxInterval(10000); // 최대 대기: 10초
return policy;
}
@Bean
public RepublishMessageRecoverer republishRecoverer() {
// 재시도 소진 후 DLQ exchange로 재발행
return new RepublishMessageRecoverer(
rabbitTemplate, "dlx.exchange", "dlq.routing-key"
);
}
// RabbitTemplate은 별도 Bean으로 주입
@Autowired
private RabbitTemplate rabbitTemplate;
}
@Configuration 방식은 MessageRecoverer를 직접 지정할 수 있다는 장점이 있습니다. application.yml 방식에서는 기본 RejectAndDontRequeueRecoverer가 사용됩니다.
ExponentialBackOff — 지수 백오프
공부하다 보니 "왜 고정 간격이 아니라 지수적으로 늘려야 하는가?"라는 질문이 자주 나옵니다.
고정 간격 vs 지수 백오프
| 시도 | FixedBackOff (1초 고정) | ExponentialBackOff (1초 시작, 배수 2) |
|---|---|---|
| 1차 재시도 | 1초 후 | 1초 후 |
| 2차 재시도 | 1초 후 | 2초 후 |
| 3차 재시도 | 1초 후 | 4초 후 |
| 4차 재시도 | 1초 후 | 8초 후 |
DB 연결이 끊기거나 외부 API가 다운된 상황을 생각해 보겠습니다. 고정 간격으로 1초마다 재시도하면, 이미 과부하인 시스템에 매초 요청을 보내는 셈입니다. 지수 백오프는 간격을 점진적으로 늘려서 시스템이 복구될 시간을 확보 합니다.
지수 백오프는 "실패가 반복될수록 일시적 장애가 아닐 가능성이 높다"는 가정에 기반한다. 간격을 늘려 불필요한 부하를 줄이면서도,
maxInterval로 상한선을 두어 너무 오래 기다리지 않도록 한다.
설정 파라미터 정리
| 파라미터 | 설명 | 권장 값 |
|---|---|---|
initialInterval | 첫 재시도까지 대기 시간 | 1000ms (1초) |
multiplier | 대기 시간 증가 배수 | 2.0 |
maxInterval | 대기 시간 상한선 | 10000~30000ms |
MessageRecoverer — 재시도 소진 후 처리
모든 재시도가 실패하면 MessageRecoverer가 최종 처리를 담당합니다. Spring AMQP는 두 가지 주요 구현체를 제공합니다.
RejectAndDontRequeueRecoverer (기본값)
// 메시지를 reject하고 requeue하지 않음
// DLX가 설정되어 있으면 DLX로 전달, 없으면 메시지 유실
new RejectAndDontRequeueRecoverer();
이 방식은 간단하지만, DLX를 별도로 설정하지 않으면 메시지가 그냥 사라집니다. DLX 글에서 다뤘던 것처럼, 큐에 x-dead-letter-exchange argument를 설정해 둬야 DLQ로 전달됩니다.
RepublishMessageRecoverer (권장)
// 지정된 Exchange/Queue로 메시지를 재발행
// 에러 정보(스택트레이스, 예외 메시지)를 헤더에 추가
RepublishMessageRecoverer recoverer = new RepublishMessageRecoverer(
rabbitTemplate, "dlx.exchange", "dlq.routing-key"
);
RepublishMessageRecoverer는 단순히 메시지를 옮기는 것이 아니라, 에러 정보를 메시지 헤더에 추가 합니다. DLQ에서 메시지를 확인할 때 왜 실패했는지 바로 알 수 있어서 디버깅이 훨씬 수월합니다.
헤더에 추가되는 정보
| 헤더 키 | 내용 |
|---|---|
x-exception-stacktrace | 예외 스택트레이스 |
x-exception-message | 예외 메시지 |
x-original-exchange | 원래 exchange |
x-original-routingKey | 원래 routing key |
RetryTemplate vs Requeue 차이
여기서 헷갈리기 쉬운 부분이 있습니다. RetryTemplate의 재시도와 requeue를 통한 재시도는 완전히 다른 메커니즘입니다.
| 구분 | RetryTemplate 재시도 | Requeue 재시도 |
|---|---|---|
| 실행 위치 | 소비자 내부 (같은 스레드) | 브로커 → 소비자 (네트워크 왕복) |
| ** 횟수 제한** | SimpleRetryPolicy로 제한 가능 | 기본적으로 무한 |
| ** 대기 전략** | BackOffPolicy 적용 가능 | 즉시 재전달 |
| ** 최종 실패 처리** | MessageRecoverer 위임 | 별도 처리 없음 |
RetryTemplate은 소비자 내부에서 재시도하므로 네트워크 왕복이 없고, 횟수와 간격을 정밀하게 제어할 수 있다.requeue는 브로커 수준에서 동작하므로 다른 소비자에게 메시지가 전달될 수 있다는 차이가 있다.
DLQ 연계 전략
재시도가 소진된 메시지를 DLQ(Dead Letter Queue)로 보내는 것까지는 설정했습니다. 그 다음이 중요합니다.
전체 흐름
Producer → Exchange → Queue → Consumer
↓ (3회 재시도 실패)
RepublishMessageRecoverer
↓
DLX Exchange → DLQ
↓
모니터링 / 알림 / 수동 재처리
DLQ 메시지 재처리 패턴
@Component
public class DlqProcessor {
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* DLQ 메시지를 확인하고 원래 큐로 재전송하는 관리 기능
* 운영자가 원인을 파악한 뒤 수동으로 호출
*/
public void retryFromDlq(String dlqName, String originalExchange, String originalRoutingKey) {
Message message = rabbitTemplate.receive(dlqName);
if (message != null) {
// 에러 헤더 제거 후 원래 큐로 재전송
message.getMessageProperties().getHeaders().remove("x-exception-stacktrace");
message.getMessageProperties().getHeaders().remove("x-exception-message");
rabbitTemplate.send(originalExchange, originalRoutingKey, message);
}
}
}
DLQ 운영 시 주의할 점
- **모니터링 필수 **: DLQ에 메시지가 쌓이고 있다면 시스템에 문제가 있다는 신호입니다. Prometheus + Grafana 같은 도구로 DLQ 메시지 수를 모니터링하고, 임계치 초과 시 알림을 설정합니다.
- **DLQ에도 TTL 설정 **: DLQ 메시지가 무한히 쌓이면 디스크 공간을 차지합니다. 적절한 TTL을 설정하거나, 주기적으로 오래된 메시지를 정리합니다.
- ** 재처리 자동화는 신중하게 **: DLQ 메시지를 자동으로 원래 큐에 넣는 것은 위험합니다. 원인이 해결되지 않은 상태에서 재처리하면 다시 DLQ로 돌아올 뿐입니다.
멱등성(Idempotency) — 중복 처리 방지
At-least-once 전달 보장은 메시지가 ** 최소 한 번 **은 전달됨을 보장합니다. 하지만 그 말은 ** 두 번 이상 전달될 수도 있다 **는 뜻이기도 합니다.
소비자가 메시지를 성공적으로 처리했지만 ACK를 보내기 전에 네트워크가 끊기면, 브로커는 메시지가 처리되지 않았다고 판단하고 다시 전달한다. 이때 멱등성이 보장되지 않으면 이중 결제, 이중 발송 같은 심각한 문제가 발생한다.
멱등성 구현 패턴
패턴 1: 유니크 키 기반
@RabbitListener(queues = "order.queue")
public void processOrder(OrderMessage message) {
String messageId = message.getMessageId(); // UUID 등 고유 식별자
// 이미 처리된 메시지인지 확인
if (processedMessageRepository.existsByMessageId(messageId)) {
log.info("이미 처리된 메시지, 무시: {}", messageId);
return; // ACK 전송 (정상 처리로 간주)
}
// 비즈니스 로직 실행
orderService.createOrder(message);
// 처리 이력 저장 (같은 트랜잭션 내에서)
processedMessageRepository.save(new ProcessedMessage(messageId));
}
패턴 2: DB Unique Constraint 활용
@Transactional
public void processPayment(PaymentMessage message) {
try {
// orderId에 UNIQUE 제약이 걸려 있으면 중복 insert 시 예외 발생
paymentRepository.save(new Payment(
message.getOrderId(),
message.getAmount()
));
} catch (DataIntegrityViolationException e) {
// 중복 처리 시도 → 이미 처리됨, 무시
log.warn("중복 결제 시도 감지, 무시: orderId={}", message.getOrderId());
}
}
멱등성 설계 시 주의할 점
- ** 처리 이력 저장과 비즈니스 로직은 같은 트랜잭션 **에 있어야 합니다. 분리되면 사이에서 장애가 발생할 때 일관성이 깨집니다.
- ** 처리 이력 테이블의 TTL**: 메시지 ID를 영구 저장하면 테이블이 무한히 커집니다. 일정 기간이 지난 이력은 삭제하는 정책이 필요합니다.
Poison Message 문제
Poison Message(독성 메시지)는 데이터 형식 오류, 비즈니스 로직상 절대 처리할 수 없는 값 등의 이유로 ** 몇 번을 재시도해도 항상 실패하는 메시지 **를 말합니다.
예시 상황
@RabbitListener(queues = "order.queue")
public void processOrder(OrderMessage message) {
// message.getAmount()가 음수 → 비즈니스 규칙 위반
// 아무리 재시도해도 이 메시지는 절대 성공할 수 없음
if (message.getAmount() < 0) {
throw new IllegalArgumentException("주문 금액은 양수여야 합니다");
}
}
이런 메시지는 재시도가 의미 없습니다. 3회든 100회든 결과는 같습니다.
해결 전략
- ** 재시도 횟수 제한 **:
SimpleRetryPolicy로 최대 시도 횟수를 설정합니다. (이미 위에서 다룬 내용) - ** 재시도 대상 예외 필터링 **: 특정 예외는 재시도하지 않도록 설정합니다.
@Bean
public RetryOperationsInterceptor retryInterceptor() {
// 재시도할 예외와 하지 않을 예외를 구분
Map<Class<? extends Throwable>, Boolean> retryableExceptions = new HashMap<>();
retryableExceptions.put(RuntimeException.class, true); // 재시도 대상
retryableExceptions.put(IllegalArgumentException.class, false); // 재시도하지 않음 (데이터 문제)
retryableExceptions.put(MessageConversionException.class, false); // 재시도하지 않음 (역직렬화 실패)
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy(3, retryableExceptions, true);
return RetryInterceptorBuilder.stateless()
.retryPolicy(retryPolicy)
.backOffPolicy(exponentialBackOff())
.recoverer(republishRecoverer())
.build();
}
- ** 에러 분류 체계 **: 일시적 장애(Transient)와 영구적 장애(Permanent)를 구분하는 예외 계층을 설계합니다.
| 장애 유형 | 예시 | 재시도 여부 |
|---|---|---|
| ** 일시적(Transient)** | DB 연결 끊김, 외부 API 타임아웃, 네트워크 오류 | O (재시도하면 성공할 가능성 있음) |
| ** 영구적(Permanent)** | 잘못된 데이터, 역직렬화 실패, 비즈니스 규칙 위반 | X (재시도해도 항상 실패) |
전체 설정 예시
지금까지 다룬 내용을 하나의 설정 클래스로 정리하면 다음과 같습니다.
@Configuration
public class RabbitErrorHandlingConfig {
@Autowired
private RabbitTemplate rabbitTemplate;
// DLQ 인프라 선언
@Bean
public DirectExchange dlxExchange() {
return new DirectExchange("dlx.exchange");
}
@Bean
public Queue dlq() {
return QueueBuilder.durable("order.dlq")
.ttl(604800000) // 7일 후 자동 삭제
.build();
}
@Bean
public Binding dlqBinding() {
return BindingBuilder.bind(dlq())
.to(dlxExchange())
.with("order.dlq.routing-key");
}
// 재시도 인터셉터
@Bean
public RetryOperationsInterceptor retryInterceptor() {
return RetryInterceptorBuilder.stateless()
.retryPolicy(new SimpleRetryPolicy(3))
.backOffPolicy(exponentialBackOff())
.recoverer(new RepublishMessageRecoverer(
rabbitTemplate, "dlx.exchange", "order.dlq.routing-key"
))
.build();
}
private ExponentialBackOffPolicy exponentialBackOff() {
ExponentialBackOffPolicy policy = new ExponentialBackOffPolicy();
policy.setInitialInterval(1000);
policy.setMultiplier(2.0);
policy.setMaxInterval(10000);
return policy;
}
// 리스너 컨테이너 팩토리에 재시도 인터셉터 적용
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
factory.setAdviceChain(retryInterceptor());
factory.setAcknowledgeMode(AcknowledgeMode.AUTO); // 재시도 소진 후 자동 ACK/NACK
return factory;
}
}
정리
| 항목 | 핵심 내용 |
|---|---|
| ** 기본 동작 문제** | 예외 발생 시 무한 requeue → 무한 재시도 루프 |
| RetryTemplate | 소비자 내부에서 횟수/간격 제어가 가능한 재시도 |
| ExponentialBackOff | 재시도 간격을 점진적으로 늘려 시스템 복구 시간 확보 |
| RepublishMessageRecoverer | 재시도 소진 후 에러 정보와 함께 DLQ로 재발행 |
| DLQ 연계 | 실패 메시지 격리 → 모니터링 → 원인 파악 후 재처리 |
| ** 멱등성** | At-least-once에서 중복 처리 방지 (유니크 키, DB 제약) |
| Poison Message | 영구 실패 메시지는 예외 필터링으로 즉시 DLQ 전달 |
주의할 점
RetryTemplate의 재시도는 ** 소비자 스레드를 점유 **합니다.maxAttempts와maxInterval을 너무 크게 설정하면 스레드가 오래 블로킹되어 다른 메시지 처리가 지연될 수 있습니다.RepublishMessageRecoverer를 사용하려면 DLX Exchange와 DLQ가 미리 선언되어 있어야 합니다.- 멱등성 구현 시 ** 처리 이력 확인과 비즈니스 로직 실행이 원자적(atomic)**이어야 합니다. 그렇지 않으면 "확인은 했지만 처리 전에 장애 발생" 같은 빈틈이 생깁니다.