본문 바로가기
Kafka

Kafka 11회차 : DLQ(Dead Letter Queue)와 오류 처리 – 재시도·격리·폐기 전략 이해하기

by 마틴블레이크 2025. 12. 21.
반응형

 

Kafka 11회차 : DLQ(Dead Letter Queue)와 오류 처리 – 재시도·격리·폐기 전략 이해하기

요약

Kafka를 쓰다 보면 언젠가는 “깨진 메시지”를 만나게 됩니다. JSON 파싱 실패, 스키마 불일치, 일시적인 외부 시스템 장애 등으로 메시지를 제대로 처리하지 못하는 상황이죠. 이때 전체 파이프라인을 멈추지 않으면서도 문제 메시지를 놓치지 않게 해 주는 패턴이 DLQ(Dead Letter Queue)입니다.

DLQ는 “실패한 메시지만 모아 두는 별도의 Kafka 토픽”이라고 생각하시면 됩니다. 정상 메시지는 평소처럼 처리하고, 실패한 메시지는 DLQ로 보내어 재시도 / 격리 / 폐기를 운영 정책에 맞게 선택합니다.

이번 회차에서는

  • DLQ가 무엇이고 왜 필요한지
  • 변환 실패·스키마 불일치·일시 장애를 어떻게 구분할지
  • Kafka Connect/Consumer에서 DLQ를 구성하는 기본 패턴
  • 일부 메시지를 고의로 실패시켜 DLQ로 보내는 실습 아이디어
  • 운영 관점에서 재시도 vs 격리 vs 폐기를 나누는 오류 분류표

까지 한 번에 정리해 보겠습니다.

목차


1. 핵심 포인트

  • DLQ(Dead Letter Queue)는 처리에 실패한 메시지를 모아 두는 별도의 Kafka 토픽입니다. 메인 스트림을 멈추지 않고, 실패 레코드를 나중에 분석·복구할 수 있게 해 줍니다.
  • 실패 원인은 크게 변환 실패, 스키마 불일치, 일시 장애(네트워크/외부 시스템), 비즈니스 룰 위반 등으로 나눌 수 있습니다.
  • Kafka Connect는 errors.deadletterqueue.topic.name 등의 설정으로 커넥터 레벨에서 DLQ를 자동 처리할 수 있습니다.
  • 일반 Consumer/Streams 애플리케이션은 예외를 잡아서 별도 DLQ 토픽으로 직접 produce하거나, 재시도 후 최종 실패 시 DLQ로 보내는 패턴을 사용합니다.
  • 운영 관점에서는 오류를 재시도 / 격리(DLQ) / 즉시 폐기 세 가지 축으로 나누고, 유형별로 기준을 미리 정해 두어야 장애 상황에서 빠르게 의사결정할 수 있습니다.

2. DLQ 기본 개념과 동작 방식

2-1. DLQ란 무엇인가?

DLQ는 메시지 큐/스트리밍 세계에서 “끝까지 처리되지 못한 메시지가 마지막으로 머무는 안전한 장소”입니다. Kafka에서는 이 역시 그냥 또 다른 토픽일 뿐입니다.

일반적인 흐름은 다음과 같습니다.

  1. Consumer 또는 Connect가 메시지를 읽는다.
  2. 역직렬화, 스키마 검증, 비즈니스 로직 수행 중 예외가 발생한다.
  3. 정책에 따라 N번 재시도하거나, 바로 DLQ 토픽으로 보낸다.
  4. DLQ 토픽은 별도의 Consumer(운영용 툴/Batch)에서 조회·분석·수정·재처리한다.

2-2. 왜 DLQ가 필요한가?

DLQ가 없다면 선택지는 보통 두 가지뿐입니다.

  • 에러 발생 시 애플리케이션을 멈추고 운영자가 수동으로介入
  • 그냥 에러를 무시하고 메시지를 버리거나 로그만 남김

전자는 가용성이 떨어지고, 후자는 데이터 손실과 원인 분석의 어려움을 낳습니다. DLQ는 이 사이에서 “스트림은 계속 돌리면서, 실패 메시지는 나중에 처리”라는 균형점을 제공하는 패턴입니다.


3. Kafka 파이프라인에서 자주 발생하는 오류 유형

