Post

Kafka 재시도와 DLQ 운영 기준: Retry Topic, Poison Pill, 멱등성까지 한 번에 정리

#study #data-infra #kafka #retry #dlq #idempotency #poison-pill #streaming #operations

배경: Kafka를 붙였는데도 장애는 왜 더 복잡해질까?

Kafka를 도입하면 흔히 이렇게 기대한다.

  • 비동기 구조로 바꿨으니 장애 전파가 줄어들 것이다
  • 컨슈머를 늘리면 처리량 문제가 해결될 것이다
  • 실패한 메시지는 다시 읽으면 되니 복구가 쉬울 것이다

방향 자체는 맞다. 하지만 운영으로 들어가면 곧 다른 문제를 만난다.

  • 외부 API가 잠깐 느려졌는데 컨슈머 lag가 갑자기 급증한다
  • JSON 포맷이 깨진 메시지 하나 때문에 같은 파티션이 계속 멈춘다
  • 재시도를 많이 걸었더니 중복 발송, 중복 결제, 중복 적립이 터진다
  • DLQ는 쌓이는데 누가 언제 어떻게 복구해야 하는지 아무도 모른다
  • 순서가 중요한 이벤트인데 retry topic으로 보내는 순간 원래 순서가 어그러진다

즉, Kafka 운영에서 진짜 어려운 지점은 “메시지를 잘 보냈는가”보다 실패를 어떤 경로로 흘려보낼 것인가다.

특히 중급 이상 개발자에게 중요한 질문은 아래와 같다.

  • 모든 실패를 재시도해도 되는가?
  • 재시도는 어디서 해야 하는가? 컨슈머 내부인가, 별도 topic인가?
  • 재시도 중에도 순서 보장과 멱등성을 지킬 수 있는가?
  • DLQ는 단순 쓰레기통이 아니라 어떤 운영 계약이어야 하는가?
  • poison pill 같은 영구 실패 메시지를 시스템 전체 정체 없이 격리할 수 있는가?

오늘 글은 Kafka 재시도와 DLQ를 “설정 몇 개”가 아니라, 운영 가능한 실패 처리 아키텍처 관점에서 정리한다. 핵심은 다음 다섯 가지다.

  1. 실패를 먼저 분류해야 한다
  2. 재시도는 위치에 따라 시스템 성격이 달라진다
  3. 순서 보장과 처리량은 자주 충돌한다
  4. 재시도는 멱등성 없이는 장애 증폭기다
  5. DLQ는 저장소가 아니라 복구 프로세스의 시작점이다

먼저 결론: Kafka에는 “좋은 재시도”가 자동으로 들어 있지 않다

많은 팀이 Kafka를 큐처럼 쓰면서 “실패하면 몇 번 재시도하고 안 되면 DLQ”라고 말한다. 문제는 그 문장이 너무 많은 것을 생략한다는 점이다.

실제로는 아래 질문들이 비어 있으면 같은 말도 전혀 다른 시스템이 된다.

  • 어떤 실패를 재시도 대상으로 볼 것인가?
  • 재시도 간격은 얼마인가? 즉시인가, 지연인가, 지수 백오프인가?
  • 재시도 중 offset commit은 언제 할 것인가?
  • 같은 이벤트가 여러 번 실행돼도 부작용이 없는가?
  • 원본 topic의 순서를 얼마나 포기할 수 있는가?
  • DLQ에 들어간 뒤 누가 무엇을 기준으로 재처리할 것인가?

즉 재시도 정책은 단순 예외 처리 옵션이 아니라, 서비스의 정합성·지연·운영비용을 동시에 결정하는 계약이다.

이 관점을 놓치면 팀은 보통 두 극단 중 하나로 간다.

극단 1) 무한 재시도

  • 장점: 언젠가 성공할 수 있다는 기대
  • 실제 결과: lag 폭증, stuck partition, 장애 은폐, 중복 부작용 증가

극단 2) 실패 즉시 DLQ

  • 장점: 본 처리 흐름은 빨라짐
  • 실제 결과: DLQ가 조용히 적체되고, 복구 절차 부재로 결국 데이터 유실과 다를 바 없어짐

좋은 운영 기준은 그 중간이다.

