Kafka

Kafka Consumer Group 리밸런싱 완벽 가이드

백엔드 개발자 김승원 2026. 3. 27. 10:52

Kafka Consumer Group 리밸런싱이란?

Kafka에서 Consumer Group은 토픽의 파티션을 여러 Consumer가 나누어 처리하는 핵심 메커니즘입니다. 리밸런싱(Rebalancing)은 Consumer Group 내 파티션 소유권이 재분배되는 과정을 말합니다. 새로운 Consumer가 그룹에 참여하거나, 기존 Consumer가 이탈하거나, 토픽의 파티션 수가 변경될 때 트리거됩니다.

리밸런싱이 발생하면 모든 Consumer가 일시적으로 메시지 처리를 중단하고 파티션을 재할당받습니다. 이는 처리량 저하와 지연을 야기하므로, 프로덕션 환경에서 리밸런싱을 최소화하는 전략이 매우 중요합니다.

리밸런싱 트리거 조건

리밸런싱은 다음과 같은 상황에서 발생합니다.

  • Consumer 추가: 새로운 Consumer가 그룹에 조인(JoinGroup 요청)
  • Consumer 이탈: Consumer가 정상 종료(LeaveGroup 요청) 또는 비정상 종료(세션 타임아웃)
  • Heartbeat 실패: session.timeout.ms(기본 45초) 내에 Heartbeat를 보내지 못한 경우
  • Poll 간격 초과: max.poll.interval.ms(기본 5분) 내에 poll()을 호출하지 못한 경우
  • 파티션 수 변경: 토픽의 파티션이 추가된 경우
  • 구독 패턴 변경: 정규식 구독 시 매칭되는 토픽이 변경된 경우

리밸런싱 프로토콜 흐름

1. Consumer가 JoinGroup 요청을 Group Coordinator에게 전송
2. Coordinator가 모든 Consumer의 JoinGroup 요청을 수집
3. 첫 번째 Consumer(Leader)에게 그룹 멤버 정보 전달
4. Leader가 파티션 할당 전략에 따라 할당 결정
5. Leader가 SyncGroup 요청으로 할당 결과를 Coordinator에게 전송
6. Coordinator가 각 Consumer에게 할당된 파티션 정보를 SyncGroup 응답으로 전달
7. 각 Consumer가 할당된 파티션에서 메시지 소비 시작

파티션 할당 전략(Partition Assignment Strategy)

Kafka는 partition.assignment.strategy 설정을 통해 파티션 할당 전략을 지정할 수 있습니다. 각 전략의 특성을 이해하고 상황에 맞게 선택하는 것이 중요합니다.

1. RangeAssignor (기본값)

토픽별로 파티션을 사전순으로 정렬한 뒤, Consumer를 사전순으로 정렬하여 연속된 범위로 할당합니다.

// 예시: 토픽 T1(파티션 0,1,2), 토픽 T2(파티션 0,1,2), Consumer C0, C1
// T1: C0 -> [P0, P1], C1 -> [P2]
// T2: C0 -> [P0, P1], C1 -> [P2]
// 결과: C0이 항상 더 많은 파티션을 가져감 (불균형 발생 가능)

props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
    "org.apache.kafka.clients.consumer.RangeAssignor");

장점: 동일 키의 파티션이 같은 Consumer에게 할당되어 조인 처리에 유리합니다.
단점: 토픽이 많을수록 앞쪽 Consumer에 편중되는 불균형이 심해집니다.

2. RoundRobinAssignor

모든 토픽의 파티션을 하나의 리스트로 합치고, Consumer에게 라운드로빈 방식으로 순환 할당합니다.

// 예시: 토픽 T1(P0,P1,P2), T2(P0,P1,P2), Consumer C0, C1
// 전체 파티션: T1-P0, T1-P1, T1-P2, T2-P0, T2-P1, T2-P2
// C0 -> [T1-P0, T1-P2, T2-P1]
// C1 -> [T1-P1, T2-P0, T2-P2]
// 균등하게 분배됨

props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
    "org.apache.kafka.clients.consumer.RoundRobinAssignor");

장점: 균등 분배가 보장됩니다.
단점: 리밸런싱 시 기존 할당이 완전히 변경될 수 있어 상태 유지에 불리합니다.

3. StickyAssignor

두 가지 목표를 가집니다: (1) 최대한 균등하게 분배, (2) 리밸런싱 시 기존 할당을 최대한 유지(Sticky). 이를 통해 불필요한 파티션 이동을 최소화합니다.

// 초기 할당: C0 -> [T1-P0, T1-P1], C1 -> [T1-P2, T2-P0], C2 -> [T2-P1, T2-P2]
// C2 이탈 시 StickyAssignor:
//   C0 -> [T1-P0, T1-P1, T2-P1]  (기존 유지 + T2-P1 추가)
//   C1 -> [T1-P2, T2-P0, T2-P2]  (기존 유지 + T2-P2 추가)
// RoundRobin이었다면 전체 재할당 발생

props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
    "org.apache.kafka.clients.consumer.StickyAssignor");

장점: 리밸런싱 비용이 최소화됩니다. 로컬 캐시나 상태를 유지하는 Consumer에 적합합니다.
단점: 할당 계산이 다소 복잡합니다.

4. CooperativeStickyAssignor

Kafka 2.4부터 도입된 Incremental Cooperative Rebalancing을 지원하는 전략입니다. 기존 Eager 프로토콜과 달리, 리밸런싱 중에도 영향받지 않는 파티션은 계속 처리할 수 있습니다.

