본문 바로가기

소프트웨어-이야기/인프라

카프카 컨슈머가 배포될 때 발생할 수 있는 문제 — 리밸런싱과 CooperativeStickyAssignor

Kafka Consumer 애플리케이션을 배포할 때, 단순히 새 버전을 올리는 것만으로도 이벤트 처리가 수 초에서 수십 초간 중단될 수 있다. 배포 중인 인스턴스뿐 아니라 나머지 컨슈머까지 영향을 받아 전체 파티션의 처리가 멈추는 것이 문제다. 원인은 리밸런싱(Rebalancing)이다. 이 글에서는 배포 시 발생하는 순단의 원인을 분석하고, 이를 최소화하는 실질적인 방법을 정리한다.

배포 시 발생할 수 있는 문제들

Kafka Consumer를 배포하면 다양한 문제가 발생할 수 있다. 대표적으로는 다음과 같다.

  • 리밸런싱으로 인한 이벤트 처리 지연: 컨슈머가 내려갔다 올라오면서 파티션 소유권이 재분배되고, 그동안 메시지 처리가 중단된다.
  • 메시지 중복 처리: 종료 시점에 커밋되지 않은 오프셋 이후의 메시지가 재처리된다.
  • 메시지 유실: at-most-once 패턴에서 오프셋을 먼저 커밋하고 처리하는 경우 발생한다.
  • 컨슈머 랙 급증: 처리 공백 동안 프로듀서는 계속 메시지를 보내므로 밀린 메시지가 쌓인다.
  • 역직렬화 실패: 새 버전에서 메시지 스키마가 변경되었는데, 이전 포맷의 메시지가 토픽에 남아있는 경우 발생한다.

리밸런싱이란?

리밸런싱은 컨슈머 그룹 내에서 파티션 소유권을 재분배하는 과정이다. 그룹 코디네이터(브로커 중 하나)가 주관하며, 컨슈머가 그룹에 합류하거나 떠날 때, heartbeat가 끊길 때, 토픽의 파티션 수가 변경될 때 트리거된다.

배포 상황에서는 컨슈머가 종료되고 새로 기동되므로 리밸런싱이 필연적으로 발생한다. 문제는 리밸런싱이 어떤 방식으로 동작하느냐에 따라 처리 중단의 범위가 크게 달라진다는 점이다.

Eager 리밸런싱 — 기본 전략의 문제

Kafka의 전통적인 파티션 할당 전략인 RangeAssignor와 RoundRobinAssignor는 Eager 프로토콜을 사용한다. Eager 방식에서는 리밸런싱이 시작되면 모든 컨슈머가 자신의 파티션을 전부 반납(Revoke) 한 뒤, 코디네이터가 처음부터 다시 분배한다.

컨슈머 3개가 파티션 6개를 나눠 처리하는 상황에서 Consumer C가 배포를 위해 종료되는 경우를 보자.

[배포 전]
Consumer A: P0, P1
Consumer B: P2, P3
Consumer C: P4, P5

[Eager 리밸런싱]
1단계 - 전원 Revoke:
  Consumer A: (없음) ← 전부 반납
  Consumer B: (없음) ← 전부 반납
  Consumer C: (떠남)
  ⚠️ 이 시점에 6개 파티션 전부 처리 중단

2단계 - 재할당:
  Consumer A: P0, P1, P2
  Consumer B: P3, P4, P5

Consumer A는 원래 P0, P1을 갖고 있었고 결과적으로도 P0, P1을 받았다. 그런데 중간에 반납했다가 다시 받는 과정에서 불필요한 처리 중단이 발생했다. 컨슈머가 많고 파티션이 많을수록 이 중단 시간은 길어진다.

롤링 배포에서는 이 문제가 더 심각해진다. 컨슈머가 하나씩 내려갔다 올라올 때마다 리밸런싱이 반복되기 때문이다. 컨슈머 10개를 롤링 배포하면, 최대 20번(종료 시 1번 + 기동 시 1번)의 리밸런싱이 발생할 수 있고, 매번 전체 컨슈머가 멈춘다.

CooperativeStickyAssignor — 필요한 것만 움직인다

Kafka 2.4에서 도입된 CooperativeStickyAssignor는 두 가지 특성을 결합한 전략이다.

