이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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 전 crashA와 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가 답이어야 한다.