이 케이스에서 얻어 갈 것

상황

이미지 후처리 파이프라인입니다. image-uploaded 토픽은 파티션 12개, 복제 계수 3, 브로커 3대에서 평소 일 80만 건을 받습니다. 썸네일 생성 컨슈머는 파드 4개로 돌고, 파드마다 파티션 3개를 맡습니다.

컨슈머 루프는 단순합니다. poll()로 받은 레코드를 순차로 돌며 외부 리사이즈 서비스를 호출하고, 결과를 오브젝트 스토리지에 올리고, 루프가 끝나면 커밋합니다. 평소 리사이즈 호출은 건당 평균 400ms였습니다. max.poll.records는 손대지 않았으므로 기본값 500입니다.

사고 당시의 컨슈머 루프 — 설정은 전부 기본값이었습니다
while (running) {
    ConsumerRecords<String, ImageEvent> records = consumer.poll(Duration.ofSeconds(1));
    for (ConsumerRecord<String, ImageEvent> record : records) {
        // 외부 리사이즈 서비스 호출 — 평소 400ms, 부하 시 2.5s
        byte[] thumb = resizeClient.resize(record.value().getSourceUrl());
        objectStore.put(thumbKey(record), thumb);
    }
    consumer.commitSync();   // 배치 전체를 처리한 뒤 커밋
}

금요일 20:00 프로모션이 시작되자 리사이즈 서비스의 응답 시간이 건당 2.5초로 올라갔습니다. 업스트림 서비스에는 알림이 걸려 있었지만 "느려졌을 뿐 성공한다"는 이유로 무시됐습니다. 20:06부터 썸네일이 전혀 생성되지 않았고, lag은 시간당 30만 건씩 쌓였습니다.

case02 — max.poll.interval.ms 초과가 무한 리밸런스 루프로 번지는 흐름 정상 흐름에서는 poll 이 max.poll.records 기본값 500건을 돌려주고 처리 후 다시 poll 을 호출하며, 하트비트는 백그라운드 스레드가 heartbeat.interval.ms 3000 마다 따로 보냅니다. 어긋나는 지점에서는 외부 API 지연으로 건당 1초가 걸려 500건 처리에 500초가 필요해지고, max.poll.interval.ms 기본값 300000 즉 5분을 넘겨 코디네이터가 이 컨슈머를 이탈 처리합니다. 세션은 하트비트가 계속 나가므로 살아 있고 터지는 것은 poll 축입니다. 결과적으로 리밸런스가 시작되어 파티션이 회수되고 커밋이 CommitFailedException 으로 실패하며, 재할당 후 커밋되지 않은 같은 배치를 다시 처리하면서 같은 초과가 반복되어 그룹이 리밸런스 상태에 머물고 처리량이 0 에 가까워집니다. 1. 정상 흐름 두 축이 따로 돕니다 poll() 호출 최대 500건 반환 처리 30초 poll() 재호출 max.poll.records 기본 500 · max.poll.interval.ms 기본 300000(5분) · heartbeat.interval.ms 기본 3000. 하트비트는 백그라운드 스레드가 보내고 poll 은 애플리케이션 스레드가 부릅니다 — 서로 다른 축입니다. 2. 어긋나는 지점 poll 축이 먼저 터집니다 외부 API 지연 건당 1초 × 500건 poll 공백 500초 코디네이터가 이탈 처리 하트비트는 계속 나가므로 session.timeout.ms(45000) 는 만료되지 않습니다. poll 을 5분 안에 다시 부르지 못했기 때문에 max.poll.interval.ms 쪽이 먼저 터집니다. 3. 결과 리밸런스가 스스로를 재생산합니다 파티션 회수 커밋 실패 같은 배치 재처리 또 초과 → 반복 커밋하려 하면 CommitFailedException — 이미 파티션을 잃은 상태이기 때문입니다. 재할당된 컨슈머가 커밋되지 않은 같은 구간을 다시 처리하고 같은 시간이 걸려 루프가 됩니다. 그룹은 PreparingRebalance 와 CompletingRebalance 를 오가고 실효 처리량은 0 에 가까워집니다. 처방 max.poll.records 를 한 번에 확실히 처리할 수 있는 크기로 줄입니다 (예: 500 → 50). max.poll.interval.ms 를 실측 최대 처리 시간보다 넉넉하게 올립니다 — 다만 근본 해결은 배치 축소입니다. 오래 걸리는 작업은 작업 큐로 넘기고 poll 루프는 짧게 유지합니다. 확인: kafka-consumer-groups.sh --bootstrap-server :9092 --describe --group G --state
max.poll.interval.ms 초과가 만드는 리밸런스 루프 — 정상 폴 루프, 초과 시점의 LeaveGroup, 그리고 재할당 후 같은 배치를 다시 처리하며 또 초과하는 순환

