안녕하세요. 기술본부 데이터엔지니어링셀 김태완입니다.
이 글에서는 AWS EC2 기반으로 운영되던 Log Transformer(Kafka Streams 애플리케이션)를 Kubernetes(이하 k8s)로 마이그레이션하면서, Kafka consumer 워크로드에 맞는 스케일링 정책을 설계한 과정과 그 결과를 공유합니다.
이 글에서 다루는 내용은 다음과 같습니다.
- CPU 기반 HPA가 Kafka consumer 워크로드에 부적합한 이유
- KEDA와 consumer lag을 활용한 도메인 메트릭 기반 스케일링 설계
- 자정 트래픽 급증에 cron trigger로 선제 대응하는 방법
- pod당 thread 다중화로 JVM 오버헤드와 rebalance 이벤트를 줄이는 방법
- 그 결과로 얻은 EC2 대비 80% 이상의 인프라 비용 절감
#배경: EC2 기반 Log Transformer의 한계
Log Transformer는 여러 Kafka topic에서 원본 로그를 읽어 검증·변환한 뒤, 게임별 Kafka topic으로 다시 produce하는 Kafka Streams 애플리케이션입니다. 여러 source topic의 원본 로그를 게임·용도별 topic으로 분기시키므로, 후속 파이프라인은 필요한 topic만 골라 소비할 수 있습니다.
기존에는 c6g.2xlarge 인스턴스 72대 위에서 운영되고 있었으며, 다음과 같은 한계를 안고 있었습니다.
- 트래픽 패턴과 어긋나는 인스턴스 운영: ASG(Auto Scaling Group)에 cron 기반 스케줄을 걸어 시간대별로 인스턴스 수를 조 정하고 있었지만, 미리 정해둔 일정에 따라 일괄적으로 인스턴스 수를 늘리고 줄이는 방식이라 실제 트래픽 변화에 즉각 반응하기는 어려웠습니다.
- 스케일링의 경직성: 예상치 못한 트래픽 변화에는 수동으로 ASG 설정을 바꾸거나 cron 스케줄을 다시 손봐야 했습니다.
- 배포 파이프라인의 복잡성: Spinnaker 기반 EC2 배포는 단계가 많고 반영까지 오래 걸려 단순한 설정 변경에도 부담이 컸습니다.
이 한계들은 결국 트래픽에 실시간으로 반응하는 자동 스케일링과 빠르고 단순한 배포가 없다는 데서 비롯됐습니다. k8s는 HPA, KEDA 같은 오토스케일러로 트래픽 변화에 자동으로 반응할 수 있고, 선언형 매니페스트와 GitOps 배포로 설정 변경 부담도 줄일 수 있습니다. 그래서 저희는 Log Transformer를 k8s 기반으로 전환하기로 결정했습니다.
마이그레이션의 첫 단계로, 하나의 애플리케이션이 여러 source topic을 함께 consume하던 구조를 source topic별 독립 Kafka Streams application으로 분리했습니다. topic마다 트래픽 패턴이 다르므로 스케일링 정책을 독립적으로 적용하고, 장애와 배포를 topic 단위로 격리하기 위함이었습니다. 이렇게 구조 분리를 마친 뒤, 본격적으로 스케일링 정책 설계에 들어갔습니다.
#첫 시도: CPU 기반 HPA가 실패한 이유
k8s로 올린 뒤 가장 먼저 시도한 것은 기본적인 CPU 기반 HPA(Horizontal Pod Autoscaler)였습니다. 이 글의 YAML에 나오는 replica 수와 임계값은 설명을 위한 예시 값입니다.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: log-transformer-hpa
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70결과는 기대와 달랐습니다. 하나의 partition은 consumer group 안에서 한 consumer(여기서는 pod)에만 배정되므로, partition이 consumer들에 고르게 나뉘어야 부하도 고르게 퍼집니다. 그러나 실제로는 partition이 consumer에 고르게 분배 되지 않아, 일부 pod가 CPU 100%에 도달하는 동안에도 평균 CPU는 target 아래에 머물러 HPA가 반응하지 않았습니다.
#RangeAssignor와 partition 분배 불균형
Kafka의 기본 partition 할당 전략인 RangeAssignor는 partition을 consumer 수로 나눠 순서대로 할당합니다. partition 수가 consumer 수로 정확히 나누어떨어지지 않으면, 앞쪽 순번의 consumer부터 partition을 1개씩 더 가져가게 되어 특정 consumer에 부하가 집중됩니다.
[상황 예시: partition 6개, consumer 4대, RangeAssignor 기준]
6 / 4 = 1 나머지 2 → 앞 2개 consumer가 partition을 1개씩 더 가져갑니다.
Consumer A: partition 0, 1 → CPU 80%
Consumer B: partition 2, 3 → CPU 80%
Consumer C: partition 4 → CPU 40%
Consumer D: partition 5 → CPU 40%
평균 CPU: 60%
HPA target: 70%
→ 스케일링 조건 미충족. Consumer A, B는 처리가 밀립니다.#CPU 평균 지표의 전제 조건
여기서 lag은 topic에 쌓인 메시지 중 consumer가 아직 처리하지 못한 메시지 수를 뜻합니다. partition이 많이 할당된 consumer에서는 처리가 밀려 lag이 쌓이고 해당 pod의 CPU도 100%까지 치솟지만, pod 수가 많아 평균 CPU는 target 이하에 머물기 때문에 HPA는 끝내 반응하지 않습니다.
CPU 평균은 워크로드가 애플리케이션의 pod 전체에 고르게 분산된다는 전제에서만 유효한 지표입니다. Kafka consumer처럼 partition 단위로 부하가 묶이는 워크로드에는 이 전제가 성립하지 않습니다.
#해결: KEDA와 consumer lag 기반 스케일링
문제의 핵심은 CPU가 이 워크로드의 실제 부하를 제대로 대변하지 못한다는 점이었습니다. 부하를 직접 나타내는 지표인 lag으로 스케일링해야 했지만, k8s 기본 HPA는 CPU, 메모리 같은 리소스 메트릭을 기준으로 동작해 Kafka lag 같은 외부 지표를 그대로 쓰기 어렵습니다. 그래서 다양한 이벤트 소스를 스케일링 기준으로 삼을 수 있는 KEDA(Kubernetes Event-Driven Autoscaling)를 도입했습니다.
#KEDA 도입과 lagThreshold 설정
KEDA의 Kafka scaler는 consumer group의 lag을 직접 조회해 그 값을 기준으로 스케일링합니다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: log-transformer
spec:
scaleTargetRef:
name: log-transformer
minReplicaCount: 2
maxReplicaCount: 10
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker:9092
consumerGroup: log-transformer-group
topic: log-topic
lagThreshold: '100'lagThreshold는 replica 1개가 감당할 lag의 기준값입니다. KEDA는 전체 lag을 이 값으로 나눈 만큼 replica를 늘리며, replica 수는 partition 수를 넘지 않습니다.
#lag 기반 스케일링의 장점
- 도메인 메트릭 기반: 범용 지표가 아닌, Kafka consumer 워크로드에서 비롯한 지표로 판단합니다.
- partition 불균형에 무관: 개별 consumer의 CPU가 아니라 전체 lag 총량을 기준으로 하므로, partition이 불균등하게 분배되어도 적절히 반응합니다.
- scale-to-zero 지원: source topic에 데이터가 들어오지 않는 시간대에는 pod 수를 0까지 줄였다가, 메시지가 다시 쌓이기 시작하면 그때 pod를 띄워 처리할 수 있습니다. 저희는 트래픽이 간헐적인 개발 환경 topic에 이 방식을 적용해 idle 비용을 제거했고, 운영 환경 topic은 처리 지연을 피하기 위해 최소 replica를 유지합니다.
#보완: 자정 트래픽 급증과 cron trigger
KEDA를 적용한 뒤 대부분의 시간대는 안정적으로 운영됐지만, lag 기반 스케일링만으로는 대응이 늦는 구간이 하나 있었습니다.
많은 게임이 자정(0시)을 기준으로 일일 미션·출석 같은 콘텐츠를 초기화하는데, 그 시점에 관련 배치 작업과 일간 집계 로그가 한꺼번에 쏟아지면서 트래픽이 급증하는 패턴이 있었습니다. consumer lag 기반 스케일링은 lag이 쌓인 뒤에 반응하므로, 이렇게 급격한 트래픽 증가에는 대응이 늦을 수밖에 없습니다. 게다가 새 pod가 ready 상태가 되어 consumer group rebalance까지 마쳐야 비로소 처리가 시작되고, 여기에 JVM warm-up 시간까지 더해지므로, scale out이 완료되기 전까지 lag이 계속 치솟고 그만큼 다운스트림 파이프라인으로의 로그 전달이 지연됩니다.
#cron trigger로 선제 대응
KEDA는 Kafka scaler 외에도 cron trigger를 지원합니다. 0시 직전에 미리 pod 수를 늘려놓는 방식으로 해결했습니다.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: log-transformer
spec:
scaleTargetRef:
name: log-transformer
minReplicaCount: 2
maxReplicaCount: 10
triggers:
- type: kafka
metadata:
bootstrapServers: kafka-broker:9092
consumerGroup: log-transformer-group
topic: log-topic
lagThreshold: '100'
- type: cron
metadata:
timezone: Asia/Seoul
start: '50 23 * * *' # 23:50에 scale out
end: '30 0 * * *' # 00:30에 cron trigger 해제
desiredReplicas: '8'Kafka lag trigger와 cron trigger를 함께 사용하면, KEDA는 두 trigger 중 더 높은 replica 수를 채택합니다. 23:50에 미리 8대로 늘려놓고 0시 트래픽을 흡수한 뒤, 00:30 이후에는 lag 기반으로 자연스럽게 scale in됩니다.
#최적화: thread 다중화로 rebalance 이벤트 줄이기
스케일링 정책과 자정 대응까지 정리한 뒤, 마지막으로 pod 구성 자체를 재설계했습니다. pod 1대에 Kafka Streams thread 1개를 두는 단순한 구조는 운영이 쉽지만, JVM 기동 오버헤드가 pod 수만큼 선형으로 늘고, pod 수명 주기 이벤트(재시작, rolling deploy, eviction)가 곧 consumer group rebalance의 트리거로 이어집니다.
이를 줄이기 위해 저희는 pod 1대가 여러 개의 Kafka Streams thread를 동시에 실행하도록 구성했습니다. Kafka Streams는 num.stream.threads 설정으로 인스턴스(=pod)당 여러 stream thread를 띄우는 것을 지원하며, 각 thread는 독립적으로 task(partition 묶음)를 할당받아 처리합니다(Kafka Streams Architecture). consumer group 입장에서는 thread 하나하나가 독립 consumer처럼 동작하므로, pod당 thread 수를 늘려도 partition 분배와 처리 정합성은 그대로 유지됩니다. 즉 전체 consumer thread 수는 동일하게 두고 pod 수만 줄이는 변경이어서, JVM 인스턴스 수와 메모리 오버헤드, 그리고 수명 주기 이벤트의 절대 빈도를 함께 낮출 수 있었습니다.
다만 pod 1대에 더 많은 thread가 묶이는 만큼 pod 1대의 장애 영향 범위는 커지므로, pod당 thread 수는 무한정 늘리지 않고 아래의 CPU 할당 기준에 맞춰 제한했습니다.
thread 수는 thread 간 CPU 경합을 피하기 위해 thread당 CPU core 1개를 기준으로 잡고, pod의 CPU request/limit을 거기에 맞췄습니다. 예를 들어 6 thread를 실행하는 pod에는 6 core를 할당합니다. Kafka Streams의 capacity planning 가이드도 stream thread 수를 가용 CPU core 규모에 맞춰 잡도록 권하는데, 저희는 처리량을 예측 가능하게 만들기 위해 thread당 1 core로 다소 보수적으로 운영했습니다. 이렇게 하면 pod당 처리량이 안정적으로 유지되고, lag 기반 스케일링이 기준으로 삼는 "pod 1대가 흡수할 수 있는 lag 처리량"도 예측 가능한 값으로 수렴합니다.
#결과: 트래픽에 맞춘 리소스 운영과 비용 절감
#비용 절감 수치
EC2 고정 인스턴스 운영 대비, k8s + KEDA 기반으로 전환한 후 80% 이상의 인프라 비용을 절감할 수 있었습니다.
| 항목 | EC2 기반 | k8s + KEDA |
|---|---|---|
| 스케일링 | cron 스케줄 기반 일괄 조정 | 자동 (lag 기반 + cron) |
| 비용 | 24시간 고정 비용 | 트래픽에 비례하는 비용 |
| 운영 부담 | 게임 추가 시 인스턴스 증설 등 인프라 작업 필요 | 설정 배포로 완료 |
80%라는 수치 자체보다 중요한 것은 운영 방식의 변화입니다. EC2에서는 피크 기준으로 인스턴스를 상시 유지해야 했지만, KEDA 기반에서는 새벽에 2대까지 줄었다가 피크에만 scale out되어 그 순간의 부하에 맞는 만큼만 pod를 할당합니다. 80%는 현재 트래픽 패턴에서 측정된 결과이며, 트래픽 패턴이 바뀌어도 리소스가 그에 따라 움직인다는 점이 핵심입니다.
#운영 부담 감소
- 게임 추가 시 Helm chart에 값만 추가하면 됩니다.
- 스케일링 정책을 Helm chart로 관리하므로 리뷰와 이력 추적이 가능합니다.
- image tag와 환경 변수 변경만으로 ArgoCD를 통해 배포할 수 있어, 기존 Spinnaker 기반 EC2 파이프라인 대비 배포 준비와 반영 시간이 크게 줄었습니다.
#남은 과제: thread 단위까지 고려한 partition 균등 분배
현재 구성에서도 lag 기반 스케일링은 안정적으로 동작하지만, partition이 consumer에 균등하게 분배되지 않는 근본적인 문제는 남아 있습니다.
KEDA의 ensureEvenDistributionOfPartitions 옵션은 전체 partition 수의 약수로 replica 수를 제한해 이 문제를 해소합니다. 다만 이 옵션은 consumer(=pod) 단위 균등만 보장할 뿐, 앞서 도입한 pod당 multi-thread 구성과는 그대로 맞물리지 않습니다. 예를 들어 24 partition을 pod당 4 thread로 처리한다면 replica 4는 pod 단위로는 균등해도 thread당 partition 수가 1.5개가 되어 일부 thread가 유휴 상태가 됩니다. 결국 균등 분배를 thread 수준까지 끌어내리려면 KEDA가 pod당 thread 수까지 알고 replica를 결정해야 합니다.
저는 이 공백을 메우기 위해 KEDA 저장소에 threadsPerConsumer 옵션 추가 issue를 올려두었습니다. 이 값에 pod당 thread 수를 지정하면 partition 수 / (replicas × threadsPerConsumer)가 정수가 되는 replica 값만 선택되어, pod 단위와 thread 단위 모두에서 균등한 분배를 보장할 수 있습니다.
한편 더 근본적으로는, Scala로 작성된 Log Transformer를 대체할 확장된 역할의 컴포넌트를 Rust로 작성하는 작업도 진행 중입니다. JVM 기동 오버헤드와 GC 지연 감소, CPU·메모리 사용량 절감, throughput 향상을 기대하고 있으며, threadsPerConsumer 옵션의 진행 상황과 함께 결과가 정리되는 대로 별도의 글로 공유드리겠습니다.
#마치며: 워크로드 특성에 맞는 스케일링 메트릭
이번 마이그레이션에서 저희가 다시 확인한 것은 한 가지였습니다.
스케일링 메트릭은 워크로드의 부하 특성에 맞춰 골라야 합니다.
CPU 사용률은 부하가 pod 전반에 고르게 퍼지는 워크로드라면 충분히 유효한 지표입니다. 다만 Kafka consumer처럼 partition 단위로 부하가 묶이는 워크로드에서는 평균 CPU만으로 실제 처리 지연을 포착하기 어려웠습니다. 저희는 consumer lag을 기준으로 삼아 스케일링의 정확도를 높일 수 있었습니다.
KEDA는 Kafka 외에도 RabbitMQ, AWS SQS, Prometheus metrics 등 다양한 이벤트 소스에 대한 scaler를 제공합니다. CPU 외의 지표를 스케일링 기준으로 손쉽게 가져올 수 있다는 점이 저희에게는 가장 큰 장점이었습니다.
Kafka consumer 스케일링에 비슷한 고민이 있으시다면, consumer lag 기반 스케일링을 검토해보시길 권합니다.
감사합니다.

