학습 목표

컨슈머 그룹과 파티션 소유권

컨슈머는 거의 항상 컨슈머 그룹(consumer group)의 멤버로 동작합니다. 그룹은 group.id로 식별되며, 그룹 하나가 하나의 논리적 구독자입니다. 같은 토픽을 서로 다른 그룹이 구독하면 각 그룹이 전체 데이터를 독립적으로 받습니다. 같은 그룹 안에서는 데이터가 나뉘어 처리됩니다.

소유권 규칙은 단 한 줄입니다. 하나의 토픽-파티션은, 하나의 그룹 안에서 정확히 한 컨슈머에게만 할당됩니다. 공식 문서의 설계 문서도 파티션을 "각 구독 그룹 내에서 정확히 하나의 컨슈머가 소비하는" 단위로 정의합니다. 이 규칙에서 세 가지 실무적 결론이 나옵니다.

리밸런스 시뮬레이터 — 전략에 따라 파티션 할당이 어떻게 달라지는가 파티션 8개를 컨슈머 최대 5명에게 나누는 격자입니다. 가로는 파티션 p0 부터 p7, 세로는 컨슈머 C1 부터 C5 이고, 각 칸은 그 컨슈머가 그 파티션을 담당하는지 나타냅니다. 칸의 상태는 네 가지입니다. 유지는 리밸런스 전과 소유자가 같은 파티션, 신규는 이번에 새로 받은 파티션, 회수는 cooperative 프로토콜의 1차 리밸런스에서 이전 소유자에게서 떨어진 파티션, 빈 칸은 담당하지 않는 파티션입니다. 버튼으로 컨슈머를 추가하거나 정상 종료시키거나 크래시시킬 수 있고, 셀렉트로 할당 전략을 바꿀 수 있습니다. RangeAssignor 는 토픽별로 파티션을 앞에서부터 잘라 나누므로 연속된 번호가 한 컨슈머에게 뭉칩니다. RoundRobinAssignor 는 정렬된 파티션을 순환 배정하므로 번호가 흩어집니다. StickyAssignor 는 균형을 먼저 맞추되 가능한 만큼 이전 할당을 유지합니다. CooperativeStickyAssignor 는 최종 할당이 StickyAssignor 와 같지만, 옮겨 갈 파티션만 1차 리밸런스에서 회수하고 2차 리밸런스에서 새 소유자에게 넘기므로 나머지 파티션은 처리가 끊기지 않습니다. 초기 상태는 RangeAssignor 로 컨슈머 3명에게 배정한 결과이며 C1 이 p0 부터 p2, C2 가 p3 부터 p5, C3 가 p6 과 p7 을 담당합니다. 컨슈머를 추가·제거하며 전략별 할당 변화를 비교해 보세요 파티션 p0 p1 p2 p3 p4 p5 p6 p7 컨슈머 C1 3개 담당 유지 유지 유지 컨슈머 C2 3개 담당 유지 유지 유지 컨슈머 C3 2개 담당 유지 유지 컨슈머 C4 없음 컨슈머 C5 없음 RangeAssignor · eager 프로토콜 (전체 회수) 초기 상태 — 컨슈머 3명이 파티션 8개를 나눠 담당하고 있습니다 유지 (소유자 그대로) 신규 (이번에 받음) 회수 (1차에서 보류) 담당하지 않음 Range 는 번호가 뭉치고, RoundRobin 은 흩어지고, Sticky 는 이전 할당을 최대한 지킵니다. CooperativeSticky 의 최종 할당은 Sticky 와 같습니다. 다른 것은 리밸런스 프로토콜입니다. 컨슈머가 파티션 수보다 많으면 남는 컨슈머는 아무 파티션도 받지 못합니다 — 파티션 수가 병렬성의 상한입니다. 4.x 기본값 partition.assignment.strategy=[RangeAssignor, CooperativeStickyAssignor]
리밸런스 시뮬레이터 — 파티션 8개에 컨슈머를 추가·제거·크래시시키며 할당이 어떻게 바뀌는지, 그리고 할당 전략(Range / RoundRobin / Sticky / CooperativeSticky)에 따라 결과가 어떻게 달라지는지 관찰합니다.
그룹 상태 확인 — 코디네이터 · 할당 전략 · 멤버 수
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group my-group --state
# COORDINATOR (ID)          ASSIGNMENT-STRATEGY       STATE                #MEMBERS
# localhost:9092 (0)        range                     Stable               4

# 멤버별로 어떤 파티션을 들고 있는지
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group my-group --members --verbose
# CONSUMER-ID   HOST         CLIENT-ID   #PARTITIONS  ASSIGNMENT
# consumer1-…   /127.0.0.1   consumer1   2            topic1(0), topic2(0)
# consumer3-…   /127.0.0.1   consumer3   0            -

위 출력에서 consumer3#PARTITIONS0이라는 점을 보세요. 컨슈머가 살아 있고 그룹에도 들어와 있지만 할당받은 파티션이 없습니다. 파티션 수보다 컨슈머가 많은 상태입니다. 이런 상황에서 "컨슈머를 더 늘렸는데 처리량이 안 오른다"는 문의가 나옵니다. 파티션 수 증가의 실제 비용은 케이스 5에서 다룹니다.

poll 루프의 실제 동작

poll()은 이름과 달리 "브로커에 한 번 물어보고 돌아오는" 함수가 아닙니다. 컨슈머 내부에는 백그라운드 fetch와 사용자 스레드 반환이 분리된 구조가 있습니다. 순서를 정확히 이해해야 뒤의 타임아웃 설명이 성립합니다.

  1. 컨슈머가 리더 브로커들에게 Fetch 요청을 보냅니다. 여러 파티션이 한 요청에 묶이고, 여러 브로커에 대해 병렬로 나갑니다.
  2. 브로커는 fetch.min.bytes만큼 데이터가 모이거나 fetch.max.wait.ms가 지날 때까지 응답을 붙잡아 둡니다.
  3. 응답으로 온 레코드 배치는 컨슈머 내부 버퍼에 캐시됩니다.
  4. poll()은 그 버퍼에서 최대 max.poll.records를 꺼내 사용자에게 돌려줍니다.
  5. 애플리케이션이 그 레코드들을 처리합니다. 이 시간 동안 컨슈머는 사용자 코드에 갇혀 있습니다.
  6. 다음 poll()이 호출되면 4번으로 돌아갑니다. 버퍼가 비었으면 1번부터 다시 합니다.
poll 루프 내부 동작 — poll() 은 대개 네트워크 호출이 아니라 버퍼에서 꺼내는 일 컨슈머 내부를 두 구역으로 나눈 그림입니다. 위 구역은 데이터를 미리 받아 두는 경로입니다. 컨슈머 내부의 Fetcher 가 브로커에 FetchRequest 를 보내면 브로커는 fetch.min.bytes 만큼 데이터가 모이거나 fetch.max.wait.ms 가 지날 때까지 기다린 뒤 응답하고, 응답으로 온 레코드 배치들이 컨슈머 안의 완료된 fetch 큐에 쌓입니다. 아래 구역은 poll 이 무엇을 반환하는지 두 경우로 보여 줍니다. 첫째 경우는 큐에 이미 레코드가 있는 경우로, poll 은 네트워크 왕복 없이 큐에서 최대 max.poll.records 500 건을 꺼내 즉시 반환합니다. 둘째 경우는 큐가 빈 경우로, fetch 응답이 도착할 때까지 기다리고 timeout 안에 오지 않으면 빈 목록을 반환합니다. max.poll.records 는 한 번에 몇 건을 돌려줄지만 정하고 브로커에서 얼마나 받아오는지는 바꾸지 않습니다. 받아온 레코드는 캐시에 두고 여러 번의 poll 에 나눠 반환합니다. 반환된 레코드를 처리하고 오프셋을 커밋한 뒤 다시 poll 을 부르는 것이 컨슈머 루프이며, poll 을 계속 부르는 것 자체가 그룹 멤버십 유지 조건입니다. poll() 이 반환하는 곳은 내부 버퍼 입니다. 브로커까지 가는 것은 그 앞 단계입니다. 구역 A · 데이터를 미리 받아 큐에 쌓는 경로 Fetcher 컨슈머 내부에서 fetch 요청을 보냄 fetch 브로커 fetch.min.bytes=1 만큼 모이거나 fetch.max.wait.ms=500 까지 대기 배치 완료된 fetch 큐 (내부 버퍼) 받아온 레코드가 여기 쌓입니다 한도: max.partition.fetch.bytes 구역 B · poll(Duration) 이 반환하는 두 경우 poll() 경우 ① 큐에 레코드가 있음 네트워크 왕복 없음 최대 max.poll.records=500 건 반환 즉시 돌아옵니다 poll() 경우 ② 큐가 비어 있음 fetch 응답을 기다림 도착분 반환 · 안 오면 빈 목록 poll(Duration) 인자만큼만 기다립니다 반환된 레코드 처리 애플리케이션 스레드 오프셋 커밋 자동 또는 수동 다시 poll() 늦으면 그룹에서 빠집니다 max.poll.records 는 fetch 크기를 바꾸지 않습니다. 받아 둔 레코드를 몇 건씩 나눠 줄지만 정합니다. 그래서 이 값을 줄여도 네트워크 사용량은 그대로이고, 한 번의 처리 시간만 짧아집니다. 받아오는 양은 fetch.max.bytes=52428800max.partition.fetch.bytes=1048576 가 정합니다.
poll 루프 내부 동작 — Fetch 요청이 내부 버퍼를 채우고, poll()은 그 버퍼에서 max.poll.records만큼만 잘라 반환합니다. 네트워크 왕복과 사용자 처리가 분리된 구조입니다.
기본 poll 루프 (수동 커밋)
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
// 커밋 시점을 코드가 통제해야 유실·중복을 설계할 수 있습니다.
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
// 한 번에 돌려받는 건수를 처리 시간 예산에 맞춰 줄입니다 (기본 500).
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "100");

