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

  • DB transaction과 Kafka/Redis ack는 어떤 순서로 배치해야 누락을 피할 수 있는가?
  • business commit 전 ack와 business commit 후 ack는 장애 시 각각 어떤 결과를 만드는가?
  • 처리 이력 insert, business update, offset commit은 하나의 원자적 작업이 될 수 있는가?
  • Spring Kafka manual ack 코드에서 어떤 지점을 리뷰해야 하는가?

개요

consumer 처리에는 최소 두 개의 commit이 있다.

하나는 업무 DB transaction commit이다.

다른 하나는 broker에 처리 완료를 알리는 offset commit 또는 ack다.

이 둘은 보통 하나의 원자적 transaction이 아니다.

따라서 순서가 중요하다.

누락을 피하려면 business commit 이후 ack하고, 중복은 idempotency로 흡수하는 설계가 일반적이다.

기본 순서

poll message
  -> begin DB transaction
  -> insert processed_message
  -> update business table
  -> commit DB transaction
  -> ack / offset commit

DB transaction 안에는 처리 이력과 business update가 함께 있어야 한다.

ack는 transaction 밖에서 DB commit 이후 수행한다.

ack 실패나 crash가 나면 같은 message가 다시 들어올 수 있다.

이때 duplicate branch가 작동해야 한다.

Ack를 먼저 하면

poll message
  -> ack
  -> DB update
  -> crash

broker는 이미 처리했다고 본다.

DB update는 실패했다.

message는 다시 오지 않을 수 있다.

이것이 누락이다.

누락은 나중에 source-target reconciliation을 하기 전까지 모를 수 있다.

DB Commit 후 Ack 실패

poll message
  -> DB commit success
  -> crash before ack
  -> restart
  -> same message redelivered

이 경우 중복이다.

하지만 processed table과 business unique key가 있으면 안전하게 건너뛸 수 있다.

실무에서는 이 쪽이 운영하기 쉽다.

중복은 metric과 log로 보이지만, 누락은 발견하기 더 어렵다.

Spring Kafka 예시

@KafkaListener(topics = "order.events", groupId = "settlement-service")
public void listen(
    ConsumerRecord<String, OrderPaidEvent> record,
    Acknowledgment ack
) {
    settlementConsumer.handle(record.value(), SourcePosition.from(record));
    ack.acknowledge();
}

handle method는 transaction을 가져야 한다.

@Transactional
public void handle(OrderPaidEvent event, SourcePosition source) {
    if (!processedMessages.insertIfAbsent("settlement-service", event.eventId(), source)) {
        return;
    }
 
    settlementRepository.upsert(event.orderId(), event.paymentId(), event.amount());
}

ack가 handle 이전에 있으면 위험하다.

handle 내부에서 비동기 작업만 예약하고 바로 return해도 위험하다.

Redis Stream에도 동일하다

Redis Stream의 XACK도 같은 의미다.

business commit 전 XACK하면 누락될 수 있다.

business commit 후 XACK 전에 죽으면 pending entry가 남거나 claim되어 다시 처리될 수 있다.

Redis Stream consumer도 idempotent handler가 필요하다.

Kafka와 Redis의 API는 다르지만 commit 경계 문제는 같다.

Transaction으로 묶을 수 없는 것

DB transaction과 Kafka offset commit을 완전히 하나로 묶는 것은 일반적인 Spring 서비스에서 쉽지 않다.

Kafka transaction 기능이 있어도 consumer DB side effect까지 end-to-end exactly-once로 만들었다고 단정하면 안 된다.

외부 API, 이메일, 다른 DB가 들어오면 다시 경계가 나뉜다.

따라서 문서에는 “원자적으로 묶는다”보다 “ack 실패 시 중복을 어떻게 흡수한다”를 적는 편이 현실적이다.

운영 지표

  • ack failure count: ack 또는 offset commit 실패다.
  • duplicate skip count: ack 실패 후 재전달을 흡수한 수다.
  • transaction failure count: DB transaction 실패다.
  • processing latency: poll부터 DB commit까지 시간이다.
  • ack latency: DB commit 후 ack까지 시간이다.
  • redelivery count: 같은 message 재전달 횟수다.

ack failure가 증가하면 duplicate skip도 함께 늘 수 있다.

둘이 함께 보이면 설계가 중복을 흡수하고 있는지 확인한다.

개인 프로젝트 기준

테스트는 두 가지를 반드시 만든다.

DB commit 전에 예외가 나면 ack되지 않아 재전달되는지 확인한다.

DB commit 후 ack 전에 예외를 내고 재시작했을 때 duplicate branch로 건너뛰는지 확인한다.

이 두 테스트가 transaction boundary 문서의 핵심이다.

위험 신호!

  • auto commit을 켠 채 business transaction을 별도로 처리한다.
  • ack가 listener 시작 부분에 있다.
  • @Transactional method가 비동기 작업만 예약하고 끝난다.
  • offset commit 실패를 무시한다.
  • duplicate skip 없이 “재전달은 안 일어난다”고 가정한다.

확인 질문

확인 질문

  • DB commit과 ack 중 무엇이 먼저인가?
    • 누락을 피하려면 DB commit 이후 ack다.
  • ack 전에 죽으면 같은 message가 다시 와도 안전한가?
    • processed table과 idempotent write가 있어야 한다.
  • ack가 완료됐다는 사실과 business 검증 완료를 구분하는가?
    • offset은 broker checkpoint일 뿐 target 정합성 검증이 아니다.

참고 문서