Cooperative는 리밸런싱 시 이동이 필요한 파티션만 revoke하는 프로토콜이다. 기존 할당에 변화가 없는 컨슈머는 멈추지 않고 계속 처리한다.

Sticky는 기존에 어떤 컨슈머가 어떤 파티션을 처리했는지 기억하고, 가능한 한 그 할당을 유지하는 것이다. 불필요한 파티션 이동을 최소화하여 로컬 상태나 캐시를 보존할 수 있다.

같은 상황을 CooperativeStickyAssignor로 처리하면 다음과 같다.

[배포 전]
Consumer A: P0, P1
Consumer B: P2, P3
Consumer C: P4, P5

[Cooperative 리밸런싱]
1차 리밸런싱 - 변경 사항 파악:
  Consumer A: P0, P1 ← 유지, 계속 처리 중 ✅
  Consumer B: P2, P3 ← 유지, 계속 처리 중 ✅
  P4, P5 → 미할당 상태 (C가 떠났으므로)

2차 리밸런싱 - 미할당 파티션만 분배:
  Consumer A: P0, P1, P4
  Consumer B: P2, P3, P5

Consumer A와 B는 한 번도 멈추지 않았다. 처리 중단은 C가 담당하던 P4, P5에서만 발생했고, 이마저도 2차 리밸런싱에서 빠르게 할당된다.

파티션 할당 전략 비교

Kafka에는 네 가지 파티션 할당 전략이 있다.

RangeAssignor는 토픽별로 파티션을 사전순 정렬 후 균등 분배한다. Eager 프로토콜이며 토픽 간 co-partitioning을 보장하므로 Kafka Streams의 조인 연산에 적합하다. 다만 토픽이 여러 개일 때 앞쪽 컨슈머에 파티션이 편중되는 경향이 있다.

RoundRobinAssignor는 모든 파티션을 하나의 목록으로 합쳐 라운드 로빈으로 분배한다. Range보다 균등하지만 co-partitioning을 보장하지 않으며, 역시 Eager 프로토콜이다.

StickyAssignor는 균등 분배를 하되 기존 할당을 기억하고 유지한다. 파티션 이동을 최소화하지만, 리밸런싱 과정에서 일단 전부 revoke한 뒤 다시 같은 곳에 할당하는 Eager 방식이므로 전체 중단은 여전히 발생한다.

CooperativeStickyAssignor는 StickyAssignor의 할당 로직에 Cooperative 프로토콜을 결합한 것이다. 이동이 필요한 파티션만 revoke하고, 나머지 컨슈머는 중단 없이 계속 동작한다.

Kafka Streams에서 조인 연산을 사용하는 등 co-partitioning이 필요한 특수한 경우가 아니라면, CooperativeStickyAssignor가 대부분의 상황에서 최선의 선택이다.

Static Group Membership — 리밸런싱 자체를 방지한다

CooperativeStickyAssignor는 리밸런싱의 영향 범위를 줄여준다. 하지만 배포 시 컨슈머가 떠났다 돌아오는 과정에서 리밸런싱 자체는 여전히 발생한다. 순단을 더 근본적으로 줄이려면 리밸런싱이 트리거되지 않도록 해야 한다. 이것이 Static Group Membership의 역할이다.

Dynamic Membership의 문제

기본 설정(Dynamic Membership)에서 컨슈머는 종료할 때 코디네이터에게 LeaveGroup 요청을 보낸다. 코디네이터는 이를 받는 즉시 리밸런싱을 트리거한다. 배포로 인해 컨슈머가 내려갔다 올라오는 것뿐인데, 그룹 전체가 리밸런싱의 영향을 받는 것이다.

롤링 배포에서는 이 문제가 증폭된다. 컨슈머 3개를 롤링 배포하면 종료 시 1번, 기동 시 1번, 총 6번의 리밸런싱이 발생할 수 있다. Eager 프로토콜이라면 매번 전체 파티션이 중단되고, CooperativeStickyAssignor를 쓰더라도 리밸런싱 프로토콜 자체의 오버헤드(JoinGroup → SyncGroup 왕복)가 반복된다.

Static Membership의 동작 원리

group.instance.id를 설정하면 해당 컨슈머는 Static Member가 된다. Static Member는 두 가지 점에서 Dynamic Member와 다르게 동작한다.

첫째, 종료 시 LeaveGroup을 보내지 않는다. 코디네이터는 이 컨슈머가 떠났는지 알 수 없고, session.timeout.ms가 만료될 때까지 해당 컨슈머의 파티션 할당을 그대로 유지한다.

