본문 바로가기

Java/기술회고

Kafka Consumer Lag 600을 0으로 만든 방법 (k6 + Prometheus +Grafana)

개요

 

Kafka 기반 실시간 경매 시스템 부하 테스트 및 병목 개선 (k6 + Prometheus + Grafana)

개인 프로젝트로 Kafka를 기반으로 한 실시간 경매·채팅 플랫폼을 직접 설계하고 배포했다.이 서비스는 WebSocket을 통해 실시간으로 입찰 및 채팅 이벤트를 Kafka로 전송하고,여러 Consumer들이 메시

kimmangtae.tistory.com

 

Kafka 기반 경매 입찰 시스템에 대해 트래픽 테스트를 진행하고, Consumer Lag가 급격히 증가하는 현상을 관찰했다.

이번 글에서는

  • 왜 Lag가 발생했는지
  • 어떤 설정이 병목을 만들었는지
  • 실제 배포 환경(CPU 2 코어)에서 어디까지 늘리는 게 합리적인지
  • 그리고 개선 후 지표가 어떻게 달라졌는지

를 중심으로 정리해보려 한다.

테스트 환경 요약

  • 배포 환경
    • EC2: c7i-flex.large (CPU 2 Core)
    • Kafka / Redis / PostgreSQL / MongoDB / Spring Boot 모두 Docker Compose로 구성
  • Kafka Topic
    • auction-bid
    • Partition: 4
    • Replication: 1
  • Consumer 설정 (초기)
spring:
  kafka:
    consumer:
      enable-auto-commit: false
      max-poll-records: 1000
      fetch-min-size: 1048576
      fetch-max-wait: 1000
    listener:
      ack-mode: batch
      concurrency: 4

 

부하 테스트

k6 기반 WebSocket 입찰 시뮬레이션을 통해

다수의 사용자가 동일한 경매 방에 동시에 입찰하는 상황을 재현했다.

 

트래픽 테스트 도중 다음과 같은 현상이 발생했다.

 

  • HTTP Error Rate: 0%에 가깝게 유지
  • Kafka Consumer Lag: 순간적으로 600 이상까지 증가
  • DLQ(Dead Letter Topic)로 메시지 반복 적재
  • 동일한 경매에 대해
  • retry → rollback → retry → DLQ 흐름이 지속 발생

요청은 정상적으로 수신되지만 실제 비동기 처리 구간(Kafka Consumer)에서 병목이 발생하는 구조가 발견되었다.

 

  kafka:
    consumer:
      group-id: chat-group
      auto-offset-reset: earliest
      enable-auto-commit: false
      max-poll-records: 1000  
      fetch-min-size: 1000000   
      fetch-max-wait: 1000 
    listener:
      ack-mode: batch
      concurrency: 4
      poll-timeout: 1500
  thymeleaf:
    cache: false

문제

문제 1 – Consumer concurrency가 병목 상한

초기 설정은 다음과 같았다.

  • Kafka Partition: 4
  • Consumer concurrency: 4

이 구조에서 병렬 처리의 상한은 4로 고정된다.

 

원인 분석

Kafka에서 병렬 처리의 상한은 Consumer 개수 자체가 아니라 Partition 수로 결정된다.

 

정리하면:

  • concurrency < partition → 병목 발생
  • concurrency = partition → 최대 병렬 처리
  • concurrency > partition → 효과 없음

트래픽이 순간적으로 몰릴 경우

Consumer가 처리 속도를 따라가지 못해 Lag가 급격히 증가할 수밖에 없는 구조였다.


문제 2 – max-poll-records 값이 과도하게 컸다

  • Consumer가 poll 한 번에 최대 1000개 메시지를 가져옴
  • batch ack 사용 중이므로
    • 처리 중 하나라도 예외 발생 시
    • 1000개 전체가 retry 대상

실제 로그에서도 다음 흐름이 반복됐다.

Record in retry and not yet recovered
Seeking to offset XXX
retry → rollback → retry → DLQ

결과적으로

  • Lag는 줄지 않고
  • 동일 메시지가 반복 처리되며
  • DLQ 적재가 계속 발생

많이 가져오는 게 항상 좋은 것은 아니다는 점을 명확히 확인했다.


문제 3 – fetch 옵션이 실시간 경매 처리와 맞지 않았다

fetch-min-size: 1048576  # 1MB
fetch-max-wait: 1000
  • 중개인이 최소 1MB가 찰 때까지 대기
  • 경매 입찰 메시지는 크기가 작고
  • 지연에 민감한 실시간 이벤트

결과적으로

  • poll 대기 시간이 증가
  • 처리 타이밍이 늦어지며 Lag 악화

개선

개선 1 – Partition / Consumer 구조 재설계

목표

  • 무작정 늘리는 것이 아니라
  • 배포 환경(CPU 2 Core)에 맞는 병렬성 확보

변경 사항

  • Kafka Partition: 4 → 6
  • Consumer concurrency: 4 → 6
listener:
  concurrency: 6

 

배포 환경이 c7i-flex.large (CPU 2 Core)였기 때문에 Context Switching 비용과 안정성을 고려해 8 이상이 아닌 6으로 조정했다.

개선 2 – poll & fetch 전략 조정

consumer:
  max-poll-records: 500
  fetch-min-size: 524288   # 512KB
  fetch-max-wait: 500
listener:
  ack-mode: batch

기대 효과

  • poll 단위 부담 감소
  • retry 시 영향 범위 축소
  • 지연에 민감한 이벤트 처리 개선

개선 결과

개선 전 (이전 글 마지막 테스트 )

  • HTTP Error Rate: 최대 58%
  • Kafka Lag: 40~600 이상 지속
  • DLQ 지속 적재
  • 시스템이 “버티는 것처럼 보이지만 실제로는 밀림”

개선 후 (Partition 6 / Consumer 6)

  • HTTP Requests/sec: 안정적
  • HTTP Error Rate: 0.42%
  • Kafka Consumer Lag: 거의 0
  • DLQ: 간헐적, 반복 패턴 사라짐
  • Redis / Postgres 지표도 안정적

Lag를 “처리하는 구조”가 아니라 “애초에 쌓이지 않게 만든 구조”로 변경된 게 핵심이다.

 

정리

이번 튜닝에서 가장 크게 느낀 점 Kafka 튜닝은 Kafka 튜닝은 “설정을 많이 키우는 문제”가 아니라
“내 시스템이 감당할 수 있는 병렬성의 한계를 찾는 문제”였다.

  • 파티션 수
  • Consumer concurrency
  • poll / fetch 옵션
  • retry & DLQ 전략

이 모든 게 같이 맞물려야 의미 있는 개선이 되는 걸 경험했다.