일시 장애는 제한적으로 재시도하고, 영구 실패는 빠르게 격리하며, 재시도 자체는 멱등성과 관측성을 전제로 설계해야 한다.


핵심 개념 1: 실패를 한 종류로 취급하면 재시도 정책이 무조건 망가진다

Kafka 컨슈머 실패를 하나로 뭉뚱그리면 안 된다. 실무에서는 적어도 아래 세 종류로 분리해야 한다.

1) 일시적 실패(transient failure)

잠시 후 다시 시도하면 성공 가능성이 있는 유형이다.

  • 외부 API timeout
  • DB 커넥션 풀 일시 고갈
  • 잠깐의 네트워크 단절
  • 락 경합이나 rate limit

이런 실패는 재시도가 의미 있다. 다만 즉시 무한 재시도가 아니라, 짧은 제한 횟수 + 백오프가 필요하다.

2) 영구 실패(permanent failure)

입력 자체가 잘못됐거나 현재 코드/계약으로는 절대 처리할 수 없는 유형이다.

  • 필수 필드 누락
  • 스키마 불일치
  • enum 미지원 값
  • 이미 삭제된 참조 엔티티를 반드시 필요로 하는 로직
  • 파싱 불가능한 payload

이건 다시 시도해도 성공하지 않는다. 이런 메시지를 재시도 큐에 오래 붙잡아 두면 처리 지연만 늘고 파티션만 막힌다.

3) 독성 메시지(poison pill)

영구 실패와 겹치지만, 운영적으로는 더 위험한 유형이다. 보통 같은 메시지를 다시 읽을 때마다 같은 예외를 내면서 파티션 진행을 막는 메시지를 말한다.

예를 들어 manual commit 구조에서 메시지 하나가 역직렬화 예외를 계속 낸다면, 해당 offset 이후 메시지가 영원히 처리되지 않을 수 있다.

poison pill은 단순 실패가 아니라 흐름 정체를 유발하는 실패다. 그래서 빠른 격리가 핵심이다.

왜 분류가 중요한가?

이 분류가 있어야 다음이 가능하다.

  • 재시도 대상과 비대상을 나눈다
  • DLQ로 보낼 시점을 정한다
  • 알람 우선순위를 다르게 둔다
  • 운영자가 “지금 기다릴 것인가, 바로 개입할 것인가”를 판단할 수 있다

즉 실패 분류는 예외 클래스 정리가 아니라 운영 판단 모델이다.


핵심 개념 2: 재시도는 위치에 따라 시스템 의미가 바뀐다

재시도는 “몇 번”보다 어디서 하느냐가 더 중요하다. 실무에서 주로 쓰는 방식은 세 가지다.

1) 컨슈머 내부에서 즉시 재시도

가장 단순하다. 메시지를 읽고 처리하다 실패하면 동일 poll 흐름 안에서 바로 다시 시도한다.

for (var record : records) {
    int attempts = 0;
    while (attempts < 3) {
        try {
            handle(record);
            commit(record);
            break;
        } catch (TransientException e) {
            attempts++;
            Thread.sleep(200L * attempts);
        }
    }
}

장점:

  • 구현이 단순하다
  • 짧은 일시 장애에는 빠르게 회복된다
  • 추가 topic 설계가 필요 없다

단점:

  • 재시도 중 같은 컨슈머 스레드가 묶인다
  • 파티션 진행이 멈춘다
  • 처리 시간이 길면 rebalance 위험이 커진다
  • 긴 백오프에는 부적합하다

즉 이 방식은 매우 짧고 제한된 재시도에만 어울린다. 수 초~수 분 단위 지연 재시도를 여기서 해결하려 하면 거의 항상 운영이 나빠진다.

2) Retry Topic으로 우회

실패한 메시지를 별도 retry topic으로 보내고, 거기서 일정 시간 뒤 다시 소비한다.

예:

  • orders.created
  • orders.created.retry.10s
  • orders.created.retry.1m
  • orders.created.retry.10m
  • orders.created.dlq

이 구조의 장점은 원본 consumer를 오래 붙잡지 않는다는 점이다. 본 파티션 흐름을 빠르게 비우고, 실패 메시지는 지연 큐 쪽에서 따로 처리할 수 있다.

장점:

  • 본 흐름 처리량을 지키기 쉽다
  • 긴 백오프 정책을 만들 수 있다
  • 재시도 횟수별 관측과 분리가 쉽다

