Data & Platform/Event Streaming
Kafka - 05. consumer group
consumer group — 병렬로 읽되 중복은 막는 팀
orders topic에 주문이 초당 수만 건 쏟아진다. consumer 하나로는 다 못 읽는다. consumer를 여럿 띄워 병렬로 읽고 싶다 — 하지만 같은 메시지를 두 consumer가 동시에 읽으면 중복이 생긴다. 이 모순을 푸는 장치가 consumer group이다. 여러 consumer가 topic의 partition을 서로 겹치지 않게 나눠 읽는 팀.
여기서 "partition을 겹치지 않게 나눈다"가 단순한 규칙이 아니다 — consumer가 합류하고 이탈할 때마다 partition을 다시 나눠야 하고(rebalance), 그 과정에서 구형 프로토콜은 모든 consumer를 멈추게 한다(stop-the-world). Kafka 4.0의 차세대 rebalance 프로토콜은 이 멈춤을 최소화하지만, 트레이드오프로 일부 설정이 무력화된다. consumer group의 규칙, partition 할당 전략, rebalance의 비용과 4.0의 개선이 어떻게 엮여서 "병렬 소비 + 순서 보장 + 확장"을 동시에 잡는지를 추적한다.
consumer group은 partition을 겹치지 않게 나눠 읽는 팀이다
consumer group은 같은 group 이름을 가진 consumer들의 모임이다. 핵심 규칙은 한 줄이다:
한 consumer group 안에서, partition 1개는 정확히 한 consumer가 담당한다.
이 규칙이 왜 이렇게 단순한 형태인지를 이해하면 consumer group의 모든 동작이 보인다.
Kafka는 partition 내 순서를 보장하는데(장 01), 같은 partition을 두 consumer가 동시에 읽으면 순서를 보장할 방법이 없다.
그래서 한 partition은 한 consumer만 단독 소유하도록 강제한다 — 이 제약 하나가 순서·중복 방지·병렬성을 동시에 만든다.
| 구성 | 무슨 일 |
|---|---|
| partition 4개 + consumer 2명 | 각 consumer가 2 partition씩 |
| partition 4개 + consumer 4명 | 각 consumer가 1 partition씩 (최대 병렬) |
| partition 4개 + consumer 6명 | 4개만 일하고 2개는 놀음(idle) |
표의 마지막 줄이 자주 놓치는 함정이다 — "consumer를 늘리면 빨라진다"는 직감과 달리, partition 수를 넘는 consumer는 idle 상태로 자원만 소모한다. consumer 수를 늘리기 전에 partition 수를 먼저 확인하는 습관이 필요하다(장 02).
왜 "1 partition = 1 consumer"인가 — 같은 partition을 두 consumer가 동시에 읽으면 순서가 꼬이고 offset commit이 충돌한다. 그래서 한 partition은 한 consumer만 단독 소유. 병렬성의 상한 = partition 수다(장 02).
이 제약이 단점이 아니라 설계의 핵심이다 — partition 내 순서 보장(장 01)을 지키면서 병렬로 읽으려면, "병렬의 단위 = partition"이어야 한다. 같은 partition을 여러 consumer가 읽게 허용하면 순서 보장이 깨진다. 그래서 "consumer 병렬성을 늘리려면 partition 수를 늘려야 한다"는 역방향 제약이 생긴다 — 이게 운영자가 "consumer가 느리다 → consumer를 늘리자"가 항상 통하지 않는 이유다(partition 수가 상한이므로).
다중 consumer group의 독립성 — 같은 topic을 여러 팀이 따로 읽는다
consumer group의 강력한 특성 하나 — 서로 다른 group은 같은 topic을 각자 독립적으로 전부 소비한다. 한 topic을 여러 group이 읽어도, 각 group은 자기 offset을 따로 관리하므로 서로 간섭하지 않는다.
flowchart TD
T[(orders topic<br/>partition 6)]
T --> G1["group: order-processors<br/>consumer A, B, C<br/>각자 partition 2개씩"]
T --> G2["group: analytics<br/>consumer D, E<br/>각자 partition 3개씩"]
T --> G3["group: notifications<br/>consumer F<br/>partition 6개 전부"]
이 구조가 만드는 실제 아키텍처 패턴:
order-processorsgroup: 주문을 처리(재고·결제).analyticsgroup: 같은 주문 이벤트를 분석(매출 집계).notificationsgroup: 같은 이벤트로 알림 발송.
세 group은 같은 orders topic을 전부 읽지만, 각자 자기 속도·자기 offset으로 독립 동작한다. 한 group이 느려져도 다른 group은 영향 안 받는다. 이게 Kafka를 "이벤트를 여러 목적으로 재사용하는 이벤트 소싱 플랫폼"으로 만드는 핵심이다 — consumer가 topic에서 데이터를 빼먹지 않으므로(로그 모델, 장 01), 여러 소비자가 같은 이벤트를 독립적으로 소비할 수 있다.
같은 group 내에서 partition을 나눠 갖는 것(1 partition = 1 consumer)과, 다른 group이 전부를 따로 읽는 것은 다른 층의 규칙이다.前者은 병렬성/순서, 후자는 재사용/분리에 관한 것이다.
orders topic을 consumer 3개로 확장할 때
orders topic(partition 6개)을 consumer 3개로 확장:
flowchart TB
T["orders topic<br/>(partition 0~5)"] --> A["consumer A<br/>(p0, p1)"]
T --> B["consumer B<br/>(p2, p3)"]
T --> C["consumer C<br/>(p4, p5)"]
subgraph GP["group: order-processors"]
A
B
C
end
- consumer 3개를 같은 group(
order-processors)으로 띄운다. - Kafka가 partition을 나눠 줌 — A: p0,p1 / B: p2,p3 / C: p4,p5.
- 각 consumer가 자기 partition만 읽어 처리 → 3배 병렬, 중복 없음.
consumer 3개가 6 partition을 어떻게 나눠 갖는지는 assignment strategy에 따라 다르다(아래 'partition assignment 전략' 절). 위 다이어그램은 균등 분배를 가정한 예시일 뿐, 실제 분배는 strategy와 현재 멤버십에 따라 결정된다.
다른 group은 독립적:
order-processorsgroup과analyticsgroup은 서로 간섭 없이 같은orderstopic을 전부 각자 읽는다. group마다 자기 offset을 따로 관리한다. 이 독립성이 같은 topic을 여러 목적(처리·분석·알림)으로 동시에 소비하는 이벤트 소싱 아키텍처의 기반이다(아래 '다중 consumer group의 독립성' 절 참조).
consumer group 설정 — 어떻게 구성하나
consumer 설정 (Java)
# group 식별자 (핵심 — 같은 group.id = 같은 팀)
group.id=order-processors
# broker 연결
bootstrap.servers=localhost:9092
# 역직렬화
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# auto-commit (신뢰성 필요시 false, 장 04)
enable.auto.commit=false
# partition 할당 전략
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor
# rebalance 관련 (차세대 프로토콜 사용시 일부 무시)
session.timeout.ms=45000
heartbeat.interval.ms=3000
max.poll.interval.ms=300000
partition 할당 전략 설정
partition.assignment.strategy로 assignment 알고리즘을 선택한다. 전략 비교는 아래 'partition assignment 전략' 절에서 다룬다.
차세대 rebalance 프로토콜(4.0 GA) 활성 시
partition.assignment.strategy는 사용 불가. 프로토콜이 자체 관리.
rebalance — consumer가 들락날락하면 partition을 다시 나눈다
consumer group의 구성원이 바뀌면 partition을 재분배한다(rebalance):
- consumer 합류(새 consumer 띄움) 또는 이탈(크래시·종료).
- 구독 topic/partition 변경.
- consumer가
max.poll.interval.ms초과로 퇴출(장 04).
rebalance의 비용 — stop-the-world
구형(eager) 프로토콜에선 rebalance 시작 시 모든 consumer가 partition을 내려놓고 전체를 다시 나눈다 — 끝날 때까지 아무도 소비 못함. consumer가 많을수록 멈춤이 길어진다. "consumer를 함부로 재시작하면 안 된다"는 이유.
stop-the-world가 왜 비싼지를 구체적으로 보자. partition을 100개 가진 topic에 consumer 20개가 붙어 있다고 하자. 한 consumer가 재시작하면 rebalance가 일어나고, 구형 프로토콜은 20개 consumer 전부가 "partition을 내려놓는다" → 새 할당을 계산한다 → "다시 받는다"는 3단계를 거친다. 이 동안 partition 100개에서 아무 메시지도 소비되지 않는다. 이 시간이 수 초에서 수십 초면, 처리량이 그만큼 떨어지고 LAG이 자란다(장 04). 빈번한 배포·오토스케일링이 있는 환경에선 rebalance 비용이 실측 가능한 가용성 손실로 나타난다.
partition assignment 전략 — 누가 어느 partition을 갖나
rebalance 시 partition을 consumer에게 어떻게 나눌지 결정하는 게 assignment strategy다. 전략마다 rebalance 비용과 분배 균형이 다르다.
| 전략 | 분배 방식 | rebalance 시 이동량 |
|---|---|---|
| RangeAssignor(오랜 기본) | topic별로 partition을 연속 범위로 나눔 | 큼(전체 재분배 경향) |
| RoundRobinAssignor | 모든 topic의 partition을 round-robin으로 순환 분배 | 중간 |
| StickyAssignor | 기존 할당을 최대한 유지, 변경분만 이동 | 작음 |
| CooperativeStickyAssignor(KIP-429, 권장) | sticky + 증분 교환(톱니바퀴式 rebalance) | 가장 작음 |
왜 전략이 중요한가 — rebalance마다 "partition이 다른 consumer로 옮겨가면" 그 partition은 일시적으로 소비가 멈추고, 새 consumer가 이어받을 때 offset부터 다시 시작해야 한다. 이동량이 적을수록 멈추는 partition이 적어진다. Sticky 계열이 이동량을 최소화하므로, 빈번한 rebalance 환경(오토스케일링, 잦은 배포)에선 CooperativeStickyAssignor가 사실상 표준이다.
cooperative rebalance — 변경분만 교체
Kafka 4.0에선 차세대 rebalance 프로토콜(KIP-848)이 GA됐다 (consumer-rebalance-protocol.md):
- 유지 가능한 partition은 그대로, 변경이 필요한 partition만 교체.
- 나머지 consumer는 계속 소비 → 멈춤 최소화.
- 활성화:
group.protocol=consumer.
flowchart LR
subgraph EAGER["구형 eager 프로토콜"]
E1["모든 consumer partition 내려놓음"] --> E2["전체 재할당 계산"] --> E3["모두 다시 받음"]
Note["stop-the-world:<br/>전 partition 소비 중단"]
end
subgraph COOP["차세대 cooperative (4.0 GA)"]
C1["변경 필요 partition만 식별"] --> C2["해당 partition만 교체"] --> C3["나머지는 계속 소비"]
Note2["incremental:<br/>대부분의 partition은 소비 유지"]
end
차세대 프로토콜 활성 시
heartbeat.interval.ms,session.timeout.ms,partition.assignment.strategy사용 불가. 프로토콜이 자체 관리하므로 이 설정들은 무시된다 — 마이그레이션 시 이 설정들이 사라지는 것을 놀라지 말 것.
왜 4.0에서 차세대 프로토콜이 GA가 됐나
구형 eager 프로토콜은 "일단 다 내려놓고 다시 나눈다"는 단순한 방식이라 안정적이지만, partition 수와 consumer 수가 커질수록 stop-the-world 비용이 선형으로 자란다. Kafka 클러스터 규모가 커지고(partition 수만 개, consumer 수백) 이 비용이 실측 가능한 가용성 손실로 드러나면서, "변경분만 교체"하는 cooperative 모델이 KIP-848로 설계됐고 4.0에서 GA됐다. 단, 하위 호환성을 위해 기본은 여전히 구형이고, group.protocol=consumer로 명시적 전환해야 활성화된다 — 기존 앱이 갑자기 rebalance 동작이 바뀌는 것을 막기 위한 보수적 전환 정책이다.
static membership — rebalance 비용을 더 줄이는 장치
consumer가 재시작할 때마다 rebalance가 일어나는 게 비싸면, static membership(KIP-345)으로 identity를 고정할 수 있다. group.instance.id를 consumer마다 고정값으로 주면, broker는 재시작한 consumer를 "새 멤버"가 아니라 "잠시 떠났다 돌아온 기존 멤버"로 인식한다.
# consumer 설정
group.id=order-processors
group.instance.id=order-processor-pod-1 # 고정 identity
session.timeout.ms=300000 # 길게(재시작 여유)
static membership의 이점:
- consumer 재시작 시 rebalance가 일어나지 않는다(session timeout 내에 돌아오면). partition 할당이 그대로 유지된다.
- 상태 저장 consumer(Streams 등)에 특히 유리 — rebalance 없이 재시작하면 state store를 다시 구축할 필요가 줄어든다.
단점은 identity 관리 부담 — 각 consumer 인스턴스마다 고유한 group.instance.id를 줘야 하므로, 오토스케일링 환경에선 id 할당 체계가 필요하다(pod 이름 등을 id로 쓰는 패턴). 또 session timeout이 지나면 결국 rebalance가 일어나므로, 재시작 시간이 길면 도움이 안 된다.
ConsumerRebalanceListener — 상태 정리 핵심 훅
partition이 할당/해제될 때 콜백을 받아 상태를 정리한다:
consumer.subscribe(Arrays.asList("orders"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
// partition을 잃기 전에 처리 중인 offset을 commit (유실 방지)
consumer.commitSync();
// state store 정리 등
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
// 새 partition 할당 시 초기화 (예: offset seek, 캐시 갱신)
}
});
이 훅이 rebalance 중 데이터 유실/중복을 막는 핵심이다. 멱등 처리와 함께 써야 at-least-once를 지킨다.
onPartitionsRevoked가 왜 유실을 막는가
rebalance가 일어나면 partition이 다른 consumer에게 넘어간다. 이때 내가 처리했지만 아직 commit하지 않은 offset이 있으면, 새 consumer는 commit된 offset부터 읽으므로 미처리 메시지가 중복 처리된다(중복 방향). 반대로 내가 처리 전에 commit했으면(흔치 않지만), 새 consumer가 그 이후부터 읽으므로 미처리 메시지가 유실된다(유실 방향).
onPartitionsRevoked의 역할은 "내가 떠나기 전에, 처리 완료한 것까지 offset을 확실히 commit"하는 것이다. 이 콜백 안에서 commitSync()를 호출하면, partition이 넘어가더라도 새 consumer가 올바른 offset부터 이어받는다. 이걸 빼먹으면 at-least-once 보장이 구멍 난다 — rebalance 순간에만 발생하는 미묘한 유실/중복이라 디버깅이 어렵다.
상태 저장 consumer(Streams 앱, 로컬 캐시를 쓰는 consumer)에선
onPartitionsRevoked에서 캐시 flush·state store 정리까지 해야 한다. 그렇지 않으면 partition이 넘어간 뒤 새 consumer가 옛 state로 시작해 불일치가 생긴다.
Kafka 4.3 실습 — 직접 확인하기
사전: KRaft 단일 노드,
grp-demotopic(--partitions 4), 메시지 다수 사전 produce.
group 상태와 할당 확인
# Kafka 4.3, KRaft 단일 노드
bin/kafka-consumer-groups.sh --describe --group g-state --state --bootstrap-server localhost:9092
COORDINATOR (ID) ASSIGNMENT-STRATEGY STATE #MEMBERS
localhost:9092 (0) range Stable 4
확인할 것: STATE(Stable/PreparingRebalance/CompletingRebalance), 멤버 수.
partition 할당 분포
bin/kafka-consumer-groups.sh --describe --group g-state --members --verbose --bootstrap-server localhost:9092
다중 consumer 병렬 실습
터미널 3개를 열어 같은 group으로 consumer를 각각 띄운다:
# 터미널 1
bin/kafka-console-consumer.sh --topic grp-demo --group demo-group --bootstrap-server localhost:9092
# 터미널 2 (같은 group)
bin/kafka-console-consumer.sh --topic grp-demo --group demo-group --bootstrap-server localhost:9092
# 터미널 3 (같은 group)
bin/kafka-console-consumer.sh --topic grp-demo --group demo-group --bootstrap-server localhost:9092
메시지를 produce하면 3개 consumer가 partition을 나눠 각자 다른 메시지를 받는다. 한 consumer를 종료(Ctrl+C)하면 → rebalance → 남은 consumer가 그 partition을 이어받는다.
rebalance 관찰 — 단계별 절차
rebalance가 어떻게 일어나는지 눈으로 확인하는 절차:
- 준비:
grp-demotopic을 partition 4로 만들고 메시지를 다수 produce. - 터미널 3개에 같은 group(
demo-group)으로 console-consumer를 띄운다. 각 consumer가 partition을 나눠 갖는지--describe --members --verbose로 확인. - 메시지 produce: 다른 터미널에서 console-producer로 메시지를 보내면 3개 consumer가 각자 다른 partition의 메시지만 받는다(중복 없음).
- consumer 종료: 터미널 1의 consumer를 Ctrl+C로 종료. 즉시
--describe로 상태를 보면STATE가PreparingRebalance를 거쳐Stable로 돌아오고, 종료된 consumer의 partition이 남은 2개에게 재분배된다. - consumer 추가: 같은 group으로 consumer를 하나 더 띄우면 다시 rebalance가 일어나 partition이 3-way로 재분배.
- 차세대 프로토콜 대비: 위 과정을
group.protocol=consumer를 준 consumer로 반복하면 rebalance 멈춤이 짧아지는지(메시지 소비 중단 시간) 비교할 수 있다(정량 측정은 어려움, 미검증).
이 절차가 "consumer 그룹 = 동적 partition 할당"을 직접 확인하는 가장 확실한 방법이다. consumer 추가/제거 시 partition 재분배와 STATE 전이를 한 번에 보는 것이 핵심.
차세대 rebalance 프로토콜 활성화
# consumer 설정에 추가
group.protocol=consumer
이 설정으로 4.0 차세대 프로토콜 활성 → rebalance 멈춤 최소화.
활성 전후로 --describe --state의 ASSIGNMENT-STRATEGY 값이 바뀌는지(또는 cooperative 모드 표시가 나타나는지) 확인하는 것도 좋은 검증이다. 단일 노드에선 rebalance 멈춤 차이가 미미하게 관찰되지만, 다중 consumer 환경에서 차이가 선명해진다.
흔히 묻는 것, 흔히 틀리는 것
| 오해 | 정정 |
|---|---|
| "consumer를 늘리면 무조건 빨라진다" | partition 수가 상한. 초과분은 idle |
| "다른 consumer group은 메시지를 나눠 갖는다" | 각 그룹이 독립적으로 전체 소비 |
| "rebalance는 순식간이라 무시해도 된다" | 구형에선 stop-the-world. 빈번 재시작은 처리량 저하 |
| "partition.assignment.strategy는 항상 설정해야" | 차세대 프로토콜 활성 시 사용 불가 |
| "consumer가 크래시 나면 그 partition의 메시지가 유실된다" | rebalance로 다른 consumer가 이어받음. 단 onPartitionsRevoked에서 commit 안 했으면 중복/유실 가능 |
| "같은 group에 consumer를 여러 개 띄우면 메시지가 복제돼 여러 consumer에게 간다" | 아니다. 한 partition은 한 consumer만 소유. 메시지는 group 내에서 정확히 한 consumer에게만 |
| "static membership이면 rebalance가 절대 안 일어난다" | session timeout 내 재시작할 때만. timeout 지나면 결국 rebalance |
| "consumer group 코디네이터가 죽으면 group 전체가 죽는다" | 다른 broker가 이어받음. 단, 그 동안 rebalance/commit이 지연될 수 있다 |
더 깊이
- static membership(KIP-345):
group.instance.id로 고정 identity. 재시작 시 rebalance 최소화 → 상태 저장 앱에 유리. 단, identity 관리 부담이 생긴다. - assignment 전략 선택: CooperativeStickyAssignor(권장)가 가장 적은 이동으로 rebalance. 단, 차세대 프로토콜(
group.protocol=consumer) 활성 시 assignment 전략 설정은 무의미(프로토콜이 자체 관리). - rebalance 리스너와 at-least-once:
onPartitionsRevoked에서 commit하지 않으면, partition 이관 시 미처리 메시지가 중복/유실.onPartitionsRevoked의 역할은 "내가 떠나기 전에 처리 중인 것을 마무리하고 offset을 영속"이다. - rebalance 비용 메트릭: consumer JMX에서 rebalance 횟수·소요 시간을 잡을 수 있다. 잦은 rebalance는 건강 신호 — 오토스케일링·배포가 너무 빈번한 것일 수 있다.
- consumer group 코디네이터: broker 중 한 대가 각 group의 코디네이터 역할을 한다(offset 저장·rebalance 진행). 코디네이터가 있는 broker가 장애 나면 다른 broker가 이어받지만, 그 동안 해당 group의 rebalance/commit이 지연된다.
요약 — 이 글의 결론
- consumer group = 같은 group 이름의 consumer 모임. partition을 겹치지 않게 나눠 읽음.
- 핵심 규칙: partition 1개 = consumer 1명. 병렬성 상한 = partition 수. 이 제약이 순서 보장(장 01)과 병렬 소비를 동시에 잡는 핵심.
- group 간 독립: 각 group이 전체 topic을 따로 소비(자기 offset 관리). 같은 이벤트를 여러 목적으로 재사용하는 이벤트 소싱 기반.
- 설정:
group.id가 팀 식별자.partition.assignment.strategy로 할당 알고리즘 선택(CooperativeStickyAssignor 권장). - rebalance: 멤버십 변화 시 재분배. 구형=stop-the-world(전 partition 소비 중단), 차세대(4.0 GA,
group.protocol=consumer)=변경분만 교체(증분). - assignment 전략: Range/RoundRobin/Sticky/CooperativeSticky. 이동량이 적을수록 rebalance 멈춤이 적음.
- static membership(KIP-345):
group.instance.id로 재시작 시 rebalance 최소화. - ConsumerRebalanceListener:
onPartitionsRevoked에서 commit+정리 → rebalance 중 유실/중복 방지.
생각해 볼 문제
- consumer group이 "partition을 겹치지 않게 나눠 읽는 팀"이라는 것이 왜 필요한가? 순서 보장과 어떻게 연결되나?
- partition 6개에 consumer 4개. 각 consumer는 몇 partition? 8개로 늘리면? 왜 partition 수가 상한인가?
- 주문 처리 group과 분석 group이 같은 topic을 읽을 때 서로 간섭이 없는 이유는? 이벤트 소싱 관점.
- rebalance가 일어나는 상황 세 가지. 구형에서 왜 "stop-the-world"가 문제인가? 비용이 어떻게 자라나나?
- 차세대 rebalance 프로토콜(4.0)을 활성화하는 설정은? 활성 시 어떤 설정들이 무력화되나?
- ConsumerRebalanceListener의
onPartitionsRevoked에서 해야 할 일은? 안 하면 어떤 일이? - CooperativeStickyAssignor가 RangeAssignor보다 나은 점은? 빈번한 rebalance 환경에서 왜 중요한가?
- static membership(
group.instance.id)이 재시작 비용을 줄이는 원리는? 단점은? - consumer group 코디네이터 broker가 장애 나면 해당 group은 어떻게 되는가?
참고
- KafkaConsumer.java (consumer group, rebalance) - 접근 2026-07-09 (subscribe, poll loop, ConsumerRebalanceListener)
- Consumer Rebalance Protocol (4.0 GA) - 접근 2026-07-09 (차세대 cooperative rebalance, group.protocol=consumer)
- Consumer configs - 접근 2026-07-09 (group.id, session.timeout, max.poll.interval, partition.assignment.strategy)
- KIP-429(cooperative rebalance), KIP-345(static membership), KIP-848(차세대 rebalance protocol)
'Data & Platform > Event Streaming' 카테고리의 다른 글
| Kafka - 07. log retention (0) | 2026.07.09 |
|---|---|
| Kafka - 06. replication (0) | 2026.07.09 |
| Kafka - 04. consumer (0) | 2026.07.09 |
| Kafka - 03. producer (0) | 2026.07.09 |
| Kafka - 02. topic and partition (0) | 2026.07.09 |