운영 관점에서 DLQ를 설계하려면, 먼저 “어떤 오류를 어떻게 다룰 것인지”를 분류해야 합니다.

3-1. 변환 실패 (Transformation / Serialization Error)

  • JSON → Avro 변환 실패
  • 잘못된 인코딩, 숫자 필드에 문자열이 들어온 경우
  • Kafka Connect SMT(Single Message Transform) 단계에서 예외 발생 등

대부분 해당 메시지 자체가 잘못됐을 가능성이 크므로, 재시도보다는 DLQ로 보내고 나중에 수동/배치로 수정하는 전략을 많이 씁니다.

3-2. 스키마 불일치 (Schema Mismatch)

  • Producer가 새로운 필드를 추가했는데, Consumer 스키마에는 아직 반영되지 않은 경우
  • 필수 필드가 누락되거나 타입이 다른 경우
  • Schema Registry의 스키마와 실제 메시지가 맞지 않는 경우

이 경우도 대부분 “프로세스 고쳐도 그 메시지는 그대로”이므로, DLQ 격리 후 스키마 배포·조정이 일반적인 접근입니다.

3-3. 일시 장애 (Transient Error)

  • DB/외부 API 타임아웃, 네트워크 끊김
  • 외부 시스템의 짧은 장애, 잠깐의 서킷 브레이커 오픈 상태 등

이런 오류는 시간이 지나면 자연스럽게 회복되는 경우가 많기 때문에, 재시도가 중요합니다. 재시도에도 계속 실패하면 그때 DLQ로 넘기도록 설계합니다.

3-4. 비즈니스 룰 위반 (Business Logic Error)

  • “자기 자신을 팔로우할 수 없다” 같은 도메인 규칙 위반
  • 존재하지 않는 상품 ID, 마이너스 가격 등

이 경우 데이터는 문법적으로는 정상이라 재시도해도 계속 실패합니다. 보통 DLQ에 보내고, 별도 분석·레포트·수정 도구로 처리합니다.


4. Kafka Connect에서 DLQ 설정하기

4-1. Connect DLQ 개념

Kafka Connect는 자체적으로 오류 처리 설정을 제공하며, 특정 설정을 켜면 커넥터가 실패한 레코드를 자동으로 DLQ 토픽으로 보내도록 구성할 수 있습니다.

대표적인 설정은 다음과 같습니다.

  • errors.tolerancenone이면 한 레코드에서 오류가 나도 커넥터를 바로 중단, all이면 실패 레코드를 건너뛰며 계속 진행
  • errors.deadletterqueue.topic.name – 실패 레코드를 보낼 DLQ 토픽 이름
  • errors.deadletterqueue.context.headers.enable – 원본 토픽/파티션/오프셋 및 예외 메시지 등을 헤더로 넣을지 여부
  • errors.log.enable, errors.log.include.messages – 로그 출력 옵션

4-2. Sink 커넥터 예시 설정

{
  "name": "orders-elastic-sink",
  "config": {
    "connector.class": "io.confluent.connect.elasticsearch.ElasticsearchSinkConnector",
    "topics": "orders",
    "connection.url": "http://elasticsearch:9200",
    "type.name": "_doc",
    "tasks.max": "1",

    // 오류 처리 & DLQ 설정
    "errors.tolerance": "all",
    "errors.deadletterqueue.topic.name": "orders.dlq",
    "errors.deadletterqueue.context.headers.enable": "true",
    "errors.log.enable": "true",
    "errors.log.include.messages": "true"
  }
}

이렇게 설정해 두면, 변환 실패나 Elasticsearch 전송 오류 등으로 처리에 실패한 레코드는 orders.dlq 토픽으로 자동 전송됩니다. 운영자는 이 토픽을 별도 Consumer나 UI(예: Confluent Control Center, 자체 콘솔 등)에서 조회하면서 원인을 분석하면 됩니다.


5. 애플리케이션 Consumer/Streams에서 DLQ 패턴

5-1. 기본 전략: 재시도 → DLQ → 로그만 남기고 Skip

