이 케이스에서 얻어 갈 것

상황

커머스 정산 파이프라인입니다. orders 토픽은 파티션 24개, 복제 계수 3이고 브로커 3대에서 일 500만 건의 주문 이벤트를 받습니다. 보관 기간은 기본값인 7일입니다. 정산 서비스는 group.id=order-settlement로 이 토픽을 구독해 하루 마감 배치의 입력을 만듭니다.

수요일 14:10에 정산 서비스 v2.4.0을 배포했습니다. 이번 릴리스에서 팀은 배포 스크립트를 정리하면서 컨슈머 그룹 ID를 차트 버전 변수로 치환했습니다. 의도는 "카나리 배포 때 그룹을 분리하자"였습니다.

values.yaml — 이 한 줄이 사고의 전부입니다
kafka:
  consumer:
    # 변경 전: group-id: order-settlement
    group-id: "order-settlement-{{ .Chart.AppVersion }}"   # → order-settlement-2.4.0

배포 자체는 조용히 끝났습니다. 에러율 0, 응답 시간 정상, 컨슈머 lag 그래프는 오히려 깨끗하게 0이 되었습니다. 14:40부터 "일요일 주문 정산이 누락됐다"는 CS 문의가 들어오기 시작했고, 17:00에는 토요일 06:00 이후의 주문 중 상당수가 정산 테이블에 아예 없다는 것이 확인됐습니다.

case01 — 새 group.id 와 auto.offset.reset=latest 가 겹쳐 과거 데이터를 건너뛰는 흐름 세 단계로 나눈 장애 시퀀스입니다. 정상 흐름에서는 토픽 orders 에 오프셋 0부터 999까지 1000건이 쌓여 있고 기존 그룹이 오프셋 1000까지 커밋해 두었으며 auto.offset.reset 은 커밋된 오프셋이 없을 때에만 쓰이는 규칙입니다. 어긋나는 지점에서는 배포하면서 group.id 를 새 값으로 바꾸는데 새 그룹에는 커밋된 오프셋이 없고 auto.offset.reset 기본값이 latest 이므로 시작 위치가 로그 끝인 1000으로 잡힙니다. 결과적으로 오프셋 0부터 999까지 1000건이 처리되지 않는데, 컨슈머는 정상 동작 중이고 lag 도 0으로 보이므로 알아채기 어렵습니다. 다만 데이터는 토픽에 남아 있으므로 유실이 아니라 읽지 않은 상태이며 오프셋을 되돌려 복구할 수 있습니다. 1. 정상 흐름 auto.offset.reset 은 예외 상황용 규칙입니다 프로듀서 orders 오프셋 0~999 그룹 billing-v1 커밋 1000 · lag 0 auto.offset.reset 은 그룹에 커밋된 오프셋이 없을 때, 또는 커밋된 오프셋이 로그 범위를 벗어났을 때만 적용됩니다. 평소에는 __consumer_offsets 에 저장된 커밋 오프셋에서 이어 읽으므로 이 설정이 동작에 끼어들지 않습니다. 2. 어긋나는 지점 배포에서 group.id 를 바꿨습니다 group.id=billing-v2 커밋 오프셋 없음 reset=latest 적용 시작 오프셋 1000 group.id 를 바꾸면 완전히 새 그룹입니다 — 이전 그룹의 커밋 오프셋을 물려받지 않습니다. auto.offset.reset 기본값은 latest 이므로 시작 위치가 로그의 끝으로 정해집니다. 3. 결과 조용히 지나가는 사고 0~999 미처리 lag 0 으로 표시 지표상 정상으로 보임 컨슈머는 예외 없이 잘 돌고 lag 도 0 이라 모니터링에 아무 신호가 없습니다. 데이터 자체는 토픽에 남아 있으므로 유실이 아니라 "읽지 않은 것"이며 되돌릴 수 있습니다. 처방 새 그룹으로 과거 데이터를 처리해야 하면 auto.offset.reset=earliest 로 둡니다. 이미 지나간 경우: kafka-consumer-groups.sh --group billing-v2 --topic orders --reset-offsets --to-earliest --execute group.id 변경을 배포 체크리스트에 넣고, 변경 시 시작 위치를 함께 결정합니다.
그룹 ID 변경이 데이터 유실로 보이는 경로 — 기존 그룹의 커밋 오프셋은 그대로 남고, 새 그룹은 커밋 기록이 없어 auto.offset.reset=latest에 따라 로그 끝에서 시작합니다