try (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props)) {
    consumer.subscribe(List.of("orders"));

    while (running) {
        // timeout 은 "버퍼가 빌 때 최대 이만큼 기다린다"는 뜻입니다.
        // 이 값이 max.poll.interval.ms 와 직접 관계있는 것은 아닙니다.
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));

        for (ConsumerRecord<String, String> record : records) {
            handle(record);   // 여기서 오래 걸리면 max.poll.interval.ms 가 터집니다
        }

        if (!records.isEmpty()) {
            try {
                // 처리 후 커밋 → at-least-once (중복 가능, 유실 없음)
                consumer.commitSync();
            } catch (CommitFailedException e) {
                // 이미 리밸런스가 끝나 파티션이 남에게 갔습니다. 재시도해도 소용없습니다.
                log.warn("커밋 실패 — 처리 로직의 멱등성으로 흡수해야 합니다", e);
            }
        }
    }
} catch (WakeupException e) {
    // 다른 스레드의 consumer.wakeup() 에 의한 정상 종료 신호
}

fetch 크기 설정 4종

fetch 동작을 결정하는 컨슈머 설정 (Apache Kafka 4.3 기본값)
설정 기본값 의미 튜닝 포인트
fetch.min.bytes 1 브로커가 응답에 담아야 하는 최소 바이트. 모일 때까지 응답을 지연시킵니다. 올리면 요청 수가 줄어 브로커 처리량이 오르지만 지연이 늘어납니다.
fetch.max.wait.ms 500 fetch.min.bytes가 안 모였을 때 브로커가 최대로 붙잡는 시간 fetch.min.bytes와 짝입니다. 지연 상한이 여기서 정해집니다.
fetch.max.bytes 52428800
(50 MiB)
한 fetch 응답 전체의 최대 크기 절대 상한이 아닙니다. 첫 배치가 이보다 크면 그 배치는 그대로 반환됩니다(전진 보장).
max.partition.fetch.bytes 1048576
(1 MiB)
파티션 하나당 응답 최대 크기 브로커가 받는 최대 배치 크기는 message.max.bytes/max.message.bytes가 정합니다. 함께 맞춰야 합니다(케이스 10).
max.poll.records 500 한 번의 poll()이 반환하는 최대 레코드 수 네트워크 fetch 양과 무관합니다. 처리 시간 예산을 맞추는 손잡이입니다.

타임아웃 4종 — 하트비트 축과 poll 축은 별개입니다

이 절이 5장의 핵심입니다. 컨슈머 장애 문의의 대부분과 CCDAK 출제의 상당 부분이 여기 걸려 있습니다. 네 개의 설정은 서로 다른 두 개의 축에 놓여 있습니다.

타임아웃 4종 타임라인 — 하트비트 축과 poll 축은 별개입니다 두 시나리오를 각각 두 개의 수평 레인으로 그린 그림입니다. 위 레인은 백그라운드 스레드가 보내는 하트비트 축이고, 아래 레인은 애플리케이션 스레드가 도는 poll 축입니다. 두 축은 서로 독립이며 감시하는 값도 다릅니다. 하트비트 축은 session.timeout.ms 45000 밀리초를 감시하고, poll 축은 max.poll.interval.ms 300000 밀리초를 감시합니다. 시나리오 1 은 정상입니다. 하트비트가 3000 밀리초마다 도착해 session 예산이 계속 초기화되고, poll 도 자주 불려 max.poll.interval 예산이 소진되지 않습니다. 시나리오 2 는 한 번의 처리가 너무 오래 걸리는 경우입니다. 백그라운드 스레드는 처리 중에도 하트비트를 계속 보내므로 session.timeout.ms 는 만료되지 않습니다. 대신 poll 이 300000 밀리초 안에 다시 불리지 않아 max.poll.interval.ms 가 초과되고, 컨슈머가 스스로 그룹을 떠나 리밸런스가 일어납니다. 즉 처리가 길어질 때 터지는 것은 session.timeout.ms 가 아니라 max.poll.interval.ms 입니다. 한 번에 처리할 양은 max.poll.records 500 이 정하므로, 이 값을 줄이는 것이 가장 직접적인 대응입니다. heartbeat.interval.ms 와 session.timeout.ms 는 group.protocol 이 classic 인 경우의 클라이언트 설정입니다. 하트비트는 백그라운드 스레드, poll 은 애플리케이션 스레드 — 감시 대상이 다릅니다 ① 정상 — 처리가 짧아 두 예산 모두 여유 하트비트 축 백그라운드 heartbeat.interval.ms=3000 간격 (그림은 30초마다 하나만 표시) session.timeout.ms=45000 — 하트비트가 올 때마다 다시 채워집니다 poll 축 앱 스레드 처리 처리 처리 처리 처리 처리 처리 사이마다 poll() 이 불립니다 → max.poll.interval.ms 예산이 매번 초기화 결과: 어느 예산도 소진되지 않습니다 — 리밸런스 없음 ② 처리 지연 — 한 번의 처리가 max.poll.interval.ms 를 넘김 하트비트 축 백그라운드 처리 중에도 하트비트는 계속 갑니다 → session.timeout.ms 는 만료되지 않습니다 poll 축 앱 스레드 처리 한 건이 계속 진행 중 — poll() 이 다시 불리지 않습니다 max.poll.interval.ms=300000 초과 → 여기서 터집니다 결과: 컨슈머가 스스로 그룹을 떠나고 리밸런스가 시작됩니다 그 파티션은 다른 컨슈머에게 재할당되고, 커밋하지 않은 구간은 다시 처리됩니다 처리가 길어질 때 터지는 것은 session.timeout.ms 가 아니라 max.poll.interval.ms 입니다. 대응 순서: max.poll.records(500) 줄이기 → 처리 최적화 → 그래도 부족하면 max.poll.interval.ms 올리기
타임아웃 4종 타임라인 — 위쪽은 백그라운드 스레드가 heartbeat.interval.ms마다 하트비트를 보내고 브로커가 session.timeout.ms로 감시하는 축. 아래쪽은 사용자 스레드가 poll()을 호출하는 간격을 max.poll.interval.ms로 감시하는 축. 처리가 길어지면 하트비트는 계속 나가는데도 poll 축이 먼저 터집니다.

축 1 — 하트비트: "프로세스가 살아 있나"

group.protocol=classic(기본값)에서 컨슈머는 별도의 백그라운드 스레드heartbeat.interval.ms(기본 3000)마다 코디네이터에 하트비트를 보냅니다. 브로커는 session.timeout.ms(기본 45000) 안에 하트비트를 못 받으면 그 멤버를 그룹에서 제거하고 리밸런스를 시작합니다.

중요한 것은 이 하트비트가 사용자 스레드와 독립적으로 나간다는 점입니다. 애플리케이션이 레코드 하나를 30초 동안 처리하고 있어도 하트비트는 계속 나갑니다. 그래서 처리 지연으로 session.timeout.ms가 터지는 일은 일어나지 않습니다. 이 설정이 잡는 것은 프로세스 크래시, JVM full GC로 인한 정지, 네트워크 단절입니다.

축 2 — poll 간격: "처리 루프가 돌고 있나"

