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-processors group: 주문을 처리(재고·결제).
  • analytics group: 같은 주문 이벤트를 분석(매출 집계).
  • notifications group: 같은 이벤트로 알림 발송.

세 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
  1. consumer 3개를 같은 group(order-processors)으로 띄운다.
  2. Kafka가 partition을 나눠 줌 — A: p0,p1 / B: p2,p3 / C: p4,p5.
  3. 각 consumer가 자기 partition만 읽어 처리 → 3배 병렬, 중복 없음.

consumer 3개가 6 partition을 어떻게 나눠 갖는지는 assignment strategy에 따라 다르다(아래 'partition assignment 전략' 절). 위 다이어그램은 균등 분배를 가정한 예시일 뿐, 실제 분배는 strategy와 현재 멤버십에 따라 결정된다.

다른 group은 독립적: order-processors group과 analytics group은 서로 간섭 없이 같은 orders topic을 전부 각자 읽는다. 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-demo topic(--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가 어떻게 일어나는지 눈으로 확인하는 절차:

  1. 준비: grp-demo topic을 partition 4로 만들고 메시지를 다수 produce.
  2. 터미널 3개에 같은 group(demo-group)으로 console-consumer를 띄운다. 각 consumer가 partition을 나눠 갖는지 --describe --members --verbose로 확인.
  3. 메시지 produce: 다른 터미널에서 console-producer로 메시지를 보내면 3개 consumer가 각자 다른 partition의 메시지만 받는다(중복 없음).
  4. consumer 종료: 터미널 1의 consumer를 Ctrl+C로 종료. 즉시 --describe로 상태를 보면 STATEPreparingRebalance를 거쳐 Stable로 돌아오고, 종료된 consumer의 partition이 남은 2개에게 재분배된다.
  5. consumer 추가: 같은 group으로 consumer를 하나 더 띄우면 다시 rebalance가 일어나 partition이 3-way로 재분배.
  6. 차세대 프로토콜 대비: 위 과정을 group.protocol=consumer를 준 consumer로 반복하면 rebalance 멈춤이 짧아지는지(메시지 소비 중단 시간) 비교할 수 있다(정량 측정은 어려움, 미검증).

이 절차가 "consumer 그룹 = 동적 partition 할당"을 직접 확인하는 가장 확실한 방법이다. consumer 추가/제거 시 partition 재분배와 STATE 전이를 한 번에 보는 것이 핵심.

차세대 rebalance 프로토콜 활성화

# consumer 설정에 추가
group.protocol=consumer

이 설정으로 4.0 차세대 프로토콜 활성 → rebalance 멈춤 최소화.
활성 전후로 --describe --stateASSIGNMENT-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 중 유실/중복 방지.

생각해 볼 문제

  1. consumer group이 "partition을 겹치지 않게 나눠 읽는 팀"이라는 것이 왜 필요한가? 순서 보장과 어떻게 연결되나?
  2. partition 6개에 consumer 4개. 각 consumer는 몇 partition? 8개로 늘리면? 왜 partition 수가 상한인가?
  3. 주문 처리 group과 분석 group이 같은 topic을 읽을 때 서로 간섭이 없는 이유는? 이벤트 소싱 관점.
  4. rebalance가 일어나는 상황 세 가지. 구형에서 왜 "stop-the-world"가 문제인가? 비용이 어떻게 자라나나?
  5. 차세대 rebalance 프로토콜(4.0)을 활성화하는 설정은? 활성 시 어떤 설정들이 무력화되나?
  6. ConsumerRebalanceListener의 onPartitionsRevoked에서 해야 할 일은? 안 하면 어떤 일이?
  7. CooperativeStickyAssignor가 RangeAssignor보다 나은 점은? 빈번한 rebalance 환경에서 왜 중요한가?
  8. static membership(group.instance.id)이 재시작 비용을 줄이는 원리는? 단점은?
  9. consumer group 코디네이터 broker가 장애 나면 해당 group은 어떻게 되는가?

참고

'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