Kafka 파티션 불균형 해결과 컨슈머 TPS 극대화

프로젝트 개요

개발 기간
3개월
2025.04 — 2025.06
팀 구성
1명
담당 역할
백엔드 개발
Spring Boot 2.7 Apache Kafka Project Reactor kafka-clients 3.0.1

들어가며

카프카(Kafka)를 도입하고 파티션을 늘렸는데도 "왜 성능이 안 올라가지?"라는 의문에 직면하는 경우가 있습니다. 이 글은 알림 발송 서비스 운영 중 마주친 파티션 불균형(Skew) 이슈를 진단하고, 컨슈머 처리량(TPS)을 극대화하기까지의 과정을 SBI(Situation → Behavior → Impact) 구조로 정리한 기록입니다.


Situation — 파티션 증설이 오히려 독이 된 상황

파티션을 2개로 늘렸는데 Lag이 한쪽에만 쌓인다

알림 발송 서비스의 처리량 확보를 위해 기존 1개이던 토픽 파티션을 2개로 늘리고, 컨슈머 인스턴스도 2대로 늘렸습니다. 이론상 처리량이 두 배로 늘어야 했지만, 실제 운영 환경에서는 예상과 정반대의 결과가 나타났습니다.

모니터링 대시보드를 살펴보니 Consumer Lag이 한쪽 파티션에만 집중적으로 쌓이는 현상이 확인되었습니다. 프로듀서 발행량 차트를 꺼내보니 충격적인 수치가 찍혀 있었습니다.

Partition 0 발행량
12,000건
Partition 1 발행량
2,000건
컨슈머 TPS (개선 전)
~400
컨슈머 TPS (개선 후, 파티션당)
4K — 5K

전체 메시지가 10만 건을 넘어가는 시점부터 격차는 더 벌어졌고, 사실상 파티션 하나에만 부하가 집중되어 있었습니다. 파티션을 늘린 것이 수평 확장이 아니라 단순한 비용 증가로 끝난 셈이었습니다.

기존 환경 제약

  • 사용 중인 Kafka 클라이언트: kafka-clients 3.0.1
  • 애플리케이션 프레임워크: Spring Boot 2.7 (클라이언트 버전 자유 업그레이드 불가)
  • 프로듀서 파티셔너: 기본값인 StickyPartitioner 사용 중
  • 프로듀서 설정: linger.ms = 0
  • 컨슈머 방식: ReactiveKafkaConsumerTemplate 기반의 리액티브 파이프라인

Behavior — 원인 분석부터 단계적 해결까지

1단계: StickyPartitioner 버그 진단 (KAFKA-10888)

파티셔너의 동작 방식부터 다시 들여다봤습니다. StickyPartitioner는 배칭(Batching) 효율을 높이기 위해 한 배치가 꽉 찰 때까지 동일 파티션에 메시지를 집중시킨 뒤, 배치가 완성되면 다음 파티션으로 넘어가는 방식으로 동작합니다.

그러나 kafka-clients 3.0.1에는 알려진 버그(KAFKA-10888)가 존재했습니다. 배치가 꽉 찬 이후에도 파티션 전환이 이루어지지 않고 동일 파티션에 계속 메시지를 보내는 현상입니다.

설상가상으로 linger.ms = 0 설정이 문제를 증폭시키고 있었습니다. linger.ms가 0이면 메시지가 도착하는 즉시 전송되어 배치가 제대로 채워질 틈이 없습니다. 파티셔너 입장에서는 "배치가 아직 안 찼으니 이 파티션에 계속 보내야지"라고 판단하게 되고, 그 결과 특정 파티션으로의 쏠림이 가속화된 것입니다.

"linger.ms = 0이면 배치가 채워지기 전에 전송이 먼저 일어나고, StickyPartitioner는 배치 미완성을 이유로 파티션 전환을 하지 않는다."

두 조건이 맞물리면서 Partition 0에만 메시지가 6배 이상 집중되는 결과로 이어진 것입니다.

2단계: linger.ms 튜닝으로 우회 (프로듀서 해결)

근본적인 해결책은 버그가 수정된 버전으로 클라이언트를 업그레이드하는 것이었습니다. 그러나 Spring Boot 2.7 환경의 의존성 제약으로 인해 마음대로 kafka-clients 버전을 올리기 어려운 상황이었습니다.

그래서 설정 튜닝을 통한 우회 전략을 선택했습니다.

# 변경 전
spring.kafka.producer.properties.linger.ms=0

# 변경 후
spring.kafka.producer.properties.linger.ms=20

