TL;DR
  • 에러 유형: 역직렬화 에러(건너뛰기), 일시적 에러(재시도), 영구적 에러(DLT)
  • @RetryableTopic으로 선언적 재시도 및 DLT 처리 (Spring Kafka 2.7+)
  • DefaultErrorHandler + DeadLetterPublishingRecoverer로 DLT 발행
  • 역직렬화 에러는 ErrorHandlingDeserializer로 처리
  • DLT 메시지 도착 시 알림 설정으로 빠른 대응 필요

대상 독자: Consumer 에러 처리 전략을 구현하려는 개발자

선수 지식: Consumer Group & Offset의 Consumer 동작 원리, Spring Kafka 기초

소요 시간: 약 25-30분


Kafka Consumer의 에러 처리 전략과 Dead Letter Topic 패턴을 이해합니다.

전체 비유: 우편물 처리#

Consumer 에러 처리를 우편물 배달에 비유하면 이해하기 쉽습니다:

우편물 배달 비유Kafka 에러 처리설명
주소 오류 (읽을 수 없음)역직렬화 에러메시지 자체가 잘못됨, 재시도 무의미
부재중 (나중에 재배달)일시적 에러잠시 후 재시도하면 성공 가능
수취 거부 (반송)영구적 에러재시도해도 실패, DLT로 보관
반송 우편함Dead Letter Topic처리 불가 우편물 보관소
재배달 시도 (1회, 2회…)재시도 정책지수 백오프로 간격 늘려가며 재시도

핵심은 우편물 유형에 따라 다르게 처리하는 것입니다. 주소 오류는 재배달해도 소용없고, 부재중은 재배달하면 됩니다.

에러 유형#

Kafka Consumer에서 발생하는 에러는 크게 세 가지 유형으로 분류됩니다.

flowchart TB
    subgraph Errors["에러 유형"]
        DESER["역직렬화 에러<br>(메시지 형식 문제)"]
        TRANS["일시적 에러<br>(네트워크, DB 연결)"]
        PERM["영구적 에러<br>(비즈니스 로직 실패)"]
    end

    DESER --> SKIP["건너뛰기/DLT"]
    TRANS --> RETRY["재시도"]
    PERM --> DLT["Dead Letter Topic"]

다이어그램: 에러 유형별 처리 전략 - 역직렬화 에러는 건너뛰기/DLT, 일시적 에러는 재시도, 영구적 에러는 DLT로 전송.

역직렬화 에러는 JSON 파싱 실패와 같은 메시지 형식 문제입니다.

핵심 포인트
  • 역직렬화 에러: 메시지 형식 문제, 재시도 무의미 → 건너뛰기/DLT
  • 일시적 에러: DB 연결, 타임아웃 등 → 재시도로 해결 가능
  • 영구적 에러: 비즈니스 로직 실패 → DLT로 보내 별도 처리
메시지 자체가 잘못되었으므로 재시도해도 해결되지 않습니다. 건너뛰거나 Dead Letter Topic으로 보냅니다.

일시적 에러는 DB 연결 실패, 타임아웃과 같이 일시적으로 발생하는 문제입니다. 잠시 후 재시도하면 성공할 가능성이 높으므로 재시도 전략을 적용합니다.

영구적 에러는 유효성 검증 실패와 같이 비즈니스 로직에서 발생하는 문제입니다. 재시도해도 결과가 같으므로 Dead Letter Topic으로 보내 별도로 처리합니다.

기본 에러 처리#

DefaultErrorHandler (Spring Kafka 2.8+)

Spring Kafka 2.8부터 제공되는 기본 에러 핸들러입니다. 설정된 간격과 횟수로 재시도하고, 최대 재시도 횟수를 초과하면 레코드를 건너뜁니다.

@Configuration
public class KafkaConfig {

    @Bean
    public DefaultErrorHandler errorHandler() {
        // 1초 간격으로 3회 재시도
        return new DefaultErrorHandler(
            new FixedBackOff(1000L, 3L)
        );
    }
}

재시도 전략

FixedBackOff는 고정된 간격으로 재시도합니다. 위 예시에서는 1초 간격으로 3회 재시도합니다.

ExponentialBackOff는 지수 형태로 대기 시간을 증가시킵니다. 첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후와 같이 대기 시간이 증가합니다.

@Bean
public DefaultErrorHandler errorHandler() {
    ExponentialBackOff backOff = new ExponentialBackOff(1000L, 2.0);
    backOff.setMaxElapsedTime(60000L);  // 최대 1분
    return new DefaultErrorHandler(backOff);
}

Dead Letter Topic (DLT)#

Dead Letter Topic은 재시도 후에도 처리할 수 없는 메시지를 저장하는 별도 Topic입니다. 실패한 메시지를 버리지 않고 보관하여 나중에 분석하거나 수동으로 처리할 수 있습니다.

