Kafka DLQ(Dead Letter Queue) 적용기

송현진·2025년 5월 2일
0

Kafka

목록 보기
7/7

⚠️ 문제 상황

Kafka를 통해 쿠폰 발급 성공 이벤트만 전송하고 있었는데 DB 저장 실패 등의 예외가 발생해도 로그만 남기고 메시지가 유실되는 문제가 있었다. Kafka Consumer에서 예외가 발생하면 메시지가 무한 재시도되거나 손실될 수 있었고 개발 환경에서는 이슈 발생 시 어떤 요청이 실패했는지를 파악하기 어려웠다.

이러한 상황에서는 메시지의 정상 처리 여부를 명확히 구분하기 어려워서 장애 대응이나 테스트 결과 분석에 시간이 오래 걸리는 문제가 있었다. 이 문제를 해결하고 실패한 이벤트를 명확히 분리하고 추적 가능하게 만들기 위해 Kafka DLQ(Dead Letter Queue) 구조를 도입하게 되었다.

🛠️ 적용 내용

1. 성공 이벤트만 Kafka 전송

RedisQueueWorker에서는 쿠폰 발급이 성공한 경우에만 Kafka에 메시지를 전송하도록 했다.

case SUCCESS -> {
    couponIssueProducer.send("coupon.issue", String.valueOf(couponId),
        new CouponIssueEventDto(couponId, userId));
}

2. Kafka Consumer에 @RetryableTopic 적용

@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으로 분리해서 재시도 없이 무시하도록 설정했다.

3. DLQ Consumer 구성

@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 저장, 관리자 페이지 연동 등으로 확장 가능하다.

  1. DLQ 전용 Kafka Listener Factory 등록
@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 기반의 비동기 시스템을 운영하면서 꼭 필요한 구조라고 생각한다.

profile
개발자가 되고 싶은 취준생

0개의 댓글