이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- 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 commitDB transaction 안에는 처리 이력과 business update가 함께 있어야 한다.
ack는 transaction 밖에서 DB commit 이후 수행한다.
ack 실패나 crash가 나면 같은 message가 다시 들어올 수 있다.
이때 duplicate branch가 작동해야 한다.
Ack를 먼저 하면
poll message
-> ack
-> DB update
-> crashbroker는 이미 처리했다고 본다.
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 시작 부분에 있다.
@Transactionalmethod가 비동기 작업만 예약하고 끝난다.- offset commit 실패를 무시한다.
- duplicate skip 없이 “재전달은 안 일어난다”고 가정한다.
확인 질문
확인 질문
- DB commit과 ack 중 무엇이 먼저인가?
- 누락을 피하려면 DB commit 이후 ack다.
- ack 전에 죽으면 같은 message가 다시 와도 안전한가?
- processed table과 idempotent write가 있어야 한다.
- ack가 완료됐다는 사실과 business 검증 완료를 구분하는가?
- offset은 broker checkpoint일 뿐 target 정합성 검증이 아니다.