이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Kafka에서 “순서 보장”은 정확히 어느 범위에서 성립하는가?
- offset commit은 business 처리 완료와 어떤 관계를 가져야 하는가?
- DB commit 전후에 ack 위치가 달라지면 누락과 중복이 어떻게 생기는가?
- Spring Kafka에서 manual ack를 쓰는 이유와 위험은 무엇인가?
개요
Kafka는 topic 전체 순서를 보장하지 않는다.
순서는 partition 내부에서만 보장된다.
offset은 partition 안에서 record의 위치다.
offset commit은 consumer group이 “이 위치까지 처리했다”고 Kafka에 남기는 checkpoint다.
그래서 offset commit은 business transaction과 분리해서 생각하면 안 된다.
DB 저장이 끝나기 전에 offset을 commit하면 장애 시 메시지가 사라진 것처럼 보일 수 있다.
DB 저장은 끝났지만 offset commit 전에 죽으면 같은 메시지가 다시 들어와 중복 처리가 생길 수 있다.
실무 설계는 보통 중복을 허용하고, 누락을 피하며, consumer를 멱등하게 만든다.
순서 보장의 범위
topic: order.events
partition-0
offset 10: order-1 OrderCreated
offset 11: order-1 OrderPaid
offset 12: order-1 OrderCanceled
partition-1
offset 20: order-2 OrderCreated
offset 21: order-2 OrderPaidpartition-0 내부에서는 offset 10, 11, 12 순서가 유지된다.
partition-0과 partition-1 사이의 전역 순서는 보장되지 않는다.
order-1의 상태 전이 순서가 중요하면 모든 order-1 event가 같은 partition으로 가야 한다.
이를 위해 producer는 orderId를 key로 보내야 한다.
Offset Commit
consumer는 record를 poll하고 처리한 뒤 offset을 commit한다.
poll record(offset=11)
-> validate event
-> update settlement table
-> commit DB transaction
-> commit Kafka offset 12Kafka에서 commit하는 값은 보통 “다음에 읽을 offset”이다.
offset 11을 처리했다면 committed offset은 12가 된다.
중요한 것은 Kafka offset commit이 DB commit을 대신하지 않는다는 점이다.
offset commit은 Kafka consumer group의 checkpoint일 뿐이다.
Ack 위치
Spring Kafka에서 manual ack를 쓰면 처리 완료 시점을 코드에서 명시할 수 있다.
@KafkaListener(topics = "order.events", groupId = "settlement-service")
public void consume(
ConsumerRecord<String, OrderPaidEvent> record,
Acknowledgment ack
) {
settlementService.apply(record.key(), record.value());
ack.acknowledge();
}이 코드는 settlementService.apply가 transaction을 완료한 뒤 ack한다는 전제가 있어야 안전하다.
만약 apply 안에서 비동기 작업만 예약하고 바로 return한다면 ack가 너무 빠르다.
그 경우 downstream 저장 실패가 나도 Kafka는 이미 처리 완료로 본다.
장애 시나리오
DB commit 전에 offset commit
poll -> ack -> DB update -> process crashconsumer group은 이미 offset을 넘겼다.
DB에는 반영되지 않았다.
다시 시작해도 해당 record를 읽지 않는다.
이것이 누락이다.
DB commit 후 offset commit 전 crash
poll -> DB update commit -> process crash -> restart -> same record pollDB에는 이미 반영됐다.
Kafka offset은 아직 이전 위치다.
재시작하면 같은 record를 다시 읽는다.
이것이 중복이다.
누락보다 중복이 복구하기 쉽기 때문에 보통 DB commit 후 ack를 택한다.
그 대신 consumer 멱등성이 필요하다.
멱등 처리
중복 입력을 안전하게 만들려면 처리 이력을 남긴다.
CREATE TABLE processed_kafka_events (
event_id varchar(80) PRIMARY KEY,
topic varchar(120) NOT NULL,
partition_no int NOT NULL,
offset_no bigint NOT NULL,
processed_at timestamp NOT NULL DEFAULT now()
);consumer는 business update와 처리 이력 insert를 같은 transaction에 둔다.
@Transactional
public void apply(String eventId, OrderPaidEvent event) {
if (processedEventRepository.existsById(eventId)) {
return;
}
settlementRepository.upsertPaidOrder(event.orderId(), event.amount());
processedEventRepository.save(new ProcessedEvent(eventId));
}exists와 save 사이 race를 피하려면 unique constraint를 기준으로 처리해야 한다.
동시 중복이 들어와도 하나만 성공하도록 DB 제약을 사용한다.
Retry와 Commit
일시 오류는 offset을 commit하지 않고 다시 시도할 수 있다.
하지만 poison message가 계속 실패하면 같은 partition 뒤의 메시지까지 막힌다.
그래서 retry 횟수와 backoff를 제한하고, 영구 오류는 DLT로 이동한 뒤 원본 offset을 commit한다.
이 결정은 “해당 event를 버린다”가 아니다.
운영자가 나중에 DLT에서 원인을 고치고 재처리할 수 있게 격리하는 것이다.
운영 지표
- committed offset: consumer group이 어디까지 처리했다고 선언했는지 본다.
- end offset: partition log의 최신 위치를 본다.
- consumer lag: end offset과 committed offset 차이를 본다.
- commit latency: 처리 완료 후 offset commit까지 지연을 본다.
- duplicate skip count: 멱등 처리로 건너뛴 record 수를 본다.
- poison message age: 한 record가 partition을 막고 있는 시간을 본다.
offset 지표만으로 business 완료를 판단하면 안 된다.
정산 테이블 반영 건수, 처리 이력, DLQ 건수를 함께 봐야 한다.
개인 프로젝트 기준
작은 실습에서는 manual ack와 처리 이력 테이블만으로 충분히 배울 수 있다.
테스트는 다음 세 가지를 만든다.
- DB 저장 전에 예외가 나면 ack되지 않아 재전달되는가?
- DB 저장 후 ack 전에 프로세스가 죽으면 중복 입력을 건너뛰는가?
- 같은
eventId가 두 번 들어와도 정산 금액이 한 번만 반영되는가?
이 테스트가 없으면 “Kafka를 붙였다”는 사실만 있고 처리 보장은 검증되지 않는다.
위험 신호!
- auto commit을 켜고 business transaction 결과를 따로 확인하지 않는다.
- “Kafka가 exactly-once라서 중복이 없다”고 설명한다.
- partition key 없이 주문별 순서를 기대한다.
- offset lag가 0이면 downstream DB도 정확하다고 본다.
- DLT로 보낸 뒤 원본 offset을 왜 commit하는지 설명하지 못한다.
확인 질문
확인 질문
- 이 consumer는 DB commit 전과 후 중 어느 시점에 offset을 commit하는가?
- 누락을 피하려면 business commit 이후 commit하고, 중복은 멱등성으로 흡수한다.
- 순서 보장이 필요한 business key는 무엇인가?
- 그 key가 producer record key로 들어가 같은 partition에 배치되어야 한다.
- 중복 record가 들어왔다는 사실을 어디에서 확인할 수 있는가?
- 처리 이력 table, unique constraint violation, duplicate skip metric으로 확인한다.
참고 문서
- Apache Kafka Documentation
- Spring for Apache Kafka Reference
- Martin Kleppmann, Designing Data-Intensive Applications