이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- end-to-end exactly-once가 왜 대부분의 서비스 흐름에서 성립하기 어려운가?
- Kafka transaction, DB transaction, outbox, CDC는 각각 어느 범위까지만 보장을 제공하는가?
- at-least-once를 전제로 consumer를 어떻게 멱등하게 만들어야 하는가?
- 중복 처리와 재처리 결과를 어떤 지표와 쿼리로 검증해야 하는가?
개요
exactly-once라는 표현은 범위를 좁히면 의미가 있다.
Kafka 내부 producer transaction, DB unique constraint, 단일 DB transaction 안에서는 특정한 exactly-once 효과를 만들 수 있다.
하지만 운영 DB, outbox, Kafka, consumer DB, 외부 API를 모두 통과하는 end-to-end exactly-once는 대부분 성립하지 않는다.
네트워크 timeout, process crash, offset commit 실패, publisher status update 실패 중 하나만 있어도 같은 event가 다시 전달될 수 있다.
현실적인 설계는 at-least-once delivery를 받아들이고 consumer side effect를 멱등하게 만드는 것이다.
보장 범위부터 나눈다
보장 범위를 섞으면 설계가 위험해진다.
DB transaction
-> business row + outbox row 원자적 저장
outbox publisher
-> Kafka로 적어도 한 번 발행
Kafka
-> topic/partition log에 record 보관
consumer
-> DB side effect와 processed event 기록DB transaction은 Kafka broker의 성공을 보장하지 않는다.
Kafka producer retry는 consumer DB의 중복 반영을 막지 않는다.
consumer offset commit은 business DB commit을 대신하지 않는다.
각 단계의 보장 범위를 분리해서 문서화해야 한다.
Exactly-once가 흔들리는 지점
outbox를 쓰더라도 중복 가능성은 남는다.
1. outbox row PENDING
2. publisher가 Kafka publish 성공
3. publisher가 outbox status를 PUBLISHED로 바꾸기 전에 crash
4. 재시작 후 같은 row를 다시 publishconsumer도 같은 문제가 있다.
1. consumer가 event poll
2. settlement table update commit
3. offset commit 전에 crash
4. 재시작 후 같은 event 재수신이 두 시나리오 모두 “성공했지만 성공 표시를 남기기 전에 죽었다”는 공통점을 가진다.
분산 시스템에서는 이 사이를 완전히 없애기 어렵다.
At-least-once 관점
at-least-once는 event가 한 번 이상 도착할 수 있다는 전제다.
누락을 피하는 대신 중복을 받아들인다.
이 전제를 명시하면 설계 질문이 바뀐다.
- 같은 event가 두 번 오면 결과가 같은가?
- 이미 처리한 event를 어떤 key로 알아보는가?
- 처리 이력과 business update가 같은 transaction에 있는가?
- 중복을 건너뛴 횟수를 metric으로 볼 수 있는가?
중복이 “예외”가 아니라 “정상 운영 조건”이 된다.
Idempotent Consumer
consumer 멱등성은 event id와 DB 제약으로 만든다.
CREATE TABLE processed_events (
event_id varchar(80) PRIMARY KEY,
event_type varchar(80) NOT NULL,
aggregate_id varchar(80) NOT NULL,
processed_at timestamp NOT NULL DEFAULT now()
);business update와 처리 이력 insert는 같은 transaction에 둔다.
@Transactional
public void handle(OrderPaidEvent event) {
if (!processedEventRepository.insertIfAbsent(event.eventId())) {
metrics.duplicateSkipped(event.eventType());
return;
}
settlementRepository.upsertPaidOrder(
event.orderId(),
event.paymentId(),
event.amount()
);
}insertIfAbsent는 unique constraint를 이용해야 한다.
memory cache나 단순 조회 후 insert는 재시작과 동시 처리에서 깨질 수 있다.
멱등 연산과 Dedup의 차이
deduplication은 이미 처리한 입력을 걸러내는 기술이다.
idempotency는 같은 입력을 여러 번 처리해도 결과가 같게 만드는 설계다.
둘은 함께 쓰는 경우가 많다.
정산 row를 insert만 하면 중복에서 실패하거나 중복 row가 생긴다.
payment_id unique constraint와 upsert를 쓰면 business 결과를 한 번으로 수렴시킬 수 있다.
processed event table은 “왜 두 번째 입력을 건너뛰었는가”를 설명하는 운영 근거가 된다.
외부 Side Effect
외부 API 호출은 더 조심해야 한다.
예를 들어 포인트 지급 API를 consumer가 호출한다면 같은 event 재전달이 실제 포인트 중복 지급으로 이어질 수 있다.
이 경우 consumer 내부 dedup만으로 부족할 수 있다.
외부 API에도 idempotency key를 전달해야 한다.
Idempotency-Key: event-7f1d-order-paid-10042상대 시스템이 이 key를 지원하지 않으면 outbox를 한 단계 더 두거나 보상 절차를 설계해야 한다.
검증 쿼리
중복 처리 설계는 쿼리로 확인할 수 있어야 한다.
SELECT event_id, count(*)
FROM settlement_event_attempts
GROUP BY event_id
HAVING count(*) > 1;이 쿼리는 중복 시도가 있었는지 보여준다.
SELECT payment_id, count(*)
FROM settlements
GROUP BY payment_id
HAVING count(*) > 1;이 쿼리는 business side effect가 중복됐는지 보여준다.
중복 시도는 있을 수 있지만 business 결과는 중복되지 않아야 한다.
운영 지표
- duplicate delivery count: 같은 event id가 다시 들어온 횟수다.
- duplicate skip count: dedup으로 건너뛴 횟수다.
- idempotency conflict count: unique constraint 충돌 횟수다.
- outbox republish count: 같은 outbox row가 다시 발행된 횟수다.
- consumer retry count: consumer 처리 재시도 횟수다.
- reconciliation gap: source와 target의 business 합계 차이다.
지표 이름에 topic, group, event type, aggregate type을 붙여야 원인 추적이 가능하다.
개인 프로젝트 기준
개인 프로젝트에서는 exactly-once를 주장하기보다 실패 시나리오를 보여주는 편이 낫다.
다음 테스트가 있으면 충분하다.
- 같은 event를 두 번 넣어도 settlement row가 하나인지 확인한다.
- DB commit 후 offset commit 전 crash를 흉내 내고 재처리를 확인한다.
- outbox publish 성공 후 status update 실패를 흉내 내고 consumer dedup을 확인한다.
이 테스트가 있어야 at-least-once 설계를 설명할 수 있다.
위험 신호!
- “Kafka exactly-once를 켰으니 중복이 없다”고 말한다.
- outbox를 쓰면서 consumer dedup table이 없다.
- offset commit 성공을 business 처리 성공으로 기록한다.
- 외부 API 호출에 idempotency key가 없다.
- 중복 delivery와 중복 side effect를 같은 지표로 본다.
확인 질문
확인 질문
- 이 시스템에서 exactly-once라고 말하는 범위는 어디까지인가?
- DB transaction, Kafka transaction, consumer side effect를 분리해서 답해야 한다.
- 같은 event가 두 번 발행되면 downstream 결과는 어떻게 되는가?
- event id, business unique key, upsert, processed event table로 설명해야 한다.
- 중복 시도는 허용하되 business 결과가 중복되지 않았음을 무엇으로 검증하는가?
- duplicate attempt metric과 source-target reconciliation query가 필요하다.
참고 문서
- Apache Kafka Documentation
- Chris Richardson, Idempotent Consumer
- Chris Richardson, Transactional Outbox
- Martin Kleppmann, Designing Data-Intensive Applications