단점:

  • topic 수와 운영 복잡도가 늘어난다
  • 순서 보장이 느슨해질 수 있다
  • header 관리, 원본 topic 추적, 재처리 도구가 필요하다

이 방식은 가장 현실적인 기본값이지만, 순서가 중요한 도메인에는 그대로 쓰기 어렵다.

3) 외부 워크플로우/보상 시스템으로 넘김

실패 메시지를 바로 재시도하지 않고, DB 상태 전이·작업 테이블·보상 워크플로우로 넘기는 방식이다. 예를 들면 Outbox 테이블, job scheduler, 수동 승인 큐가 이에 해당한다.

장점:

  • 긴 복구 흐름, 사람 개입, 보상 트랜잭션에 강하다
  • 실패를 비즈니스 프로세스로 승격할 수 있다
  • Kafka consumer를 가볍게 유지할 수 있다

단점:

  • 설계가 무거워진다
  • Kafka만으로 닫히는 구조가 아니다
  • 운영 경계가 여러 시스템으로 분산된다

주문/결제/정산처럼 한 번의 자동 재시도보다 상태 전이와 운영 승인이 더 중요한 도메인에서는 이 방식이 더 낫다.


핵심 개념 3: Retry Topic을 쓰는 순간 순서 보장 모델이 달라진다

이 부분을 가볍게 보면 실제 장애가 난다.

Kafka는 같은 key가 같은 partition에 들어갈 때, 그 partition 안에서만 순서 보장을 제공한다. 그런데 실패 메시지를 retry topic으로 빼면, 원본 흐름의 순서와 재시도 흐름의 순서가 분리된다.

예를 들어 주문 상태 이벤트가 아래 순서로 왔다고 하자.

  1. ORDER_CREATED
  2. ORDER_PAID
  3. ORDER_CANCELLED

그런데 ORDER_PAID 처리만 실패해서 retry topic으로 이동하면, 원본 consumer는 3번 메시지를 먼저 처리할 수도 있다. 그러면 다운스트림 상태는 다음처럼 꼬인다.

  • 취소가 먼저 반영됨
  • 나중에 결제가 재처리됨
  • 결과적으로 이미 취소된 주문이 다시 결제 완료처럼 보일 수 있음

즉 retry topic은 편리하지만, 순서가 중요한 상태 머신 도메인에서는 그대로 넣으면 안 된다.

순서가 중요한 도메인에서 고려할 수 있는 방법

1) 엔티티 단위 직렬화 유지

  • 같은 order_id는 항상 같은 파티션
  • 실패 시 해당 키의 후속 처리도 잠시 보류
  • 키 단위 상태 저장소 또는 in-flight 제어 필요

정합성은 좋지만 처리량이 줄고 구현이 복잡해진다.

2) 최신 상태 재검증

후속 이벤트를 처리할 때 단순 순서 의존 대신 현재 상태를 다시 확인한다.

예:

  • ORDER_PAID 처리 전 현재 주문 상태 조회
  • 이미 CANCELLED면 no-op 또는 보상 처리

이 방식은 순서 문제를 애플리케이션 로직으로 흡수한다. 대신 DB 조회와 상태 전이 규칙이 더 중요해진다.

3) 명시적 버전/시퀀스 사용

이벤트에 event_version 또는 sequence를 넣고, 예상 버전이 아닐 경우 보류/무시/재조회한다.

이건 이벤트 소싱이나 강한 상태 전이 도메인에서 자주 쓴다.

핵심은 이것이다.

Retry Topic은 처리량 문제를 잘 풀어주지만, 순서 문제는 자동으로 풀어주지 않는다.


핵심 개념 4: 재시도와 offset commit 시점은 같이 설계해야 한다

Kafka 재시도에서 가장 많은 사고는 “실패하면 다시 읽으면 되지”라는 막연한 감각에서 나온다. 실제로는 언제 commit하느냐에 따라 중복과 정체의 모양이 완전히 달라진다.

시나리오 A) 처리 성공 후 commit

가장 보수적이고 일반적인 방식이다.

  • 장점: 실패 시 같은 메시지를 다시 읽을 수 있다
  • 단점: 멱등성이 없으면 중복 실행 위험이 있다