둘째, 같은 instance id로 재합류하면 리밸런싱 없이 기존 할당을 복원한다. 코디네이터는 "기존 멤버가 돌아왔다"고 인식하고, 파티션을 재분배할 필요 없이 이전 할당을 그대로 돌려준다.

session.timeout.ms = 180초

[Dynamic Membership - 컨슈머 3개 롤링 배포]
Consumer A 종료 → LeaveGroup 전송 → 즉시 리밸런싱 (B, C 영향)
Consumer A 기동 → JoinGroup → 또 리밸런싱 (B, C 영향)
Consumer B 종료 → LeaveGroup 전송 → 즉시 리밸런싱 (A, C 영향)
Consumer B 기동 → JoinGroup → 또 리밸런싱 (A, C 영향)
Consumer C 종료 → LeaveGroup 전송 → 즉시 리밸런싱 (A, B 영향)
Consumer C 기동 → JoinGroup → 또 리밸런싱 (A, B 영향)
→ 총 6번의 리밸런싱, 매번 다른 컨슈머도 영향

[Static Membership - 컨슈머 3개 롤링 배포]
Consumer A 종료 → LeaveGroup 안 보냄 → 코디네이터: A의 파티션 180초간 보존
Consumer A 기동 (같은 instance id) → 리밸런싱 없이 기존 파티션 복원
Consumer B 종료 → 같은 흐름
Consumer B 기동 → 리밸런싱 없이 복원
Consumer C 종료 → 같은 흐름
Consumer C 기동 → 리밸런싱 없이 복원
→ 리밸런싱 0번, 각 컨슈머는 자기 배포 시에만 잠시 공백

순단은 완전히 사라지는가?

아니다. Static Membership을 적용해도 배포 중인 인스턴스 자체의 처리 공백은 존재한다. 해당 컨슈머가 종료된 시점부터 새 인스턴스가 기동되어 poll을 시작하는 시점까지, 그 컨슈머가 담당하던 파티션의 메시지는 처리되지 않는다.

Static Membership이 해결하는 것은 다른 컨슈머까지 영향받는 연쇄 중단이다. Dynamic Membership에서는 Consumer A를 배포할 때 B와 C도 리밸런싱에 휘말려 모든 파티션이 멈추지만, Static Membership에서는 A가 담당하던 파티션만 잠시 공백이 생기고 B와 C는 아무 영향 없이 계속 처리한다.

파티션 6개, 컨슈머 3개, Consumer A 배포 시

[Dynamic Membership + Eager]
순단 범위: P0, P1, P2, P3, P4, P5 전부 (리밸런싱으로 전체 중단)
순단 시간: 리밸런싱 소요 시간 + A의 재기동 시간

[Static Membership]
순단 범위: P0, P1만 (A가 담당하던 파티션만)
순단 시간: A의 재기동 시간만

session.timeout.ms 설정

Static Membership의 핵심 설정은 session.timeout.ms이다. 이 값은 코디네이터가 "이 컨슈머가 돌아올 수 있다"고 기다리는 시간이다.

이 값이 너무 작으면 배포 중에 timeout이 만료되어 리밸런싱이 발생한다. Static Membership을 쓰는 의미가 없어진다.

이 값이 너무 크면 컨슈머가 실제로 장애로 죽었을 때도 오랫동안 파티션이 방치된다. 그 파티션의 메시지는 timeout이 만료되어 다른 컨슈머에게 재할당될 때까지 처리되지 않는다.

적절한 값을 정하려면 배포 시 인스턴스 하나가 재시작되는 시간을 측정해야 한다. Graceful Shutdown 시간과 새 Pod의 기동 시간을 합친 것이 기준이 된다.

terminationGracePeriodSeconds: 60초 (종료 유예 시간)
+ Pod 기동 ~ Consumer poll 시작: 약 30초
= 최대 공백: 약 90초

→ session.timeout.ms: 180000 (180초, 공백의 약 2배)

배포 소요 시간의 1.5~2배 정도가 실무적으로 적절하다. 너무 타이트하게 잡으면 네트워크 지연이나 이미지 pull 시간 등 예외 상황에서 timeout이 만료될 수 있으므로 여유를 두는 것이 좋다.