max.poll.interval.ms(기본 300000, 5분)는 poll() 호출 사이의 최대 허용 시간입니다. 이 시간 안에 다음 poll()이 오지 않으면 컨슈머는 스스로를 실패로 간주하고 그룹을 떠나 리밸런스를 유발합니다.

이 축은 "프로세스가 살아 있는가"가 아니라 "일을 제때 소화하고 있는가"를 봅니다. 하트비트만 계속 보내면서 실제로는 아무 레코드도 처리하지 못하는 컨슈머가 파티션을 붙잡고 있는 상황을 막기 위한 장치입니다. 그리고 처리가 오래 걸릴 때 실제로 터지는 것은 이쪽입니다.

실제로 나타나는 예외 — CommitFailedException의 기본 메시지
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.

메시지가 처방까지 알려 줍니다 — max.poll.interval.ms를 늘리거나 max.poll.records를 줄이라는 것입니다. 둘 중 먼저 손대야 하는 것은 max.poll.records입니다. 타임아웃을 늘리면 진짜로 멈춘 컨슈머를 감지하는 시간도 함께 늘어나기 때문입니다. 이 장애의 전개 과정은 케이스 2에서 로그와 함께 재현합니다.

처리 시간 예산 계산

max.poll.records를 얼마로 둘지는 계산으로 정합니다. 한 레코드 처리에 걸리는 최악 시간을 t, 안전 계수를 2배로 잡으면

예산 식
max.poll.records × (레코드당 최악 처리 시간) × 2  ≤  max.poll.interval.ms

예: 레코드당 최악 200ms, max.poll.interval.ms = 300000 (기본)
    max.poll.records ≤ 300000 / (200 × 2) = 750
    → 기본값 500 은 안전. 처리가 1초로 늘면 150 이하로 낮춰야 합니다.

4종 비교표

타임아웃·배치 설정 4종 — 어느 축에 속하고, 누가 감시하며, 초과 시 무엇이 일어나는가
설정 기본값 누가 보내고/감시하는가 초과·초과 시 결과
heartbeat.interval.ms 3000 하트비트 컨슈머의 백그라운드 스레드가 이 주기로 전송 초과 개념이 없습니다. 주기 설정입니다. 줄이면 리밸런스 감지가 빨라지고 요청이 늘어납니다.
session.timeout.ms 45000 하트비트 브로커(코디네이터)가 감시 하트비트 미수신 → 멤버 제거 + 리밸런스. 프로세스 크래시·GC 정지·네트워크 단절을 잡습니다.
max.poll.interval.ms 300000 poll 컨슈머 자신이 감시 → 스스로 그룹 이탈 poll() 미호출 → 자발적 이탈 + 리밸런스. 처리 지연을 잡습니다.
max.poll.records 500 poll 클라이언트 측 반환 건수 제한 타임아웃이 아닙니다. poll 축의 예산을 조절하는 손잡이입니다.

리밸런스 — 언제, 어떻게 일어나는가

발생 조건

리밸런스는 그룹의 구성이나 구독 대상이 바뀔 때 일어납니다. 공식 문서(ConsumerRebalanceListener 문서)가 드는 조건과 설정 문서의 서술을 합치면 다음과 같습니다.

리밸런스 발생 조건과 근거
조건설명
컨슈머 합류새 인스턴스 기동, 스케일 아웃, 장애 복구 후 재합류
컨슈머 정상 이탈close() 또는 unsubscribe() 호출
컨슈머 크래시session.timeout.ms 안에 하트비트 미수신
max.poll.interval.ms 초과컨슈머가 스스로 실패로 판단해 이탈
파티션 수 증가구독 중인 토픽의 파티션이 추가되면 재할당이 필요합니다. 공식 문서 표현: "changes affecting the subscribed topics (e.g. when the number of partitions is administratively adjusted)"
구독 패턴에 새 토픽 매칭subscribe(Pattern) 사용 시 패턴에 맞는 토픽이 새로 생기면 발생
멤버의 구독 변경어떤 멤버가 구독 토픽 목록을 바꿈
코디네이터 장애그룹 코디네이터 브로커가 교체되면 그룹이 재구성됩니다

eager vs cooperative

리밸런스를 어떻게 수행하는지에는 두 가지 방식이 있습니다.

eager vs cooperative 리밸런스 — 처리가 멈추는 파티션의 범위가 다릅니다 파티션 6개를 컨슈머 2대가 나눠 처리하다가 세 번째 컨슈머가 합류하는 같은 상황을, eager 프로토콜과 cooperative 프로토콜로 나눠 그린 그림입니다. 세로 방향은 파티션 p0 부터 p5, 가로 방향은 시간입니다. eager 에서는 리밸런스가 시작되면 모든 컨슈머가 자기가 가진 파티션 전부를 반납하므로 여섯 줄 모두에 처리 중단 구간이 생깁니다. 이것이 stop-the-world 리밸런스입니다. cooperative 에서는 소유자가 바뀌지 않는 p0, p1, p3, p4 는 반납하지 않고 계속 처리하며, 실제로 새 컨슈머에게 넘어가는 p2 와 p5 만 회수됩니다. 회수된 파티션은 1차 리밸런스에서 이전 소유자에게서 떨어지고 2차 리밸런스에서 새 소유자에게 할당되므로 리밸런스가 두 번 일어납니다. 대신 중단 구간이 이동 대상 파티션에만 국한되므로 전체 처리량 손실이 훨씬 작습니다. 두 그림에서 최종 할당은 동일합니다. StickyAssignor 와 CooperativeStickyAssignor 는 같은 할당 로직을 쓰고 리밸런스 프로토콜만 다르기 때문입니다. RangeAssignor 와 RoundRobinAssignor 도 eager 프로토콜이므로 위 그림처럼 전체가 멈춥니다. Kafka 4.x 의 기본 partition.assignment.strategy 는 RangeAssignor 와 CooperativeStickyAssignor 목록이며, cooperative 프로토콜을 쓰려면 그룹의 모든 컨슈머가 CooperativeStickyAssignor 를 사용해야 합니다. 같은 상황(컨슈머 1대 합류)에서 중단되는 파티션의 범위 가 다릅니다 eager 프로토콜 (StickyAssignor) — 전원이 전체 반납, stop-the-world 파티션 ← 리밸런스 전 리밸런스 리밸런스 후 → p0 C1 처리 중단 C1 처리 p1 C1 처리 중단 C1 처리 p2 C1 처리 중단 C3 처리 (이동) p3 C2 처리 중단 C2 처리 p4 C2 처리 중단 C2 처리 p5 C2 처리 중단 C3 처리 (이동) 6개 전부 중단 — 실제로 옮겨진 것은 p2, p5 두 개뿐인데도 전원이 멈춥니다. cooperative 프로토콜 (CooperativeStickyAssignor) — 이동 대상만 회수 p0 C1 처리 — 끊기지 않습니다 (소유자 변경 없음) p1 C1 처리 — 끊기지 않습니다 p2 C1 처리 회수 → 보류 C3 처리 (2차에서 할당) p3 C2 처리 — 끊기지 않습니다 p4 C2 처리 — 끊기지 않습니다 p5 C2 처리 회수 → 보류 C3 처리 (2차에서 할당) 2개만 중단 — 1차에서 회수하고 2차에서 새 소유자에게 할당합니다. 그룹의 모든 컨슈머가 같은 assignor 를 써야 합니다. 두 assignor 는 같은 최종 할당 을 만듭니다 — 차이는 프로토콜뿐입니다. Range·RoundRobin 도 eager 입니다.
eager vs cooperative 리밸런스 — 전체 회수 후 재할당(stop-the-world)과, 이동이 필요한 파티션만 증분으로 옮기는 방식의 차이

ConsumerRebalanceListener — 콜백 3개

리밸런스 시점에 오프셋을 커밋하거나 캐시를 비우려면 ConsumerRebalanceListener를 씁니다. 콜백은 사용자 스레드에서 poll() 호출의 일부로 실행됩니다.

ConsumerRebalanceListener의 세 콜백과 프로토콜별 호출 규칙
콜백 언제 Classic / eager Classic / cooperative · Consumer 프로토콜
onPartitionsRevoked 파티션을 정상적으로 내놓기 직전. 여기서 오프셋을 커밋합니다. 보유한 전체 집합으로 호출. 내놓을 것이 없어도 호출됩니다. 실제로 반납할 부분집합으로만 호출. 집합이 비면 호출되지 않습니다.
onPartitionsAssigned 재할당 완료 후, fetch 시작 전. 리밸런스 완료 알림으로도 쓸 수 있습니다. 전체 할당 집합으로 호출 새로 추가된 파티션만 전달됩니다. 계속 갖고 있던 것은 포함되지 않습니다.
onPartitionsLost 정상 반납 기회를 놓친 경우(세션 타임아웃, 치명적 오류). 이미 남이 소유하고 있을 수 있어 커밋이 불가합니다. 잃은 파티션이 비어 있지 않을 때만 호출 동일. 기본 구현은 onPartitionsRevoked를 그대로 호출합니다.

