[Kafka] 컨슈머 오프셋과 커밋
개요
Kafka 컨슈머는 메시지를 어디까지 처리했는지 오프셋으로 기록하며, 이 기록 시점이 손실과 중복을 가른다. poll 직후 커밋하면 장애 시 메시지가 사라지고, 처리 후 커밋하면 재시작 시 같은 메시지를 다시 받는다. 이 시점이 전달 보장 수준을 정하며, 대부분의 서비스는 At-Least-Once를 택해 멱등성으로 중복을 흡수한다. 커밋 기록이 없거나 만료됐을 때 어디서부터 읽을지 정하는 auto.offset.reset까지 함께 다룬다.
다이어그램
오프셋과 __consumer_offsets
Committed Offset, Current Position, Log End Offset, Consumer Lag 네 값이 컨슈머의 진행 상태를 나타낸다.
- Committed Offset: 컨슈머가 여기까지 처리했다고 브로커에 기록한 위치. 재시작 시 이 위치부터 다시 읽는다.
- Current Position: poll로 받아 지금 처리 중인 레코드의 오프셋. Committed Offset 보다 앞서 있을 수 있다.
- Log End Offset: 파티션에 실제로 쓰인 마지막 메시지의 다음 오프셋. 다음 쓰기가 들어갈 위치다.
- Consumer Lag: Log End Offset에서 Committed Offset 을 뺀 값. 처리 지연을 나타낸다.
오프셋은 과거 ZooKeeper 에 저장됐지만 지금은 __consumer_offsets 라는 Kafka 내부 토픽에 저장된다.
- 키는
(group.id, topic, partition)조합이다. - 커밋 한 번이 이 내부 토픽에 메시지 한 건을 발행하는 것과 같다.
cleanup.policy=compact라 같은 키의 최신 값만 남고 옛 커밋 기록은 압착된다.
자동 커밋과 수동 커밋
poll 주기마다 자동으로 커밋되면 무엇이 문제인가?
enable.auto.commit=true이면auto.commit.interval.ms(기본 5000ms) 주기로 커밋한다.- 별도 타이머가 도는 게 아니라 poll() 호출 시점에 마지막 커밋 이후 interval이 지났는지 확인하고 커밋한다.
- poll 주기가 5000ms 보다 길어지면 커밋도 그만큼 늦게 일어난다.
- 커밋은 처리 성공 여부를 보지 않고 시간만 보고 실행된다.
- 처리가 자동 커밋 주기보다 길면 처리 실패와 무관하게 이미 커밋된 상태가 된다.
- 자동 커밋은 처리 성공 여부와 무관하게 시간으로만 커밋하므로 사실상 At-Most-Once에 가깝다.
- 비즈니스 토픽에는 거의 항상
enable.auto.commit=false를 쓰고 처리 후 직접 커밋한다. enable.auto.commit=false여도 처리 전에ack.acknowledge()를 호출하면 같은 유실이 재현된다.
// 잘못된 패턴: 처리 전에 커밋하면 자동 커밋을 꺼도 손실이 그대로 재현된다
@KafkaListener(topics = "orders")
public void listen(OrderEvent event, Acknowledgment ack) {
ack.acknowledge();
orderRepository.save(Order.from(event));
}// 올바른 패턴: 처리가 끝난 뒤에만 커밋해야 처리 중 장애가 유실이 아니라 재처리로 이어진다
@KafkaListener(topics = "orders")
public void listen(OrderEvent event, Acknowledgment ack) {
orderRepository.save(Order.from(event));
ack.acknowledge();
}commitSync와 commitAsync는 언제 쓰나?
| 방식 | 특징 |
|---|---|
commitSync() | 브로커 응답까지 블로킹. 실패 시 내부 재시도. 가장 안전하지만 처리량이 떨어진다 |
commitAsync() | 응답을 기다리지 않는다. 빠르지만 실패해도 재시도하지 않고 콜백으로만 알린다. 순서 역전 위험이 있다 |
try {
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
process(record);
}
consumer.commitAsync(); // 평소에는 빠른 비동기 커밋
}
} finally {
try {
consumer.commitSync(); // 종료 직전에는 동기 커밋으로 최종 보장
} finally {
consumer.close();
}
}- 평소에는
commitAsync()로 처리량을 유지한다. - 종료 직전에는
commitSync()로 마지막 커밋을 보장한다.
커밋 시점이 결정하는 전달 보장
| 보장 | 커밋 시점 | 장애 시 결과 | 트레이드오프 |
|---|---|---|---|
| At-Most-Once | 처리 전 커밋 | 처리 실패 시 메시지 유실 | 중복 없음, 손실 가능 |
| At-Least-Once | 처리 후 커밋 | 재시작 시 재처리로 중복 발생 | 손실 없음, 중복 가능 |
| Exactly-Once | 처리와 커밋을 한 트랜잭션으로 묶음 | 손실도 중복도 없음 | 처리량 저하, 구성 복잡 |
세 보장은 poll 이후 커밋과 처리 중 무엇을 먼저 하느냐, 그리고 장애가 그 사이 어디서 나느냐로 갈린다.
poll 직후 커밋하면 왜 메시지가 사라지나?
- 커밋이 처리보다 먼저 끝나면, 그 사이 장애가 나도 오프셋은 이미 앞서 있다.
sequenceDiagram participant C as Consumer participant K as Kafka(__consumer_offsets) participant B as 비즈니스 로직 C->>K: poll() K-->>C: offset 3 메시지 반환 C->>K: commit offset 4 Note over K: Committed Offset = 4로 즉시 갱신 C->>B: process(offset 3 레코드) B--xC: 처리 중 장애 발생 (커밋은 이미 끝난 뒤) Note over C: 컨슈머 재시작 C->>K: poll() K-->>C: offset 4부터 반환 Note over C: offset 3 레코드는 다시 오지 않는다 → 유실
- 재시작 후 poll 은 Committed Offset(4)부터 읽으므로 처리하지 못한 offset 3은 다시 오지 않는다.
- 손실보다 처리량이 중요한 메트릭, 로그 수집 같은 곳에만 쓴다.
처리 후 커밋하면 중복은 어떻게 막나?
- 처리가 커밋보다 먼저 끝나면, 처리 중 장애가 나도 오프셋은 아직 그대로다.
sequenceDiagram participant C as Consumer participant K as Kafka(__consumer_offsets) participant B as 비즈니스 로직 C->>K: poll() K-->>C: offset 3 메시지 반환 C->>B: process(offset 3 레코드) B--xC: 처리 중 장애 발생 (커밋 호출 전) Note over K: Committed Offset 은 여전히 3 Note over C: 컨슈머 재시작 C->>K: poll() K-->>C: offset 3부터 다시 반환 C->>B: process(offset 3 레코드) 재실행 Note over C: 같은 레코드를 두 번 처리 → 중복 B-->>C: 처리 완료 C->>K: commit offset 4
- At-Least-Once는 중복 수신을 전제로 한다. 컨슈머가 같은 메시지를 두 번 받아도 결과가 같아야 한다.
- 흔한 멱등 처리 방법은 세 가지다.
- DB unique key로 중복 삽입을 막는다.
- 처리 기록을 별도 테이블에 남기고 재수신 시 조회해 건너뛴다.
topic+partition+offset또는orderId같은 비즈니스 키로 중복을 판정한다.
@KafkaListener(topics = "orders")
@Transactional
public void listen(@Payload OrderEvent event,
@Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
@Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
@Header(KafkaHeaders.OFFSET) long offset,
Acknowledgment ack) {
String key = "%s-%d-%d".formatted(topic, partition, offset);
if (processedEventRepo.existsByIdempotencyKey(key)) {
ack.acknowledge();
return;
}
orderRepository.save(Order.from(event));
processedEventRepo.save(new ProcessedEvent(key, ZonedDateTime.now()));
ack.acknowledge();
}- 처리와 처리 기록 저장을 같은 트랜잭션에 묶어야 처리만 되고 기록이 안 남는 경우를 막는다.
손실도 중복도 없으려면 무엇을 트랜잭션으로 묶나?
- 처리 결과 쓰기와 오프셋 커밋을 하나의 트랜잭션으로 묶으면 중간 상태가 없다.
sequenceDiagram participant C as Consumer participant K as Kafka(__consumer_offsets) participant B as 비즈니스 로직 C->>K: poll() K-->>C: offset 3 메시지 반환 C->>C: beginTransaction() C->>B: process(offset 3 레코드) B-->>C: 처리 결과 C->>K: send(처리 결과) + sendOffsetsToTransaction(offset 4) alt 커밋 성공 C->>K: commitTransaction() Note over K: 결과 레코드와 offset 4 커밋이 원자적으로 함께 반영 else 커밋 전 장애 C--xK: abortTransaction() 또는 타임아웃 Note over K: 결과 레코드도 offset 커밋도 반영되지 않음 Note over C: 컨슈머 재시작 C->>K: poll() K-->>C: offset 3부터 다시 반환, 유실도 중복도 없음 end
- 트랜잭셔널 프로듀서(
transactional.id)를 쓴다. - 컨슈머는
isolation.level=read_committed로 커밋되지 않은 결과를 걸러낸다. sendOffsetsToTransaction이 결과 발행과 오프셋 커밋을 한 트랜잭션에 담는다.- Kafka에서 외부 DB로 처리하는 경우 Kafka 트랜잭션이 외부 DB 까지 묶어주지 못한다.
- At-Least-Once에 멱등 쓰기 또는 outbox 패턴을 더해 Exactly-Once를 만든다.
- 처리량이 떨어지고 구성이 복잡해지므로 정산, 결제처럼 중복과 손실이 치명적인 도메인에만 쓴다.
auto.offset.reset 과 파티션 증설
| 옵션 | 동작 | 적합한 상황 |
|---|---|---|
latest (기본) | 최신 메시지 다음부터 읽음 | 실시간 처리, 신규 컨슈머 그룹 |
earliest | 처음부터 읽음 | 초기 적재, 미처리 메시지 복구 |
none | 오프셋이 없으면 예외 발생 | 명시적 오프셋 관리 |
새 파티션에 아무도 커밋하지 않았다면 latest는 무엇을 건너뛰나?
- 기존 파티션 P0~P2에는 컨슈머 그룹의 커밋 기록이 이미 있다.
- P3, P4를 새로 추가하고 한동안 메시지가 그쪽으로도 라우팅됐다고 하자.
- P3, P4에는 어떤 컨슈머도 커밋한 적이 없다.
- 이 상태에서 신규 컨슈머를 추가하고
latest로 두면 P3, P4 에 이미 쌓인 메시지를 전부 건너뛴다. - 그 결과는 유실과 같다.
earliest로 두면 P3, P4 의 처음부터 읽어 모든 메시지를 처리한다.
이미 커밋된 오프셋을 특정 시점으로 되돌리려면?
- 장애 복구나 재처리가 필요하면
kafka-consumer-groups.sh로 오프셋을 이동한다. - 컨슈머 그룹이 멈춰 있는 상태에서 실행해야 한다.
# 특정 시각으로 리셋
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--reset-offsets --group order-service --topic orders \
--to-datetime 2026-06-01T00:00:00.000 --execute
# 1000개 되감기
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--reset-offsets --group order-service --topic orders \
--shift-by -1000 --execute언제 무엇을 쓰는가
| 상황 | 선택 | 근거 |
|---|---|---|
| 일반적인 비즈니스 토픽 처리 | 수동 커밋 + At-Least-Once + 멱등 컨슈머 | 손실이 곧 사고이고, 중복은 멱등 처리로 흡수하는 비용이 가장 낮다 |
| 손실을 감수할 수 있는 메트릭, 로그 수집 | 자동 커밋 또는 At-Most-Once | 유실을 허용하는 대신 코드가 가장 단순하다 |
| Kafka 에서 Kafka 로 흐르며 손실, 중복 모두 불가 | 트랜잭셔널 프로듀서 + read_committed | sendOffsetsToTransaction 이 결과 발행과 오프셋 커밋을 한 트랜잭션으로 묶는다 |
| Kafka 에서 외부 DB 로 흐르며 손실, 중복 모두 불가 | At-Least-Once + 멱등 쓰기 또는 outbox | Kafka 트랜잭션이 외부 DB 까지 묶어주지 못한다 |
| 신규 컨슈머 그룹, 실시간성이 우선 | auto.offset.reset=latest | 커밋 기록이 없을 때 오래된 메시지까지 밀어내지 않는다 |
| 장애 복구, 초기 적재, 재처리가 필요 | auto.offset.reset=earliest | 새로 추가한 파티션의 기존 메시지를 건너뛰지 않는다 |
| 오프셋 관리를 명시적으로 강제하고 싶음 | auto.offset.reset=none | 커밋 기록 없이 조용히 시작하는 상황 자체를 예외로 막는다 |
| 특정 시점, 개수로 되감기가 필요 | kafka-consumer-groups.sh --reset-offsets | 컨슈머 그룹을 멈춘 뒤 정확한 위치를 지정해 재실행한다 |