Kafka 컨슈머 그룹과 파티션 전략을 잘못 설계하면, 아무리 강력한 서버를 써도 Kafka의 진짜 성능을 절반도 끌어내지 못합니다. “메시지가 쌓이는데 소비가 따라가지 못한다”, “컨슈머를 늘렸는데 처리량이 그대로다”라는 상황이 바로 파티션 설계 실패의 전형적인 증상입니다. Kafka의 병렬 처리 핵심 단위는 파티션이며, 파티션과 컨슈머 그룹의 관계를 정확히 이해하고 설계해야 비로소 처리량을 선형적으로 끌어올릴 수 있습니다. 이 글에서는 기초 개념부터 실전 파티션 수 결정 공식, 리밸런싱 최소화 전략, 모니터링 방법까지 체계적으로 정리합니다.
목차
- 컨슈머 그룹의 기초 – 왜 병렬 처리의 핵심인가
- 파티션과 컨슈머의 관계 – 1:1 원칙과 그 이유
- 파티션 수 결정 공식 – 처리량을 2배 높이는 설계 기준
- 리밸런싱의 위험과 최소화 전략
- 실전 파티션 키 설계와 컨슈머 그룹 구성
- 컨슈머 랙 모니터링과 성능 튜닝 도구
1. 컨슈머 그룹의 기초 – 왜 병렬 처리의 핵심인가
컨슈머 그룹이란 무엇인가
Kafka에서 **컨슈머 그룹(Consumer Group)**은 동일한 group.id를 공유하는 컨슈머 인스턴스들의 집합입니다. 하나의 토픽을 여러 컨슈머가 팀을 이뤄 나눠서 처리하는 구조라고 생각하면 됩니다. 카페에 비유하면, 주문서(토픽)를 혼자 처리하는 바리스타 한 명보다, 주문서를 나눠 가진 바리스타 세 명이 동시에 음료를 만드는 것이 훨씬 빠른 것과 같습니다.
컨슈머 그룹의 핵심 특성은 두 가지입니다.
① 그룹 내 파티션 독점 소비 하나의 파티션은 동일 컨슈머 그룹 내에서 반드시 한 컨슈머만 담당합니다. 두 컨슈머가 같은 파티션을 동시에 읽는 일은 없으므로, 같은 메시지를 중복 처리하는 문제가 발생하지 않습니다.
② 그룹 간 독립 소비 서로 다른 컨슈머 그룹은 완전히 독립적으로 같은 토픽을 소비합니다. 예를 들어 order-events 토픽을 billing-group(결제 서비스)과 notification-group(알림 서비스)이 각자의 오프셋으로 독립적으로 읽을 수 있습니다. 한 그룹의 처리 속도가 다른 그룹에 전혀 영향을 주지 않습니다.
[order-events 토픽]
Partition 0 ──┬──→ billing-group : Consumer A
└──→ notification-group: Consumer X
Partition 1 ──┬──→ billing-group : Consumer B
└──→ notification-group: Consumer Y
Partition 2 ──┬──→ billing-group : Consumer C
└──→ notification-group: Consumer Z
컨슈머 그룹 오프셋 관리
각 컨슈머 그룹은 자신이 **어디까지 메시지를 읽었는지(오프셋)**를 Kafka의 내부 토픽인 __consumer_offsets에 저장합니다. 컨슈머가 재시작되어도 마지막 오프셋부터 이어서 읽을 수 있어 메시지 유실이 발생하지 않습니다.
__consumer_offsets (Kafka 내부 토픽)
┌─────────────────┬───────────┬─────────┬────────┐
│ Group ID │ Topic │ Partition│ Offset │
├─────────────────┼───────────┼─────────┼────────┤
│ billing-group │ order-ev. │ 0 │ 1024 │
│ billing-group │ order-ev. │ 1 │ 987 │
│ notify-group │ order-ev. │ 0 │ 512 │
└─────────────────┴───────────┴─────────┴────────┘
2. 파티션과 컨슈머의 관계 – 1:1 원칙과 그 이유
파티션이 Kafka 병렬 처리의 최소 단위인 이유
Kafka의 병렬 처리 단위는 파티션입니다. 컨슈머가 아무리 많아도, 파티션 수보다 많은 컨슈머는 유휴(idle) 상태가 됩니다. 이것이 Kafka 컨슈머 그룹 설계의 가장 중요한 원칙입니다.
파티션 3개, 컨슈머 3개 → 이상적인 1:1 배분 (최대 병렬 처리)
P0 ──→ Consumer 1 ✅ 처리 중
P1 ──→ Consumer 2 ✅ 처리 중
P2 ──→ Consumer 3 ✅ 처리 중
파티션 3개, 컨슈머 5개 → 2개 컨슈머 유휴 (자원 낭비)
P0 ──→ Consumer 1 ✅ 처리 중
P1 ──→ Consumer 2 ✅ 처리 중
P2 ──→ Consumer 3 ✅ 처리 중
Consumer 4 💤 유휴 (파티션 없음)
Consumer 5 💤 유휴 (파티션 없음)
파티션 3개, 컨슈머 1개 → 순차 처리 (병렬 처리 불가)
P0 ┐
P1 ├──→ Consumer 1 ⚠️ 혼자 3개 파티션 처리
P2 ┘
파티션 수와 처리량의 선형 관계
파티션 수와 컨슈머 수를 함께 늘리면 처리량이 선형적으로 증가합니다. 단일 파티션 처리량을 T라고 하면:
| 파티션 수 | 컨슈머 수 | 이론적 처리량 |
|---|---|---|
| 1 | 1 | T |
| 2 | 2 | 2T |
| 4 | 4 | 4T |
| 8 | 8 | 8T |
물론 네트워크 대역폭, 브로커 성능, 컨슈머 로직 복잡도에 따라 실제 처리량은 이론치보다 낮을 수 있습니다. 그러나 파티션·컨슈머 설계가 올바를 때 처리량을 2배 높이는 것은 파티션과 컨슈머를 2배로 늘리는 것만으로 달성 가능합니다.
한 컨슈머가 여러 파티션을 담당하는 경우
컨슈머 수가 파티션 수보다 적으면 일부 컨슈머가 여러 파티션을 담당합니다. 이 경우 파티션 간 순서 보장은 각 파티션 내부에서만 유효합니다. 서로 다른 파티션의 메시지 간 순서는 보장되지 않으므로, 전체 순서가 중요한 작업에서는 파티션 수를 1로 제한하거나 파티션 키를 신중하게 설계해야 합니다.
3. 파티션 수 결정 공식 – 처리량을 2배 높이는 설계 기준
파티션 수 결정 공식
파티션 수를 너무 적게 설정하면 처리량 병목이 생기고, 너무 많이 설정하면 브로커 메모리와 파일 핸들 소비가 과도해집니다. 실전에서 자주 사용하는 파티션 수 결정 공식은 다음과 같습니다.
필요 파티션 수 = max(목표 처리량 / 단일 파티션 처리량, 목표 처리량 / 단일 컨슈머 처리량)
예시:
- 목표 처리량: 100,000 msg/s
- 단일 파티션 쓰기 처리량(Producer 기준): 50,000 msg/s
- 단일 컨슈머 처리량: 25,000 msg/s
Producer 기준: 100,000 / 50,000 = 2개
Consumer 기준: 100,000 / 25,000 = 4개
→ max(2, 4) = 파티션 4개 필요
파티션 수 설계 실전 원칙
① 처음부터 넉넉하게 설정하라
Kafka는 파티션 수를 늘리는 것은 쉽지만 줄이는 것은 불가능합니다. 파티션을 줄이려면 토픽을 삭제하고 재생성해야 하므로, 초기 설계 시 예상 트래픽의 2~3배 여유분을 포함해 설정하는 것이 좋습니다.
② 브로커 수의 배수로 설정하라
파티션이 브로커들에 고르게 분산되려면 파티션 수가 브로커 수의 배수인 것이 이상적입니다.
브로커 3대, 파티션 6개:
Broker 1: Partition 0, 3
Broker 2: Partition 1, 4
Broker 3: Partition 2, 5
→ 각 브로커가 2개씩 균등 분산 ✅
브로커 3대, 파티션 5개:
Broker 1: Partition 0, 3
Broker 2: Partition 1, 4
Broker 3: Partition 2 ← 한 브로커만 1개 담당
→ 불균형 발생 ⚠️
③ 파티션 수 상한선 가이드
Confluent 공식 가이드 기준으로, 브로커당 파티션 수는 다음 범위를 권장합니다.
| 브로커 메모리 | 권장 파티션 수 (브로커당) |
|---|---|
| 8GB 이하 | 최대 2,000개 |
| 16GB | 최대 4,000개 |
| 32GB 이상 | 최대 8,000개 |
파티션 수가 과도하면 리더 선출 오버헤드와 파일 디스크립터 고갈 문제가 발생할 수 있으므로 주의가 필요합니다.
Java 코드로 파티션 수 동적 조회 및 생성
java
// KafkaPartitionManager.java
@Component
public class KafkaPartitionManager {
private final AdminClient adminClient;
public KafkaPartitionManager(AdminClient adminClient) {
this.adminClient = adminClient;
}
/**
* 현재 토픽의 파티션 수 조회
*/
public int getPartitionCount(String topicName) throws Exception {
DescribeTopicsResult result = adminClient.describeTopics(
Collections.singletonList(topicName));
TopicDescription desc = result.topicNameValues()
.get(topicName).get();
return desc.partitions().size();
}
/**
* 파티션 수 증가 (기존 파티션 수 → 목표 파티션 수)
* ⚠️ 파티션 추가 후 기존 메시지의 파티션 배분은 변경되지 않음
*/
public void increasePartitions(String topicName, int newPartitionCount) {
Map<String, NewPartitions> newPartitions = Collections.singletonMap(
topicName,
NewPartitions.increaseTo(newPartitionCount)
);
adminClient.createPartitions(newPartitions);
System.out.printf("토픽 '%s' 파티션 수를 %d개로 증가%n",
topicName, newPartitionCount);
}
}
4. 리밸런싱의 위험과 최소화 전략
리밸런싱이란 무엇이며 왜 위험한가
**리밸런싱(Rebalancing)**은 컨슈머 그룹 내 컨슈머 수가 변경될 때(추가·제거·장애) 파티션을 재배분하는 과정입니다. 리밸런싱이 일어나는 동안 그룹 내 모든 컨슈머가 일시적으로 메시지 처리를 중단합니다. 이 시간을 Stop-The-World 구간이라고 부르며, 트래픽이 많은 서비스에서는 수초~수십 초의 처리 공백이 발생해 컨슈머 랙이 급격히 쌓이거나 타임아웃이 발생할 수 있습니다.
리밸런싱 발생 트리거:
① 컨슈머 인스턴스 추가 (스케일 아웃)
② 컨슈머 인스턴스 제거 (스케일 인, 배포)
③ 컨슈머 장애 (session.timeout.ms 초과)
④ 컨슈머 처리 지연 (max.poll.interval.ms 초과)
⑤ 파티션 수 변경
리밸런싱 중 타임라인:
t=0 : 컨슈머 C 장애 감지
t=3s : 브로커가 그룹 리더(컨슈머 A)에게 리밸런싱 지시
t=3s : 그룹 내 모든 컨슈머 처리 중단 ← Stop-The-World 시작
t=8s : 새 파티션 할당 완료
t=8s : 모든 컨슈머 처리 재개 ← 약 5초간 공백 발생
리밸런싱을 최소화하는 4가지 전략
① 협력적 리밸런싱(Cooperative Rebalancing) 사용
Kafka 2.4+부터 도입된 CooperativeStickyAssignor는 기존 Stop-The-World 방식 대신, 재배분이 필요한 파티션만 이동시키는 점진적 방식으로 동작합니다. 대부분의 컨슈머가 처리를 계속하면서 일부 파티션만 이관되므로 처리 공백이 최소화됩니다.
yaml
# application.yml
spring:
kafka:
consumer:
properties:
# 협력적 리밸런싱 활성화 (Kafka 2.4+ 권장)
partition.assignment.strategy: >
org.apache.kafka.clients.consumer.CooperativeStickyAssignor
② 정적 그룹 멤버십(Static Group Membership) 활용
컨슈머에 고정 ID(group.instance.id)를 부여하면, 해당 컨슈머가 재시작되더라도 Kafka가 동일한 인스턴스로 인식하여 리밸런싱을 생략합니다. 배포나 일시적 재시작이 잦은 환경에서 매우 효과적입니다.
yaml
# application.yml
spring:
kafka:
consumer:
properties:
# 각 컨슈머 인스턴스에 고유 ID 부여 (Pod 이름 등 활용)
group.instance.id: order-consumer-pod-1
# 세션 타임아웃을 길게 설정해 짧은 재시작에서 리밸런싱 방지
session.timeout.ms: 60000
③ max.poll.interval.ms 적절히 설정
컨슈머가 poll()을 호출하는 간격이 max.poll.interval.ms를 초과하면 Kafka는 해당 컨슈머가 죽었다고 판단하고 리밸런싱을 트리거합니다. 처리 시간이 긴 작업이 있다면 이 값을 충분히 늘려야 합니다.
yaml
spring:
kafka:
consumer:
properties:
# 기본값 300,000ms(5분). 무거운 처리 작업이 있다면 늘릴 것
max.poll.interval.ms: 600000 # 10분
# 한 번 poll 시 가져오는 최대 레코드 수 줄이기 → 처리 시간 단축
max.poll.records: 50
④ 컨슈머 헬스체크와 Graceful Shutdown
java
// GracefulShutdownConsumer.java
@Component
public class GracefulShutdownConsumer {
private volatile boolean running = true;
@KafkaListener(topics = "order-events", groupId = "order-group")
public void consume(ConsumerRecord<String, OrderEvent> record,
Acknowledgment ack) {
if (!running) return;
try {
processRecord(record.value());
// 수동 커밋: 처리 완료 후 오프셋 커밋 (메시지 유실 방지)
ack.acknowledge();
} catch (Exception e) {
// 처리 실패 시 커밋하지 않음 → 재시도 보장
log.error("메시지 처리 실패: {}", record.key(), e);
}
}
// 애플리케이션 종료 시 안전하게 컨슈머 중단
@PreDestroy
public void shutdown() {
this.running = false;
log.info("컨슈머 Graceful Shutdown 시작");
}
private void processRecord(OrderEvent event) {
// 실제 비즈니스 로직
}
}
5. 실전 파티션 키 설계와 컨슈머 그룹 구성
파티션 키 설계 원칙
파티션 키는 메시지를 어느 파티션으로 보낼지 결정하는 값입니다. 키가 없으면 라운드로빈 방식으로 파티션에 균등 분산되고, 키가 있으면 동일 키는 항상 동일 파티션으로 전달됩니다. 키 설계는 순서 보장과 부하 분산이라는 두 목표 사이의 균형을 맞추는 작업입니다.
[좋은 파티션 키 설계 예시]
케이스 1 – 주문 시스템: orderId를 키로 사용
→ 같은 주문의 이벤트(생성→결제→배송)가 항상 같은 파티션 → 순서 보장 ✅
케이스 2 – 사용자 활동 로그: userId를 키로 사용
→ 같은 사용자의 행동이 순서대로 처리 → 세션 분석 가능 ✅
케이스 3 – 센서 데이터: deviceId를 키로 사용
→ 같은 장치의 시계열 데이터가 순서 유지 → 이상 탐지 정확도 향상 ✅
[나쁜 파티션 키 설계 예시]
케이스 4 – 상태값(status)을 키로 사용: "CREATED", "PAID", "SHIPPED"
→ 값의 종류가 3개뿐 → 파티션이 10개라도 3개만 사용 → Hot Partition ⚠️
케이스 5 – 날짜(date)를 키로 사용: "2024-01-01"
→ 같은 날짜 트래픽이 특정 파티션에 집중 → 불균형 ⚠️
Hot Partition 문제와 해결법
특정 파티션에 트래픽이 집중되는 Hot Partition 문제는 Kafka 성능 저하의 주요 원인 중 하나입니다.
java
// HotPartitionResolver.java
// 해결책: 키에 랜덤 접미사를 붙여 파티션 분산 (순서 포기 시)
public String generateDistributedKey(String baseKey, int numPartitions) {
// "userId_123" → "userId_123-2" (0~파티션수 사이 랜덤 접미사)
int suffix = (int) (Math.random() * numPartitions);
return baseKey + "-" + suffix;
}
// 해결책 2: 커스텀 파티셔너로 균등 분산
@Component
public class UniformPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster) {
int numPartitions = cluster.partitionCountForTopic(topic);
if (keyBytes == null) {
// 키가 없으면 라운드로빈 (균등 분산)
return (int) (System.currentTimeMillis() % numPartitions);
}
// 키 해시값으로 파티션 결정 (기본 동작)
return Math.abs(Utils.murmur2(keyBytes)) % numPartitions;
}
@Override public void close() {}
@Override public void configure(Map<String, ?> configs) {}
}
멀티 컨슈머 그룹 설계 패턴
하나의 토픽을 목적이 다른 여러 서비스가 소비할 때는 그룹 ID를 분리하여 독립적인 처리 파이프라인을 구성합니다.
java
// 동일 토픽을 3개의 컨슈머 그룹이 독립 소비
// ① 결제 서비스 컨슈머
@KafkaListener(topics = "order-events",
groupId = "billing-group",
concurrency = "3") // 3개 스레드 → 파티션 3개와 매칭
public void billingConsumer(OrderEvent event) {
billingService.processPayment(event);
}
// ② 알림 서비스 컨슈머
@KafkaListener(topics = "order-events",
groupId = "notification-group",
concurrency = "3")
public void notificationConsumer(OrderEvent event) {
notificationService.sendAlert(event);
}
// ③ 분석 서비스 컨슈머 (배치 처리로 성능 최적화)
@KafkaListener(topics = "order-events",
groupId = "analytics-group",
concurrency = "3",
containerFactory = "batchListenerFactory")
public void analyticsConsumer(List<OrderEvent> events) {
analyticsService.processBatch(events);
}
💡
concurrency설정:@KafkaListener의concurrency값은 해당 컨슈머 빈이 생성하는 스레드(컨슈머 인스턴스) 수입니다. 파티션 수와 일치시키거나 그 약수로 설정할 때 가장 효율적입니다.
6. 컨슈머 랙 모니터링과 성능 튜닝 도구
컨슈머 랙(Consumer Lag)이란
컨슈머 랙은 프로듀서가 마지막으로 쓴 오프셋과 컨슈머가 마지막으로 처리한 오프셋의 차이입니다. 쉽게 말해 “처리되지 않고 쌓인 메시지 수”입니다. 랙이 지속적으로 증가한다면 컨슈머의 처리 속도가 프로듀서의 생산 속도를 따라가지 못한다는 신호입니다.
Consumer Lag = Log End Offset - Current Offset
예시:
Partition 0: Log End Offset = 10,000 / Current Offset = 9,500 → Lag = 500
Partition 1: Log End Offset = 10,000 / Current Offset = 8,000 → Lag = 2,000 ⚠️
Partition 2: Log End Offset = 10,000 / Current Offset = 9,900 → Lag = 100
→ Partition 1에 병목 발생, 해당 파티션 컨슈머 로직 점검 필요
CLI로 컨슈머 랙 확인
bash
# 컨슈머 그룹 전체 랙 조회
kafka-consumer-groups.sh \
--bootstrap-server localhost:9092 \
--group billing-group \
--describe
# 출력 예시:
# GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
# billing-group order-events 0 9500 10000 500
# billing-group order-events 1 8000 10000 2000 ← 위험
# billing-group order-events 2 9900 10000 100
Prometheus + Grafana로 랙 시각화
yaml
# docker-compose.yml에 추가
kafka-exporter:
image: danielqsj/kafka-exporter:latest
command:
- "--kafka.server=kafka:9092"
ports:
- "9308:9308"
depends_on:
- kafka
Grafana에서 kafka_consumergroup_lag 메트릭을 대시보드에 추가하면 파티션별·그룹별 랙을 실시간으로 시각화할 수 있습니다. 랙이 임계치(예: 10,000건)를 초과하면 알림을 보내도록 Alerting 규칙을 설정하는 것을 권장합니다.
처리량 병목 시 빠른 해결 체크리스트
컨슈머 랙이 지속 증가할 때 점검 순서:
Step 1. 파티션 수 확인
→ 컨슈머 수 > 파티션 수? → 파티션 증가 검토
Step 2. 컨슈머 로직 처리 시간 측정
→ 단일 메시지 처리에 수백 ms 이상? → DB 쿼리 최적화, 캐시 도입
Step 3. max.poll.records 조정
→ 기본 500개가 너무 많다면 줄여 처리 시간 단축
→ 배치 처리가 효율적이라면 늘려 처리량 향상
Step 4. concurrency 설정 확인
→ @KafkaListener concurrency = 파티션 수로 설정했는지 확인
Step 5. 외부 시스템 I/O 병목 확인
→ DB, 외부 API 호출이 병목? → 비동기 처리, 커넥션 풀 확장
결론
Kafka 컨슈머 그룹과 파티션 전략의 핵심은 세 가지로 요약됩니다. 첫째, 파티션 수와 컨슈머 수를 1:1로 맞추는 것이 최대 병렬 처리의 전제 조건이며, 파티션은 처음부터 넉넉하게 설정해야 합니다. 둘째, 리밸런싱은 처리 공백을 유발하는 최대 적이므로 CooperativeStickyAssignor와 정적 그룹 멤버십을 적극 활용해 리밸런싱을 최소화해야 합니다. 셋째, 컨슈머 랙을 지속적으로 모니터링하고 병목 파티션을 빠르게 식별하는 것이 안정적인 파이프라인 운영의 핵심입니다. 오늘 배운 파티션 수 결정 공식과 설계 원칙을 적용해 처리량을 단계적으로 끌어올려 보세요.
⚠️ 면책 고지: 본 글은 기술 학습 및 참고 목적으로 작성된 가이드입니다. 파티션 수 결정 공식과 설정값은 워크로드 특성에 따라 크게 달라질 수 있으며, 제시된 수치는 일반적인 가이드라인입니다. 실제 운영 환경 적용 전 반드시 부하 테스트를 통해 최적값을 검증하시기 바랍니다. 설정 변경으로 인한 장애·데이터 손실에 대해 본 블로그는 책임을 지지 않습니다.
답글 남기기