Kafka 재시도 및 DLT

Spring Boot와 Kafka를 연동할 때 필수적으로 고려해야 할 메시지 처리 실패 시 재시도(Retry) 전략과 데드 레터 토픽(Dead Letter Topic, DLT) 활용 방안에 대해 심층적으로 알아보자. 이는 비동기 메시지 처리 시스템의 견고성과 안정성을 확보하는 데 매우 중요하다

메시지 처리 실패 상황 재현

Kafka 컨슈머가 메시지 처리 중 실패하는 상황을 가정하여, 의도적으로 예외를 발생시키는 코드를 추가한다

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Service
public class EmailSendConsumer {
    private final ObjectMapper objectMapper;

    public EmailSendConsumer(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @KafkaListener(
            topics = "email.send",
            groupId = "email-send-group"
    )
    public void consume(String message) {
        System.out.println("Kafka로부터 받아온 메시지: " + message);

        EmailSendMessage emailSendMessage = EmailSendMessage.fromJson(message);

        // 의도적인 실패 로직 추가: 'to' 주소가 'fail@test.com'이면 런타임 예외 발생
        if (emailSendMessage.getTo().equals("fail@test.com")) {
            System.out.println("잘못된 이메일 주소로 인해 발송 실패: " + emailSendMessage.getTo());
            throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
        }

        // 실제 이메일 발송 로직은 생략. 처리 지연을 시뮬레이션
        try {
            Thread.sleep(3_000); // 3초 지연
        } catch (InterruptedException e) {
            // InterruptedException 발생 시 RuntimeException으로 래핑
            Thread.currentThread().interrupt(); // 인터럽트 상태 복원
            throw new RuntimeException("이메일 발송 중 인터럽트 발생", e);
        }

        System.out.println("이메일 발송 완료: " + emailSendMessage.getSubject() + " to " + emailSendMessage.getTo());
    }
}

수정된 consume 메시드는 EmailSendMessage의 to 필드가 “fail@test.com”일 경우 RuntimeException을 발생시켜 메시지 처리 실패를 유도한다

테스트

POST http://localhost:8080/api/emails
Content-Type: application/json

{
  "from": "sender@test.com",
  "to": "fail@test.com",
  "subject": "제목",
  "body": "내용"
}

Consumer 서버의 로그를 확인하면 다음과 같은 패턴이 반복된다

Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"fail@test.com","subject":"제목","body":"내용"}
잘못된 이메일 주소로 인해 발송 실패: fail@test.com

이 메시지가 여러 번 반복된 후, ListenerExecutionFailedException과 함께 스택 트레이스가 출력될 것이다. 이 로그는 Spring Kafka가 메시지 처리 실패 시 기본 재시도 정책에 따라 여러 번 재시도를 수행했음을 보여준다

로그에 Backoff FixedBackOff{interval=0, currentAttempts=10, maxAttempts=9} exhausted for email.send-0@3와 같은 메시지를 찾을 수 있다

  • interval=0: 재시도 간격이 0ms, 즉 즉시 재시도한다는 의미이다.
  • currentAttempts=10: 현재까지 시도한 횟수 (최초 시도 + 9번의 재시도)
  • maxAttempts=9: 최대 재시도 횟수이다. 이는 Spring Kafka의 기본 재시도 정책이 즉시, 최대 9번의 재시도를 수행한다는 것을 나타낸다

@RetryableTopic을 활용한 재시도 정책 설정

기본 재시도 정책은 즉시 재시도하므로 시스템에 과부하를 줄 수 있고, 일시적인 장애가 길어질 경우 비효율적이다. 현업에서는 재시도 간격을 점진적으로 늘리는 지수 백오프(Exponential Backoff) 전략을 많이 사용한다. Spring Kafka는 @RetryableTopic 어노테이션 통해 이러한 고급 재시도 정책을 쉽게 설정할 수 있도록 한다

EmailSendConsumer의 @KafkaListener 위에 @RetryableTopic 어노테이션을 추가한다

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic; // import 추가
import org.springframework.retry.annotation.Backoff; // import 추가
import org.springframework.stereotype.Service;

@Service
public class EmailSendConsumer {
    private final ObjectMapper objectMapper;

