기본개념 · 5장
Consumer 심화
Consumer는 Kafka 클라이언트 중 설정이 가장 많이 얽혀 있고, 실무 장애도 가장 자주 일어나는 쪽입니다.
원인은 대개 하나입니다 — 하트비트를 보내는 축과 poll()을 호출하는 축이 별개인데
이를 하나로 착각한 것입니다. 이 장은 그 두 축을 분리하는 데서 출발해, 리밸런스와 오프셋 커밋,
그리고 Kafka 4.0에서 GA가 된 새 컨슈머 그룹 프로토콜(KIP-848)까지 정리합니다.
학습 목표
heartbeat.interval.ms·session.timeout.ms·max.poll.interval.ms·max.poll.records가 각각 무엇을 재는지 구분하고, 처리 지연 시 어느 쪽이 먼저 터지는지 판단할 수 있습니다.- 리밸런스가 발생하는 조건을 전부 열거하고, eager와 cooperative의 차이를 설명할 수 있습니다.
- KIP-848 새 프로토콜을 켜는 방법과, 기본값이 아니라는 점을 정확히 말할 수 있습니다.
subscribe()와assign()을 섞어 쓰면 무엇이 일어나는지 알고, 각각을 언제 쓰는지 판단할 수 있습니다.auto.offset.reset이 언제 적용되는지, 커밋 방식별로 유실과 중복이 어디서 생기는지 설명할 수 있습니다.
컨슈머 그룹과 파티션 소유권
컨슈머는 거의 항상 컨슈머 그룹(consumer group)의 멤버로 동작합니다.
그룹은 group.id로 식별되며, 그룹 하나가 하나의 논리적 구독자입니다.
같은 토픽을 서로 다른 그룹이 구독하면 각 그룹이 전체 데이터를 독립적으로 받습니다.
같은 그룹 안에서는 데이터가 나뉘어 처리됩니다.
소유권 규칙은 단 한 줄입니다. 하나의 토픽-파티션은, 하나의 그룹 안에서 정확히 한 컨슈머에게만 할당됩니다. 공식 문서의 설계 문서도 파티션을 "각 구독 그룹 내에서 정확히 하나의 컨슈머가 소비하는" 단위로 정의합니다. 이 규칙에서 세 가지 실무적 결론이 나옵니다.
- 소비 병렬성의 상한은 파티션 수입니다. 파티션 8개인 토픽에 컨슈머를 12개 띄우면 4개는 아무 파티션도 못 받고 유휴 상태로 남습니다.
- 순서 보장의 단위도 파티션입니다. 한 파티션은 한 컨슈머가 순서대로 읽으므로, 키를 지정해 같은 파티션에 보낸 이벤트는 순서가 유지됩니다.
- 컨슈머 하나가 여러 파티션을 받을 수 있습니다. 그리고 그 파티션들의 레코드는 한 스레드에서 번갈아 처리됩니다 — 이것이 뒤에서 볼
max.poll.interval.ms문제의 근원입니다.
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의 #PARTITIONS가 0이라는 점을 보세요.
컨슈머가 살아 있고 그룹에도 들어와 있지만 할당받은 파티션이 없습니다. 파티션 수보다 컨슈머가 많은 상태입니다.
이런 상황에서 "컨슈머를 더 늘렸는데 처리량이 안 오른다"는 문의가 나옵니다.
파티션 수 증가의 실제 비용은 케이스 5에서 다룹니다.
poll 루프의 실제 동작
poll()은 이름과 달리 "브로커에 한 번 물어보고 돌아오는" 함수가 아닙니다.
컨슈머 내부에는 백그라운드 fetch와 사용자 스레드 반환이 분리된 구조가 있습니다.
순서를 정확히 이해해야 뒤의 타임아웃 설명이 성립합니다.
- 컨슈머가 리더 브로커들에게 Fetch 요청을 보냅니다. 여러 파티션이 한 요청에 묶이고, 여러 브로커에 대해 병렬로 나갑니다.
- 브로커는
fetch.min.bytes만큼 데이터가 모이거나fetch.max.wait.ms가 지날 때까지 응답을 붙잡아 둡니다. - 응답으로 온 레코드 배치는 컨슈머 내부 버퍼에 캐시됩니다.
poll()은 그 버퍼에서 최대max.poll.records개를 꺼내 사용자에게 돌려줍니다.- 애플리케이션이 그 레코드들을 처리합니다. 이 시간 동안 컨슈머는 사용자 코드에 갇혀 있습니다.
- 다음
poll()이 호출되면 4번으로 돌아갑니다. 버퍼가 비었으면 1번부터 다시 합니다.
poll()은 그 버퍼에서
max.poll.records만큼만 잘라 반환합니다. 네트워크 왕복과 사용자 처리가 분리된 구조입니다.
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.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 출제의 상당 부분이 여기 걸려 있습니다. 네 개의 설정은 서로 다른 두 개의 축에 놓여 있습니다.
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종 비교표
| 설정 | 기본값 | 축 | 누가 보내고/감시하는가 | 초과·초과 시 결과 |
|---|---|---|---|---|
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: 리밸런스가 시작되면 모든 멤버가 보유한 파티션 전부를 반납하고,
새 할당을 받은 뒤에 다시 처리를 시작합니다. 그 사이 그룹 전체가 소비를 멈춥니다(stop-the-world).
RangeAssignor,RoundRobinAssignor,StickyAssignor가 이 방식입니다. - cooperative: 실제로 옮겨야 하는 파티션만 반납합니다.
유지되는 파티션은 계속 처리됩니다. 대신 리밸런스가 두 번의 라운드로 나뉘어 진행됩니다.
CooperativeStickyAssignor가 이 방식입니다.
ConsumerRebalanceListener — 콜백 3개
리밸런스 시점에 오프셋을 커밋하거나 캐시를 비우려면 ConsumerRebalanceListener를 씁니다.
콜백은 사용자 스레드에서 poll() 호출의 일부로 실행됩니다.
| 콜백 | 언제 | 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에서는 인스턴스마다 하나만 지정하면 되고 스레드 수와는 무관합니다.
group.instance.id로 영속 ID를 부여해 재시작 구간에서 리밸런스를 회피하는 구조
# 파드/컨테이너마다 고유한 값. 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 피처 플래그로 제어됩니다.
그리고 기존에 클라이언트가 갖고 있던 세 가지 책임이 브로커로 넘어갑니다.
| 브로커 설정 | 기본값 | 대체하는 클라이언트 설정 |
|---|---|---|
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)도 서버가 정합니다. 기본 제공은 uniform과 range 두 개이고,
목록의 첫 번째인 uniform이 기본입니다.
컨슈머가 group.remote.assignor로 다른 것을 고를 수 있습니다.
커스텀 할당 전략은 서버에서 ConsumerGroupPartitionAssignor를 구현해 등록합니다.
| Classic (클라이언트 측) | Consumer (서버 측) |
|---|---|
RangeAssignor | range |
CooperativeStickyAssignor | uniform |
StickyAssignor | uniform |
RoundRobinAssignor | uniform |
쓸 수 없게 되는 설정과 API
새 프로토콜을 켜면 다음은 사용할 수 없습니다.
heartbeat.interval.mssession.timeout.mspartition.assignment.strategyenforceRebalance(String)및enforceRebalance()
반대로 max.poll.interval.ms와 max.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)은 클라이언트가 전체 토픽 메타데이터를 받아 직접 매칭하는 방식이었으므로,
토픽이 많은 클러스터에서 메타데이터 부담이 크게 줄어듭니다.
// RE2J 형식. 서버가 평가하므로 클라이언트가 전체 토픽 목록을 받지 않습니다.
consumer.subscribe(new SubscriptionPattern("orders\\..*"));
마이그레이션 — 온라인과 오프라인
그룹은 비어 있을 때 자동으로 Classic ↔ Consumer 사이를 전환합니다. 두 가지 경로가 있습니다.
| 방식 | 절차 | 대가 |
|---|---|---|
| 오프라인 | 모든 컨슈머를 종료한 뒤 group.protocol=consumer로 다시 기동합니다. 빈 그룹이 되면 자동 변환됩니다. |
그룹 전체 다운타임 |
| 온라인 | group.protocol=consumer로 롤링 배포합니다. 첫 번째 새 프로토콜 컨슈머가 합류하는 순간 그룹이 Classic에서 Consumer로 변환되고, 남아 있는 Classic 멤버와 상호 운용됩니다. |
다운타임 없음. 단 커스텀 메타데이터를 임베드하는 할당자를 쓰는 Classic 그룹에서는 불가 |
| 다운그레이드 | 역순으로 진행합니다. 마지막 Consumer 프로토콜 멤버가 떠날 때 그룹이 Classic으로 돌아갑니다. | — |
진화 타임라인과 제약
공식 문서가 KIP-1274 기준으로 제시하는 예정 경로입니다.
| 버전 | 상태 |
|---|---|
| Apache Kafka 3.7 | Early Access |
| Apache Kafka 4.0 | GA (production-ready) |
| Apache Kafka 5.0 | KafkaConsumer의 기본값이 Consumer 프로토콜로 변경. Classic도 계속 지원 |
| Apache Kafka 6.0 | KafkaConsumer는 Consumer 프로토콜만 지원. 브로커는 하위 호환을 위해 Classic 유지 |
오프셋 커밋 — 유실과 중복은 여기서 갈립니다
컨슈머의 위치는 정수 하나입니다. 브로커가 메시지별 배달 상태를 추적하는 대신
컨슈머 그룹이 파티션별로 "다음에 읽을 오프셋"을 커밋합니다.
공식 설계 문서는 이 선택 덕분에 "메시지 확인(acknowledgement)에 해당하는 것이 매우 저렴해진다"고 설명합니다.
커밋된 오프셋은 내부 토픽 __consumer_offsets에 저장됩니다.
| 설정 | 기본값 | 의미 |
|---|---|---|
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 |
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 — 언제 적용되는가
가장 오해가 잦은 설정입니다. 이 값은 평소에는 아무 일도 하지 않습니다. 공식 문서 기준으로 다음 두 경우에만 발동합니다.
- Kafka에 초기 오프셋이 없을 때 — 새
group.id이거나, 오프셋이 만료되어 사라진 경우 - 현재 오프셋이 서버에 더 이상 존재하지 않을 때 — 리텐션으로 해당 데이터가 삭제된 경우
| 값 | 동작 | 쓰는 곳 |
|---|---|---|
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이 실제보다 작아 보일 수 있습니다.
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]입니다.
| 전략 | 배분 방식 | 리밸런스 | 특징과 주의점 |
|---|---|---|---|
RangeAssignor |
토픽별로 파티션을 정렬해 연속 구간으로 나눠 줍니다 | eager | 여러 토픽을 구독할 때 같은 파티션 번호가 같은 컨슈머에 모입니다(조인에 유리). 파티션 수가 컨슈머 수로 나뉘지 않으면 앞쪽 컨슈머에 쏠립니다. |
RoundRobinAssignor |
구독 전체의 파티션을 라운드로빈으로 하나씩 배분 | eager | 균등성이 Range보다 좋습니다. 멤버 간 구독 토픽이 다르면 결과가 어긋날 수 있습니다. |
StickyAssignor |
최대한 균등하게 하면서 기존 할당을 최대한 보존 | eager | 재할당 이동량이 적습니다. 그래도 리밸런스 시작 시 전체를 반납합니다. |
CooperativeStickyAssignor |
Sticky와 같은 로직 | cooperative | 이동이 필요한 파티션만 반납 → stop-the-world가 없습니다. 프로덕션 권장. 마이그레이션 절차에 주의(아래). |
# 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()과 subscribe() — 그룹 조정 참여 여부, 리밸런스 유무,
group.id 필요 여부, 파티션 추가 감지 여부의 차이
| 관점 | 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 | 동작 | 주의 |
|---|---|---|
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 재개 | — |
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와 클라이언트 메트릭이 다른 숫자를 내놓는 이유가 여기 있습니다.
LOG-END-OFFSET은 리더의 LEO가 아니라
high watermark입니다. 복제가 끝나지 않은 구간은 lag에 포함되지 않습니다
kafka-consumer-groups --describe) ·
클라이언트 메트릭(records-lag) · 브로커 메트릭이 각각 무엇을 재는지와 함정
이 장에서 나온 설정 정리
| 설정 | 기본값 | 설명 | 튜닝 포인트 |
|---|---|---|---|
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종의 구분, KIP-848 활성화 조건, assign()과 subscribe()의 차이가
집중적으로 나옵니다. 키보드만으로 완주할 수 있습니다.
이어서 볼 곳
- 6장 · 전달 보장과 트랜잭션 커밋 순서가 만드는 유실·중복을 설정 조합으로 정리하고, EOS의 경계를 확정합니다.
-
4장 · Producer 심화
전송 파이프라인, 배치와
linger.ms, acks와 멱등성. -
케이스 1 · 오프셋이 사라진 아침
auto.offset.reset이 언제 발동하는지를 장애로 배웁니다. -
케이스 2 · 끝나지 않는 리밸런스
max.poll.interval.ms초과와 리밸런스 스톰의 전개 과정. - 예제 4 · 컨슈머 그룹과 리밸런스 실험 동작하는 코드로 리밸런스와 커밋 전략을 직접 관찰합니다.
- CCDAK · Application Development 28% 도메인의 압축 정리와 함정 비교표.
공식 문서 출처
이 장의 설정 기본값·예외 메시지·프로토콜 동작은 모두 아래에서 확인했습니다 (Apache Kafka 4.3 문서 기준).
- Consumer Configs —
group.protocol(classic),session.timeout.ms(45000),heartbeat.interval.ms(3000),max.poll.interval.ms(300000),max.poll.records(500),auto.offset.reset(latest),fetch.*,partition.assignment.strategy - Consumer Rebalance Protocol — KIP-848 GA,
group.version피처 플래그, 서버 측 할당자uniform/range, 사용 불가 설정 목록, 온라인/오프라인 마이그레이션, 진화 타임라인, 제약 - Group Configs —
consumer.heartbeat.interval.ms(5000),consumer.session.timeout.ms(45000) - Broker Configs —
group.consumer.assignors(uniform,range),offsets.topic.num.partitions(50),offsets.topic.replication.factor(3),offsets.retention.minutes(10080),group.min/max.session.timeout.ms - Design — Consumer Position — 오프셋 하나로 소비 위치를 표현하는 설계, rewind
- Design — Static Membership —
group.instance.id, KIP-345,FencedInstanceIdException - Operations — Managing Consumer Groups —
--describe/--members/--state출력 형식,--reset-offsets시나리오 전체 - Javadoc — ConsumerRebalanceListener — 세 콜백의 호출 규칙, eager/cooperative/Consumer 프로토콜 차이,
onPartitionsLost의 기본 구현 - Javadoc — CommitFailedException —
max.poll.interval.ms초과 시의 메시지 전문 - Javadoc — KafkaConsumer —
subscribe/assign/seek/pause/resume, 구독 유형의 상호 배타성