이 문서의 결론을 먼저 적으면 하나다. > "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에서 특히 치명적이다. ack가 즉시 커밋되므로, 그 뒤 실패한 작업은 재전송으로 복구되지 않는다.
ack는 모든 부수 효과(특히 DB 커밋)가 끝난 뒤 호출해야 한다.
조건 분기에서 어떤 경로는 ack를 호출하고 어떤 경로는 빠뜨리면, 그 파티션의 오프셋이 영영 안 올라간다. 다음 리밸런싱 때 마지막 커밋 지점부터 다시 읽으므로 같은 구간을 무한 재처리하거나 lag가 계속 쌓인다.
@Async나 별도 executor로 처리를 넘긴 뒤 그 안에서 ack를 호출하면, 컨테이너 스레드는 이미 다음 poll()로 넘어가 있다.
오프셋 순서가 꼬이고, 컨테이너의 단일 스레드 모델이 깨진다. ack는 컨슈머 스레드(리스너 본문) 안에서 호출한다.
대부분은 RECORD 또는 BATCH로 충분하다. manual ack는 다음처럼 커밋 단위를 비즈니스 단위로 직접 통제해야 할 때만 쓴다.
이 경우에도 "성공 부수 효과 완료 → ack" 순서를 기계적으로 지킨다.
"그럼 DB 커밋과 오프셋 커밋을 하나의 트랜잭션으로 묶으면 되지 않나?"가 자연스러운 다음 질문이다. 결론은 저렴하게는 못 묶는다이다.
Kafka 트랜잭션(producer.beginTransaction() / sendOffsetsToTransaction() / commitTransaction())은
consume-process-produce 패턴에서 "입력 오프셋 커밋 + 출력 메시지 발행"을 원자적으로 묶는다.
즉 Kafka 안에서 읽고-처리하고-다시 Kafka로 쓰는 경로는 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이며 이후 제거 방향이다.
새 코드에서 되살리지 말고, 두 자원의 비원자성을 설계로 인정하는 쪽으로 간다.
진짜 분산 트랜잭션(XA/2PC)은 운영 비용과 성능 부담이 커서 대부분 피한다. 대신 둘 중 하나를 단일 진실원으로 택한다.
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) {
try {
processedEventRepository.saveAndFlush(
new ProcessedEvent(event.eventId(), "order-projection"));
} catch (DataIntegrityViolationException e) {
// unique 제약 위반 = 이미 처리한 이벤트 → 비즈니스 로직 건너뜀
log.info("중복 이벤트 스킵: {}", event.eventId());
return;
}
// 여기까지 왔다는 건 이 이벤트를 처음 본다는 뜻
orderRepository.save(OrderProjection.from(event));
}processed_event INSERT와 OrderProjection 저장이 하나의 DB 트랜잭션이라, 둘 다 커밋되거나 둘 다 롤백된다.
재전송된 중복은 INSERT 단계에서 unique 위반으로 걸러지고, 비즈니스 로직은 실행되지 않는다.
unique 제약 위반을 catch할 때 주의할 함정이 둘 있다.
return해 트랜잭션을 깨끗하게 종료시킨다.saveAndFlush로 즉시 반영: JPA에서 그냥 save만 하면 flush가 커밋 시점까지 지연돼 위반을 그 자리에서 못 잡는다. saveAndFlush로 INSERT를 즉시 DB에 보내 위반을 리스너 본문에서 catch한다.DB 종류에 따라 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)이나 INSERT IGNORE(MySQL)로 처리한 뒤
영향받은 행 수(0이면 중복)로 분기하는 방식도 깔끔하다. 트랜잭션 오염 문제를 피할 수 있어 선호되기도 한다.
매번 DB를 때리는 비용이 부담이면, DB 트랜잭션 진입 전에 Redis SETNX로 1차 필터링한 뒤 DB unique 제약을 최종 방어선으로 둔다.
Redis는 캐시일 뿐이라 정합성의 최종 책임은 DB 제약에 있어야 한다.
지금까지를 한 흐름으로 잇는다.
@Transactional 안에서 processed_event INSERT(dedup) + 비즈니스 로직을 묶어 실행.이 구조에서 정확히 한 번 "전달"은 보장하지 못해도, 정확히 한 번 "처리"한 것과 같은 효과(effectively-once)를 얻는다. 이것이 실무에서 Kafka 정합성을 다루는 표준 답이다 — exactly-once delivery를 좇기보다 at-least-once + 멱등 설계로 간다.
Q. 리스너가 정상 종료하면 오프셋은 언제 커밋되나요?
Spring Kafka는
enable.auto.commit=false로 두고 컨테이너가 직접 커밋합니다. AckMode 기본값이BATCH라 한poll()배치를 다 처리한 뒤 커밋되고,RECORD로 두면 레코드마다 커밋됩니다. 중요한 건 리스너가 예외 없이 반환된 뒤에 커밋된다는 점이고, 그래서 DB 작업이 함께 있으면 DB 커밋이 오프셋 커밋보다 먼저 일어납니다.
Q. DB 커밋과 오프셋 커밋 순서가 왜 중요한가요?
DB 커밋 → 오프셋 커밋순서라야 at-least-once가 됩니다. 둘 사이에서 죽으면 오프셋이 안 올라가 메시지가 재전송되고, DB에는 이미 반영됐으니 중복 처리가 됩니다. 반대 순서면 오프셋만 올라가고 DB가 롤백돼 메시지를 잃습니다. 유실보다 중복이 다루기 쉬우니 전자를 택하고 중복은 멱등성으로 흡수합니다.
Q. Kafka 트랜잭션으로 DB까지 원자적으로 묶을 수 있나요?
Kafka 트랜잭션은 consume-process-produce, 즉 Kafka 안에서 읽고 다시 Kafka로 쓰는 경로의 오프셋 커밋과 발행을 묶어줍니다. 하지만 DB는 다른 자원이라 함께 못 묶습니다.
ChainedKafkaTransactionManager로 순서대로 커밋할 수는 있었지만 2PC가 아니라 커밋 사이 실패 창이 남고, 지금은 deprecated입니다. 그래서 DB를 진실원으로 하는 Outbox나, Kafka를 진실원으로 하는 idempotent consumer 중 하나로 설계합니다.
Q. 중복 메시지는 어떻게 막나요?
processed_event테이블에 이벤트 고유 ID를 unique 제약으로 두고, dedup INSERT와 비즈니스 로직을 같은 DB 트랜잭션에 넣습니다. 재전송된 중복은 INSERT에서 unique 위반으로 걸러지고 비즈니스 로직은 건너뜁니다. 위반 catch 시 트랜잭션 오염을 피하려고 dedup INSERT를 트랜잭션 맨 앞에 두거나,ON CONFLICT DO NOTHING같은 구문으로 처리합니다.
DB 커밋 → 오프셋 커밋 순서가 보장되는가 (enable.auto.commit=false 확인)BATCH의 배치 단위 재전송 영향을 이해했는가)acknowledge()를 호출하는가@Async/별도 스레드에서 호출하지 않는가)processed_event 또는 동등한 dedup이 있는가)