관측된 증상

메트릭이 어떻게 보였는가

컨슈머 애플리케이션 로그

아래는 실제 Kafka 클라이언트가 출력하는 메시지입니다. AbstractCoordinatorhandlePollTimeoutExpiry()가 찍는 WARN 한 줄이 시작점입니다.

thumbnailer 파드 로그 — 이 5줄이 하나의 사이클입니다
WARN  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time processing messages. You can address this either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll.records.
INFO  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Member thumbnailer-2-4b7d1c9a-5f31-4e02-9a1d-0c8f7e2b6a44 sending LeaveGroup request to coordinator kafka-2:9092 (id: 2 rack: null) due to consumer poll timeout has expired.
INFO  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Resetting generation and member id due to: consumer pro-actively leaving the group
INFO  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Request joining group due to: consumer pro-actively leaving the group
INFO  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] (Re-)joining group

커밋 실패 — 배치를 다 처리한 뒤에 터진다

더 나쁜 것은 1250초를 들여 배치를 다 처리한 뒤입니다. 그 사이 파티션이 다른 멤버에게 재할당되었으므로 commitSync()가 실패합니다. CommitFailedException의 기본 메시지는 원인을 그대로 설명합니다.

커밋 시도 시점의 예외 — 1250초의 작업이 통째로 버려집니다
org.apache.kafka.clients.consumer.CommitFailedException: Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member. This means that the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time message processing. You can address this either by increasing max.poll.interval.ms or by reducing the maximum size of batches returned in poll() with max.poll.records.
	at org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.sendOffsetCommitRequest(ConsumerCoordinator.java:...)
	at org.apache.kafka.clients.consumer.internals.ConsumerCoordinator.commitOffsetsSync(ConsumerCoordinator.java:...)

자동 커밋을 쓰고 있었다면 예외 대신 아래 형태의 WARN이 조용히 반복됩니다. 이쪽이 훨씬 위험합니다. 애플리케이션은 실패를 인지하지 못합니다.

자동 커밋인 경우 — ConsumerCoordinator가 WARN으로만 남깁니다
WARN  [Consumer clientId=thumbnailer-2, groupId=image-thumbnailer] Asynchronous auto-commit of offsets {image-uploaded-4=OffsetAndMetadata{offset=1841020, ...}} failed: Commit cannot be completed since the group has already rebalanced and assigned the partitions to another member.

브로커(그룹 코디네이터) 로그

같은 시각 코디네이터를 맡은 브로커의 server.log에는 아래 형태가 반복됩니다. GroupMetadataManager가 출력하며, client reason에 컨슈머가 보낸 이유가 그대로 실려 옵니다.

kafka-2 server.log — generation 번호가 계속 올라가는 것이 핵심
INFO [GroupCoordinator id=2] [Group image-thumbnailer] Member thumbnailer-2-4b7d1c9a-... has left group through explicit `LeaveGroup` request; client reason: consumer poll timeout has expired.
INFO [GroupCoordinator id=2] Preparing to rebalance group image-thumbnailer in state PreparingRebalance with old generation 214 (reason: removing member thumbnailer-2-4b7d1c9a-... on LeaveGroup).
INFO [GroupCoordinator id=2] Stabilized group image-thumbnailer generation 215 with 3 members.
INFO [GroupCoordinator id=2] [Group image-thumbnailer] Member thumbnailer-0-1a09f4de-... has left group through explicit `LeaveGroup` request; client reason: consumer poll timeout has expired.
INFO [GroupCoordinator id=2] Preparing to rebalance group image-thumbnailer in state PreparingRebalance with old generation 215 (reason: removing member thumbnailer-0-1a09f4de-... on LeaveGroup).

원인 분석

1단계 — 그룹 상태를 본다

무한 리밸런스는 --state 하나로 거의 확정됩니다. 몇 초 간격으로 두 번 실행해 STATE가 바뀌는지 확인합니다.

그룹 상태 — Stable이 아니면 리밸런스 중입니다
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
    --describe --group image-thumbnailer --state

GROUP              COORDINATOR (ID)  ASSIGNMENT-STRATEGY  STATE                #MEMBERS
image-thumbnailer  kafka-2:9092 (2)  range                PreparingRebalance   3

$ # 4초 뒤 다시
GROUP              COORDINATOR (ID)  ASSIGNMENT-STRATEGY  STATE                #MEMBERS
image-thumbnailer  kafka-2:9092 (2)  range                CompletingRebalance  4

2단계 — 오프셋이 전진하지 않는 것을 확인한다