순수 Kafka Consumer(또는 Kafka Streams)를 직접 구현하는 경우에는 브로커가 자동으로 DLQ를 만들어 주지 않습니다. 보통 아래 두 가지 패턴을 조합해 씁니다.

  • 재시도 후 포기: 일정 횟수/시간 동안 재시도한 뒤 그래도 실패하면 DLQ 토픽으로 전송
  • 바로 격리: 재시도 의미가 없는 오류(스키마 불일치, 비즈니스 룰 위반 등)은 즉시 DLQ로 보내고 메인 스트림은 진행

5-2. 자바 Consumer + DLQ 간단 예제

아래 코드는 개념을 보여주기 위한 단순화된 예시입니다. (실제 운영에서는 리밸런스, 백오프, 모니터링 등을 더 고려해야 합니다.)

public class OrderConsumerWithDlq {

    private static final String MAIN_TOPIC = "orders";
    private static final String DLQ_TOPIC  = "orders.dlq";

    public static void main(String[] args) {
        Properties consumerProps = new Properties();
        consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "orders-consumer");
        consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG,
                          "org.apache.kafka.common.serialization.StringDeserializer");
        consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG,
                          "org.apache.kafka.common.serialization.StringDeserializer");
        consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

        Properties producerProps = new Properties();
        producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
                          "org.apache.kafka.common.serialization.StringSerializer");
        producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
                          "org.apache.kafka.common.serialization.StringSerializer");

        try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps);
             KafkaProducer<String, String> dlqProducer = new KafkaProducer<>(producerProps)) {

            consumer.subscribe(Collections.singletonList(MAIN_TOPIC));

            while (true) {
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));

                for (ConsumerRecord<String, String> record : records) {
                    try {
                        // 1) 메시지 파싱 (예: JSON)
                        OrderEvent event = OrderEvent.parse(record.value());

                        // 2) 비즈니스 검증 - 일부 메시지는 고의로 실패
                        if (event.getAmount() < 0) {
                            throw new IllegalArgumentException("음수 금액 주문은 허용되지 않습니다.");
                        }

                        // 3) 정상 처리 로직 (DB 저장 등)
                        handleOrder(event);

                    } catch (Exception ex) {
                        // 예외 발생 시 DLQ 토픽으로 전송
                        String dlqValue = buildDlqPayload(record, ex);
                        ProducerRecord<String, String> dlqRecord =
                                new ProducerRecord<>(DLQ_TOPIC, record.key(), dlqValue);
                        dlqProducer.send(dlqRecord);

                        // 로그 남기기
                        System.err.println("[DLQ] " + ex.getMessage()
                                + " offset=" + record.offset()
                                + " value=" + record.value());
                    }
                }

                // 처리 완료된 레코드까지 오프셋 커밋
                consumer.commitSync();
            }
        }
    }

    private static String buildDlqPayload(ConsumerRecord<String, String> record, Exception ex) {
        // 실제로는 JSON으로 원본 값, 토픽/파티션/오프셋, 에러 메시지 등을 넣어주는 것이 좋다.
        return "{ \"topic\": \"" + record.topic() + "\", " +
               "\"partition\": " + record.partition() + ", " +
               "\"offset\": " + record.offset() + ", " +
               "\"error\": \"" + ex.getMessage() + "\", " +
               "\"payload\": \"" + record.value() + "\" }";
    }
}

위 예제에서는 금액이 음수인 주문을 일부러 실패시키고, 해당 레코드를 DLQ 토픽 orders.dlq로 보내고 있습니다. 이 DLQ 토픽을 별도의 배치 프로그램이나 관리 콘솔에서 조회하면, “어떤 메시지가 왜 실패했는지”를 쉽게 추적할 수 있습니다.


6. 실습 아이디어: 일부 메시지를 고의로 실패시키고 DLQ로 보내기

