개요
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 전략
이 모든 게 같이 맞물려야 의미 있는 개선이 되는 걸 경험했다.
'Java > 기술회고' 카테고리의 다른 글
| Kafka를 제대로 쓰고 싶어서 MSA로 전환한 이야기 (0) | 2026.04.06 |
|---|---|
| Thread.sleep()은 왜 위험할까? Kafka DLQ로 안전하게 재시도 처리하기 (0) | 2025.12.15 |
| 로컬에서 Spring Boot 서버 2개 띄워 HAProxy로 트래픽 분산해보기 (0) | 2025.10.26 |
| Kafka 기반 실시간 경매 시스템 부하 테스트 및 병목 개선 (k6 + Prometheus + Grafana) (0) | 2025.10.18 |
| Copilot + JUnit 테스트 코드 자동 생성 환경 구성 (0) | 2025.10.16 |