이 문서의 결론을 먼저 적으면 하나다. "DB 커밋"과 "Kafka 오프셋 커밋"은 원자적으로 묶을 수 없다. 그래서 순서를 DB 커밋 → 오프셋 커밋으로 고정해 at-least-once로 만들고, 중복 재처리는 컨슈머 멱등성으로 흡수한다. 이 한 줄을 코드와 실패 시나리오로 풀어내는 것이 목표다. 다루는 질문은 다음과 같다. 리스너가 정상 종료하면 오프셋...
이 문서의 결론을 먼저 적으면 하나다.
"DB 커밋"과 "Kafka 오프셋 커밋"은 원자적으로 묶을 수 없다. 그래서 순서를
DB 커밋 → 오프셋 커밋으로 고정해 at-least-once로 만들고, 중복 재처리는 컨슈머 멱등성으로 흡수한다.
이 한 줄을 코드와 실패 시나리오로 풀어내는 것이 목표다. 다루는 질문은 다음과 같다.
@Transactional DB 커밋과 오프셋 커밋의 순서는 무엇이고, 그 순서가 왜 중요한가processed_event 테이블 기반 idempotent consumer는 어떻게 구성하는가이 글은 메시지를 소비하는 쪽(consumer listener)의 오프셋 커밋 메커니즘에 집중한다. DB 커밋 이후 Kafka로 발행하는 쪽의 정렬(afterCommit / Outbox)은 별도 문서에서 다룬다 (마지막 관련 문서 참고).
Kafka 컨슈머는 메시지를 지우지 않는다. 대신 "이 파티션에서 N번 오프셋까지 처리했다"를 브로커(__consumer_offsets 토픽)에 커밋한다.
컨슈머가 죽고 다시 떠서 리밸런싱되면, 마지막으로 커밋된 오프셋 다음부터 다시 읽는다.
여기서 정합성의 모든 논점이 갈린다.
실무 기본값은 후자다. 유실보다 중복이 다루기 쉽기 때문이다. 중복은 멱등성으로 흡수할 수 있지만, 유실된 메시지는 되살릴 방법이 없다.
Spring Kafka 컨테이너는 enable.auto.commit=false로 두고 컨테이너가 직접 오프셋을 커밋한다.
"언제 커밋하느냐"를 결정하는 것이 ContainerProperties.AckMode다.
| AckMode | 커밋 시점 | 비고 |
|---|---|---|
RECORD | 레코드 1건 리스너 처리가 끝날 때마다 | 가장 안전, 처리량은 낮음 |
BATCH (기본값) | poll()로 가져온 배치 전체 처리가 끝난 뒤 | Spring Kafka 컨테이너 기본값 |
TIME | ackTime 경과 후 | 시간 기반 |
COUNT | ackCount 건 처리 후 | 건수 기반 |
COUNT_TIME | ackCount 또는 ackTime 중 먼저 도달 | 혼합 |
MANUAL | Acknowledgment.acknowledge() 호출분을 모았다가 다음 poll() 때 커밋 | 큐잉 후 배치 커밋 |
MANUAL_IMMEDIATE | acknowledge() 호출 즉시 컨슈머 스레드에서 커밋 | 즉시 커밋 |
핵심 오해 하나를 먼저 정리한다.
BATCH가 기본값이라는 점이 실무에서 자주 발을 건다.
배치 중 3번째 레코드에서 죽으면, 같은 배치의 1~2번째가 이미 DB에 반영됐더라도 오프셋은 배치 단위로만 커밋되므로 배치 전체가 재전송된다. 그래서 멱등성이 없으면 1~2번째가 중복 처리된다.
@Transactional DB 커밋과 오프셋 커밋의 순서가장 흔한 구성은 리스너 메서드에 DB 트랜잭션만 거는 형태다.
@Component
@RequiredArgsConstructor
public class OrderEventConsumer {
private final OrderRepository orderRepository;
@KafkaListener(topics = "order-created", groupId = "order-projection")
@Transactional // DataSourceTransactionManager (DB 전용)
public void consume(OrderCreatedEvent event) {
orderRepository.save(OrderProjection.from(event));
// 메서드가 정상 반환되는 시점에 DB 커밋
}
}이때 실제 실행 순서는 다음과 같다.
poll()로 레코드를 가져와 리스너를 호출한다.@Transactional 프록시가 DB 트랜잭션을 연다.save)이 실행된다.즉 DB 커밋(4) → 오프셋 커밋(5) 순서가 보장된다. 이 순서가 정합성의 핵심이다.
반대 순서(오프셋 먼저, DB 나중)는 절대 피해야 한다. enable.auto.commit=true로 두거나 처리 시작 시점에 ack를 호출하면 이 함정에 빠진다. 오프셋만 올라가고 DB가 롤백되면 메시지가 영영 사라진다.
AckMode.MANUAL / MANUAL_IMMEDIATE를 쓰면 리스너 시그니처에 Acknowledgment를 받아 직접 커밋을 호출한다.
@KafkaListener(topics = "order-created", groupId = "order-projection")
public void consume(OrderCreatedEvent event, Acknowledgment ack) {
orderRepository.save(OrderProjection.from(event));
ack.acknowledge(); // 이 호출 위치가 위험의 원천
}제어권을 손에 쥐는 만큼 실수 지점도 늘어난다.
public void consume(OrderCreatedEvent event, Acknowledgment ack) {
ack.acknowledge(); // ⚠️ 오프셋 먼저 커밋
orderRepository.save(...); // 여기서 예외 → 오프셋은 이미 올라감 → 메시지 유실
}MANUAL_IMMEDIATE에서 특히 치명적이다.
consumer thread에서 ack가 즉시 처리되면 그 뒤 실패한 작업은 재전송으로 복구되지 않는다.
리스너 메서드에 @Transactional을 붙였다는 이유만으로 안전해지지도 않는다.
DB transaction은 메서드가 반환된 뒤 interceptor에서 commit되므로,
메서드 안의 ack.acknowledge()는 DB commit보다 먼저 실행된다.
DB 작업이 핵심 부수 효과라면 리스너가 정상 반환된 뒤 container가 offset을 처리하는 RECORD나 BATCH가 더 단순하다.
조건 분기에서 어떤 경로는 ack를 호출하고 어떤 경로는 빠뜨리면, 그 파티션의 오프셋이 영영 안 올라간다. 다음 리밸런싱 때 마지막 커밋 지점부터 다시 읽으므로 같은 구간을 무한 재처리하거나 lag가 계속 쌓인다.
@Async나 별도 executor로 처리를 넘긴 뒤 그 안에서 ack를 호출하면, 컨테이너 스레드는 이미 다음 poll()로 넘어가 있다.
오프셋 순서가 꼬이고, 컨테이너의 단일 스레드 모델이 깨진다. ack는 컨슈머 스레드(리스너 본문) 안에서 호출한다.
대부분은 RECORD 또는 BATCH로 충분하다.
manual ack는 커밋 단위를 직접 통제해야 하고 DB transaction 경계를 별도로 증명할 수 있을 때만 쓴다.
manual ack는 호출 위치만 보고 안전성을 판단하지 않는다. ack가 실제 offset commit으로 반영되는 시점과 DB transaction commit 시점을 실패 시나리오로 검증한다.
"그럼 DB 커밋과 오프셋 커밋을 하나의 트랜잭션으로 묶으면 되지 않나?"가 자연스러운 다음 질문이다. 결론은 저렴하게는 못 묶는다이다.
Kafka 트랜잭션(producer.beginTransaction() / sendOffsetsToTransaction() / commitTransaction())은
consume-process-produce 패턴에서 입력 오프셋 커밋과 출력 메시지 발행을 원자적으로 묶는다.
Kafka 안에서 읽고, 처리하고, 다시 Kafka로 쓰는 경로는 transaction을 지원하는 producer와 read_committed consumer를 함께 사용할 때 exactly-once 범위를 구성할 수 있다.
// 개념 예시: 입력 오프셋과 출력 발행을 하나의 Kafka 트랜잭션으로
producer.beginTransaction();
producer.send(outputRecord);
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction();문제는 DB가 Kafka와 다른 자원이라는 점이다. Kafka 트랜잭션은 DB INSERT를 함께 커밋하지 못한다.
Spring Kafka에는 과거 ChainedKafkaTransactionManager로 DB 트랜잭션 매니저와 Kafka 트랜잭션 매니저를 연결하는 방법이 있었다.
하지만 이건 진짜 2단계 커밋(2PC)이 아니라 커밋을 순서대로 호출하는 동기화일 뿐이다.
ChainedKafkaTransactionManager는 Spring Kafka 2.7부터 deprecated이며 이후 제거 방향이다.
새 코드에서 되살리지 말고, 두 자원의 비원자성을 설계로 인정하는 쪽으로 간다.
분산 transaction인 XA와 2PC는 운영 비용과 성능 부담이 크다. 대신 DB나 Kafka 중 하나를 기준 데이터로 택하고 다른 저장소의 복구 절차를 설계한다.
processed_event 테이블소비 측 중복 흡수의 표준은 "이 이벤트를 이미 처리했는가"를 DB unique 제약으로 판정하는 것이다.
CREATE TABLE processed_event (
event_id VARCHAR(64) NOT NULL,
consumer VARCHAR(64) NOT NULL, -- 같은 이벤트를 여러 컨슈머가 처리하면 그룹별로 구분
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (event_id, consumer)
) ENGINE=InnoDB;event_id는 메시지에 실린 고유 키다. 주문번호나 발행 측이 심은 UUID처럼 재전송돼도 같은 값이어야 한다. Kafka의 offset은 재전송 시 같지만 토픽/파티션 재구성에 취약하므로 비즈니스 레벨 ID를 쓰는 편이 안전하다.consumer 컬럼으로 컨슈머 그룹별 처리 여부를 분리한다. 같은 이벤트를 projection용과 알림용이 각자 한 번씩 처리해야 하기 때문이다.@KafkaListener(topics = "order-created", groupId = "order-projection")
@Transactional
public void consume(OrderCreatedEvent event) {
int inserted = processedEventRepository.insertIfAbsent(
event.eventId(), "order-projection");
if (inserted == 0) {
return;
}
orderRepository.save(OrderProjection.from(event));
}processed_event INSERT와 OrderProjection 저장이 하나의 DB 트랜잭션이라, 둘 다 커밋되거나 둘 다 롤백된다.
insertIfAbsent는 DB에 맞는 충돌 무시 INSERT를 실행하고 영향받은 행 수를 반환한다.
재전송된 중복이면 0을 반환하므로 비즈니스 로직을 실행하지 않는다.
애플리케이션에서 unique constraint exception을 잡는 방식에는 두 문제가 있다.
PostgreSQL의 INSERT ... ON CONFLICT DO NOTHING이나 MySQL의 INSERT IGNORE처럼
constraint violation을 exception으로 만들지 않는 SQL을 사용하고 영향받은 행 수로 분기하면 이 문제를 피할 수 있다.
DB transaction 전에 Redis SETNX를 사용해 중복 후보를 줄일 수는 있다.
하지만 Redis 기록 뒤 DB commit 전에 consumer가 중단되면 재전송된 이벤트를 Redis가 처리 완료로 오인할 수 있다.
Redis 결과만 보고 처리를 건너뛰지 말고 DB의 처리 완료 기록을 다시 확인해야 한다.
정확성이 중요하면 추가 복잡도와 실제 중복률을 비교해 Redis 필터를 생략하는 편이 낫다.
지금까지를 한 흐름으로 잇는다.
@Transactional 안에서 processed_event INSERT(dedup)와 비즈니스 로직을 묶어 실행.insertIfAbsent가 0을 반환하므로 비즈니스 로직을 다시 실행하지 않는다.이 구조에서 정확히 한 번 "전달"은 보장하지 못해도, 정확히 한 번 "처리"한 것과 같은 효과(effectively-once)를 얻는다. 이것이 실무에서 Kafka 정합성을 다루는 기본 접근이다. Exactly-once delivery를 좇기보다 at-least-once와 멱등 설계로 간다.
DB 커밋 → 오프셋 커밋 순서가 보장되는가 (enable.auto.commit=false 확인)BATCH의 배치 단위 재전송 영향을 이해했는가)@Transactional 리스너 안의 manual ack가 DB commit보다 먼저 실행될 수 있음을 검증했는가@Async/별도 스레드에서 호출하지 않는가)processed_event 또는 동등한 dedup이 있는가)