즉, at-least-once 처리의 기본이다. 대부분의 실무 시스템은 결국 이 모델 위에 선다.

시나리오 B) retry topic 발행 성공 후 원본 commit

원본 처리에는 실패했지만, retry topic으로 옮기는 데 성공했으면 원본 offset을 commit하는 방식이다.

이 방식은 retry topology에서 자주 필요하다.

  • 원본 파티션은 진행된다
  • 실패 메시지는 retry 흐름에서 이어받는다
  • 대신 retry topic 발행과 원본 commit의 원자성이 중요해진다

여기서 조심할 점은 아래다.

  • retry topic 발행 전에 commit하면 메시지 유실 가능
  • 발행은 했는데 commit 실패하면 중복 retry 가능
  • 따라서 header에 attempt, message id, original topic/partition/offset 등을 넣어 멱등하게 다뤄야 함

시나리오 C) commit 없이 poll loop 재진입

실패 메시지를 계속 같은 파티션에서 다시 받게 하는 구조다.

짧은 즉시 재시도에는 가능하지만, 아래 조건이 겹치면 금방 위험해진다.

  • poison pill
  • 긴 처리 시간
  • max poll interval 초과
  • rebalance 반복

즉, commit 전략은 단독으로 볼 수 없다. 재시도 위치, 멱등성, 관측성과 같이 설계해야 한다.


핵심 개념 5: 멱등성 없는 재시도는 복구가 아니라 장애 증폭이다

재시도 구조를 넣으면 시스템이 더 튼튼해질 것 같지만, 멱등성이 없으면 오히려 더 위험하다.

예를 들어 주문 완료 이벤트 하나가 아래 부작용을 일으킨다고 하자.

  • 포인트 적립
  • 알림 발송
  • 배송 시스템 상태 갱신
  • CRM 태그 업데이트

컨슈머가 중간 단계에서 timeout으로 실패해 재시도되면 어떤 일이 생길까?

  • 포인트는 이미 적립됐는데 또 적립될 수 있다
  • 알림이 여러 번 갈 수 있다
  • 외부 시스템이 같은 요청을 중복 처리할 수 있다

이건 Kafka 문제라기보다, 한 번 이상 실행될 수 있는 현실을 서비스가 견딜 수 있는가의 문제다.

멱등성의 실무 기본값

1) 비즈니스 키 기반 dedupe

이벤트마다 고유한 event_id를 두고, 이미 처리한 이벤트면 건너뛴다.

create table processed_events (
    consumer_name varchar(100) not null,
    event_id varchar(100) not null,
    processed_at timestamptz not null default now(),
    primary key (consumer_name, event_id)
);

컨슈머 처리 흐름:

  1. event_id 존재 여부 확인
  2. 이미 있으면 no-op
  3. 없으면 비즈니스 처리
  4. 성공 시 processed_events 기록

2) 외부 API idempotency key 사용

결제/알림/서드파티 연동에서는 Kafka 내부 dedupe만으로 부족하다. 외부 호출에도 Idempotency-Key나 고유 요청 ID를 전달해야 한다.

3) 상태 전이 검증

예를 들어 주문 상태가 이미 PAID 이후라면 같은 ORDER_PAID 이벤트는 무시한다.

이 방식은 단순 dedupe보다 더 비즈니스 친화적이다.

중요한 현실

Exactly-once를 Kafka 기능만으로 완전히 해결했다고 생각하면 안 된다. Kafka의 EOS는 브로커·프로듀서·트랜잭션 경계 안에서는 강력하지만, DB 업데이트와 외부 API 호출까지 포함한 전체 비즈니스 부작용을 자동으로 중복 방지해주지는 않는다.

그래서 실무 기본값은 여전히 이것이다.

at-least-once 수신 + 애플리케이션 멱등성 보강


실무 예시: 주문 이벤트 컨슈머에 Retry Topic + DLQ를 적용하는 기준

상황을 구체적으로 보자.

  • 원본 topic: order-events
  • key: order_id
  • 이벤트 예시: ORDER_CREATED, ORDER_PAID, ORDER_CANCELLED
  • 컨슈머 역할: 주문 상태 반영 + 재고 예약 + 알림 발송

이 시스템에서 모든 실패를 같은 방식으로 처리하면 안 된다.