    public EmailSendConsumer(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @RetryableTopic(
            attempts = "5", // 총 시도 횟수 (최초 1회 + 4회 재시도)
            backoff = @Backoff(delay = 1000, multiplier = 2) // 지수 백오프 설정
    )
    @KafkaListener(
            topics = "email.send",
            groupId = "email-send-group"
    )
    public void consume(String message) {
        System.out.println("Kafka로부터 받아온 메시지: " + message);

        EmailSendMessage emailSendMessage = EmailSendMessage.fromJson(message);

        if (emailSendMessage.getTo().equals("fail@test.com")) {
            System.out.println("잘못된 이메일 주소로 인해 발송 실패: " + emailSendMessage.getTo());
            throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
        }

        try {
            Thread.sleep(3_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("이메일 발송 중 인터럽트 발생", e);
        }

        System.out.println("이메일 발송 완료: " + emailSendMessage.getSubject() + " to " + emailSendMessage.getTo());
    }
}
  • attempts = “5”: 메시지 처리 시도 횟수를 총 5회로 설정한다. (최초 시도 1회 + 4회 재시도) 현업에서는 3~5회 사이가 적절하다고 평가된다. 너무 많으면 시스템 부하가 커지고, 너무 적으면 일시적인 장애에 대응하기 어렵다
  • backoff = @Backoff(delay = 1000, multiplier = 2): 재시도 간격을 설정한다
    • delay = 1000: 첫 재시도 시 1초(1000ms) 대기한다
    • multiplier = 2: 재시도할 때마다 이전 대기 시간에 2를 곱하여 지수적으로 증가시킨다. 즉, 1초 ⭢ 2초 ⭢ 4초 ⭢ 8초의 간격으로 재시도하게 된다. 이러한 지수 백오프는 일시적인 장애에는 빠르게 대응하고, 장애가 길어질 경우 무의미한 재시도를 줄여 시스템 부하를 완화하는 데 효과적이다

테스트

서버를 재시작하고 동일한 실패 요청을 다시 보낸다. Consumer 서버의 로그를 확인하면 다음과 같이 재시도 간격이 점진적으로 증가하며 메시지가 출력될 것이다

Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"fail@test.com","subject":"제목","body":"내용"}
잘못된 이메일 주소로 인해 발송 실패: fail@test.com
// 1초 후
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"fail@test.com","subject":"제목","body":"내용"}
잘못된 이메일 주소로 인해 발송 실패: fail@test.com
// 2초 후
Kafka로부터 받아온 메시지: {"from":"sender@test.com","to":"fail@test.com","subject":"제목","body":"내용"}
잘못된 이메일 주소로 인해 발송 실패: fail@test.com
// 4초 후
... (총 5회 시도 후 최종 실패)

로그의 시간 간격을 통해 설정한 재시도 정책이 올바르게 적용되었음을 확인할 수 있다

데드 레터 토픽 (Dead Letter Topic, DLT) 활용

재시도 정책을 설정했음에도 불구하고, 모든 재시도가 실패하여 최종적으로 메시지 처리가 불가능한 경우가 발생할 수 있다. 이러한 “데드 메시지”를 단순히 버리면 데이터 유실로 이어질 수 있다. 이때 데드 레터 토픽(DLT)를 활용하여 실패한 메시지를 별도로 보관하고 후속 조치를 취할 수 있다

DLT의 역할 및 장점

  • DLT( Dead Letter Topic): 오류로 인해 더 이상 처리할 수 없는 메시지를 임시로 저장하는 별도의 Kafka 토픽이다
  • 유실 방지: 재시도까지 실패한 중요한 메시지를 유실하지 않고 안전하게 보관한다
  • 원인 분석: DLT에 저장된 메시지를 통해 실패 원인을 분석하고 시스템 개선에 활용할 수 있다
  • 수동 처리: 관리자가 DLT에 있는 메시지를 확인하여 수동으로 재처리하거나, 폐기 등의 적절한 조치를 취할 수 있다

Spring Kafka의 @RetryableTopic은 기본적으로 DLT 기능을 지원한다. @RetryableTopic을 사용하면, 재시도 횟수를 모두 소진하고도 실패한 메시지를 자동으로 DLT로 전송된다. 기본 DLT 토픽 이름은 {기존 토픽명}.dlt 또는 {기존 토픽명}-dlt 형태이다

DLT 토픽 이름 설정

@RetryableTopic에 dltTopicSuffix 속성을 사용하여 DLT 토픽의 이름 접미사를 명확히 지정할 수 있다

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.annotation.RetryableTopic;
import org.springframework.retry.annotation.Backoff;
import org.springframework.stereotype.Service;

@Service
public class EmailSendConsumer {
    private final ObjectMapper objectMapper;