공식 문서는 onPartitionsAssigned리밸런스 완료 시 정확히 한 번 호출되며 새 파티션이 없어도 빈 컬렉션으로 호출된다고 명시합니다. 또한 정상 상황에서는 이전 소유자의 onPartitionsRevoked가 새 소유자의 onPartitionsAssigned보다 먼저 실행되는 것이 보장됩니다 — 그래서 revoke 콜백에서 커밋한 오프셋을 새 소유자가 반드시 볼 수 있습니다.

리밸런스 리스너 — 반납 직전 커밋, 잃은 경우는 커밋하지 않음
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // 아직 이 파티션의 소유자입니다 → 커밋이 유효합니다.
        // 여기서 커밋하지 않으면 새 소유자가 이미 처리한 레코드를 다시 읽습니다(중복).
        consumer.commitSync(currentOffsets);
        flushLocalCache(partitions);
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        // cooperative / Consumer 프로토콜에서는 "새로 추가된" 파티션만 들어옵니다.
        for (TopicPartition tp : partitions) {
            warmUpCache(tp);
        }
    }

    @Override
    public void onPartitionsLost(Collection<TopicPartition> partitions) {
        // 이미 다른 멤버가 소유 중일 수 있습니다 → 커밋을 시도하지 않습니다.
        // 기본 구현은 onPartitionsRevoked 를 호출하므로, 구분이 필요하면 반드시 오버라이드하세요.
        discardLocalCache(partitions);
    }
});

static membership — 재시작 때 리밸런스를 건너뛰기

일반적인(동적) 멤버십에서는 코디네이터가 멤버에게 일시적인 ID를 부여합니다. 그래서 배포·설정 변경·주기적 재시작 같은 운영 작업마다 ID가 바뀌고, 그때마다 리밸런스가 일어납니다. 상태가 큰 애플리케이션이라면 재할당된 태스크가 로컬 상태를 복구하는 데 오래 걸려 애플리케이션이 부분적으로 또는 전체적으로 멈춥니다.

group.instance.id인스턴스마다 고유한 영속 ID를 주면 그 ID를 기준으로 멤버십이 유지되어 리밸런스가 트리거되지 않습니다(KIP-345). Kafka Streams에서는 인스턴스마다 하나만 지정하면 되고 스레드 수와는 무관합니다.

static membership — group.instance.id 로 재시작 시 리밸런스를 건너뜁니다 컨슈머 한 대를 롤링 재시작할 때 동적 멤버와 정적 멤버가 어떻게 다른지 비교합니다. 동적 멤버는 종료할 때 코디네이터에 LeaveGroup 을 보내므로 즉시 리밸런스가 일어나고, 재시작하면 새로운 member.id 로 다시 합류하면서 또 한 번 리밸런스가 일어납니다. 재시작 한 번에 리밸런스 두 번, 처리 중단 두 번입니다. 정적 멤버는 group.instance.id 를 설정한 컨슈머입니다. 종료할 때 LeaveGroup 을 보내지 않으므로 코디네이터는 session.timeout.ms 동안 그 멤버가 돌아오기를 기다립니다. 그 사이 다른 컨슈머들은 자기 파티션을 계속 처리합니다. 같은 group.instance.id 로 다시 붙으면 코디네이터가 같은 멤버로 인식하고 이전 할당을 그대로 돌려주므로 리밸런스가 0 번입니다. 따라서 session.timeout.ms 를 재시작에 걸리는 시간보다 크게 잡아야 하고, 그 시간을 넘기면 결국 리밸런스가 일어납니다. 또한 정적 멤버가 max.poll.interval.ms 를 초과한 경우에는 파티션이 즉시 재할당되지 않고 하트비트를 멈춘 뒤 session 타임아웃이 지나서야 재할당됩니다. 같은 롤링 재시작 — 리밸런스가 2번이냐 0번이냐가 갈립니다 동적 멤버 — group.instance.id 없음 (기본값 null) ① 정상 처리 member.id=A1 ② 종료 LeaveGroup 즉시 통보 ③ 리밸런스 1 전 그룹 처리 중단 ④ 재시작 새 id =A2 ⑤ 리밸런스 2 또 중단 할당도 변함 재시작 1회 = 리밸런스 2회. 컨슈머 10대를 순차 재시작하면 리밸런스가 20번 일어납니다. 정적 멤버 — group.instance.id=consumer-a 설정 ① 정상 처리 instance.id =consumer-a ② 종료 LeaveGroup 없음 통보하지 않음 ③ 코디네이터가 기다림 session.timeout.ms 다른 컨슈머는 계속 처리 ④ 같은 id 로 복귀 이전 할당 그대로 리밸런스 0회 재시작 1회 = 리밸런스 0회. 배포·재시작이 잦은 서비스에서 효과가 큽니다. session.timeout.ms(기본 45000) 를 재시작에 걸리는 시간보다 크게 잡아야 합니다. 넘기면 결국 리밸런스가 납니다. group.instance.id 는 그룹 안에서 유일 해야 합니다. 같은 값이 둘이면 나중에 붙은 쪽이 거부됩니다. 정적 멤버가 max.poll.interval.ms 를 넘기면 즉시 재할당되지 않고 session 만료 후 재할당됩니다. 대가: 진짜로 죽은 인스턴스도 session.timeout.ms 동안 파티션이 방치됩니다 — 그만큼 lag 이 늘어납니다.
static membership — group.instance.id로 영속 ID를 부여해 재시작 구간에서 리밸런스를 회피하는 구조
static membership 설정 — 인스턴스마다 다른 값이어야 합니다
# 파드/컨테이너마다 고유한 값. StatefulSet 의 서수나 호스트명을 쓰는 것이 흔한 방식입니다.
group.instance.id=order-processor-3

# 재시작 소요 시간보다 넉넉하게 둡니다. 이 시간 안에 같은 ID 로 돌아오면 리밸런스가 없습니다.
session.timeout.ms=120000

KIP-848 새 컨슈머 그룹 프로토콜 (Kafka 4.0 GA)

Apache Kafka 4.0부터 차세대 컨슈머 리밸런스 프로토콜(KIP-848)이 GA가 되어 프로덕션 워크로드에 쓸 수 있습니다. 전면 증분 설계라서 전역 동기화 장벽(global synchronization barrier)에 의존하지 않고, 그 결과 리밸런스 시간이 짧아지고 컨슈머 그룹의 확장성이 개선됩니다. 동시에 클라이언트 구현이 단순해집니다 — 할당 계산이 브로커로 옮겨 갔기 때문입니다.

서버 쪽 — 브로커가 관리하는 것들

서버에서 이 프로토콜의 활성/비활성은 group.version 피처 플래그로 제어됩니다. 그리고 기존에 클라이언트가 갖고 있던 세 가지 책임이 브로커로 넘어갑니다.

Consumer 프로토콜에서 브로커가 관리하는 설정 (Apache Kafka 4.3 기본값)
브로커 설정 기본값 대체하는 클라이언트 설정
group.consumer.heartbeat.interval.ms 5000 heartbeat.interval.ms
group.consumer.session.timeout.ms 45000 session.timeout.ms
group.consumer.assignors uniform,range partition.assignment.strategy

할당자(assignor)도 서버가 정합니다. 기본 제공은 uniformrange 두 개이고, 목록의 첫 번째인 uniform이 기본입니다. 컨슈머가 group.remote.assignor로 다른 것을 고를 수 있습니다. 커스텀 할당 전략은 서버에서 ConsumerGroupPartitionAssignor를 구현해 등록합니다.

클라이언트 측 할당자 → 서버 측 할당자 대응 (공식 문서 매핑표)
Classic (클라이언트 측)Consumer (서버 측)
RangeAssignorrange
CooperativeStickyAssignoruniform
StickyAssignoruniform
RoundRobinAssignoruniform

쓸 수 없게 되는 설정과 API

새 프로토콜을 켜면 다음은 사용할 수 없습니다.

반대로 max.poll.interval.msmax.poll.records는 그대로 남습니다. 이 두 개는 애플리케이션 처리 속도에 관한 것이어서 브로커가 대신 정할 수 없습니다.

