Kafka Consumer 헬스 체크: 죽었는가 살아있는가
Source: Dev.to
많은 분들이 겪어보셨을 겁니다 – 새벽 3시, 휴대폰이 울리고 Kafka consumer lag가 임계값을 초과했습니다라는 알림을 보게 됩니다.
노트북을 급히 켜서 메트릭을 확인해 보니… 모든 것이 정상입니다. 메시지는 정상적으로 처리되고 있습니다. 지연은 느린 하위 서비스 때문에 일시적으로 발생한 스파이크였을 뿐이죠. 알림을 끄고 다시 잠자리에 들어가며 “언젠가 고쳐야겠다”는 리스트에 또 하나를 추가합니다.
익숙한가요?
실제로 일어나고 있는 일은 지연(lag)은 얼마나 뒤처졌는지를 알려주지만, 진행 중인지 여부는 알려주지 않는다는 것입니다. 소비자는 1 000개의 메시지 지연 상태에 10분 동안 머물 수 있는데, 이는 멈춰서인 경우일 수도 있고, 메시지가 들어오는 속도와 정확히 맞춰서 처리하고 있는 경우일 수도 있습니다. 지연만으로는 차이를 알 수 없습니다.
진짜 질문은 “얼마나 지연됐는가?”가 아니라 “우리는 진행 중인가?”입니다.
전통적인 헬스 체크가 부족한 이유
“항상 건강함” 접근법
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) // "I'm alive!" (Are you though?)
}
이것은 아무것도 알려주지 않습니다. 소비자가 완전히 멈춰 있어도 쿠버네티스(또는 다른 오케스트레이터)는 살아 있다고 판단하고 계속 유지합니다.
“브로커에 Ping” 접근법
연결성을 확인하는 것이 전혀 없는 것보다는 낫지만, 연결성이 있다고 해서 소비자가 메시지를 처리하고 있다는 보장은 없습니다. 네트워크는 정상인데 소비자 그룹이 무한 리밸런스 루프에 빠져 있을 수 있습니다.
“지연 임계값” 함정
대부분의 팀은 Prometheus 등으로 소비자 지연을 모니터링하고 지연이 임계값을 초과하면 알림을 설정합니다 – 100개일 수도, 1 000개일 수도, 백만 개일 수도 있죠.
그 임계값은 어떻게 정해야 할까요?
- 너무 낮게 설정 → 하위 API가 30초 동안 느리게 응답해 새벽 3시에 깨게 됩니다. 소비자는 괜찮지만 잠시 과부하된 것뿐입니다. 오경보.
- 너무 높게 설정 → 실제 문제가 발생해도 고객에게 영향을 주기 전까지 알림을 놓칩니다. 알림이 울릴 때는 이미 손상 복구 모드에 들어가야 합니다.
세분화 문제: 50개의 파드 중 하나만 멈춰도 평균 지연만 모니터링한다면 눈에 띄지 않을 수 있습니다. 그 파드는 조용히 실패하고 나머지 49개는 계속 동작해 전체 메트릭을 가려버립니다.
근본적인 긴장 관계: 빠른 탐지는 오경보를, 신뢰할 수 있는 알림은 탐지 지연을 초래합니다. 복잡한 휴리스틱이나 ML 모델을 도입해도 문제를 감지하는 데 눈에 띄는 지연이 남습니다.
핵심 통찰: 진행 vs. 위치
우리가 정말 필요한 것은 특정 소비자 인스턴스가 진행 중인지를 묻는 간단한 질문에 대한 답입니다.
지연을 측정하는 대신 **진행(progress)**을 측정합니다.
- Heartbeat – 각 파티션에 대해 마지막으로 처리한 메시지의 타임스탬프를 추적합니다. 메시지를 처리하고 있다면 건강한 상태입니다.
- Verification – 일정 시간(예: X 초) 동안 새로운 메시지가 없으면 Kafka 브로커에 최신 오프셋을 조회합니다.
| 비교 | 결과 |
|---|---|
| Consumer offset < Broker offset | ❌ 비정상(UNHEALTHY) – 처리할 메시지가 남아 있지만 처리되지 않음(멈춤) |
| Consumer offset ≥ Broker offset | ✅ 정상(HEALTHY) – 최신 상태이며, 현재는 유휴 상태 |
이렇게 하면 멈춘 소비자와 유휴 상태 소비자를 명확히 구분할 수 있습니다.
작동 방식: 세 가지 시나리오
시나리오 1: 활발한 처리 (정상)
메시지가 흐르고 소비자가 이를 처리하고 있다면, 헬스 체크는 즉시 성공합니다 – 최근 활동이 관찰되었기 때문에 브로커 조회가 필요 없습니다.

시나리오 2: 멈춘 소비자 (비정상)
소비자는 멈춰 있지만 메시지는 계속 들어옵니다. 브로커 조회를 통해 작업이 남아 있음을 확인하지만, 소비자는 이를 처리하지 못하고 있습니다.

시나리오 3: 유휴 소비자 (정상)
소비자는 최신 상태에 도달했으며, 새로운 메시지를 기다리고 있습니다.

동일한 타임아웃이지만 브로커 상태에 따라 결과가 달라집니다.
구현
이 로직을 kafka-pulse-go 라는 경량 라이브러리로 패키징했습니다. 대부분의 인기 Kafka 클라이언트와 함께 사용할 수 있으며, 핵심 로직은 특정 클라이언트 구현과 분리되어 있어 다음 어댑터를 제공합니다:
모니터 설정
아래는 Sarama를 사용한 최소 예시입니다:
import (
"log"
"time"
"github.com/IBM/sarama"
adapter "github.com/vmyroslav/kafka-pulse-go/adapter/sarama"
"github.com/vmyroslav/kafka-pulse-go/pulse"
)
func main() {
// Your existing Sarama setup
config := sarama.NewConfig()
client, err := sarama.NewClient([]string{"broker1:9092", "broker2:9092"}, config)
if err != nil {
log.Fatal(err)
}
// Create the health‑checker adapter
brokerClient := adapter.NewClientAdapter(client)
// Configure the monitor
monitorConfig := pulse.Config{
Logger: log.Default(),
StuckTimeout: 30 * time.Second,
}
monitor, err := pulse.NewHealthChecker(monitorConfig, brokerClient)
if err != nil {
log.Fatal(err)
}
// Pass `monitor` to your consumer and expose its health endpoint
}
StuckTimeout이란?
새로운 메시지를 관찰하지 못하고 브로커에 조회를 시도하기까지 기다리는 시간을 정의합니다. 토픽의 일반적인 메시지 빈도에 따라 값을 선택하세요:
- 고처리량 토픽: 10–30 초
- 중간량 토픽: 1–2 분
- 저처리량 토픽: 5–10 분
이 타임아웃을 조정하면 민감도(빠른 탐지)와 오경보(일시적 정지) 사이의 균형을 맞출 수 있습니다.
💡 코드를 바로 살펴보고 싶다면 전체 소스 코드를 여기서 확인하세요.