먼저 실패를 분류한다

즉시 DLQ 대상

  • JSON 파싱 실패
  • 필수 필드 누락
  • 지원하지 않는 이벤트 타입
  • 스키마 버전 불일치로 해석 불가

이건 재시도해도 성공 가능성이 거의 없다.

짧은 재시도 대상

  • DB deadlock
  • 일시적 timeout
  • 재고 서비스 503
  • rate limit 초과

이건 짧은 재시도 후 retry topic 또는 성공 처리로 갈 수 있다.

장기 보류 또는 별도 워크플로우 대상

  • 결제 승인 시스템 장애
  • 사람이 확인해야 하는 데이터 불일치
  • 보상 트랜잭션이 필요한 상태 충돌

이건 DLQ라고 끝내면 안 되고, 운영 상태 머신이 필요하다.

토폴로지 예시

  • order-events
  • order-events.retry.30s
  • order-events.retry.5m
  • order-events.dlq

헤더에는 최소한 아래 정보가 있으면 좋다.

  • x-event-id
  • x-original-topic
  • x-original-partition
  • x-original-offset
  • x-attempt
  • x-first-failed-at
  • x-error-class
  • x-error-message (너무 길거나 민감하면 요약)

처리 흐름 예시

  1. 원본 consumer가 메시지 수신
  2. 역직렬화/기본 검증 실패 시 즉시 DLQ
  3. 비즈니스 처리 실패 시 예외 분류
  4. 일시 장애면 attempt 증가 후 다음 retry topic 발행
  5. 최대 시도 초과 시 DLQ
  6. DLQ 적재 시 알람/대시보드 집계
  7. 재처리 도구 또는 운영자 워크플로우로 복구

Java/Spring 스타일 의사 코드

public void consume(OrderEvent event, Headers headers) {
    String eventId = event.eventId();
    int attempt = headerInt(headers, "x-attempt", 0);

    if (processedEventRepository.exists("order-consumer", eventId)) {
        return;
    }

    try {
        validate(event);          // 영구 실패면 ValidationException
        applyOrderState(event);   // DB 상태 전이
        reserveInventory(event);  // 외부 API, idem key 전달
        publishNotification(event);

        processedEventRepository.markProcessed("order-consumer", eventId);
    } catch (ValidationException e) {
        publishDlq(event, headers, e, attempt);
    } catch (TransientDependencyException e) {
        if (attempt >= 2) {
            publishDlq(event, headers, e, attempt);
        } else if (attempt == 0) {
            publishRetry("order-events.retry.30s", event, headers, attempt + 1, e);
        } else {
            publishRetry("order-events.retry.5m", event, headers, attempt + 1, e);
        }
    }
}

이 의사 코드의 핵심은 단순하다.

  • 재시도 여부는 예외 분류로 결정하고
  • 재시도 횟수는 header로 추적하며
  • 최종 부작용은 processed 이벤트 기준으로 멱등 처리한다

retry topic 소비 시 주의점

retry topic consumer는 원본 consumer와 완전히 같으면 안 된다. 적어도 아래 차이는 필요하다.

  • 더 낮은 동시성 또는 별도 consumer group
  • 재시도 전용 알람/메트릭
  • 같은 메시지가 반복 실패할 때 빠르게 DLQ로 내리는 기준

본 처리와 재시도 처리를 같은 리소스 풀로 섞어버리면, 장애 상황에서 retry traffic이 정상 traffic을 잡아먹는 일이 흔하다.


DLQ는 “문제가 생긴 메시지 저장소”가 아니라 복구 계약이어야 한다

DLQ를 두고 안심하는 팀이 많지만, 운영에서는 DLQ가 존재한다는 사실보다 어떻게 다루는지가 더 중요하다.

DLQ가 사실상 유실과 다르지 않게 되는 대표 상황은 아래다.

  • 메시지는 쌓이는데 누가 보지 않는다
  • 원인 분류가 안 되어 같은 장애가 반복된다
  • 재처리 도구가 없어 수동 복붙만 한다
  • payload만 있고 원본 context가 없어 복원 판단이 어렵다
  • DLQ 적재 건수는 모니터링하지만 체류 시간은 안 본다

DLQ에 최소한 있어야 할 것

1) 원본 복원 정보

  • original topic / partition / offset
  • event id / key
  • 실패 시각 / 최초 실패 시각