새 프로토콜 활성화 — 함께 지우고 함께 남기는 설정
group.id=order-processor
group.protocol=consumer          # 기본값은 classic. 명시해야 새 프로토콜이 켜집니다.

# 아래 3개는 이제 지원되지 않습니다 — 브로커가 관리합니다. 지워야 합니다.
# heartbeat.interval.ms=3000
# session.timeout.ms=45000
# partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

# 서버 측 할당자를 직접 고를 때만 지정합니다 (미지정 시 group.consumer.assignors 의 첫 번째).
group.remote.assignor=uniform

# poll 축 설정은 그대로 유효합니다.
max.poll.interval.ms=300000
max.poll.records=500

정규식 구독 — 서버 측 평가

새 프로토콜에는 subscribe(SubscriptionPattern)subscribe(SubscriptionPattern, ConsumerRebalanceListener)가 추가되었습니다. 정규식은 RE2J 형식이며 서버 쪽에서 평가됩니다. 기존 subscribe(Pattern)은 클라이언트가 전체 토픽 메타데이터를 받아 직접 매칭하는 방식이었으므로, 토픽이 많은 클러스터에서 메타데이터 부담이 크게 줄어듭니다.

서버 측 정규식 구독 (Consumer 프로토콜 전용)
// RE2J 형식. 서버가 평가하므로 클라이언트가 전체 토픽 목록을 받지 않습니다.
consumer.subscribe(new SubscriptionPattern("orders\\..*"));

마이그레이션 — 온라인과 오프라인

그룹은 비어 있을 때 자동으로 Classic ↔ Consumer 사이를 전환합니다. 두 가지 경로가 있습니다.

KIP-848 프로토콜 전환 경로
방식절차대가
오프라인 모든 컨슈머를 종료한 뒤 group.protocol=consumer로 다시 기동합니다. 빈 그룹이 되면 자동 변환됩니다. 그룹 전체 다운타임
온라인 group.protocol=consumer로 롤링 배포합니다. 첫 번째 새 프로토콜 컨슈머가 합류하는 순간 그룹이 Classic에서 Consumer로 변환되고, 남아 있는 Classic 멤버와 상호 운용됩니다. 다운타임 없음. 단 커스텀 메타데이터를 임베드하는 할당자를 쓰는 Classic 그룹에서는 불가
다운그레이드 역순으로 진행합니다. 마지막 Consumer 프로토콜 멤버가 떠날 때 그룹이 Classic으로 돌아갑니다.

진화 타임라인과 제약

공식 문서가 KIP-1274 기준으로 제시하는 예정 경로입니다.

Consumer 리밸런스 프로토콜 진화 타임라인 (공식 문서 기재)
버전상태
Apache Kafka 3.7Early Access
Apache Kafka 4.0GA (production-ready)
Apache Kafka 5.0KafkaConsumer기본값이 Consumer 프로토콜로 변경. Classic도 계속 지원
Apache Kafka 6.0KafkaConsumer는 Consumer 프로토콜만 지원. 브로커는 하위 호환을 위해 Classic 유지

오프셋 커밋 — 유실과 중복은 여기서 갈립니다

컨슈머의 위치는 정수 하나입니다. 브로커가 메시지별 배달 상태를 추적하는 대신 컨슈머 그룹이 파티션별로 "다음에 읽을 오프셋"을 커밋합니다. 공식 설계 문서는 이 선택 덕분에 "메시지 확인(acknowledgement)에 해당하는 것이 매우 저렴해진다"고 설명합니다. 커밋된 오프셋은 내부 토픽 __consumer_offsets에 저장됩니다.

__consumer_offsets 관련 브로커 설정 (Apache Kafka 4.3 기본값)
설정기본값의미
offsets.topic.num.partitions 50 오프셋 커밋 토픽의 파티션 수. 배포 후 변경 금지(그룹 → 코디네이터 매핑이 바뀝니다)
offsets.topic.replication.factor 3 클러스터 크기가 이 값을 충족하지 못하면 내부 토픽 생성이 실패합니다
offsets.retention.minutes 10080
(7일)
커밋된 오프셋 보관 기간. 아래 노트 참조

커밋 방식 4가지

커밋 방식별 특성과 전달 보장
방식 설정·호출 특성 결과 보장
자동 커밋 enable.auto.commit=true(기본)
auto.commit.interval.ms=5000(기본)
poll() 호출 시점에 주기가 지났으면 백그라운드로 커밋됩니다. 코드가 없어 편하지만 커밋 시점을 통제할 수 없습니다. 처리 완료와 커밋이 어긋날 수 있어 유실도 중복도 가능
동기 수동 커밋 commitSync() 브로커 응답을 기다립니다. 실패 시 재시도합니다. 처리량이 떨어집니다. 처리 후 커밋하면 at-least-once
비동기 수동 커밋 commitAsync(callback) 기다리지 않습니다. 빠르지만 실패를 재시도하면 순서가 뒤집힐 수 있어 보통 재시도하지 않습니다. at-least-once. 커밋 유실 구간이 조금 더 넓습니다.
혼합 (권장 패턴) 루프에서 commitAsync(), 종료·리밸런스 시 commitSync() 정상 경로는 빠르게, 마지막 한 번은 확실하게 at-least-once
커밋 시점별 유실과 중복 — 처리 전 커밋은 유실, 처리 후 커밋은 중복 poll 로 오프셋 5 부터 9 까지 다섯 건을 받은 컨슈머가 도중에 크래시하는 상황을 두 가지 커밋 순서로 비교합니다. 첫째는 처리 전에 커밋하는 경우입니다. 오프셋 10 을 먼저 커밋한 뒤 5 와 6 만 처리하고 크래시하면, 재시작한 컨슈머는 커밋된 10 부터 읽으므로 7, 8, 9 는 아무도 처리하지 않은 채 건너뛰게 됩니다. 이것이 유실이며 at-most-once 성질입니다. 둘째는 처리 후에 커밋하는 경우입니다. 5 부터 9 까지 처리를 마쳤지만 커밋 직전에 크래시하면, 재시작한 컨슈머는 이전 커밋 지점인 5 부터 다시 읽으므로 다섯 건을 다시 처리합니다. 이것이 중복이며 at-least-once 성질입니다. 기본값 enable.auto.commit 이 true 이면 poll 을 부를 때 주기적으로 커밋되므로, 처리가 끝나기 전에 커밋이 나가 첫째 경우가 될 수 있습니다. 중복을 없애려면 커밋 순서만으로는 부족하고, 컨슈머 쪽 처리를 멱등하게 만들거나 처리 결과와 오프셋을 한 트랜잭션으로 묶어야 합니다. 같은 크래시, 커밋 순서만 다릅니다 — 결과는 유실과 중복으로 갈립니다 ① 처리 커밋 — enable.auto.commit=true(기본) 에서 흔히 생기는 모양 poll() offset 5 ~ 9 5건 받음 커밋 committed = 10 아직 처리 안 함 처리 중 크래시 5, 6 만 처리됨 7, 8, 9 미처리 재시작 10 부터 읽음 건너뜀 결과 5 ✔ 6 ✔ 7 ✕ 8 ✕ 9 ✕ 3건 유실 · 아무도 처리하지 않았습니다 (at-most-once) ② 처리 커밋 — enable.auto.commit=false + 처리 완료 후 commitSync() poll() offset 5 ~ 9 이전 커밋 = 5 처리 완료 5 ~ 9 모두 처리 부수 효과 발생 커밋 직전 크래시 committed 은 5 커밋이 안 나감 재시작 5 부터 읽음 재처리 결과 5 ×2 6 ×2 7 ×2 8 ×2 9 ×2 5건 중복 처리 · 유실은 없습니다 (at-least-once) 유실과 중복 중 하나는 반드시 고릅니다. 대부분의 업무는 ②(중복 허용)를 고르고 처리를 멱등하게 만듭니다. 멱등하게 만드는 방법: 레코드 키를 업무 키로 쓰기 · upsert 사용 · 처리한 오프셋을 결과와 같은 트랜잭션에 기록 enable.auto.commit=truepoll() 호출 시점에 auto.commit.interval.ms=5000 주기로 커밋합니다. 즉 자동 커밋은 ①과 ② 사이 어디든 될 수 있습니다. 유실을 막아야 하면 자동 커밋을 끄고 직접 커밋하세요.
커밋 시점별 유실/중복 — 처리 전에 커밋하면 크래시 시 유실, 처리 후에 커밋하면 크래시 시 중복. 어느 쪽을 감당할지가 설계 선택입니다.
혼합 커밋 패턴 — 정상 경로는 async, 종료 경로는 sync
try {
    while (running) {
        ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
        for (ConsumerRecord<String, String> record : records) {
            handle(record);
        }
        // 정상 루프: 기다리지 않습니다. 실패해도 다음 커밋이 더 큰 오프셋을 덮어씁니다.
        consumer.commitAsync((offsets, exception) -> {
            if (exception != null) log.warn("비동기 커밋 실패 (다음 커밋이 만회합니다)", exception);
        });
    }
} finally {
    try {
        // 마지막 한 번은 반드시 확정합니다.
        consumer.commitSync();
    } finally {
        consumer.close();   // 그룹에서 정상 이탈 → 즉시 리밸런스
    }
}