// Eager Rebalancing (기존 방식)
// 1. 모든 Consumer가 모든 파티션 revoke
// 2. 전체 재할당
// 3. 모든 Consumer가 새 파티션에서 소비 시작
// -> 전체 처리 중단(Stop-the-World)

// Cooperative Rebalancing (점진적 방식)
// 1. 이동이 필요한 파티션만 revoke
// 2. 나머지 파티션은 계속 처리
// 3. revoke된 파티션만 새 Consumer에게 할당
// -> 부분적 중단만 발생

props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
    "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");

전략 비교 표

전략 균등 분배 Sticky Cooperative 적합 상황
RangeAssignor 낮음 X X 토픽 간 키 기반 조인
RoundRobinAssignor 높음 X X 단순 균등 분배
StickyAssignor 높음 O X 상태 유지 Consumer
CooperativeStickyAssignor 높음 O O 무중단 리밸런싱 필요 시

Static Group Membership

Kafka 2.3부터 도입된 Static Group Membership은 Consumer에 고정된 group.instance.id를 부여하여 불필요한 리밸런싱을 방지합니다.

// Static Group Membership 설정
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, "consumer-host-1");
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "300000"); // 5분으로 늘림

Static Member는 다음과 같은 이점을 제공합니다.

  • 재시작 시 리밸런싱 방지: 동일한 group.instance.id로 재접속하면 이전 할당을 그대로 돌려받습니다.
  • 롤링 배포 시 안정성: 짧은 재시작 동안 세션 타임아웃 내에 복귀하면 리밸런싱이 발생하지 않습니다.
  • 일시적 네트워크 단절 대응: session.timeout.ms를 넉넉하게 설정하여 일시적 장애를 무시할 수 있습니다.

Spring Kafka에서 Static Group Membership 적용

@Configuration
public class KafkaConsumerConfig {

    @Value("${spring.kafka.consumer.group-instance-id:#{null}}")
    private String groupInstanceId;

    @Bean
    public ConsumerFactory<String, String> consumerFactory() {
        Map<String, Object> props = new HashMap<>();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-service");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        
        // Static Group Membership
        if (groupInstanceId != null) {
            props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG, groupInstanceId);
        }
        
        // CooperativeSticky 전략 사용
        props.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
            CooperativeStickyAssignor.class.getName());
        
        // 세션 타임아웃 늘림 (롤링 배포 대비)
        props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, "300000");
        
        return new DefaultKafkaConsumerFactory<>(props);
    }
}

리밸런싱 최소화 전략

프로덕션 환경에서 리밸런싱을 최소화하기 위한 종합적인 전략을 소개합니다.

1. 적절한 타임아웃 설정

# session.timeout.ms: Heartbeat 기반 장애 감지 (기본 45초)
# heartbeat.interval.ms: Heartbeat 전송 주기 (기본 3초, session.timeout의 1/3 권장)
# max.poll.interval.ms: poll() 호출 간격 (기본 5분)

session.timeout.ms=45000
heartbeat.interval.ms=15000
max.poll.interval.ms=600000

2. 메시지 처리 시간 관리

@KafkaListener(topics = "orders", groupId = "order-processor")
public void processOrder(ConsumerRecord<String, String> record) {
    // max.poll.records를 줄여 한 번에 처리하는 레코드 수 제한
    // 각 poll() 호출 간 처리 시간이 max.poll.interval.ms를 초과하지 않도록 관리
    try {
        orderService.process(record.value());
    } catch (Exception e) {
        // 실패 시 DLT(Dead Letter Topic)로 전송하여 빠르게 처리 완료
        kafkaTemplate.send("orders.DLT", record.key(), record.value());
        log.error("주문 처리 실패, DLT 전송: {}", record.key(), e);
    }
}

// application.yml
// spring.kafka.consumer.max-poll-records: 100  (기본 500에서 줄임)

3. ConsumerRebalanceListener 활용

public class OrderRebalanceListener implements ConsumerRebalanceListener {

    private final OffsetManager offsetManager;

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // 리밸런싱 전: 현재 처리 중인 오프셋 커밋
        log.info("파티션 revoke 시작: {}", partitions);
        offsetManager.commitCurrentOffsets();
        // 로컬 캐시나 상태 정리
        partitions.forEach(tp -> localCache.remove(tp));
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        // 리밸런싱 후: 새로 할당된 파티션 초기화
        log.info("파티션 assign 완료: {}", partitions);
        partitions.forEach(tp -> localCache.initialize(tp));
    }
}

4. 종합 권장 설정

설정 권장값 설명
partition.assignment.strategy CooperativeStickyAssignor 점진적 리밸런싱
group.instance.id 호스트별 고유값 Static Membership
session.timeout.ms 45000~300000 장애 감지 시간
heartbeat.interval.ms session.timeout의 1/3 Heartbeat 주기
max.poll.interval.ms 처리 시간 * 2 이상 poll 간격 상한
max.poll.records 100~500 한 번에 가져올 레코드 수

마무리

Kafka Consumer Group 리밸런싱은 분산 메시지 처리의 핵심이지만, 빈번한 리밸런싱은 처리량 저하와 중복 처리의 원인이 됩니다. CooperativeStickyAssignorStatic Group Membership을 조합하고, 적절한 타임아웃 설정과 메시지 처리 시간 관리를 통해 안정적인 Consumer 운영이 가능합니다. 특히 Kubernetes 환경에서 롤링 배포 시 Static Membership은 필수적으로 고려해야 할 설정입니다.