2) 실패 원인 힌트

  • 예외 클래스
  • 축약된 오류 메시지
  • schema/version 정보

3) 재처리 가능성 판단 기준

  • permanent / transient / unknown 분류
  • 자동 재처리 가능 여부
  • 사람 확인 필요 여부

운영 프로세스도 같이 정의해야 한다

예:

  • 파싱 실패 → producer 계약 위반으로 분류, 개발팀 수정 후 재발행
  • 외부 API 장애 장기화 → DLQ 적재 후 1시간 내 재처리 배치 실행
  • 데이터 불일치 → 운영자 검수 후 parking-lot topic으로 재주입

중요한 건 DLQ를 “끝”으로 두지 않는 것이다.

DLQ는 실패 메시지의 무덤이 아니라, 자동 처리 경계 밖으로 넘어간 항목의 관리 큐여야 한다.


Parking Lot 패턴: DLQ 다음 단계가 필요한 경우

일부 팀은 DLQ 하나만 두지만, 실무에서는 parking lot 개념이 더 유용할 때가 많다.

  • DLQ: 기술적으로 실패한 메시지 격리
  • Parking Lot: 운영 판단 후 나중에 다시 넣어볼 메시지 보관 영역

왜 굳이 나누는가?

  • DLQ에는 원인 미분류 메시지가 섞인다
  • 그중 일부는 코드 수정 후 재처리 가능하다
  • 일부는 데이터 정정이 먼저 필요하다
  • 일부는 재처리하면 안 된다

이걸 다 하나의 DLQ에서 처리하면 운영 난도가 급격히 올라간다. 그래서 아래처럼 나누는 팀도 많다.

  • orders.dlq.invalid-payload
  • orders.dlq.external-timeout
  • orders.parking-lot

물론 topic을 과도하게 쪼개면 복잡도가 커진다. 하지만 핵심 이벤트에서는 실패 유형별 후속 액션이 다른가를 기준으로 분리할 가치가 있다.


트레이드오프: 좋은 재시도 구조는 항상 무언가를 포기하게 만든다

재시도/DLQ 설계는 정답 암기가 아니라 트레이드오프 선택이다.

1) 본 consumer 내부 재시도 vs retry topic

내부 재시도

  • 장점: 단순하다
  • 장점: 짧은 장애에는 빠르다
  • 단점: 스레드가 막힌다
  • 단점: 긴 지연 재시도에 취약하다

retry topic

  • 장점: 본 흐름 처리량을 지키기 쉽다
  • 장점: 단계별 백오프를 설계할 수 있다
  • 단점: 순서가 꼬일 수 있다
  • 단점: topic/consumer 운영 비용이 늘어난다

2) 빠른 격리 vs 충분한 자동 재시도

빠른 격리

  • 장점: stuck partition을 빨리 해소한다
  • 장점: 운영 원인 파악이 빠르다
  • 단점: DLQ 처리 부담이 늘어난다

충분한 재시도

  • 장점: 일시 장애를 자동 흡수한다
  • 단점: 장기 장애를 숨길 수 있다
  • 단점: 중복과 지연이 커질 수 있다

3) 순서 보장 vs 처리량

  • 순서를 강하게 지키려면 키 단위 직렬화와 보류 전략이 필요하다
  • 처리량을 높이려면 실패 메시지를 빨리 옆으로 빼야 한다
  • 둘을 동시에 완벽히 얻기는 어렵다

4) 풍부한 DLQ 메타데이터 vs 보안/비용

  • 디버깅을 위해 payload와 오류 정보를 많이 남기고 싶다
  • 하지만 개인정보, 결제 데이터, 민감 로그가 섞이면 리스크가 커진다
  • 따라서 DLQ payload 보존 범위와 마스킹 정책이 필요하다

좋은 설계는 결국 이것을 명확히 한다.

  • 어떤 도메인에서 순서를 우선하는가?
  • 어떤 장애는 자동 복구로, 어떤 장애는 사람 개입으로 넘길 것인가?
  • 운영팀이 실제로 감당 가능한 DLQ 규모는 어느 정도인가?

흔한 실수

1) 모든 예외를 같은 retry policy로 묶는다