관측된 증상

메트릭이 어떻게 보였는가

컨슈머 애플리케이션 로그

배포 직후 컨슈머 로그에 아래 두 줄이 파티션마다 한 번씩, 즉 24쌍이 찍혀 있었습니다. ConsumerCoordinatorSubscriptionState가 출력하는 실제 메시지입니다.

settlement-service INFO 로그 (일부) — 이 두 줄이 사고의 지문입니다
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Discovered group coordinator kafka-2:9092 (id: 2 rack: null)
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] (Re-)joining group
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Successfully joined group with generation Generation{generationId=1, memberId='settlement-0-9f1c...', protocol='range'}
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Adding newly assigned partitions: orders-0, orders-1, orders-2, orders-3, orders-4, orders-5, orders-6, orders-7
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Found no committed offset for partition orders-0
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Found no committed offset for partition orders-1
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Resetting offset for partition orders-0 to position FetchPosition{offset=48211903, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[kafka-1:9092 (id: 1 rack: null)], epoch=7}}.
[Consumer clientId=settlement-0, groupId=order-settlement-2.4.0] Resetting offset for partition orders-1 to position FetchPosition{offset=47980114, offsetEpoch=Optional.empty, currentLeader=LeaderAndEpoch{leader=Optional[kafka-3:9092 (id: 3 rack: null)], epoch=6}}.

참고로, 커밋 오프셋이 있었지만 리텐션으로 사라진 경우에는 다른 로그가 남습니다. FetchCollector가 아래 형태로 출력합니다. 두 경로를 구분할 수 있으면 원인 판별이 훨씬 빨라집니다.

커밋 오프셋이 로그 범위를 벗어난 경우의 로그 형태
Fetch position FetchPosition{offset=41002877, offsetEpoch=Optional[5], currentLeader=...} is out of range for partition orders-11, resetting offset

원인 분석

1단계 — 그룹 목록을 본다

가장 먼저 확인한 것은 그룹이 몇 개인지였습니다. 여기서 이미 답이 나왔습니다.

그룹 목록 조회
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 --list
order-settlement
order-settlement-2.4.0
fraud-scoring
order-search-indexer

2단계 — 예전 그룹을 describe 한다

예전 그룹은 멤버가 없는 상태로 커밋 오프셋만 남아 있습니다. CONSUMER-ID·HOST·CLIENT-ID-로 찍히고, 도구가 표준 에러로 경고를 한 줄 출력합니다.

예전 그룹 — 오프셋은 살아 있고 소비자는 없다
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
    --describe --group order-settlement

Consumer group 'order-settlement' has no active members.

GROUP             TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG        CONSUMER-ID  HOST  CLIENT-ID
order-settlement  orders  0          43904812        48213771        4308959    -            -     -
order-settlement  orders  1          43655902        47982033        4326131    -            -     -
order-settlement  orders  2          43712004        48044190        4332186    -            -     -
...

CURRENT-OFFSET이 토요일 06:00 근처에서 멈춰 있고, LAG이 파티션마다 430만 건입니다. 24개 파티션을 합치면 약 1억 건이 읽히지 않은 상태로 남아 있습니다. 데이터는 사라지지 않았습니다. 읽는 주체가 사라졌습니다.

3단계 — 새 그룹을 describe 한다

새 그룹 — 처음부터 로그 끝에 붙어 있었다
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
    --describe --group order-settlement-2.4.0

GROUP                   TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG  CONSUMER-ID                     HOST         CLIENT-ID
order-settlement-2.4.0  orders  0          48213771        48213771        0    settlement-0-9f1c8a2e-...       /10.42.1.18  settlement-0
order-settlement-2.4.0  orders  1          47982033        47982033        0    settlement-0-9f1c8a2e-...       /10.42.1.18  settlement-0
...

CURRENT-OFFSET == LOG-END-OFFSET, LAG = 0입니다. 새 그룹은 배포 시각의 로그 끝에서 소비를 시작했고, 그 이전 구간은 한 번도 읽지 않았습니다.

4단계 — 왜 이렇게 되는가

