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

  • Retry, DLQ, ordering, 중복 처리는 각각 어떤 실패를 다루는가?
  • 신뢰성을 높이려는 장치가 어떻게 장애 증폭과 운영 복잡도를 만들 수 있는가?
  • 메시지 처리에서 유실보다 중복을 기본값으로 두는 이유는 무엇인가?

개요

Queue를 쓰면 요청과 후속 처리를 분리할 수 있지만, 메시지 처리는 실패한다. 네트워크 오류, consumer 재시작, broker rebalance, DB deadlock, 외부 API timeout, 잘못된 payload가 모두 실제 실패 경로다.

Retry, DLQ, ordering, idempotency는 이 실패를 다루는 장치다. 하지만 이 장치들은 서로 독립적이지 않다. retry는 중복을 만들고, ordering은 병렬성을 줄이며, DLQ는 운영 owner가 없으면 실패 저장소로만 남는다.

이 문서는 “메시지를 한 번만 정확히 처리한다”는 기대를 버리고, 중복 가능성을 전제로 안전하게 처리하는 기준을 다룬다.

실패 분류

먼저 실패를 분류해야 retry 정책을 정할 수 있다.

실패 유형예시적합한 대응
일시 장애네트워크 timeout, 외부 API 503backoff retry
리소스 부족DB connection 고갈, rate limitconsumer 속도 제한, retry 지연
데이터 오류필수 필드 누락, 잘못된 상태 전이DLQ 격리 후 수정
영구 업무 실패탈퇴 회원, 취소된 주문보상 처리 또는 skip 정책
코드 버그consumer 배포 오류배포 중단, rollback, DLQ 보존

모든 실패를 같은 retry로 다루면 영구 오류 메시지가 계속 재처리되어 정상 메시지까지 밀린다.

Retry 정책

Retry는 일시 장애를 흡수하기 위한 장치다. 빠른 즉시 재시도는 장애 중인 시스템에 부하를 다시 밀어 넣을 수 있다.

attempt 1: 즉시 실패
attempt 2: 5초 뒤
attempt 3: 30초 뒤
attempt 4: 5분 뒤
attempt 5: DLQ 이동

Backoff는 장애 증폭을 줄인다. Jitter는 많은 메시지가 같은 시간에 동시에 재시도되는 일을 줄인다.

long delayMillis(int attempt) {
    long base = Math.min(300_000, 1_000L * (1L << attempt));
    long jitter = ThreadLocalRandom.current().nextLong(0, 1_000);
    return base + jitter;
}

Retry 횟수는 “많을수록 안전”하지 않다. 외부 API가 30분 장애라면 100번 retry보다 DLQ 격리와 나중 재처리가 더 안전할 수 있다.

DLQ 기준

DLQ는 Dead Letter Queue다. 반복 실패 메시지를 정상 흐름에서 분리해 보존한다.

DLQ에 들어가야 하는 경우:

  • 최대 retry 횟수를 넘었다.
  • payload schema가 consumer와 맞지 않는다.
  • 업무 상태가 처리 불가능하다.
  • 같은 오류가 빠르게 반복되어 정상 처리량을 위협한다.

DLQ에 넣으면 끝이 아니다. 메시지를 분류하고, 코드 수정이 필요한지 데이터 보정이 필요한지 판단하고, 재처리할지 폐기할지 결정해야 한다.

DLQ 운영 절차

1. DLQ count alert 발생
2. eventType, errorCode, firstSeen, lastSeen 확인
3. 오류 원인을 데이터, 외부 의존성, 코드 버그로 분류
4. 정상 흐름 영향 확인
5. 수정 또는 보정 후 샘플 재처리
6. 전체 재처리
7. 재처리 결과와 재발 방지 기록

DLQ 메시지를 바로 재처리하면 같은 오류가 반복된다. 재처리 전에는 실패 원인이 제거되었는지 확인해야 한다.

Ordering 비용

순서 보장은 필요한 범위를 좁혀야 한다. 전체 메시지의 전역 순서를 보장하려 하면 처리량과 가용성이 크게 떨어진다.

주문 상태 이벤트를 예로 들면 같은 주문 안에서는 순서가 중요하다.

OrderCreated -> OrderPaid -> OrderCancelled

하지만 서로 다른 주문 간 순서는 대개 중요하지 않다. 따라서 orderId를 partition key로 삼아 같은 주문 이벤트만 같은 partition에서 처리하게 만들 수 있다.

