[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_committedsendOffsetsToTransaction 이 결과 발행과 오프셋 커밋을 한 트랜잭션으로 묶는다
Kafka 에서 외부 DB 로 흐르며 손실, 중복 모두 불가At-Least-Once + 멱등 쓰기 또는 outboxKafka 트랜잭션이 외부 DB 까지 묶어주지 못한다
신규 컨슈머 그룹, 실시간성이 우선auto.offset.reset=latest커밋 기록이 없을 때 오래된 메시지까지 밀어내지 않는다
장애 복구, 초기 적재, 재처리가 필요auto.offset.reset=earliest새로 추가한 파티션의 기존 메시지를 건너뛰지 않는다
오프셋 관리를 명시적으로 강제하고 싶음auto.offset.reset=none커밋 기록 없이 조용히 시작하는 상황 자체를 예외로 막는다
특정 시점, 개수로 되감기가 필요kafka-consumer-groups.sh --reset-offsets컨슈머 그룹을 멈춘 뒤 정확한 위치를 지정해 재실행한다