    public EmailSendConsumer(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @RetryableTopic(
            attempts = "5",
            backoff = @Backoff(delay = 1000, multiplier = 2),
            dltTopicSuffix = ".dlt" // DLT 토픽 접미사 설정
    )
    @KafkaListener(
            topics = "email.send",
            groupId = "email-send-group"
    )
    public void consume(String message) {
        // ... (이전과 동일한 실패 로직)
        System.out.println("Kafka로부터 받아온 메시지: " + message);

        EmailSendMessage emailSendMessage = EmailSendMessage.fromJson(message);

        if (emailSendMessage.getTo().equals("fail@test.com")) {
            System.out.println("잘못된 이메일 주소로 인해 발송 실패: " + emailSendMessage.getTo());
            throw new RuntimeException("잘못된 이메일 주소로 인해 발송 실패");
        }

        try {
            Thread.sleep(3_000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("이메일 발송 중 인터럽트 발생", e);
        }

        System.out.println("이메일 발송 완료: " + emailSendMessage.getSubject() + " to " + emailSendMessage.getTo());
    }
}

이제 email.send 토픽의 DLT는 email.send.dlt 라는 이름으로 생성된다

테스트

서버를 재시작하고 실패 요청을 다시 보낸다. 5번의 재시도가 모두 실패하면, 해당 메시지가 email.send.dlt 토픽으로 전송된다

bin/kafka-topics.sh --bootstrap-server localhost:9092 --list

email.send
email.send-retry-0 // 내부적으로 생성되는 재시도 토픽
email.send-retry-1 // 내부적으로 생성되는 재시도 토픽
email.send-retry-2 // 내부적으로 생성되는 재시도 토픽
email.send-retry-3 // 내부적으로 생성되는 재시도 토픽
email.send.dlt       // 우리가 설정한 DLT 토픽

email.send.dlt 토픽에 메시지가 제대로 저장되었는지 확인한다

bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic email.send.dlt --from-beginning

출력: {“from”:”sender@test.com”,”to”:”fail@test.com”,”subject”:”제목”,”body”:”내용”} 이 로그는 실패한 메시지가 DLT에 성공적으로 저장되었음을 보여준다

DLT 메시지 사후 처리 전략

DLT에 메시지가 저장되었다는 것은 문제 해결의 시작점일 뿐이다. DLT에 저장된 메시지는 여전히 해결되지 않은 문제이므로, 다음과 같은 사후 처리 전략을 통해 관리해야 한다.

DLT 컨슈머 구현

DLT 토픽을 구독하는 별도의 컨슈머를 구현한다. 이 컨슈머는 DLT에 메시지가 도착하면 다음 조치들을 수행한다

import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Service
public class EmailSendDltConsumer {

    @KafkaListener(
            topics = "email.send.dlt", // DLT 토픽 구독
            groupId = "email-send-dlt-group" // DLT 전용 컨슈머 그룹
    )
    public void consume(String message) {
        System.out.println("--- DLT 메시지 처리 시작 ---");
        System.out.println("로그 시스템에 전송 (DLT): " + message); // 로그 시스템으로 전송
        System.out.println("알림 발송 (DLT): " + message); // Slack, PagerDuty 등 알림 시스템으로 전송

        // 실제 실패 원인 분석 및 수동 조치 로직 구현
        // 예:
        // EmailSendMessage failedMessage = EmailSendMessage.fromJson(message);
        // if (failedMessage.getTo().equals("fail@test.com")) {
        //     System.out.println("DLT: 'fail@test.com' 주소로 인한 영구적 실패. 관리자 조치 필요.");
        //     // 데이터베이스에 실패 기록, 사용자에게 알림 (별도의 채널로), 등
        // }

        System.out.println("--- DLT 메시지 처리 완료 ---");
    }
}

DLT 컨슈머는 실패 메시지를 수신하여 로그 시스템(예: ELK Stack, Splunk)에 상세 정보를 기록하고, 개발자나 운영팀에게 Slack, 이메일, SMS 등 알림 채널을 통해 즉시 통보한다

실패 원인 분석 및 재시도 / 폐기 결정
  • 알림을 받은 관리자는 로그 시스템을 통해 DLT에 저장된 메시지의 상세 내용과 실패 스택 트레이스를 분석하여 원인을 파악한다
  • 일시적인 장애 해결 후 재시도: 외부 메일 서버 다운, 컨슈머 서비스 장애 등 일시적인 문제였고 현재는 해결되었다면, DLT에 있는 메시지를 수동으로 다시 원래 토픽(email.send)으로 전송하여 재처리를 시도할 수 있다
  • 영구적인 문제로 인한 폐기: 잘못된 이메일 주소 형식, 탈퇴한 사용자에게 메시지를 전송 시도 등 메시지 내용 자체가 문제여서 영구적으로 처리할 수 없는 경우, 해당 메시지는 폐기한다. 단, 폐기 시에도 반드시 로그를 남겨 기록을 보존해야 한다
(사전 처리) Producer의 검증 로직 보강
  • 가장 좋은 방법은 잘못된 메시지가 애초에 Kafka에 들어가지 않도록 Producer 단에서 최대한 유효성을 검증을 수행하는 것이다. 예를 들어, 이메일 주소 형식 검증, 필수 필드 누락 여부 확인 등을 Producer가 담당한다. 이렇게 하면 DLT로 가는 메시지의 수를 줄이고, 사용자에게도 즉각적인 피드백(잘못된 요청 값)을 줄 수 있어 시스템의 효율성을 높일 수 있다

최종 테스트

Producer, Consumer, DLT Consumer 서버를 모두 실행한 상태에서 실패 요청을 보낸다

  • Producer 로그: 요청 전송 성공
  • Consumer 로그: 메시지 수신, 5번의 재시도 실패 로그 출력
  • DLT Consumer 로그: 최종 실패 메시지를 DLT 토픽에서 수신하여 “로그 시스템에 전송” 및 “알림 발송” 메시지 출력

이러한 과정을 통해 Kafka를 활용한 비동시 시스템에 메시지 처리 실패에 대한 대응 체계를 구축할 수 있다. 재시도와 DLT는 실제 서비스의 안정성과 신뢰성을 보장하는 전략이다

인프런 강의 중 ‘실전에서 바로 써먹는 Kafka 입문’ 강의 참고