auto.offset.reset의 공식 설명은 다음과 같습니다. "Kafka에 초기 오프셋이 없거나, 현재 오프셋이 서버에 더 이상 존재하지 않을 때(예: 그 데이터가 삭제되어)" 무엇을 할지 정하는 설정입니다.

auto.offset.reset 값별 동작. Apache Kafka 4.3 Consumer 설정 기준이며 기본값은 latest입니다.
커밋 오프셋이 있을 때 커밋 오프셋이 없을 때 커밋 오프셋이 범위를 벗어났을 때
latest
기본값
커밋 위치에서 이어 읽음 (설정 무관) 로그 끝부터 — 그 이전 데이터는 건너뜀 로그 끝으로 점프 — 미처리분 유실
earliest 커밋 위치에서 이어 읽음 (설정 무관) 로그 시작부터 — 전체 재처리 남아 있는 가장 오래된 위치부터 — 중복 처리 가능
none 커밋 위치에서 이어 읽음 (설정 무관) NoOffsetForPartitionException 발생 → 컨슈머가 즉시 실패 OffsetOutOfRangeException 발생
by_duration:<duration> 커밋 위치에서 이어 읽음 (설정 무관) 현재 시각에서 지정한 ISO-8601 기간만큼 되돌린 지점부터 같은 규칙으로 재배치

5단계 — 그룹 ID를 안 바꿔도 같은 사고가 난다

여기서 멈추면 절반만 이해한 것입니다. 표의 세 번째 열, 즉 "커밋 오프셋이 없을 때"는 그룹 ID를 바꿀 때만 생기는 상태가 아닙니다. offsets.retention.minutes가 같은 상태를 만듭니다.

세 번째 경로도 있습니다. 파티션 수를 늘렸을 때입니다. 공식 운영 문서는 auto.offset.reset=latest인 기존 컨슈머가 새 파티션 생성과 컨슈머의 파티션 인식 사이 구간에 들어온 메시지를 놓칠 수 있다고 명시합니다. 새 파티션에는 커밋 오프셋이 없기 때문입니다. 파티션 증설의 다른 비용은 케이스 5에서 다룹니다.

재현 방법

단일 노드 KRaft 클러스터로 3분 안에 재현됩니다. 아래 compose는 Apache Kafka가 배포하는 공식 단일 노드 예제를 그대로 쓴 것입니다. 3노드 구성이 필요하면 예제 1 · 로컬 KRaft 클러스터를 쓰세요.

docker-compose.yml — 단일 노드 KRaft (combined 모드, ZooKeeper 없음)
services:
  broker:
    image: apache/kafka:4.3.1
    hostname: broker
    container_name: broker
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: 'broker,controller'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
      KAFKA_LISTENERS: 'CONTROLLER://:29093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://broker:19092,PLAINTEXT_HOST://localhost:9092'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@broker:29093'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
      CLUSTER_ID: '4L6g3nShT-eMCtK--X86sw'
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
재현 절차 — "새 그룹은 과거를 못 본다"를 눈으로 확인
docker compose up -d
alias kexec='docker exec -it broker /opt/kafka/bin'

# 1. 토픽 생성
docker exec -it broker /opt/kafka/bin/kafka-topics.sh --create \
  --topic orders --partitions 3 --replication-factor 1 \
  --bootstrap-server localhost:9092

# 2. "과거 데이터" 10건 적재
docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
  --topic orders --bootstrap-server localhost:9092 <<'EOF'
old-1
old-2
old-3
old-4
old-5
old-6
old-7
old-8
old-9
old-10
EOF

# 3. 기존 그룹으로 전부 읽고 커밋한다 (--from-beginning 은 커밋이 없을 때만 유효)
docker exec -it broker /opt/kafka/bin/kafka-console-consumer.sh \
  --topic orders --group order-settlement --from-beginning \
  --bootstrap-server localhost:9092 --timeout-ms 8000
#   → old-1 … old-10 이 출력되고 커밋됩니다

# 4. 그룹 ID만 바꿔 다시 읽는다 (기본값 auto.offset.reset=latest)
docker exec -it broker /opt/kafka/bin/kafka-console-consumer.sh \
  --topic orders --group order-settlement-2.4.0 \
  --bootstrap-server localhost:9092 --timeout-ms 8000
#   → 아무것도 출력되지 않습니다. 이것이 사고의 전부입니다.