linger.ms를 20ms로 상향하면 프로듀서는 메시지를 즉시 보내지 않고 20ms 동안 모아서 배치로 묶습니다. 배치가 충분히 채워지면 StickyPartitioner가 자연스럽게 다음 파티션으로 스위칭하게 되어, 버그를 설정 레벨에서 우회할 수 있었습니다. 20ms라는 값은 알림 발송 서비스의 지연 허용 범위 내에서 배치가 충분히 형성될 수 있는 최소 임계값으로 산정했습니다.

3단계: receiveAutoAck 도입으로 컨슈머 TPS 극대화

프로듀서 쪽 파티션 불균형을 해소한 뒤, 이번에는 컨슈머 측으로 시선을 옮겼습니다. 기존 컨슈머는 레코드 하나를 처리할 때마다 Ack를 발행하는 구조였고, 이 오버헤드가 TPS(약 400)를 제한하는 병목이 되고 있었습니다.

이를 해결하기 위해 ReactiveKafkaConsumerTemplate이 제공하는 receiveAutoAck() 모드를 도입했습니다.

// 기존: 레코드 단위 Ack
reactiveKafkaConsumerTemplate
    .receive()
    .flatMap(record -> process(record)
        .doOnSuccess(r -> record.receiverOffset().acknowledge()))

// 변경: 배치 단위 자동 Ack
reactiveKafkaConsumerTemplate
    .receiveAutoAck()  // Flux<Flux<ConsumerRecord<K, V>>>
    .flatMap(batchFlux -> batchFlux
        .flatMap(record -> process(record))
    )

receiveAutoAck()Flux<Flux<ConsumerRecord>> 형태의 중첩 스트림을 반환합니다. 외부 Flux는 배치 단위를 나타내고, 내부 Flux는 배치 안의 개별 레코드를 나타냅니다. Ack는 내부 Flux가 완료되는 시점, 즉 배치 전체가 처리된 후 한 번에 커밋됩니다.

레코드마다 네트워크 왕복이 발생하던 방식에서 배치 단위로 Ack를 일괄 처리하는 방식으로 전환함으로써 불필요한 I/O 오버헤드를 대폭 줄일 수 있었습니다.


Impact — 균형 잡힌 파티션, 올라간 처리량

파티션 발행량 정상화

linger.ms = 20 적용 이후, 20ms 간격으로 쌓인 메시지들이 제대로 된 배치를 형성하면서 StickyPartitioner의 파티션 스위칭이 정상 작동하기 시작했습니다. 6:1 수준이던 파티션 간 발행량 비율이 거의 균등한 분산으로 개선되었으며, 특정 파티션에 Consumer Lag이 집중되는 현상도 해소되었습니다.

컨슈머 TPS 개선

receiveAutoAck() 도입 이후 레코드 단위 Ack 오버헤드가 제거되어 컨슈머 TPS가 파티션당 4,000 — 5,000 수준까지 올라갔습니다. 2개 파티션 기준 총 처리량은 최대 약 10,000 TPS로, 기존 ~400 TPS 대비 약 20배 이상의 처리량 개선을 달성했습니다. 추가 인프라 비용 없이 동일한 하드웨어 자원에서 이뤄낸 결과입니다.

핵심 교훈

  • 파티션 증설은 프로듀서 분산이 전제될 때만 효과적입니다. 파티셔너의 동작 방식과 버전별 버그를 이해하지 않으면 증설이 오히려 비용 낭비로 끝날 수 있습니다.
  • 버전 업그레이드가 불가능한 환경에서도 설정 튜닝으로 버그를 우회할 수 있습니다. 단, 우회책의 트레이드오프(배치 지연)를 서비스 특성에 맞게 검증해야 합니다.
  • 컨슈머 최적화는 Ack 단위에서 시작합니다. 리액티브 스트림에서 레코드 단위 Ack는 숨겨진 병목이 될 수 있으며, 배치 단위 Ack로의 전환이 TPS 개선의 핵심 레버가 될 수 있습니다.

정리하며

이 경험에서 얻은 가장 큰 교훈은 "Kafka 성능 문제는 겉으로 보이는 Lag 수치가 아니라, 그 아래에 숨어 있는 파티셔너 동작과 클라이언트 버그에서 비롯된다"는 점입니다. 모니터링 지표를 단서로 삼아 프로듀서 → 파티셔너 → 컨슈머 전 구간을 체계적으로 파고든 결과, 설정 한 줄과 API 전환이라는 외과적인 해결책으로 문제를 끝낼 수 있었습니다.