실습 목표는 “DLQ가 실제로 어떻게 작동하는지 눈으로 확인”하는 것입니다.

  1. 토픽 준비
    • 메인 토픽: orders
    • DLQ 토픽: orders.dlq
    kafka-topics.sh --create --topic orders --bootstrap-server localhost:9092 --partitions 3 --replication-factor 1
    kafka-topics.sh --create --topic orders.dlq --bootstrap-server localhost:9092 --partitions 3 --replication-factor 1
  2. 프로듀서로 정상 메시지 + 오류 메시지 섞어서 전송
    • 정상: {"orderId":1,"amount":100}
    • 고의 오류 1 – 비즈니스 오류: {"orderId":2,"amount":-50}
    • 고의 오류 2 – 파싱 오류: {"orderId":3,"amount":"not-number"}
  3. 위 자바 Consumer(DLQ 처리 포함)를 실행
  4. DLQ 토픽을 별도 콘솔로 관찰
    kafka-console-consumer.sh \
      --topic orders.dlq \
      --bootstrap-server localhost:9092 \
      --from-beginning
    예상되는 결과:
    • 정상 주문 이벤트는 DLQ에 나타나지 않고, 내부 비즈니스 로직(예: 로그)만 남음
    • 음수 금액 / 파싱 오류 메시지는 모두 orders.dlq 토픽에서 확인 가능

이 실습을 통해,

  • “실패 메시지 때문에 전체 소비가 멈추지 않는다”는 점
  • DLQ에 원본 값 + 메타데이터를 함께 넣어두면 사후 분석이 훨씬 편해진다는 점

을 직접 체감하실 수 있습니다.


7. 운영 관점 오류 분류표 (재시도 vs 격리 vs 폐기)

마지막으로, 운영자가 장애 상황에서 빠르게 판단할 수 있도록 오류를 유형별로 정리해 봅니다.

오류 유형 예시 재시도 DLQ 격리 즉시 폐기 추천 전략
일시적 외부 장애 DB 타임아웃, 네트워크 순간 끊김 ◎ (지수 백오프와 함께 여러 번) △ (최종 실패 시 DLQ로) 최대 재시도 횟수·시간을 정하고, 초과 시 DLQ로 전송
변환/역직렬화 실패 JSON 파싱 에러, 숫자 필드에 문자열 △ (재시도해도 동일 실패 가능성 큼) DLQ에 원본 페이로드와 에러 메시지를 함께 저장, 배치 수정 또는 폐기 판단
스키마 불일치 Schema Registry 스키마와 메시지 포맷 불일치 △ (배포 직후 잠깐은 재시도 의미 있음) 스키마/코드 배포 상태를 점검하면서 DLQ 메시지를 기준으로 롤백 또는 재배포
비즈니스 룰 위반 음수 가격, 자기 자신 팔로우, 존재하지 않는 키 DLQ에서 별도 비즈니스 모니터링/리포트 후, 정책에 따라 수정 or 폐기
중복/지연 이벤트 이미 처리된 주문 ID, 오래된 상태 업데이트 소비자 로직에서 멱등성 처리(ID 기준으로 무시)하고, 필요 시 일부만 DLQ로 남김

위 표를 팀 위키나 Runbook에 맞게 보완하면, 실제 장애 상황에서 “이 오류는 재시도, 저 오류는 DLQ, 이건 그냥 버려도 된다” 를 빠르게 합의할 수 있습니다.


8. 운영 Best Practice와 주의사항

  • DLQ도 모니터링 대상입니다. DLQ 토픽의 레코드 수·증가 속도를 메트릭으로 수집해 알람을 걸어두어야, 문제가 커지기 전에 눈치챌 수 있습니다.:contentReference[oaicite:19]{index=19}
  • DLQ 레코드에는 원본 토픽/파티션/오프셋, 에러 유형, 스택트레이스, 처리 시각 같은 메타데이터를 함께 넣어 두면, 장애 분석·재처리에 큰 도움이 됩니다.:contentReference[oaicite:20]{index=20}
  • 보관 기간(retention) 정책도 중요합니다. 너무 짧으면 아직 분석하기도 전에 메시지가 사라지고, 너무 길면 스토리지 비용이 커집니다. 보통 “수일~수주 + 백업/아카이브” 패턴을 많이 사용합니다.:contentReference[oaicite:21]{index=21}
  • Kafka Streams는 표준 DLQ 기능이 아직 진행 중(KIP-1034)이며, 현재는 Producer를 사용해 사용자 정의 DLQ 토픽으로 직접 전송하는 패턴이 일반적입니다.:contentReference[oaicite:22]{index=22}
  • DLQ는 만능이 아닙니다. 설계 초기부터 “어떤 메시지까지 복구 대상인지, 어디까지는 폐기해도 되는지”를 비즈니스 관점에서 합의하고 들어가는 것이 좋습니다.

 

반응형