flowchart LR
    subgraph Main["메인 Topic"]
        MSG[메시지]
    end

    subgraph Consumer["Consumer"]
        PROC[처리]
        RETRY[재시도]
    end

    subgraph DLT["Dead Letter Topic"]
        DEAD[실패 메시지]
    end

    MSG --> PROC
    PROC -->|실패| RETRY
    RETRY -->|최대 재시도 초과| DEAD
    RETRY -->|성공| DONE[완료]

다이어그램: DLT 처리 흐름 - 메시지 처리 실패 시 재시도, 최대 재시도 초과 시 Dead Letter Topic으로 전송.

핵심 포인트
  • Dead Letter Topic: 재시도 후에도 실패한 메시지 저장 공간
  • 실패 메시지를 버리지 않고 보관하여 분석/수동 처리 가능
  • 기본 DLT Topic 이름: 원본-topic.DLT (예: orders.DLT)

DeadLetterPublishingRecoverer

Spring Kafka에서 제공하는 DLT 발행 기능입니다. 최대 재시도 후에도 실패하면 메시지를 DLT로 전송합니다. 기본 DLT Topic 이름은 원본-topic.DLT입니다(예: orders.DLT).

@Configuration
public class KafkaConfig {

    @Bean
    public DefaultErrorHandler errorHandler(
            KafkaTemplate<String, Object> kafkaTemplate) {

        DeadLetterPublishingRecoverer recoverer =
            new DeadLetterPublishingRecoverer(kafkaTemplate);

        return new DefaultErrorHandler(
            recoverer,
            new FixedBackOff(1000L, 3L)
        );
    }
}

DLT 커스터마이징

DLT Topic 이름을 변경하거나 특정 예외는 재시도하지 않도록 설정할 수 있습니다.

@Bean
public DefaultErrorHandler errorHandler(
        KafkaTemplate<String, Object> kafkaTemplate) {

    // DLT Topic 이름 커스터마이징
    DeadLetterPublishingRecoverer recoverer =
        new DeadLetterPublishingRecoverer(kafkaTemplate,
            (record, exception) ->
                new TopicPartition(
                    record.topic() + "-dead-letter",
                    record.partition()
                ));

    // 특정 예외는 재시도하지 않음
    DefaultErrorHandler handler = new DefaultErrorHandler(
        recoverer,
        new FixedBackOff(1000L, 3L)
    );

    handler.addNotRetryableExceptions(
        ValidationException.class,
        NullPointerException.class
    );

    return handler;
}

ValidationException이나 NullPointerException은 재시도해도 결과가 같으므로 즉시 DLT로 보냅니다.

@RetryableTopic (권장)#

Spring Kafka 2.7 이상에서 제공하는 선언적 재시도 및 DLT 처리입니다. 어노테이션으로 재시도 정책을 정의하여 코드가 간결해집니다.

기본 사용법

@Component
public class OrderConsumer {

    @RetryableTopic(
        attempts = "4",  // 원본 1회 + 재시도 3회
        backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    @KafkaListener(topics = "orders")
    public void consume(OrderEvent event) {
        // 처리 로직
        processOrder(event);
    }

    @DltHandler
    public void handleDlt(OrderEvent event,
                          @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                          @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) {
        log.error("DLT 수신 - Topic: {}, 에러: {}", topic, error);
        // DLT 메시지 처리 (알림, 로깅 등)
        alertService.sendAlert(event, error);
    }
}

재시도 Topic 구조

@RetryableTopic은 자동으로 재시도 Topic을 생성합니다. 원본 orders에서 실패하면 orders-retry-0, orders-retry-1, orders-retry-2를 거쳐 최종적으로 orders-dlt로 전송됩니다.

flowchart LR
    ORIG["orders"] -->|실패| R0["orders-retry-0"]
    R0 -->|실패| R1["orders-retry-1"]
    R1 -->|실패| R2["orders-retry-2"]
    R2 -->|실패| DLT["orders-dlt"]

    R0 -->|성공| DONE1[완료]
    R1 -->|성공| DONE2[완료]
    R2 -->|성공| DONE3[완료]

다이어그램: @RetryableTopic 재시도 흐름 - orders에서 실패 시 orders-retry-0, retry-1, retry-2를 거쳐 최종적으로 orders-dlt로 전송.

핵심 포인트
  • @RetryableTopic: 선언적 재시도 및 DLT 처리 (Spring Kafka 2.7+)
  • 자동으로 재시도 Topic 생성: topic-retry-0, retry-1, …, topic-dlt
  • @DltHandler로 DLT 메시지 처리 로직 구현

고급 설정

@RetryableTopic(
    attempts = "4",
    backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000),
    autoCreateTopics = "true",
    topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
    dltStrategy = DltStrategy.ALWAYS_RETRY_ON_ERROR,
    include = {RetryableException.class},  // 재시도할 예외
    exclude = {NonRetryableException.class}  // 재시도 안할 예외
)
@KafkaListener(topics = "orders")
public void consume(OrderEvent event) {
    // ...
}