리밸런스 중에 --describe를 실행하면 도구가 표준 에러로 경고를 출력합니다. CURRENT-OFFSET을 30초 간격으로 두 번 찍어 전혀 변하지 않음을 확인했습니다.

오프셋 정지 확인
$ kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
    --describe --group image-thumbnailer

Warning: Consumer group 'image-thumbnailer' is rebalancing.

GROUP              TOPIC           PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG      CONSUMER-ID  HOST  CLIENT-ID
image-thumbnailer  image-uploaded  0          1841020         2094882         253862   -            -     -
image-thumbnailer  image-uploaded  1          1839774         2093010         253236   -            -     -
image-thumbnailer  image-uploaded  2          1840119         2093551         253432   -            -     -
...

$ # 30초 뒤 — CURRENT-OFFSET 이 동일합니다. LAG 만 늘어납니다.

3단계 — 두 개의 타임아웃을 구분한다

여기서 팀이 처음 한 조치는 session.timeout.ms를 올리는 것이었고, 아무 효과가 없었습니다. 두 설정은 서로 다른 실패를 감지합니다.

컨슈머 생존 판정에 관여하는 4개 설정. 기본값은 Apache Kafka 4.3 Consumer 설정 기준입니다.
설정 기본값 무엇을 감지하는가 누가 판정하는가
session.timeout.ms 45000 하트비트가 끊김 — 프로세스·네트워크 장애 브로커(코디네이터). 하트비트 만료 시 멤버 제거
heartbeat.interval.ms 3000 하트비트 전송 주기. session.timeout.ms의 1/3 이하 권장 컨슈머의 백그라운드 스레드
max.poll.interval.ms 300000 poll() 호출 간격 초과 — 처리 로직이 너무 느림 컨슈머 자신. 초과 시 스스로 LeaveGroup
max.poll.records 500 한 번의 poll()이 반환할 최대 레코드 수 컨슈머(클라이언트 측 잘라내기)

4단계 — 왜 무한 루프가 되는가

한 번의 초과가 그 파드만의 문제로 끝나지 않는 이유는 순환 구조 때문입니다.

  1. 파드 A가 배치를 처리하다 300초를 넘겨 LeaveGroup을 보냅니다.
  2. 그룹이 리밸런스합니다. A가 맡았던 파티션이 B·C·D로 재할당됩니다.
  3. A가 처리하던 배치는 커밋되지 않았으므로 B가 같은 오프셋에서 같은 레코드를 다시 받습니다.
  4. B의 부담이 늘어 B도 300초를 넘깁니다. 동시에 A는 다시 조인합니다.
  5. 리밸런스가 또 발생합니다. 2번으로 돌아갑니다.

eager 리밸런스(기본 할당 전략 목록의 RangeAssignor가 선택된 경우)에서는 리밸런스마다 모든 멤버가 모든 파티션을 반납합니다. 그래서 리밸런스가 잦아지면 그룹 전체 처리량이 0에 수렴합니다. 할당 전략과 리밸런스 프로토콜의 차이는 5장 Consumer 심화에서 다룹니다.

재현 방법

단일 노드 KRaft 클러스터와 max.poll.interval.ms를 아주 짧게 준 컨슈머 하나로 재현됩니다. 아래 compose는 Apache Kafka의 공식 단일 노드 예제입니다. 3노드 구성은 예제 1 · 로컬 KRaft 클러스터를 쓰세요.

docker-compose.yml — 단일 노드 KRaft (combined 모드)
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'
SlowConsumer.java — 느린 처리를 흉내내는 최소 재현 코드
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.List;
import java.util.Properties;

