핵심 한 줄
BackpressureProducer가 2초마다 200개씩petclinic.backpressure토픽을 쏟아붓는 동안, 50ms씩 잠드는BackpressureConsumer는 따라잡지 못한다. HTTP 5xx는 0, 요청 경로는 멀쩡하다. 오직kafka_consumer_fetch_manager_records_lag_max가 치솟는다. 컨슈머가 살아있어도 뒤처지면 장애다 — “죽음”과 “뒤처짐”을 가르는 신호가 lag이다.
시리즈 — 장애를 '구별 가능'하게 만든 카오스 벤치
1. 배경 — 비동기 장애는 조용히 온다
운영 대시보드가 조용하다. HTTP 에러율 0, p99 latency 정상, 컨슈머 프로세스도 살아있다. 그런데 처리해야 할 일이 조용히 쌓이고 있다.
이것이 큐 백프레셔(queue backpressure) 의 얼굴이다. 프로듀서가 send()하고 즉시 반환하는 구조에서, 소비가 밀리는지는 요청 경로 지표에 전혀 나타나지 않는다. 0.서문에서 정의한 ‘판별 신호’가 특히 필요한 시나리오다 — 동기 계층이 완전히 침묵하고 있기 때문이다.
W8의 차별 신호는 Kafka consumer lag — 프로듀서가 찍어낸 offset과 컨슈머가 소비한 offset의 격차다.
2. 무엇을 망가뜨렸나 — queueBackpressure seam
chaos 프로파일에서 queueBackpressure를 arm하면 두 컴포넌트가 살아난다.
BackpressureProducer — 2초(interval-ms=2000)마다 200개(burst=200) 레코드를 petclinic.backpressure 토픽에 쏟아 붓는다. send()는 즉시 반환한다. 프로듀서 요청 지표에는 흔적이 없다.
BackpressureConsumer — petclinic-backpressure 그룹으로 구독 중이지만 레코드당 50ms씩 잠든다. 200개 처리에 10초가 걸린다. 2초마다 새로 쌓이는 속도를 절대 따라잡을 수 없다.
// BackpressureProducer.java (chaos seam — @Profile("chaos"))
@Scheduled(fixedRateString = "${chaos.queue.interval-ms:2000}")
void reconcile() {
if (this.state.isArmed(ActiveChaosFaults.QUEUE_BACKPRESSURE)) {
for (int i = 0; i < this.burst; i++) {
this.kafkaTemplate.send(this.topic, "backpressure-" + i);
}
}
}seam은 @Profile("chaos")로 격리된다. NewTopic 빈을 정의하지 않아 KafkaAdmin이 브로커에 연결하지 않으므로, 기본 빌드는 Kafka가 없는 것과 같다. 단일 KRaft 브로커(apache/kafka:3.9.0, localhost:9092)가 관측 가능성 컴포즈에 올라간다.
3. 증상 — 조용한 적체
arm 직후, 모든 동기 지표가 평온하다. 에러 없음, latency 정상. 컨슈머는 살아있고 메시지를 소비하고 있다 — 단지 속도가 느릴 뿐이다.
이것이 가장 위험한 상태다. 아무도 죽지 않았지만 일이 쌓이고 있다. 요청 경로만 보는 사람에게는 아무 문제도 없다. 대기열이 깊어지고 나서야 지연이 가시화된다 — 그때는 이미 늦다.
4. 판별 신호 — lag는 offset 격차다
Kafka의 consumer lag는 LEO(Log End Offset) - committed offset — 프로듀서가 찍은 최신 오프셋과 컨슈머가 완료한 오프셋의 거리다. lag이 0이면 따라잡은 것, 증가하면 뒤처지고 있는 것이다.
spring-kafka는 이 값을 Micrometer로 익스포트한다. QueueBackpressure 알림은 아래 메트릭이 500을 초과하면 발화한다.
kafka_consumer_fetch_manager_records_lag_max{service="petclinic"}
여기서 ‘컨슈머 죽음’과 ‘뒤처짐’을 갈라낼 수 있다.
| 신호 | 컨슈머 다운(죽음) | 큐 백프레셔(뒤처짐) |
|---|---|---|
kafka_consumer_fetch_manager_records_lag_max | 증가 (소비 중단) | 증가 (소비 속도 < 생산 속도) |
http_server_requests 5xx rate | 다운 여부에 따라 다름 | 0 (요청 경로 정상) |
| 컨슈머 프로세스 생존 | 없음 | 살아있음 |
| lag 증가 추세 | 단조 증가 | 완만한 증가(소비하되 느림) |
두 경우 모두 lag는 올라간다. 차이는 컨슈머 생존 여부와 lag 기울기다. 컨슈머가 살아있고 lag이 점진적으로 증가한다면 — 죽은 게 아니라 뒤처진 것이다.
살아있어도 못 따라잡으면 장애다
kafka_consumer_fetch_manager_records_lag_max가 치솟는데 HTTP 에러는 0이다 — 이것이 큐 백프레셔의 시그니처다. “컨슈머가 살아있으니 괜찮다”는 판단이 오진의 시작이다. 뒤처지는 컨슈머는 실시간성을 잠식한다 — 요청 경로 지표만 보면 절대 잡히지 않는다.
5. 측정 — smoke와 임계
smoke(scripts/smoke/queue-backpressure-to-slack.sh)는 POST /chaos/queueBackpressure/arm으로 프로듀서를 켠 뒤 QueueBackpressure 알림이 발화할 때까지 최대 150초 대기한다. 알림 발화 후 두 지표를 나란히 찍는다.
kafka consumer records-lag-max = 1200+ (> 500 임계 돌파)
http 5xx rate = 0.00 (요청 경로는 멀쩡)
lag 임계 500, burst 200, 처리 지연 50ms는 라이브 튜닝 항목이다 — 환경에 따라 달라질 수 있으며, smoke 실행 결과로 조정한다.
6. 교훈
'판별 신호'는 요청 경로 밖에도 설계해야 한다
동기 HTTP 지표는 비동기 큐의 적체를 볼 수 없다.
BackpressureProducer의send()는 즉시 반환하고,BackpressureConsumer의 지연은 요청 경로 어디에도 나타나지 않는다. 큐 백프레셔의 판별 신호는kafka_consumer_fetch_manager_records_lag_max— 동기 지표 계층과 완전히 다른 관측 축이다. 이 축을 설계하지 않으면 장애를 인지조차 못한다.
관련 글
- 0.서문 — ‘판별 신호’ 논제의 전체 그림
- 7.CPU가 남는데 스로틀당했다 — 같은 ‘겉으로 멀쩡해 보이는’ 패밀리, CPU 스로틀 계층의 신호
- 6.커넥션을 안 돌려줬다 — 리소스 고갈 패밀리(커넥션풀), 다른 관측 축
- 2.장애 도구가 장애에 죽지 않게 — 이 신호를 소비하는 자동 진단
- 근거: spring-petclinic
BackpressureProducer,BackpressureConsumer,ActiveChaosFaults.QUEUE_BACKPRESSURE = "queueBackpressure",scripts/smoke/queue-backpressure-to-slack.sh