auto.offset.reset — 언제 적용되는가

가장 오해가 잦은 설정입니다. 이 값은 평소에는 아무 일도 하지 않습니다. 공식 문서 기준으로 다음 두 경우에만 발동합니다.

  1. Kafka에 초기 오프셋이 없을 때 — 새 group.id이거나, 오프셋이 만료되어 사라진 경우
  2. 현재 오프셋이 서버에 더 이상 존재하지 않을 때 — 리텐션으로 해당 데이터가 삭제된 경우
auto.offset.reset의 값 (기본값 latest)
동작쓰는 곳
latest 기본값가장 최신 오프셋으로 재설정. 그 전에 쌓인 것은 건너뜁니다실시간 알림처럼 과거 데이터가 무의미한 경우
earliest가장 오래된 오프셋으로 재설정. 남아 있는 전부를 다시 읽습니다집계·적재처럼 누락이 치명적인 경우. 대량 재처리 폭발에 주의
none이전 오프셋이 없으면 예외를 던집니다"조용히 건너뛰거나 조용히 재처리하는" 사고를 원천 차단하고 싶을 때
by_duration:<duration>현재 시각 기준 지정한 기간만큼 뒤로 재설정. ISO-8601 형식(PnDTnHnMn.nS)이며 음수 불가"최근 1시간분만 다시" 같은 요구
오프셋 리셋 — 시나리오 옵션 (활성 멤버가 없을 때만 동작)
# 미리보기 (기본 동작: 실행하지 않고 무엇이 바뀔지 출력)
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --reset-offsets --group order-processor --topic orders --to-earliest

# 실제 적용
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --reset-offsets --group order-processor --topic orders --to-earliest --execute

# 그 밖의 시나리오
#   --to-latest                        가장 최신으로
#   --to-offset                  특정 오프셋으로
#   --shift-by                   현재에서 n 만큼 이동 (음수 가능)
#   --to-datetime 'YYYY-MM-DDThh:mm:ss.sss'
#   --by-duration 'PnDTnHnMnS'         현재 시각에서 기간만큼 뒤로
#   --to-current                       현재 오프셋으로
#   --from-file                   CSV 로 지정
#   --export                           결과를 CSV 로 내보내기
# 범위를 벗어난 값은 사용 가능한 경계로 자동 보정됩니다.

consumer lag 읽기

lag은 LOG-END-OFFSET − CURRENT-OFFSET입니다. CURRENT-OFFSET은 커밋된 오프셋이므로, 커밋 주기가 길면 실제로는 처리했는데도 lag이 커 보입니다. 반대로 자동 커밋을 쓰면 처리 전에 커밋되어 lag이 실제보다 작아 보일 수 있습니다.

lag 확인 — 커밋된 오프셋 기준임을 유의
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
  --describe --group order-processor
# TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG      CONSUMER-ID       HOST         CLIENT-ID
# orders  0          241019          395308          154289   consumer2-…       /127.0.0.1   consumer2
# orders  1          520678          803288          282610   consumer2-…       /127.0.0.1   consumer2

파티션 할당 전략 4종

group.protocol=classic에서 할당은 클라이언트가 계산하며, partition.assignment.strategy에 선호 순서대로 클래스 목록을 적습니다. Kafka 4.3의 기본값은 [RangeAssignor, CooperativeStickyAssignor]입니다.

파티션 할당 전략 4종 비교 — 같은 조건에서 결과가 어떻게 갈리는가 같은 컨슈머 수, 토픽 수, 파티션 수에 대해 네 가지 할당 전략의 결과를 2행 2열로 나란히 보여 주는 인터랙티브 그림입니다. RangeAssignor 는 토픽별로 파티션을 앞에서부터 잘라 나누므로 연속된 번호가 한 컨슈머에게 뭉칩니다. RoundRobinAssignor 는 모든 토픽의 파티션을 이름 순으로 정렬한 뒤 컨슈머에게 순환 배정하므로 번호가 흩어집니다. StickyAssignor 는 균형을 먼저 맞추고 그 안에서 이전 할당을 최대한 유지합니다. 이전 할당이 없는 첫 배정에서는 결과가 RoundRobinAssignor 와 같아지고, 차이는 재할당할 때 드러납니다. CooperativeStickyAssignor 의 최종 할당은 StickyAssignor 와 완전히 같습니다. 다른 점은 리밸런스 프로토콜이며, 옮겨 갈 파티션만 회수하고 두 번의 리밸런스로 나눠 처리합니다. 직전에 컨슈머 1명이 이탈한 상황 토글을 켜면 이탈 전 할당을 기준으로 다시 배정하므로 Sticky 계열이 유지하는 파티션 수와 Range, RoundRobin 이 이동시키는 파티션 수의 차이가 드러납니다. 초기 상태는 컨슈머 3명, 토픽 1개, 파티션 8개의 첫 배정입니다. 같은 조건, 네 전략 — 슬라이더와 토글로 조건을 바꿔 보세요 조건: 컨슈머 3명 · 토픽 1개 × 파티션 8개 = 총 8개 · 첫 배정 RangeAssignor C1 t0p0 t0p1 t0p2 C2 t0p3 t0p4 t0p5 C3 t0p6 t0p7 컨슈머별 담당: 3 / 3 / 2 (총 8개) RoundRobinAssignor C1 t0p0 t0p3 t0p6 C2 t0p1 t0p4 t0p7 C3 t0p2 t0p5 컨슈머별 담당: 3 / 3 / 2 (총 8개) StickyAssignor C1 t0p0 t0p3 t0p6 C2 t0p1 t0p4 t0p7 C3 t0p2 t0p5 컨슈머별 담당: 3 / 3 / 2 (총 8개) CooperativeStickyAssignor C1 t0p0 t0p3 t0p6 C2 t0p1 t0p4 t0p7 C3 t0p2 t0p5 컨슈머별 담당: 3 / 3 / 2 (총 8개) 첫 배정에서는 Sticky 결과가 RoundRobin 과 같습니다 — 차이는 재할당 때 드러납니다. 토글을 켜 보세요. Cooperative 와 Sticky 의 최종 할당은 같습니다. 차이는 회수 방식(전체 vs 이동 대상만)입니다. 4.x 기본값은 [RangeAssignor, CooperativeStickyAssignor] 목록입니다.
파티션 할당 전략 4종 비교 — 같은 컨슈머·파티션 구성에서 Range · RoundRobin · Sticky · CooperativeSticky의 할당 결과를 나란히 놓고 비교합니다. 이동량 차이는 "직전에 컨슈머 1명이 이탈한 상황" 토글을 켜야 나타납니다 — 첫 배정만 보면 Sticky와 RoundRobin의 결과가 같습니다.
할당 전략 비교 — 공식 문서의 partition.assignment.strategy 설명 기준
전략 배분 방식 리밸런스 특징과 주의점
RangeAssignor 토픽별로 파티션을 정렬해 연속 구간으로 나눠 줍니다 eager 여러 토픽을 구독할 때 같은 파티션 번호가 같은 컨슈머에 모입니다(조인에 유리). 파티션 수가 컨슈머 수로 나뉘지 않으면 앞쪽 컨슈머에 쏠립니다.
RoundRobinAssignor 구독 전체의 파티션을 라운드로빈으로 하나씩 배분 eager 균등성이 Range보다 좋습니다. 멤버 간 구독 토픽이 다르면 결과가 어긋날 수 있습니다.
StickyAssignor 최대한 균등하게 하면서 기존 할당을 최대한 보존 eager 재할당 이동량이 적습니다. 그래도 리밸런스 시작 시 전체를 반납합니다.
CooperativeStickyAssignor Sticky와 같은 로직 cooperative 이동이 필요한 파티션만 반납 → stop-the-world가 없습니다. 프로덕션 권장. 마이그레이션 절차에 주의(아래).
cooperative 전환 — 2단계 롤링
# 1단계 롤링: 목록에 CooperativeSticky 를 포함시킨다 (Kafka 4.x 기본값이 이미 이 상태)
partition.assignment.strategy=org.apache.kafka.clients.consumer.RangeAssignor,\
org.apache.kafka.clients.consumer.CooperativeStickyAssignor