스케일링 시에는 리밸런싱이 발생한다

Static Membership은 "기존 인스턴스가 같은 instance id로 돌아오는 경우"에만 리밸런싱을 방지한다. 레플리카 수를 3에서 5로 늘리면 새로운 instance id를 가진 컨슈머가 합류하는 것이므로 리밸런싱은 불가피하다. 이때 CooperativeStickyAssignor가 함께 설정되어 있으면 기존 3개의 할당은 유지한 채 새 컨슈머에게만 파티션을 나눠주므로 영향을 최소화할 수 있다. 이것이 두 전략을 함께 사용해야 하는 이유이다.

Kubernetes 환경에서의 적용

Kubernetes 환경에서 Static Group Membership을 적용할 때 핵심은 재시작 후에도 동일한 instance id를 유지하는 것이다.

Deployment의 Pod 이름은 my-consumer-7b9f4d5c8-xk2ml처럼 랜덤 suffix가 붙어 재시작할 때마다 바뀐다. 이를 instance id로 쓰면 코디네이터는 새로운 컨슈머가 합류한 것으로 인식한다. 기존 인스턴스는 session timeout까지 자리를 차지하고 있으니 오히려 문제가 될 수 있다.

StatefulSet을 사용하면 Pod 이름이 my-consumer-0, my-consumer-1처럼 고정되므로 이를 instance id로 활용할 수 있다.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: my-consumer
spec:
  replicas: 3
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
  template:
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: consumer
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
spring:
  kafka:
    consumer:
      group-id: my-consumer-group
      properties:
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
        session.timeout.ms: 180000
        group.instance.id: ${POD_NAME}

terminationGracePeriodSeconds는 Graceful Shutdown을 위한 유예 시간이다. 이 시간 동안 컨슈머가 처리 중인 메시지를 마무리하고 오프셋을 커밋한다. session.timeout.ms는 이 종료 시간과 Pod 재기동 시간을 합친 것보다 넉넉하게 설정해야 한다.

Graceful Shutdown

배포 시 Kubernetes는 Pod에 SIGTERM을 보낸다. 순수 Kafka Client를 사용한다면 직접 shutdown hook을 등록하고 consumer.wakeup()을 호출해 poll 루프를 탈출시켜야 하지만, Spring Kafka를 사용한다면 이 과정을 프레임워크가 처리한다.

Spring Kafka의 KafkaMessageListenerContainer는 Spring ApplicationContext가 종료될 때 내부적으로 컨슈머 루프 중단, 오프셋 커밋, 리소스 정리를 순서대로 수행한다. 개발자가 별도로 shutdown hook을 등록할 필요 없이, @KafkaListener로 메시지 처리 로직만 작성하면 된다.

다만 Spring Boot의 graceful shutdown 설정은 명시적으로 해줘야 한다.

server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 45s

timeout-per-shutdown-phase는 Spring이 SIGTERM을 받은 뒤 진행 중인 작업을 마무리할 수 있는 시간이다. 이 값은 Kubernetes의 terminationGracePeriodSeconds보다 작아야 한다. Spring이 정리를 끝내기 전에 Kubernetes가 SIGKILL을 보내면 Graceful Shutdown이 의미가 없기 때문이다.

terminationGracePeriodSeconds: 60   (Kubernetes가 SIGKILL까지 기다리는 시간)
> timeout-per-shutdown-phase: 45s   (Spring이 정리에 쓰는 시간)
> 실제 종료 소요 시간

정리

Kafka Consumer 배포 시 이벤트 처리 지연을 최소화하려면 세 가지를 조합하면 된다.

CooperativeStickyAssignor를 사용하여 리밸런싱이 발생하더라도 영향받는 파티션만 최소한으로 재분배한다. Kafka Streams 조인 등 co-partitioning이 필요한 특수한 경우가 아니라면 이 전략을 사용하는 것이 좋다.

Static Group Membership을 설정하여 배포 시 리밸런싱 자체를 방지한다. 같은 instance id로 session timeout 내에 돌아오면 파티션 재분배 없이 기존 할당을 이어받는다.

Graceful Shutdown을 보장하여 종료 시 처리 중인 메시지를 마무리하고 오프셋을 커밋한다. Kubernetes 환경에서는 StatefulSet과 적절한 terminationGracePeriodSeconds 설정을 통해 안정적인 롤링 배포를 구현할 수 있다.