Validation 실패와 timeout을 같은 횟수로 재시도하면, 쓸데없는 지연과 backlog만 생긴다.

2) retry topic을 만들었는데 header에 시도 횟수/원본 정보가 없다

이러면 같은 메시지가 몇 번째 실패인지, 어디서 왔는지, 중복인지 판단이 어려워진다.

3) 멱등성 없이 재시도부터 붙인다

이건 거의 확정적으로 중복 부작용을 만든다. 적립금, 알림, 결제, 재고는 특히 치명적이다.

4) DLQ를 만들어놓고 재처리 도구가 없다

메시지를 격리만 하고 다시 넣는 표준 경로가 없으면, 결국 운영자는 수동 스크립트와 감으로 대응하게 된다.

5) 순서가 중요한 도메인에 retry topic을 무비판적으로 넣는다

주문 상태, 계좌 잔액, 재고 수량처럼 상태 전이가 중요한 곳은 특히 조심해야 한다.

6) 장기 장애를 재시도로 덮는다

외부 시스템이 2시간 죽어 있는데 5초마다 재시도만 반복하면 장애를 흡수하는 것이 아니라 증폭하는 것이다.

7) lag만 보고 성공/실패를 판단한다

lag가 낮아도 DLQ가 폭증하면 시스템은 건강하지 않다. 반대로 lag가 일시적으로 높아도 의도된 백오프라면 문제 아닐 수 있다.

8) retry traffic과 normal traffic을 같은 리소스로 돌린다

장애 상황에서 재시도 메시지가 정상 신규 메시지를 밀어내면 복구보다 전체 체감 품질이 더 나빠질 수 있다.

9) DLQ 체류 시간(age)을 안 본다

DLQ 건수만 보는 팀이 많다. 하지만 진짜 위험 신호는 오랫동안 처리되지 않는 DLQ다.

10) producer 계약 문제를 consumer 재시도로 해결하려 한다

payload가 잘못 만들어졌다면 소비 쪽 재시도로는 답이 없다. producer, schema registry, 계약 테스트 쪽으로 문제를 올려야 한다.


실무 체크리스트

실패 분류

  • transient / permanent / poison pill 분류 기준이 있는가?
  • 재시도 비대상 예외가 명확한가?
  • 예외 클래스와 운영 액션이 연결돼 있는가?

재시도 설계

  • 즉시 재시도와 지연 재시도를 구분했는가?
  • retry topic 단계(예: 30s, 5m, DLQ)가 과하지도 부족하지도 않은가?
  • retry 발행 성공과 원본 commit 순서를 명확히 정의했는가?
  • retry header에 event id, attempt, original topic/offset이 담기는가?

멱등성

  • consumer가 event id 기준 dedupe를 하는가?
  • 외부 API 호출에 idempotency key를 전달하는가?
  • 상태 전이 로직이 중복 이벤트를 안전하게 무시할 수 있는가?

순서와 정합성

  • 같은 key의 순서가 비즈니스적으로 중요한가?
  • 중요하다면 retry topic 이후 순서 왜곡을 어떻게 흡수할지 설계했는가?
  • 최신 상태 재검증 또는 sequence 검증이 있는가?

DLQ 운영

  • DLQ 적재 시 알람이 발생하는가?
  • DLQ 건수뿐 아니라 age, 재처리 성공률도 측정하는가?
  • DLQ → 재처리 표준 경로가 있는가?
  • payload 저장 범위와 마스킹 정책이 있는가?

관측성

  • retry attempt 분포를 보는가?
  • 실패 유형별 건수를 본다?
  • retry topic 적체량과 소비 지연을 본다?
  • stuck partition, 반복 실패 event id를 식별할 수 있는가?

조직 운영

  • DLQ를 누가, 얼마나 자주, 어떤 SLA로 확인하는가?
  • producer 계약 위반이 consumer 운영문제로 방치되지 않는가?
  • 장애 리뷰에서 “왜 재시도했는가/왜 바로 격리하지 않았는가”를 회고하는가?

한 줄 정리

Kafka 재시도와 DLQ의 핵심은 “실패하면 다시 해본다”가 아니라, 실패를 분류하고, 순서와 멱등성을 지키면서, 자동 복구의 한계를 넘는 순간 사람과 프로세스가 개입할 수 있도록 경계를 설계하는 것이다.

댓글