# 2단계 롤링: RangeAssignor 를 제거한다 → 이 시점부터 cooperative 로 동작
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

assign() vs subscribe()

컨슈머가 파티션을 얻는 방법은 두 가지이고, 둘은 상호 배타적입니다.

assign() vs subscribe() — 그룹 조정에 참여하는지가 갈림길 왼쪽은 subscribe 로 토픽을 구독하는 경우입니다. 컨슈머들이 그룹 코디네이터에 합류하고 코디네이터가 파티션을 나눠 주며, 멤버가 바뀌면 리밸런스가 일어납니다. group.id 가 반드시 필요합니다. 오른쪽은 assign 으로 파티션을 직접 지정하는 경우입니다. 그룹 코디네이터가 관여하지 않으므로 리밸런스가 없고, 다른 컨슈머가 죽어도 내 파티션은 그대로입니다. 같은 group.id 를 공유해도 서로 조정하지 않고 각자 독립적으로 동작합니다. 파티션 할당 자체에는 group.id 가 필요하지 않지만, 오프셋을 Kafka 에 커밋하려면 group.id 가 있어야 하며 커밋 충돌을 피하려면 인스턴스마다 다른 group.id 를 쓰는 것이 좋습니다. 아래 표는 파티션 결정 주체, 그룹 조정 참여, 리밸런스 유무, group.id 필요 여부, 다른 컨슈머 장애 시 동작, 파티션 수가 늘었을 때의 동작을 비교합니다. 두 방식을 같은 컨슈머 인스턴스에서 섞어 쓸 수는 없습니다. 가운데에 코디네이터가 있는지 없는지 — 나머지 차이는 전부 여기서 나옵니다 subscribe(topics) 그룹 코디네이터 C1 C2 C3 코디네이터가 파티션을 나눠 줍니다 → 리밸런스 있음 assign(partitions) 코디네이터 없음 C1 → p0 C2 → p1 C3 → p2 내가 파티션을 직접 지정합니다 → 리밸런스 없음 항목 subscribe() assign() 파티션 결정 주체 코디네이터 애플리케이션 그룹 조정 참여 참여 참여하지 않음 리밸런스 있음 없음 group.id 필수 불필요 (커밋할 때만 필요) 다른 컨슈머가 죽으면 파티션이 재할당됨 아무 변화 없음 파티션 수가 늘면 자동으로 반영 assign() 을 다시 호출 두 방식을 같은 컨슈머 인스턴스에서 섞어 쓸 수 없습니다. 같은 group.idassign() 을 쓰면 커밋이 충돌합니다 — 인스턴스별로 다른 값을 권장합니다.
assign()subscribe() — 그룹 조정 참여 여부, 리밸런스 유무, group.id 필요 여부, 파티션 추가 감지 여부의 차이
subscribe()assign() 비교
관점 subscribe(topics) assign(partitions)
파티션 결정브로커/그룹이 자동 할당애플리케이션이 직접 지정
그룹 관리 참여참여. 하트비트·멤버십 유지참여하지 않음
리밸런스발생발생하지 않음
group.id필수오프셋 커밋을 Kafka에 하려면 필요. 순수 읽기만이라면 없어도 동작
파티션 추가 감지자동. 리밸런스로 반영감지하지 않음. 다시 assign()해야 합니다
ConsumerRebalanceListener사용 가능의미 없음(리밸런스가 없음)
쓰는 곳일반적인 확장형 소비. 기본 선택특정 파티션만 읽기, 오프셋을 외부 저장소에 두는 완전 수동 제어, 디버깅·재처리 도구
잘못된 사용과 올바른 사용
// ✕ 잘못된 사용 — IllegalStateException
consumer.subscribe(List.of("orders"));
consumer.assign(List.of(new TopicPartition("orders", 0)));   // 여기서 던집니다

// ✓ 방식을 바꿔야 한다면 먼저 초기화합니다
consumer.subscribe(List.of("orders"));
consumer.unsubscribe();                                       // 구독 유형 초기화
consumer.assign(List.of(new TopicPartition("orders", 0)));

// ✓ 수동 할당 + 특정 오프셋부터 읽기 (재처리 도구의 전형적 형태)
TopicPartition tp = new TopicPartition("orders", 0);
consumer.assign(List.of(tp));
consumer.seek(tp, 1_000_000L);   // assign/subscribe 이후에만 호출할 수 있습니다

seek · pause · resume — 흐름을 직접 제어하기

컨슈머는 로그 위의 위치를 자유롭게 옮길 수 있습니다. 공식 설계 문서는 이를 "큐의 일반적인 계약을 위반하지만 많은 소비자에게 필수적인 기능"이라고 설명합니다. 버그를 고친 뒤 과거 데이터를 다시 흘리는 재처리가 여기서 나옵니다.

위치·흐름 제어 API
API동작주의
seek(tp, offset)다음 poll()이 읽을 위치를 지정파티션이 할당된 뒤에만 호출 가능. subscribe()와 함께 쓸 때는 onPartitionsAssigned 안에서 호출합니다
seekToBeginning(tps)남아 있는 가장 오래된 위치로커밋된 오프셋을 바꾸는 것이 아닙니다. 커밋해야 영속됩니다
seekToEnd(tps)가장 최신 위치로isolation.level=read_committed이면 LSO를 반환합니다(6장)
pause(tps)해당 파티션의 fetch를 멈춤. poll()은 계속 호출됩니다백프레셔의 정석입니다. poll()을 멈추지 않으므로 max.poll.interval.ms가 터지지 않습니다
resume(tps)fetch 재개
백프레셔 — pause/resume 으로 리밸런스 없이 버티기
while (running) {
    ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));

    if (downstreamOverloaded()) {
        // fetch 만 멈춥니다. poll() 은 계속 도므로 max.poll.interval.ms 가 터지지 않습니다.
        consumer.pause(consumer.assignment());
    } else if (!consumer.paused().isEmpty()) {
        consumer.resume(consumer.paused());
    }

    for (ConsumerRecord<String, String> record : records) {
        handle(record);
    }
    consumer.commitAsync();
}
subscribe()seek()을 함께 쓰기 — 할당 콜백 안에서
consumer.subscribe(List.of("orders"), new ConsumerRebalanceListener() {
    @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        saveOffsetsToExternalStore(partitions);
    }
    @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        // 할당이 확정된 이 시점 이후에만 seek() 이 유효합니다.
        for (TopicPartition tp : partitions) {
            long offset = loadOffsetFromExternalStore(tp);
            consumer.seek(tp, offset);
        }
    }
});

컨슈머 lag — 무엇을 재는가

lag은 “컨슈머가 얼마나 뒤처졌는가”를 나타내지만, 무엇을 기준으로 재는지가 측정 방법마다 다릅니다. 같은 상황에서 CLI와 클라이언트 메트릭이 다른 숫자를 내놓는 이유가 여기 있습니다.

