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

  • 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를 다시 publish

consumer도 같은 문제가 있다.

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가 필요하다.

참고 문서