attempts는 총 시도 횟수이며 기본값은 3입니다. backoff.delay는 기본 대기 시간(기본값 1000ms), backoff.multiplier는 대기 시간 증가율(기본값 0으로 고정), backoff.maxDelay는 최대 대기 시간입니다. include는 재시도할 예외를, exclude는 재시도하지 않을 예외를 지정합니다.

역직렬화 에러 처리#

메시지 역직렬화가 실패하면 Consumer가 해당 메시지를 처리할 수 없습니다. ErrorHandlingDeserializer를 사용하면 역직렬화 실패를 애플리케이션에서 처리할 수 있습니다.

spring:
  kafka:
    consumer:
      key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      properties:
        spring.deserializer.key.delegate.class: org.apache.kafka.common.serialization.StringDeserializer
        spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
        spring.json.trusted.packages: "com.example.*"

역직렬화에 실패하면 value가 null로 전달되고, 헤더에 예외 정보가 포함됩니다.

@KafkaListener(topics = "orders")
public void consume(ConsumerRecord<String, OrderEvent> record) {
    if (record.value() == null) {
        // 역직렬화 실패
        log.error("역직렬화 실패: {}", new String(record.headers()
            .lastHeader("springDeserializerExceptionValue").value()));
        return;
    }
    processOrder(record.value());
}

에러 처리 패턴#

패턴 1: 재시도 + DLT

가장 일반적인 패턴입니다. 일정 횟수 재시도 후 실패하면 DLT로 보냅니다.

@RetryableTopic(attempts = "4")
@KafkaListener(topics = "orders")
public void consume(OrderEvent event) {
    validateAndProcess(event);
}

@DltHandler
public void handleDlt(OrderEvent event) {
    saveToFailedOrders(event);
    notifyAdmin(event);
}

패턴 2: 조건부 재시도

예외 유형에 따라 재시도 여부를 결정합니다. 일시적 에러는 재시도하고, 영구적 에러는 로깅 후 건너뜁니다.

@KafkaListener(topics = "orders")
public void consume(OrderEvent event) {
    try {
        processOrder(event);
    } catch (TemporaryException e) {
        // 재시도 가능 → 예외 던지기
        throw e;
    } catch (PermanentException e) {
        // 재시도 불가 → 로깅 후 건너뛰기
        log.error("처리 불가: {}", event, e);
        saveToFailedOrders(event, e);
    }
}

패턴 3: 수동 재처리

DLT에서 메시지를 읽어 수동으로 검토하고 재처리합니다.

@KafkaListener(topics = "orders-dlt")
public void processDlt(
        ConsumerRecord<String, OrderEvent> record,
        @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) {

    OrderEvent event = record.value();

    // 수동 검토 후 재처리
    if (canBeFixed(event)) {
        OrderEvent fixed = fixEvent(event);
        kafkaTemplate.send("orders", record.key(), fixed);
        log.info("재처리 완료: {}", record.key());
    } else {
        permanentlyFailed(event, error);
    }
}

모니터링 및 알림#

DLT에 메시지가 도착하면 즉시 알림을 보내 빠르게 대응할 수 있도록 합니다.

@DltHandler
public void handleDlt(
        ConsumerRecord<String, OrderEvent> record,
        @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error,
        @Header(KafkaHeaders.ORIGINAL_TOPIC) String originalTopic,
        @Header(KafkaHeaders.ORIGINAL_OFFSET) long originalOffset) {

    DltMessage dltMessage = DltMessage.builder()
        .key(record.key())
        .value(record.value())
        .originalTopic(originalTopic)
        .originalOffset(originalOffset)
        .error(error)
        .timestamp(Instant.now())
        .build();

    // Slack/이메일 알림
    alertService.sendDltAlert(dltMessage);

    // 메트릭 기록
    meterRegistry.counter("kafka.dlt.received",
        "topic", originalTopic).increment();
}

정리#

에러 처리 전략으로 @RetryableTopic은 선언적 재시도를, DefaultErrorHandler는 프로그래밍적 처리를, @DltHandler는 DLT 처리를 제공합니다.

에러 유형별로 일시적 에러는 지수 백오프로 재시도하고, 영구적 에러는 즉시 DLT로 이동하며, 역직렬화 에러는 로깅 후 건너뜁니다. DLT에 적재된 메시지는 알림을 보내고 수동 검토합니다.

다음 단계#