컨슈머 lag 의 정확한 정의 — 무엇에서 무엇을 빼는가 파티션 하나의 로그를 12칸으로 그리고 네 지점을 표시했습니다. 커밋된 오프셋은 4 로, kafka-consumer-groups 명령의 CURRENT-OFFSET 열에 나오는 값입니다. 현재 위치는 7 로, 컨슈머가 실제로 읽어 간 지점이며 아직 커밋하지 않은 구간이 그 사이에 있습니다. high watermark 는 10 으로, 클라이언트가 조회하는 로그 끝이고 CLI 의 LOG-END-OFFSET 열에 나옵니다. read_uncommitted 기준으로 이 값은 ISR 전원이 복제를 마친 지점입니다. 리더의 LEO 는 12 이지만 아직 복제가 끝나지 않아 컨슈머에게 보이지 않으며 lag 계산에도 들어가지 않습니다. 따라서 CLI 가 보여 주는 LAG 는 LOG-END-OFFSET 에서 CURRENT-OFFSET 을 뺀 6 이고, 클라이언트 메트릭 records-lag 는 커밋 오프셋이 아니라 현재 위치를 기준으로 하므로 10 에서 7 을 뺀 3 입니다. 두 값이 다른 이유는 기준점이 다르기 때문이며, 그 차이가 바로 처리했지만 아직 커밋하지 않은 구간입니다. lag 은 기준점이 무엇이냐 에 따라 값이 달라집니다 파티션 orders-0 의 로그 0 1 2 3 4 5 6 7 8 9 10 11 리더 커밋 4 = CURRENT-OFFSET 현재 위치 7 = position HW 10 = LOG-END-OFFSET 리더 LEO 12 · 복제 미완 두 가지 lag CLI 의 LAG = 6 LOG-END(10) − CURRENT(4) records-lag = 3 HW(10) − position(7) 복제 미완 구간(10~11)은 어느 lag 에도 들어가지 않습니다 — 조회 자체가 HW 까지만 됩니다. 두 값의 차이 3건 = 이미 처리했지만 아직 커밋하지 않은 구간입니다. CLI 의 LOG-END-OFFSET 은 리더의 LEO 가 아니라 high watermark 입니다 (read_uncommitted 기준). read_committed 컨슈머는 이 값이 LSO(last stable offset)가 되고 lag 도 LSO 기준으로 바뀝니다. 커밋을 자주 하지 않으면 실제 처리는 따라가고 있는데도 CLI 의 LAG 이 크게 보입니다.
컨슈머 lag의 정확한 정의 — LOG-END-OFFSET은 리더의 LEO가 아니라 high watermark입니다. 복제가 끝나지 않은 구간은 lag에 포함되지 않습니다
컨슈머 lag 측정 3가지 방법 — 무엇을 재고 어떤 함정이 있는가 컨슈머 lag 을 확인하는 세 가지 경로를 비교합니다. 첫째는 CLI 입니다. kafka-consumer-groups 명령의 describe 로 CURRENT-OFFSET, LOG-END-OFFSET, LAG 열을 봅니다. 커밋된 오프셋을 기준으로 하므로 커밋 주기가 길면 실제보다 크게 보이고, 실행한 순간의 값만 알 수 있어 추세를 볼 수 없습니다. 둘째는 클라이언트 메트릭입니다. consumer-fetch-manager-metrics 의 records-lag, records-lag-max, records-lag-avg 를 봅니다. 공식 문서가 명시하듯 이 값은 커밋 오프셋이 아니라 현재 위치를 기준으로 하며, 컨슈머 프로세스가 발행하므로 컨슈머가 죽으면 값 자체가 사라져 가장 위험한 상황에서 아무것도 보이지 않습니다. 셋째는 브로커 메트릭입니다. 브로커에는 컨슈머 lag 메트릭이 없습니다. 이름이 ConsumerLag 인 FetcherLagMetrics 는 팔로워 레플리카의 복제 lag 이며 컨슈머와 무관합니다. 브로커 쪽에서 보려면 __consumer_offsets 와 로그 끝 오프셋을 읽어 계산하는 외부 exporter 가 필요합니다. 결론은 클라이언트 메트릭으로 상시 감시하고, 컨슈머가 죽은 경우를 대비해 외부 exporter 를 함께 두는 것입니다. 세 방법이 서로 다른 값 을 봅니다. 어느 것 하나로는 부족합니다. ① CLI 명령 kafka-consumer-groups.sh --describe --group my-group AdminClient 로 조회합니다 무엇을 재는가 LOG-END-OFFSET − CURRENT-OFFSET = 커밋 기준 함정 커밋 주기가 길면 실제보다 크게 보입니다 실행한 순간의 스냅샷 — 추세를 볼 수 없습니다 멤버가 없으면 열이 비어 보임 ② 클라이언트 메트릭 (권장) 메트릭 records-lag records-lag-max / -avg consumer-fetch-manager-metrics 무엇을 재는가 로그 끝 − 현재 위치 = 커밋이 아니라 position 공식 문서에 명시된 주의사항 함정 컨슈머가 죽으면 값이 아예 사라집니다 가장 위험한 순간에 아무것도 안 보입니다 fetch 가 없으면 갱신되지 않음 ③ 브로커 메트릭 — 없습니다 브로커가 제공하는 것 컨슈머 lag 메트릭은 브로커에 없습니다 컨슈머가 직접 발행합니다 이름에 속지 마세요 FetcherLagMetrics name=ConsumerLag = 팔로워 복제 lag 입니다 대안 __consumer_offsets 와 로그 끝을 읽어 계산하는 외부 exporter 를 둡니다 컨슈머가 죽어도 계속 측정됩니다 ②로 상시 감시하고 ③(외부 exporter)로 보완합니다. ②만 두면 컨슈머가 죽은 순간을 놓칩니다. ①과 ②의 값이 다른 것은 정상입니다 — ①은 커밋 기준, ②는 현재 위치 기준입니다 (D-048 참고). lag 을 건수로만 보지 말고 시간 으로도 보세요. 초당 처리량이 다르면 같은 건수라도 심각도가 다릅니다. lag 이 계속 늘면 컨슈머 수를 늘리거나(파티션 수 상한까지) 처리를 최적화합니다.
lag 측정 3가지 방법 — CLI(kafka-consumer-groups --describe) · 클라이언트 메트릭(records-lag) · 브로커 메트릭이 각각 무엇을 재는지와 함정

이 장에서 나온 설정 정리

Consumer 핵심 설정 — Apache Kafka 4.3 공식 문서 기준 기본값
설정 기본값 설명 튜닝 포인트
group.id null 컨슈머 그룹 식별자 subscribe() 또는 Kafka 기반 오프셋 관리를 쓰면 필수
group.protocol classic classic 또는 consumer(KIP-848) 4.0 GA이지만 기본값이 아닙니다. 명시해야 켜집니다
group.instance.id null static membership용 영속 인스턴스 ID 인스턴스마다 유일해야 합니다. 중복 시 FencedInstanceIdException
heartbeat.interval.ms 3000 하트비트 전송 주기 (classic에서만 지원) session.timeout.ms의 1/3 이하
session.timeout.ms 45000 하트비트 미수신 판정 시간 (classic에서만 지원) 브로커의 group.min/max.session.timeout.ms 범위 안이어야 합니다
max.poll.interval.ms 300000 poll() 사이 최대 허용 시간 초과 시 자발적 그룹 이탈. 먼저 max.poll.records를 줄여 보세요
max.poll.records 500 poll()의 최대 반환 건수 fetch 양과 무관. poll 축 예산 조절용
enable.auto.commit true 백그라운드 주기 커밋 전달 보장을 설계하려면 false로 두고 수동 커밋하세요
auto.commit.interval.ms 5000 자동 커밋 주기 길면 중복 구간이 넓어집니다
auto.offset.reset latest 초기 오프셋이 없거나 범위를 벗어났을 때의 동작 earliest / latest / none / by_duration:<ISO-8601>
partition.assignment.strategy [RangeAssignor,
CooperativeStickyAssignor]
클라이언트 측 할당 전략 목록 (classic에서만) 앞의 것을 제거하는 한 번의 롤링으로 cooperative 전환
isolation.level read_uncommitted 트랜잭션 메시지 가시성 EOS를 쓰려면 read_committed. 컨슈머 설정입니다(6장)
fetch.min.bytes 1 응답 최소 바이트 올리면 처리량↑ 지연↑
fetch.max.wait.ms 500 최소 바이트 미달 시 대기 상한 fetch.min.bytes와 짝
fetch.max.bytes 52428800 응답 전체 최대 크기 (절대 상한 아님) 메모리 사용량과 직결
max.partition.fetch.bytes 1048576 파티션당 응답 최대 크기 (절대 상한 아님) 브로커 message.max.bytes와 정합해야 합니다
request.timeout.ms 30000 요청 응답 대기 상한
default.api.timeout.ms 60000 타임아웃 인자가 없는 클라이언트 API의 기본 타임아웃
allow.auto.create.topics true 구독·할당 시 브로커에서 토픽 자동 생성 허용 브로커의 auto.create.topics.enable도 허용해야 실제로 생성됩니다. 오타 토픽 방지를 위해 false 권장
client.rack "" 클라이언트의 랙 식별자. 브로커 broker.rack과 대응 가까운 레플리카에서 fetch해 네트워크 비용을 줄입니다

전체 설정 목록과 프리셋은 설정 치트시트에 있습니다. 동작하는 컨슈머 예제는 예제 4를 보세요.

흔한 오해

시험 포인트 요약

확인 문제

타임아웃 4종의 구분, KIP-848 활성화 조건, assign()subscribe()의 차이가 집중적으로 나옵니다. 키보드만으로 완주할 수 있습니다.

공식 문서 출처

이 장의 설정 기본값·예외 메시지·프로토콜 동작은 모두 아래에서 확인했습니다 (Apache Kafka 4.3 문서 기준).