이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • Kafka consumer 실패를 일시 오류와 영구 오류로 어떻게 나누는가?
  • retry, retry topic, DLQ는 각각 어떤 장애를 다루는가?
  • 중복 처리는 왜 consumer의 기본 설계 조건인가?
  • DLQ에 쌓인 메시지를 운영에서 어떻게 재처리하고 검증하는가?

개요

Kafka consumer는 같은 record를 두 번 받을 수 있다고 가정해야 한다.

네트워크 오류, consumer crash, rebalance, offset commit 실패, 수동 replay는 모두 중복 입력을 만들 수 있다.

retry와 DLQ는 이 현실을 다루기 위한 장치다.

retry는 일시적인 실패를 흡수한다.

DLQ는 현재 코드나 데이터로 처리할 수 없는 메시지를 격리한다.

중복 처리는 retry와 DLQ 이전에 깔려 있어야 하는 기본 조건이다.

실패 분류

consumer 실패는 먼저 성격을 나눠야 한다.

실패예시처리
일시 오류DB deadlock, HTTP 503, timeout제한된 retry와 backoff
영구 오류필수 field 누락, 지원하지 않는 event versionDLQ로 격리
과부하consumer 처리량 부족, hot partitionscale out, partition/key 조정
코드 버그NPE, mapping 오류배포 수정 후 replay

모든 오류를 무한 retry하면 poison message 하나가 partition 전체를 막는다.

모든 오류를 DLQ로 보내면 일시 장애에도 데이터 지연과 운영 부담이 커진다.

Blocking Retry

가장 단순한 방식은 listener 안에서 같은 record를 몇 번 재시도하는 것이다.

poll record
  try process
  fail -> wait 1s
  retry
  fail -> wait 5s
  retry
  fail -> DLT

이 방식은 구현이 쉽다.

하지만 retry 동안 같은 partition 뒤의 record가 기다린다.

짧은 DB deadlock이나 순간적인 timeout에는 적합하지만, 긴 외부 장애에는 partition lag를 크게 만든다.

Retry Topic

retry topic은 실패 record를 별도 topic으로 보내고 원본 partition 처리를 계속 진행하게 한다.

order.events
  -> consumer fail
  -> order.events.retry.5s
  -> order.events.retry.1m
  -> order.events.dlt

장점은 원본 topic의 head-of-line blocking을 줄이는 것이다.

단점은 원본 partition 순서가 엄격히 필요한 흐름에서는 순서가 깨질 수 있다는 것이다.

주문 상태 전이처럼 순서가 중요한 event는 retry topic 사용 전에 business 영향부터 검토해야 한다.

DLQ와 DLT

DLQ 또는 DLT는 처리 실패 record를 보관하는 별도 위치다.

Kafka에서는 dead letter topic이라는 표현을 자주 쓴다.

DLQ에 넣어야 할 정보는 원본 payload만이 아니다.

  • original topic
  • original partition
  • original offset
  • event key
  • exception class
  • exception message
  • failed at
  • consumer group
  • retry count

이 정보가 없으면 나중에 영향 범위와 재처리 시작점을 찾기 어렵다.

DLQ는 버리는 곳이 아니라 조사와 재처리를 위한 보관소다.

중복 처리

중복 처리는 event id 또는 business id 기준으로 설계한다.

CREATE TABLE processed_events (
    event_id varchar(80) PRIMARY KEY,
    aggregate_id varchar(80) NOT NULL,
    event_type varchar(80) NOT NULL,
    processed_at timestamp NOT NULL DEFAULT now()
);

consumer는 business update와 processed event 기록을 같은 transaction 안에서 처리한다.

@Transactional
public void apply(OrderPaidEvent event) {
    if (!processedEvents.markIfFirst(event.eventId())) {
        metrics.incrementDuplicateSkip();
        return;
    }
 
    settlementRepository.upsertPayment(
        event.orderId(),
        event.paymentId(),
        event.amount()
    );
}

markIfFirst는 unique constraint를 사용해야 한다.

단순 조회 후 insert는 동시 중복에서 race가 생길 수 있다.

Spring Kafka 구성

Spring Kafka에서는 DefaultErrorHandlerDeadLetterPublishingRecoverer로 기본 구조를 만들 수 있다.

@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
    var recoverer = new DeadLetterPublishingRecoverer(
        template,
        (record, ex) -> new TopicPartition(record.topic() + ".dlt", record.partition())
    );
 
    var backOff = new ExponentialBackOffWithMaxRetries(3);
    backOff.setInitialInterval(1_000L);
    backOff.setMultiplier(2.0);
    backOff.setMaxInterval(10_000L);
 
    return new DefaultErrorHandler(recoverer, backOff);
}

이 설정은 시작점일 뿐이다.

어떤 예외는 retry하지 않고 바로 DLT로 보낼지 분류해야 한다.

payload validation 오류와 DB timeout을 같은 방식으로 다루면 운영자가 원인을 파악하기 어렵다.

재처리 절차

DLQ 재처리는 별도 운영 절차가 있어야 한다.

1. DLQ 증가 알림 확인
2. original topic/partition/offset과 exception 분류
3. 코드 버그 또는 데이터 문제 수정
4. 샘플 메시지 재처리
5. 전체 범위 재처리
6. source/target count와 business aggregate 검증
7. 처리 결과와 제외 건수 기록

재처리 도구는 같은 메시지를 다시 넣어도 안전해야 한다.

운영자가 DLQ payload를 수정할 수 있다면 수정 이력도 남겨야 한다.

운영 지표

  • retry attempt count: 일시 장애가 얼마나 반복되는지 본다.
  • dlt publish count: 영구 실패 또는 retry 고갈을 본다.
  • duplicate skip count: 멱등 처리로 막은 중복을 본다.
  • oldest dlt age: 가장 오래 방치된 실패 메시지를 본다.
  • retry topic lag: 재시도 topic이 새로운 병목이 됐는지 본다.
  • reprocess success/fail count: DLQ 재처리 결과를 본다.

개인 프로젝트 기준

개인 프로젝트에서는 retry topic까지 만들지 않아도 된다.

다만 다음은 반드시 남긴다.

  • 실패 메시지를 보관하는 table 또는 DLT
  • exception class와 원본 key
  • 처리 이력 table
  • 같은 event를 두 번 넣는 테스트
  • DLQ row를 다시 처리하는 간단한 admin command

이 정도만 있어도 “비동기 처리가 실패하면 어디서 어떻게 복구하는가”를 설명할 수 있다.

위험 신호!

  • 모든 exception을 catch하고 ack한다.
  • retry 횟수 제한 없이 무한 재시도한다.
  • DLQ payload만 저장하고 original offset과 exception을 잃어버린다.
  • 멱등성 없이 retry를 늘린다.
  • DLQ 재처리 후 검증 SQL이나 처리 결과 로그가 없다.

확인 질문

확인 질문

  • 이 오류는 시간이 지나면 성공할 가능성이 있는가?
    • 그렇다면 제한된 retry와 backoff 대상이다.
  • 이 record는 현재 코드와 데이터 계약으로 처리 불가능한가?
    • 그렇다면 DLQ로 격리하고 원인 분류가 가능해야 한다.
  • 같은 record를 다시 넣으면 business 결과가 한 번만 반영되는가?
    • 처리 이력과 unique constraint로 확인해야 한다.

참고 문서