실수 케이스 · 1
배포 후 며칠치 데이터가 사라졌다
컨슈머 그룹 ID에 애플리케이션 버전을 붙이는 관례 하나가 나흘치 정산 이벤트를 건너뛰게 만들었습니다.
데이터는 브로커에 그대로 남아 있었지만 아무도 읽지 않았습니다.
auto.offset.reset이 언제 적용되는지를 잘못 알고 있으면 이 사고는 반드시 재발합니다.
이 케이스에서 얻어 갈 것
auto.offset.reset이 커밋된 오프셋이 없을 때, 또는 현재 오프셋이 서버에서 사라졌을 때만 적용된다는 점을 설명할 수 있습니다.- lag이 0으로 보이는 것이 오히려 위험 신호인 상황을 구분할 수 있습니다.
offsets.retention.minutes가 그룹 ID를 바꾸지 않아도 같은 사고를 일으키는 경로를 설명할 수 있습니다.kafka-consumer-groups --reset-offsets로 복구하는 절차와 그 전제 조건을 알 수 있습니다.
상황
커머스 정산 파이프라인입니다. orders 토픽은 파티션 24개, 복제 계수 3이고
브로커 3대에서 일 500만 건의 주문 이벤트를 받습니다. 보관 기간은 기본값인 7일입니다.
정산 서비스는 group.id=order-settlement로 이 토픽을 구독해 하루 마감 배치의 입력을 만듭니다.
수요일 14:10에 정산 서비스 v2.4.0을 배포했습니다. 이번 릴리스에서 팀은 배포 스크립트를 정리하면서 컨슈머 그룹 ID를 차트 버전 변수로 치환했습니다. 의도는 "카나리 배포 때 그룹을 분리하자"였습니다.
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 이후의 주문 중 상당수가 정산 테이블에 아예 없다는 것이 확인됐습니다.
auto.offset.reset=latest에 따라 로그 끝에서 시작합니다
관측된 증상
메트릭이 어떻게 보였는가
- consumer lag: 배포 시각에 계단처럼 수직 낙하해 0에 붙었습니다. 이후 정상 처리량 수준에서 미세하게 진동합니다. 장애 그래프가 아니라 개선 그래프처럼 보입니다.
records-consumed-rate: 배포 직후 몇 분간 0에 가깝다가, 신규 유입분만 소비하면서 평소의 유입 속도와 같아졌습니다.- 정산 테이블 행 수: 시간당 삽입 건수가 평소의 40% 수준. 이 지표만 유일하게 이상했지만 알림이 걸려 있지 않았습니다.
__consumer_offsets: 그룹 수가 하나 늘었습니다. 이것을 감시하는 사람은 없었습니다.
컨슈머 애플리케이션 로그
배포 직후 컨슈머 로그에 아래 두 줄이 파티션마다 한 번씩, 즉 24쌍이 찍혀 있었습니다.
ConsumerCoordinator와 SubscriptionState가 출력하는 실제 메시지입니다.
[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에 초기 오프셋이 없거나, 현재 오프셋이 서버에 더 이상 존재하지 않을 때(예: 그 데이터가 삭제되어)"
무엇을 할지 정하는 설정입니다.
| 값 | 커밋 오프셋이 있을 때 | 커밋 오프셋이 없을 때 | 커밋 오프셋이 범위를 벗어났을 때 |
|---|---|---|---|
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 클러스터를 쓰세요.
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이 기본값이라 사고가 조용히 성공합니다.
spring:
kafka:
consumer:
group-id: "order-settlement-${app.version}"
# auto-offset-reset 미지정 → 기본값 latest
그룹 ID는 업무 단위 식별자로 고정합니다.
초기 위치는 none으로 두어 "커밋 오프셋 없음"을 조용한 성공이 아니라 실패로 만듭니다.
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"을 믿지 않는 알림
- 그룹 개수 변화를 알림 대상으로 둡니다. 예상하지 못한 그룹이 나타나면 배포 사고입니다.
- 비즈니스 지표(정산 행 수, 처리 건수)에 알림을 겁니다. 컨슈머 지표만 보면 이 사고는 감지되지 않습니다.
CURRENT-OFFSET이 불연속으로 점프하는 것을 감지합니다. 정상 소비에서는 단조 증가가 완만합니다.- 배포 전후
kafka-consumer-groups --describe출력을 저장해 diff를 남깁니다.
예방 체크리스트
배포 전에 확인할 항목입니다.
시험 포인트
이어서 볼 곳
-
5장 · Consumer 심화
오프셋 커밋,
__consumer_offsets, 리밸런스와 정적 멤버십. -
케이스 2 · 무한 리밸런스 루프
max.poll.interval.ms초과가 만드는 진짜 정지 상태. - 케이스 7 · 재처리했더니 결제가 두 번 됐다 오프셋을 되돌리기 전에 반드시 확인해야 하는 것.
-
CLI 치트시트
kafka-consumer-groups옵션과 오프셋 리셋 원라이너. - 예제 4 · 컨슈머 오프셋 전략 수동 커밋과 배치 처리로 유실·중복을 통제하는 코드.
- 트러블슈팅 결정 트리 "컨슈머가 안 읽는다"에서 시작하는 분기 플로우차트.
공식 문서 출처
이 페이지의 설정 기본값·동작 설명·로그 문자열은 Apache Kafka 4.3 문서와 클라이언트 소스에서 확인했습니다.
- Consumer Configs —
auto.offset.reset— 기본값latest, 적용 조건,by_duration값 - Broker Configs —
offsets.retention.minutes— 기본값 10080, 만료 조건 3가지 - Consumer Configs —
enable.auto.commit,group.instance.id - Operations — Modifying topics — 파티션 증설 시
auto.offset.reset=latest컨슈머의 메시지 누락 가능성 - Operations — Managing Consumer Groups —
kafka-consumer-groups사용법 - Apache Kafka 소스 (4.3) —
ConsumerCoordinator,SubscriptionState,FetchCollector,NoOffsetForPartitionException의 로그·예외 문자열