partition key = orderId
보장 범위 = 같은 주문의 이벤트 순서
포기하는 것 = 주문 간 전역 순서

이 판단은 성능과 정합성의 trade-off다. 순서 범위를 좁히면 병렬성을 얻고, 넓히면 정합성 설명은 쉬워지지만 처리량이 낮아진다.

중복 처리

메시지 시스템에서는 중복을 예외가 아니라 정상 실패 모델로 본다.

중복이 생기는 경우:

  • consumer가 DB commit 후 ack 전에 죽는다.
  • broker rebalance로 같은 메시지가 다시 배정된다.
  • producer가 발행 성공 응답을 받지 못해 다시 발행한다.
  • DLQ 재처리로 과거 메시지가 다시 들어온다.

따라서 consumer는 idempotent해야 한다.

create table point_grant (
    id bigint primary key,
    order_id varchar(50) not null,
    message_id varchar(100) not null unique,
    amount integer not null
);

message_id unique constraint는 중복 적립을 막는 마지막 방어선이다. 애플리케이션에서 먼저 검사하더라도 DB constraint가 있어야 race condition에 안전하다.

Exactly Once라는 표현

Kafka 같은 시스템은 특정 조건에서 exactly-once processing을 지원한다고 말할 수 있다. 하지만 애플리케이션 관점에서는 외부 DB, 외부 API, 이메일 발송 같은 부작용까지 한 번만 보장하기 어렵다.

실무 문서에서는 “exactly once”를 주장하기보다 다음을 구체적으로 쓰는 편이 안전하다.

  • broker 안에서 중복 publish를 줄이는가?
  • consumer DB 변경은 idempotent한가?
  • 외부 API는 idempotency key를 받는가?
  • 이메일이나 문자 같은 부작용은 재발송 방어가 있는가?
  • 재처리 시 어떤 결과가 중복될 수 있는가?

장애 증폭 시나리오

외부 포인트 API가 장애라고 하자.

1. consumer timeout 증가
2. consumer 처리량 감소
3. lag 증가
4. retry 메시지 증가
5. 외부 API에 더 많은 재시도 유입
6. 장애 지속

이때 retry만 늘리면 장애가 커진다. 필요한 것은 retry 지연, consumer concurrency 제한, circuit breaker, DLQ 격리, 포인트 적립 pending 안내다.

위험 신호

  • 모든 예외를 즉시 retry한다.
  • DLQ owner와 처리 기한이 없다.
  • 순서 보장 범위를 전체 topic으로 잡는다.
  • idempotency를 메모리 set으로만 처리한다.
  • 재처리하면 외부 API 호출이 다시 나간다.
  • retry count는 보지만 oldest failed message age는 보지 않는다.

개인 프로젝트 기준

  • retry 최대 횟수와 지연 시간을 명시한다.
  • DLQ가 없다면 실패 메시지 저장 테이블을 둔다.
  • idempotency key나 unique constraint를 하나 둔다.
  • 같은 메시지 두 번 처리 테스트를 작성한다.
  • 순서가 필요한 aggregate 기준을 문서화한다.

기업 운영 기준

  • 에러 종류별 retry/DLQ 정책을 분리한다.
  • DLQ alert, owner, 처리 SLA를 둔다.
  • 재처리 도구는 dry-run과 샘플 재처리를 지원한다.
  • ordering key별 hot partition을 관측한다.
  • retry 폭주가 downstream을 압박하지 않도록 rate limit과 circuit breaker를 둔다.

확인 질문

확인 질문

  • 확인 질문: retry가 신뢰성을 높이면서도 위험한 이유는 무엇인가?
    • 답변: 일시 장애에는 도움이 되지만 영구 오류나 downstream 장애에는 같은 요청을 반복해 장애를 증폭할 수 있기 때문이다.
  • 확인 질문: 순서 보장 범위를 좁혀야 하는 이유는 무엇인가?
    • 답변: 전역 순서를 보장하면 병렬성과 처리량을 크게 잃으므로 업무상 필요한 aggregate 범위로 제한해야 한다.
  • 확인 질문: 메시지 처리에서 중복을 기본값으로 보는 이유는 무엇인가?
    • 답변: commit과 ack 사이의 실패, rebalance, 재발행, DLQ 재처리 때문에 같은 메시지가 다시 들어올 수 있기 때문이다.

참고 문서