같은 group.id를 가진 컨슈머들의 묶음. 파티션을 서로 나눠 갖고 각자 자기 몫만 읽는다.
하나의 규칙에서 모든 게 나온다
한 파티션은 그룹 안에서 오직 한 컨슈머만 읽는다
이 규칙 하나가 Kafka의 확장 모델 전체를 결정한다.
토픽 (파티션 4개) 그룹 A 그룹 B
P0 ──────────────────→ 컨슈머 1 ──→ 컨슈머 X
P1 ──────────────────→ 컨슈머 2 ──→ 컨슈머 X
P2 ──────────────────→ 컨슈머 3 ──→ 컨슈머 Y
P3 ──────────────────→ 컨슈머 3 ──→ 컨슈머 Y
컨슈머 4 → 논다
① 그룹의 최대 병렬성 = 파티션 수. 컨슈머를 파티션보다 많이 띄우면 남는 컨슈머는 아무 파티션도 배정받지 못하고 논다. 그래서 처리량을 늘리려면 컨슈머가 아니라 파티션을 먼저 늘려야 한다.
② 그룹이 다르면 서로 독립이다. 위 그림에서 그룹 A와 B는 같은 데이터를 각자 처음부터 자기 속도로 읽는다. 이게 pub/sub이 되는 방식이다 — 브로커가 메시지를 복사해 뿌리는 게 아니라, 하나의 로그를 그룹마다 각자의 오프셋으로 읽을 뿐이다. 구독자가 10개 늘어도 저장 비용은 그대로다.
진행 위치는 어디에 있나
각 그룹이 "어디까지 읽었는지"는 __consumer_offsets 라는 내부 토픽에 저장된다. 컨슈머 로컬이 아니라 브로커에 있으므로, 컨슈머가 죽었다 다른 서버에서 살아나도 이어서 읽는다.
이 토픽은 로그 컴팩션이 걸려 있다 — 필요한 건 "이 그룹의 현재 위치"이지 그 이력이 아니기 때문이다.
멤버가 바뀌면 — 리밸런싱
컨슈머가 들어오거나 나가면 파티션을 다시 나눠 갖는 리밸런싱이 일어난다. 이때 잠깐 소비가 멈추고, 커밋 전에 처리한 메시지가 다른 컨슈머에게 다시 배정되어 중복 처리가 생길 수 있다. 그래서 컨슈머 멱등성이 필요하다.
실무 설정
// Spring Kafka: concurrency = 이 인스턴스가 띄울 컨슈머 스레드 수
@KafkaListener(topics = "orders", groupId = "order-consumer", concurrency = "3")
void onOrderPlaced(OrderPlaced event) { ... }
인스턴스를 여러 대 띄워도 groupId가 같으면 모든 인스턴스의 스레드가 하나의 그룹으로 파티션을 나눠 갖는다. 즉 전체 컨슈머 스레드 수가 파티션 수를 넘지 않도록 설계해야 낭비가 없다.
면접 함정
- ❌ "컨슈머를 늘리면 계속 빨라진다" → 파티션 수가 상한이다.
- ❌ "그룹이 여러 개면 메시지가 복사돼 저장된다" → 로그는 하나이고 오프셋만 그룹별로 관리된다.
리밸런싱을 줄이는 실무 설정
# 배포·재시작 때마다 리밸런싱이 도는 걸 줄인다 (정적 멤버십)
group.instance.id=order-consumer-1 # 인스턴스마다 고유하게 고정
session.timeout.ms=45000 # 이 시간 안에 돌아오면 재배정하지 않는다
# 한 번에 가져올 레코드 수 — 처리 시간이 길면 줄인다
max.poll.records=100
max.poll.interval.ms=300000 # 이 시간을 넘기면 죽은 걸로 보고 쫓아낸다
max.poll.interval.ms 초과가 흔한 사고 원인이다. 한 번에 가져온 레코드를 처리하는 데 이 시간을 넘기면 그룹에서 쫓겨나고, 리밸런싱이 돌고, 커밋 못 한 메시지가 재처리되고, 다시 느려져서 또 쫓겨나는 악순환이 된다. 처리가 느리면 max.poll.records부터 줄인다.
# 현재 배정 상태 확인
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group order-consumer