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

  • consumer는 메시지를 받은 뒤 어떤 지점에서 실패할 수 있고, 각 실패는 누락과 중복 중 무엇을 만드는가?
  • DB commit, offset ack, 외부 side effect의 순서가 장애 결과를 어떻게 바꾸는가?
  • idempotent consumer는 왜 중복을 오류가 아니라 정상 분기로 다뤄야 하는가?
  • 실패 모델을 테스트로 검증하려면 어떤 crash point를 만들어야 하는가?

개요

consumer는 message를 받은 뒤 여러 지점에서 죽을 수 있다.

처리 전에 죽을 수도 있고, DB commit 뒤 ack 전에 죽을 수도 있다.

외부 API 호출 후 죽을 수도 있다.

각 실패 위치는 다른 결과를 만든다.

idempotent consumer 설계는 이 실패 위치를 먼저 나열하는 데서 시작한다.

실패 지점 지도

poll message
  A. validate 전 crash
  B. business transaction 중 crash
  C. DB commit 후 ack 전 crash
  D. ack 후 side effect 전 crash
  E. side effect 후 ack 전 crash

A와 B는 보통 재전달로 복구된다.

C는 DB에는 반영됐지만 message가 다시 들어오는 중복 상황이다.

D는 ack를 너무 빨리 했기 때문에 side effect가 누락될 수 있다.

E는 side effect가 이미 실행됐는데 다시 들어와 중복될 수 있다.

DB Commit 후 Ack 전 Crash

가장 흔한 중복 시나리오다.

consumer poll OrderPaid
  -> settlement DB update commit
  -> process crash
  -> offset commit 없음
  -> restart
  -> same OrderPaid poll

이 경우 consumer는 같은 event를 다시 처리한다.

누락은 피했지만 중복이 생긴다.

따라서 DB update는 idempotent해야 한다.

processed message table과 business unique key가 필요하다.

Ack 후 DB Commit 전 Crash

반대로 ack를 너무 빨리 하면 누락이 생긴다.

consumer poll OrderPaid
  -> ack
  -> DB update 전 crash
  -> restart
  -> broker는 이미 처리 완료로 봄

이 경우 message는 다시 오지 않는다.

DB에는 반영되지 않았다.

누락은 중복보다 발견과 복구가 어렵다.

그래서 일반적으로 business commit 이후 ack를 둔다.

외부 Side Effect 실패

외부 side effect는 DB transaction으로 되돌릴 수 없다.

포인트 지급 API, 이메일 발송, 결제 취소 API가 대표적이다.

DB processed_messages insert
  -> external point API success
  -> process crash before local status update

이 경우 재시작 후 같은 message가 들어오면 포인트 API를 다시 호출할 수 있다.

외부 API에 idempotency key를 보내거나, side effect outbox를 따로 둬야 한다.

실패 Matrix

실패 위치broker 재전달DB 결과위험
처리 전있음없음지연
DB transaction 중있음rollback지연
DB commit 후 ack 전있음반영됨중복
ack 후 DB commit 전없음없음누락
side effect 후 기록 전있음외부 반영됨외부 중복

이 matrix를 코드 리뷰 전에 작성하면 ack 위치와 side effect 위치를 더 명확히 볼 수 있다.

정상 Branch로서 중복

중복은 예외가 아니다.

consumer handler에는 duplicate branch가 있어야 한다.

if (!processedMessages.insertIfAbsent(messageId)) {
    metrics.duplicateSkipped(consumerName);
    return;
}

중복을 error log로만 남기면 알림이 시끄러워지고 운영자가 실제 장애를 구분하기 어렵다.

duplicate skip은 info metric에 가깝다.

단, duplicate side effect는 장애다.

테스트해야 할 지점

idempotent consumer 테스트는 정상 처리만으로 부족하다.

다음 crash point를 만든다.

  • business transaction 전에 예외
  • business transaction 후 ack 전 예외
  • 같은 message id 두 번 입력
  • 외부 API 성공 후 local status update 실패
  • consumer 재시작 후 재전달

각 테스트에서 누락이 없고 business side effect가 한 번인지 확인한다.

운영 지표

  • duplicate delivery count: 같은 message가 다시 온 횟수다.
  • duplicate skip count: 정상적으로 건너뛴 횟수다.
  • ack failure count: ack 또는 offset commit 실패 수다.
  • side effect duplicate count: 외부 중복 실행 수다.
  • processed transaction failure count: business transaction 실패 수다.

중복 delivery가 있어도 duplicate side effect가 0이면 설계가 작동한다.

개인 프로젝트 기준

개인 프로젝트에서는 failure injection을 간단히 넣는다.

환경 변수나 test flag로 “DB commit 후 ack 전 crash”를 재현한다.

재시작 후 같은 message가 들어와도 결과가 한 번인지 확인한다.

이 실험이 없으면 consumer 멱등성은 설명으로만 남는다.

위험 신호!

  • ack를 먼저 하고 DB 저장을 나중에 한다.
  • duplicate message를 error로만 처리한다.
  • 외부 API 호출 위치가 실패 matrix에 없다.
  • DB commit 후 ack 전 crash 테스트가 없다.
  • offset lag가 0이면 business 성공이라고 본다.

확인 질문

확인 질문

  • 이 consumer가 죽을 수 있는 지점을 순서대로 나열했는가?
    • poll, DB commit, ack, side effect 기준으로 나눠야 한다.
  • 누락과 중복 중 어느 쪽을 선택했는가?
    • 보통 business commit 후 ack로 중복을 선택하고 멱등성으로 흡수한다.
  • 외부 side effect는 어떤 실패 위치에서 중복될 수 있는가?
    • provider idempotency key나 side effect outbox가 답이어야 한다.

참고 문서