# 5. 두 그룹의 커밋 위치를 나란히 본다
docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
  --describe --group order-settlement --bootstrap-server localhost:9092
docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
  --describe --group order-settlement-2.4.0 --bootstrap-server localhost:9092

변형 — 오프셋 만료 경로 재현

두 번째 경로를 재현하려면 offsets.retention.minutes를 아주 짧게 두고 브로커를 띄운 뒤, 그룹의 컨슈머를 모두 종료한 상태로 그 시간을 넘기면 됩니다. compose의 environment에 아래를 추가하세요. 운영 클러스터에서는 절대 이 값을 쓰지 마세요.

재현 전용 브로커 설정
# 재현 전용. 운영 금지.
offsets.retention.minutes=1
offsets.retention.check.interval.ms=10000

해결

즉시 조치 — 새 그룹의 오프셋을 되돌린다

이미 건너뛴 구간은 보관 기간이 남아 있는 동안에만 복구할 수 있습니다. 이 사고에서는 리텐션 7일 중 나흘째였으므로 데이터가 남아 있었습니다. --reset-offsets그룹이 비활성(Empty/Dead) 상태일 때만 동작하므로 컨슈머를 먼저 전부 내려야 합니다.

복구 절차 — 반드시 --dry-run을 먼저 봅니다
# 0. 컨슈머 파드를 0으로 줄인다 (그룹이 Empty 가 되어야 함)
kubectl scale deploy/settlement-service --replicas=0

# 1. 계획 확인 — --dry-run 이 기본 동작이지만 명시하는 습관을 권장합니다
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
  --group order-settlement-2.4.0 --topic orders \
  --reset-offsets --to-datetime 2026-07-25T06:00:00.000 --dry-run

GROUP                   TOPIC   PARTITION  NEW-OFFSET
order-settlement-2.4.0  orders  0          43904812
order-settlement-2.4.0  orders  1          43655902
order-settlement-2.4.0  orders  2          43712004

# 2. 적용
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
  --group order-settlement-2.4.0 --topic orders \
  --reset-offsets --to-datetime 2026-07-25T06:00:00.000 --execute

# 3. 컨슈머 복구
kubectl scale deploy/settlement-service --replicas=8

예전 그룹의 오프셋을 그대로 옮기고 싶다면 --to-datetime 대신 예전 그룹의 오프셋을 CSV로 내보내 --from-file로 적용하는 편이 정확합니다.

예전 그룹의 커밋 위치를 그대로 이관
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
  --group order-settlement --topic orders \
  --reset-offsets --to-current --export --dry-run > old-offsets.csv

kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
  --group order-settlement-2.4.0 \
  --reset-offsets --from-file old-offsets.csv --execute

근본 해결 — 그룹 ID를 배포 산출물에서 분리한다

그룹 ID가 배포될 때마다 바뀔 수 있는 값에 묶여 있습니다. 게다가 auto.offset.reset이 기본값이라 사고가 조용히 성공합니다.

application.yml
spring:
  kafka:
    consumer:
      group-id: "order-settlement-${app.version}"
      # auto-offset-reset 미지정 → 기본값 latest

그룹 ID는 업무 단위 식별자로 고정합니다. 초기 위치는 none으로 두어 "커밋 오프셋 없음"을 조용한 성공이 아니라 실패로 만듭니다.

application.yml
spring:
  kafka:
    consumer:
      group-id: "order-settlement"        # 버전과 무관하게 고정
      auto-offset-reset: none             # 커밋 없으면 기동 실패
      enable-auto-commit: false           # 처리 후 수동 커밋
    properties:
      group.instance.id: "${POD_NAME}"    # 정적 멤버십 — 재배포 리밸런스 축소

group.instance.id를 함께 넣은 이유는 별개입니다. 정적 멤버십은 재배포 때 불필요한 리밸런스를 줄여 줍니다. 리밸런스 자체가 장애가 되는 경로는 케이스 2에서 다룹니다. 설정 배경은 5장 Consumer 심화를 보세요.

운영 조치 — "lag 0"을 믿지 않는 알림

예방 체크리스트

배포 전에 확인할 항목입니다.

시험 포인트

공식 문서 출처

이 페이지의 설정 기본값·동작 설명·로그 문자열은 Apache Kafka 4.3 문서와 클라이언트 소스에서 확인했습니다.