Kafka를 통해 쿠폰 발급 성공 이벤트만 전송하고 있었는데 DB 저장 실패 등의 예외가 발생해도 로그만 남기고 메시지가 유실되는 문제가 있었다. Kafka Consumer에서 예외가 발생하면 메시지가 무한 재시도되거나 손실될 수 있었고 개발 환경에서는 이슈 발생 시 어떤 요청이 실패했는지를 파악하기 어려웠다.
이러한 상황에서는 메시지의 정상 처리 여부를 명확히 구분하기 어려워서 장애 대응이나 테스트 결과 분석에 시간이 오래 걸리는 문제가 있었다. 이 문제를 해결하고 실패한 이벤트를 명확히 분리하고 추적 가능하게 만들기 위해 Kafka DLQ(Dead Letter Queue) 구조를 도입하게 되었다.
RedisQueueWorker
에서는 쿠폰 발급이 성공한 경우에만 Kafka에 메시지를 전송하도록 했다.
case SUCCESS -> {
couponIssueProducer.send("coupon.issue", String.valueOf(couponId),
new CouponIssueEventDto(couponId, userId));
}
@KafkaListener(topics = "coupon.issue", groupId = "coupon-consumer-group", concurrency = "1")
@RetryableTopic(
attempts = "3", // 최대 3회 재시도
backoff = @Backoff(delay = 2000, multiplier = 2.0), // 2초 -> 4초 -> 8초 간격으로 재시도
dltTopicSuffix = ".dlq", // 실패 시 전송될 토픽 이름 접미사
autoCreateTopics = "false" // DLQ 토픽 자동 생성 여부 (운영에선 false 권장)
)
public void consume(CouponIssueEventDto event) {
try {
saveCouponIssue(event);
} catch (DataIntegrityViolationException e) {
log.info("중복 insert 무시: couponId={}, userId={}", ...);
} catch (Exception e) {
log.error("Kafka 처리 중 예외 발생", e);
throw e; // retry → 3회 실패 시 DLQ로 전송됨
}
}
@RetryableTopic
을 통해 Kafka Consumer에서 예외 발생 시 최대 3회 재시도하고 실패 시 coupon.issue.dlq
토픽으로 자동 전송되도록 구성했다.
중복 발급 같은 예외는 DataIntegrityViolationException
으로 분리해서 재시도 없이 무시하도록 설정했다.
@KafkaListener(topics = "coupon.issue.dlq", groupId = "coupon-dlq-group",
concurrency = "1", containerFactory = "dlqKafkaListenerContainerFactory")
public void handleDLQ(CouponIssueEventDto event) {
String reason = event.getReason() != null
? event.getReason()
: ExceptionEnum.DB_SAVE_FAILED.getMsg();
log.warn("⚠️ [DLQ] 쿠폰 발급 실패 - couponId={}, userId={}, reason={}",
event.getCouponId(), event.getUserId(), reason);
}
DLQ 토픽으로 전송된 실패 메시지를 별도로 소비하여 로깅 처리했다. 그리고 추후 여기서 Slack 알림, DB 저장, 관리자 페이지 연동 등으로 확장 가능하다.
@Bean
public ConcurrentKafkaListenerContainerFactory<String, CouponIssueEventDto> dlqKafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, CouponIssueEventDto> factory =
new ConcurrentKafkaListenerContainerFactory<>();
factory.setConsumerFactory(consumerFactory());
DefaultErrorHandler errorHandler = new DefaultErrorHandler(
new FixedBackOff(0L, 0) // 재시도 없이 바로 skip
);
errorHandler.addNotRetryableExceptions(DataIntegrityViolationException.class);
factory.setCommonErrorHandler(errorHandler);
return factory;
}
DLQ Consumer에는 별도의 KafkaListenerContainerFactory
를 적용해 재시도 없이 즉시 처리되도록 구성했다. 만약 DLQ Consumer에서도 처리 실패 시 무한 재시도되는 걸 막기 위해 FixedBackOff(0, 0)
설정을 사용했고 DataIntegrityViolationException
은 무시 대상 예외로 등록하여 DLQ에서도 반복 실패 없이 안전하게 동작하도록 했다.
Kafka는 기본적으로 DLQ 개념이 내장되어 있지 않지만 Spring Kafka의 @RetryableTopic
을 활용하면 매우 쉽게 재시도 -> 실패 시 DLQ로 전송하는 구조를 손쉽게 구현할 수 있었다.
이번 구조 개선을 통해 Kafka 메시지 처리 실패를 명확히 분리하고 장애 상황을 추적할 수 있는 기반이 마련되었다. DLQ를 통해 운영 중 발생할 수 있는 예외 상황을 손쉽게 로그나 알림 기반으로 모니터링 가능해졌고 추후 슬랙 알림, Sentry 연동, 실패 내역 DB 저장 등으로 확장하기도 수월해졌다.
단순히 실패를 무시하는 구조에서 벗어나 실패를 운영 가능하게 "보이는 실패"로 전환한 것이 가장 큰 개선 포인트였다. 그래서 DLQ는 Kafka 기반의 비동기 시스템을 운영하면서 꼭 필요한 구조라고 생각한다.