public class SlowConsumer {
    public static void main(String[] args) {
        Properties p = new Properties();
        p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        p.put(ConsumerConfig.GROUP_ID_CONFIG, "slow-group");
        p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        p.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

        // 재현 포인트 1 — 폴 간격 한계를 10초로 줄인다 (기본 300000)
        p.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 10_000);
        // 재현 포인트 2 — 한 배치에 20건. 20 x 1s = 20s > 10s 이므로 반드시 초과한다
        p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 20);

        try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
            c.subscribe(List.of("slow-topic"));
            while (true) {
                ConsumerRecords<String, String> records = c.poll(Duration.ofSeconds(1));
                System.out.println("polled " + records.count());
                for (ConsumerRecord<String, String> r : records) {
                    Thread.sleep(1_000);           // 건당 1초 처리
                    System.out.println("  done " + r.offset());
                }
                c.commitSync();                    // 여기서 CommitFailedException 이 터집니다
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
재현 절차
docker compose up -d

# 1. 파티션 2개 토픽
docker exec -it broker /opt/kafka/bin/kafka-topics.sh --create \
  --topic slow-topic --partitions 2 --replication-factor 1 \
  --bootstrap-server localhost:9092

# 2. 200건 적재
docker exec -i broker /opt/kafka/bin/kafka-console-producer.sh \
  --topic slow-topic --bootstrap-server localhost:9092 \
  < <(seq 1 200)

# 3. SlowConsumer 를 두 개 띄운다 (같은 group.id)
#    → 10초마다 WARN "consumer poll timeout has expired" 가 반복되고
#      commitSync() 에서 CommitFailedException 이 발생합니다.
java -cp kafka-clients-4.3.1.jar:. SlowConsumer &
java -cp kafka-clients-4.3.1.jar:. SlowConsumer &

# 4. 그룹 상태가 Stable 에 머물지 못하는 것을 확인
watch -n 2 'docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
  --describe --group slow-group --state --bootstrap-server localhost:9092'

# 5. CURRENT-OFFSET 이 전진하지 않는 것을 확인
docker exec -it broker /opt/kafka/bin/kafka-consumer-groups.sh \
  --describe --group slow-group --bootstrap-server localhost:9092

해결

즉시 조치 — 배치를 잘게 쪼갠다

장애 대응 중에 가장 빠르고 안전한 손잡이는 max.poll.records입니다. max.poll.interval.ms를 올리는 것보다 부작용이 적습니다. 타임아웃을 늘리면 진짜로 멈춘 컨슈머를 감지하는 시간도 같이 늘어납니다.

즉시 조치 — 재배포 없이 환경변수만으로 가능하다면 이쪽이 먼저입니다
# 20 x 2.5s = 50s. max.poll.interval.ms 기본 300s 안에 충분히 들어옵니다.
max.poll.records=20

근본 해결 — 폴 루프에서 느린 작업을 분리한다

폴 루프가 외부 호출 지연에 직접 노출되어 있습니다. 업스트림이 느려지는 순간 컨슈머 생존 판정까지 같이 위험해집니다.

consumer.properties (변경 전)
group.id=image-thumbnailer
enable.auto.commit=true
# max.poll.records 미지정  → 500
# max.poll.interval.ms 미지정 → 300000
# partition.assignment.strategy 미지정
#   → [RangeAssignor, CooperativeStickyAssignor] 중 앞의 것이 선택됨

배치를 작게 유지하고, 커밋을 명시적으로 통제하며, 리밸런스 시 반납 범위를 줄이고, 재배포로 인한 리밸런스를 없앱니다.

consumer.properties (변경 후)
group.id=image-thumbnailer
enable.auto.commit=false
max.poll.records=20
max.poll.interval.ms=120000
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
group.instance.id=${POD_NAME}
session.timeout.ms=45000
heartbeat.interval.ms=3000

설정만으로는 부족합니다. 폴 루프의 구조를 바꿔야 합니다. 느린 처리를 워커 풀로 넘기고, 폴 루프는 진행 상황에 따라 파티션을 pause/resume하게 만듭니다. 이렇게 하면 처리 시간이 아무리 늘어나도 poll() 호출 간격은 일정하게 유지됩니다.

구조 변경 — pause/resume 으로 폴 간격을 처리 시간과 분리
while (running) {
    ConsumerRecords<String, ImageEvent> records = consumer.poll(Duration.ofMillis(500));

    for (TopicPartition tp : records.partitions()) {
        for (ConsumerRecord<String, ImageEvent> r : records.records(tp)) {
            workerPool.submit(() -> process(r));   // 느린 작업은 여기서
        }
        // 워커 큐가 깊어지면 그 파티션의 fetch 를 잠시 멈춘다.
        // poll() 자체는 계속 호출되므로 max.poll.interval.ms 를 넘기지 않는다.
        if (queueDepth(tp) > HIGH_WATERMARK) consumer.pause(List.of(tp));
    }

    // 워커가 완료한 지점까지만 커밋한다 (완료 오프셋 추적기 필요)
    Map<TopicPartition, OffsetAndMetadata> done = completedOffsets();
    if (!done.isEmpty()) consumer.commitSync(done);

    // 큐가 비면 다시 재개
    for (TopicPartition tp : consumer.paused()) {
        if (queueDepth(tp) < LOW_WATERMARK) consumer.resume(List.of(tp));
    }
}

KIP-848 새 프로토콜을 쓰면 달라지는 것

알림 — 이 사고를 5분 안에 잡는 두 지표

메트릭 MBean 이름은 JMX 메트릭 치트시트에 정리되어 있습니다.

예방 체크리스트

시험 포인트

공식 문서 출처

설정 기본값·동작 설명은 Apache Kafka 4.3 문서에서, 로그·예외 문자열은 4.3 소스에서 확인했습니다.