← 전체 글

Kafka Consumer Lag가 줄지 않을 때: 느린 처리와 리밸런싱을 함께 진단하기

TL;DR

  • Lag 숫자 하나보다 어느 파티션이, 언제부터, 어떤 처리 단계에서 밀리는지가 중요합니다.
  • 느린 DB·외부 API와 긴 재시도는 다음 poll()을 늦추고, 그룹 재할당까지 이어질 수 있습니다.
  • Consumer를 늘리기 전에 파티션 편중과 하위 시스템의 처리 여유를 확인합니다.
  • 복구 기준은 Lag 감소뿐 아니라 비즈니스 처리 성공, 중복 방지, 실패 이벤트 회수까지 포함해야 합니다.

들어가며

지난 Kafka 중복 처리와 멱등성 글에서는 DB 반영과 offset 커밋 사이의 실패 구간을 살펴봤습니다. 이번에는 그보다 앞선 질문을 다뤄보려 합니다. 프로세스는 살아 있는데 왜 이벤트 처리가 계속 밀릴까요?

Kafka 기반 서비스를 운영할 때 참고할 수 있도록, 소비 지연과 리밸런싱을 하나의 진단 흐름으로 정리했습니다. 특정 회사의 장애를 공개하는 회고는 아닙니다. 아래 상황과 수치는 설명용 가정이며, 실제 장애 기록이나 성능 개선 실측값이 아닙니다. 설정 설명은 Kafka 4.1 문서를 기준으로 합니다. 적용할 때는 사용하는 클라이언트와 Spring Kafka 버전을 함께 확인해야 합니다.


1. Lag가 늘었다고 원인이 같은 것은 아닙니다

먼저 대시보드가 무엇을 Lag로 보여주는지 확인합니다. 그룹의 커밋 위치를 기준으로 한 지연과 클라이언트의 현재 소비 위치를 기준으로 한 지연은 다를 수 있습니다. 읽기는 진행됐지만 DB 처리가 끝나지 않았거나 커밋 주기가 긴 경우에도 그래프가 다르게 보입니다.

Kafka 클라이언트의 records-lag-max는 커밋 offset이 아니라 현재 offset을 기준으로 합니다. 그룹 Lag와 같은 지표라고 가정하지 않는 편이 좋습니다. Kafka 모니터링 문서

제가 진단 순서를 잡는다면 먼저 다음과 같이 나누겠습니다.

보이는 증상 먼저 확인할 것 대응 방향
대부분의 파티션이 함께 밀림 유입량 변화, 처리 속도, DB·API 대기 병목 제거 후 필요한 만큼 확장
일부 파티션만 크게 밀림 특정 key 집중, 비싼 이벤트, 반복 실패 key·이벤트 단위 원인 분석
배포 이후 주기적으로 멈췄다 진행 재시작, 할당 변경, poll 간격 종료·배포 설정과 처리 시간 확인
읽기는 되는데 업무 결과가 늦음 작업 큐, DB 커밋, 외부 호출 완료 업무 완료 지표와 offset 대조

이 표는 원인을 확정하는 규칙이 아니라 조사할 순서입니다. 네트워크나 브로커 쪽 문제도 배제하지 않습니다. 같은 시간대의 요청 지연, 오류, 리소스 변화와 맞춰봐야 합니다.

예를 들어 적체가 12,000건이고 유입이 초당 100건, 처리가 초당 140건으로 일정하다면 순수하게 줄어드는 양은 초당 40건입니다. 단순 계산상 해소 시간은 300초입니다. 처리율이 유입률 이하라면 기다리는 것만으로는 적체가 사라지지 않습니다. 실제로는 파티션 편중, 재시도, 커밋 주기 때문에 이 계산과 차이가 납니다.


2. heartbeat가 정상이어도 처리 루프는 늦을 수 있습니다

여기서는 일반적인 Consumer group 구독과 동기식 처리 루프를 가정합니다. Kafka Share Consumer나 직접 파티션을 할당하는 방식은 범위에서 제외합니다.

max.poll.interval.ms는 다음 poll() 호출까지 허용하는 시간을 제한합니다. heartbeat가 유지된다는 사실만으로 처리 루프가 정상 진행 중이라고 볼 수는 없습니다. 이 간격을 넘기면 그룹에서 계속 파티션을 담당할 수 없게 되고, 커밋 시점에 실패가 드러날 수도 있습니다. KafkaConsumer 문서

다음과 같은 가정 사례를 생각해보겠습니다.

poll()로 500건 수신
  → 레코드마다 DB 또는 외부 API 호출
  → 일부 요청 지연 + 동기 재시도
  → 다음 poll()까지 350초 소요
  → max.poll.interval.ms=300000인 구성의 시간 예산 초과

500건을 각각 0.7초씩 순차 처리한다면 350초입니다. 평균이 평소보다 조금 느려졌을 뿐이어도 한 번에 받은 묶음 전체에서는 큰 시간이 됩니다. 여기에 retry backoff, DB 커넥션 대기, GC와 커밋 시간까지 더해질 수 있고요.

이때 session.timeout.ms만 늘리는 것은 poll 지연 자체를 해결하지 못합니다. 정적 멤버십에서는 poll 제한 초과 직후 파티션이 즉시 재할당되지 않는 차이도 있습니다. Classic과 Consumer 그룹 프로토콜의 heartbeat·session 설정 주체 역시 다르므로, 인터넷의 설정 묶음을 그대로 복사하지 않습니다. Consumer 설정 문서

CommitFailedException이 보였다는 이유만으로 poll timeout을 단정해서도 안 됩니다. 오류 전후의 할당 변경, 배포·종료 시각, 실제 poll 간격을 같이 확인해야 합니다.


3. timeout보다 먼저 ‘한 번에 얼마나 처리하는가’를 봅니다

동기식 순차 처리라면 다음 항목을 합쳐 시간 예산을 잡을 수 있습니다.

다음 poll까지의 시간
≈ 레코드 처리 시간의 합
 + 같은 루프에서 수행하는 재시도와 대기
 + 커밋·기타 작업·런타임 정지 시간

이를 측정한 뒤 한 번에 반환받는 양인 max.poll.records를 조정하는 방식이 출발점이 될 수 있습니다. 단, 이 설정은 브로커 fetch 자체의 크기나 메모리 사용량을 직접 제한하는 옵션은 아닙니다. Consumer 설정 문서

예를 들어 위 가정에서 100건씩 처리하면 단순 처리 시간은 70초가 됩니다. 이 숫자는 권장 설정값이 아니라 계산 예시입니다. 묶음을 줄이면 호출·커밋 오버헤드가 늘 수 있으므로 처리량과 poll 간격을 함께 비교해야 합니다.

평균 처리 시간만으로 안전하다고 판단하지도 않습니다. 느린 요청이 몰리는 구간의 실제 묶음 처리 시간, 높은 백분위수, 오류 시 재시도 시간을 측정합니다. 건별 p99에 개수를 곱한 값이 묶음 처리 시간의 p99를 정확히 뜻하는 것은 아닙니다.

DB나 HTTP 호출이 무한히 기다릴 수 있다면 먼저 대기 상한을 정합니다. 요청 timeout, 재시도 횟수와 총 소요 시간, 실패 시 보낼 곳을 함께 설계해야 “언젠가 끝나겠지”가 처리 루프 전체를 붙잡지 않습니다.


4. Consumer 증설이 해결되는 경우와 아닌 경우

일반적인 Consumer group에서는 한 파티션이 동시에 여러 Consumer에 나뉘어 할당되지 않습니다. 파티션 6개인 단일 토픽에 Consumer가 6개 배치돼 있다면, 12개로 늘려도 나머지는 그 토픽의 파티션을 받지 못합니다. KafkaConsumer 그룹 설명

특정 key가 한 파티션으로 몰리는 상황도 Consumer 수만으로 풀리지 않습니다. 다음을 따져봐야 합니다.

  • 순서 보장이 필요한 단위가 무엇인지
  • 한 key에 이벤트가 과도하게 집중되는지
  • 같은 레코드가 계속 실패하며 뒤의 처리를 막는지
  • Consumer를 늘려도 DB 커넥션·락·외부 API 제한에 여유가 있는지

파티션을 늘리거나 key를 바꾸는 결정에는 순서와 라우팅 검토가 따라옵니다. 기존 레코드가 자동으로 새 파티션에 고르게 재배치되는 것도 아닙니다. 적체 해소를 위해 서둘러 변경하기보다, 현재 데이터와 새 데이터의 처리 경계를 먼저 설계하는 편이 안전합니다.

처리를 worker thread로 분리하는 방법도 있지만 단순히 submit() 후 offset을 커밋하면 작업 완료 전에 소비 위치가 앞설 수 있습니다. KafkaConsumer 자체는 thread-safe하지 않습니다. 분리하려면 파티션별 순서, 완료 위치, 유한한 작업 큐와 재할당 시 남은 작업 처리가 필요합니다. KafkaConsumer 멀티스레드 처리 설명


5. 실패 한 건 때문에 계속 제자리라면

일시적인 통신 오류와 입력 자체의 오류를 같은 방식으로 무한 재시도하면 안 됩니다. 재시도로 회복될 가능성이 있는 오류인지 먼저 분류하고, 횟수와 총 대기 시간을 제한합니다.

Spring Kafka에는 오류 처리와 backoff를 구성하는 기능이 있고, 긴 대기에서 컨테이너를 일시 정지하는 ContainerPausingBackOffHandler도 제공합니다. 실제 동작은 listener 유형과 트랜잭션 설정에 맞춰 확인해야 합니다. Spring Kafka 오류 처리

별도 retry topic으로 실패 이벤트를 옮기면 본 흐름이 기다리지 않게 할 수 있습니다. 대신 앞선 이벤트의 재시도보다 뒤의 이벤트가 먼저 완료될 수 있어 순서 보장이 달라집니다. 상태 변경 순서가 중요한 업무에서는 특히 조심해야 합니다. Retry topic의 동작과 순서 제약

Spring의 non-blocking retry는 batch listener 및 container transaction과의 결합에도 제약이 있습니다. annotation 하나만 추가하면 모든 구성에 적용되는 기능은 아닙니다. Non-Blocking Retries

DLT로 보낸 뒤 main topic의 Lag가 줄었다고 복구 완료로 보지는 않습니다. 별도로 남겨야 할 것은 다음입니다.

  • 원본 topic·partition·offset과 안정적인 event ID
  • 민감 정보를 제거한 오류 원인과 실패 횟수
  • 재처리 담당자, 조건, 실행 기록
  • 재처리 때도 중복 결과가 생기지 않는 멱등성 처리

전송 실패를 무시한 채 원본 offset만 넘기면 유실 위험이 생깁니다. DLT 발행 확인과 offset 처리의 실패 경계까지 테스트해야 합니다.


6. 배포와 종료도 소비 흐름의 일부입니다

롤링 배포와 재시작 시점에 지연이 커진다면 애플리케이션 로그만 보지 않고 배포 이벤트를 같은 시간축에 놓습니다. 종료 신호 이후 listener를 멈추고 진행 중인 작업을 마무리할 시간이 있는지, 강제 종료 전에 완료 위치를 기록할 수 있는지 확인합니다.

하위 시스템 장애 중에는 계속 밀어 넣기보다 소비를 잠시 제한하는 편이 나을 수 있습니다. Spring listener container의 pause()는 정지 중에도 poll을 계속하도록 설계돼 있습니다. 다만 이미 실행 중인 느린 작업을 취소하지는 않으며, 기본적으로 이전 poll의 레코드 처리가 끝난 뒤 정지가 적용됩니다. 컨테이너 pause/resume 문서

중지 버튼만 만들고 재개 조건을 빠뜨리지 않아야 합니다. 왜 멈췄는지, 언제 재개할지, 누가 확인할지를 남깁니다. 재개 직후 적체를 한꺼번에 보내 하위 시스템을 다시 과부하시키지 않는지도 살펴봐야 하고요.


7. 해결됐다는 판단은 어떻게 할까요?

운영 변경 후에는 같은 유입 조건에서 적체가 실제로 줄어드는지 봅니다. 한가한 시간대로 넘어가 Lag가 줄어든 것을 설정 개선의 효과로 착각할 수도 있기 때문입니다.

아래는 실제 운영 수치가 아니라 검증 시나리오 체크리스트입니다.

주입할 상황 확인할 결과
DB·API 응답 지연 대기 상한이 작동하고 poll 간격이 예산 안에 머무는지
반복 실패 레코드 정한 횟수 후 실패 경로로 이동하고 후속 처리 정책이 지켜지는지
DB 반영 직후 종료 다시 읽더라도 업무 효과가 중복되지 않는지
롤링 배포·강제 종료 미완료 작업이 누락되지 않고 재시작 후 회복하는지
DLT 발행 실패 원본을 성공 처리한 것처럼 넘기지 않는지
높은 유입 지속 처리율·업무 완료 지연·DB 대기가 함께 안정되는지

대시보드에는 파티션별 Lag와 유입·처리율, poll 간격, rebalance 횟수, 처리 시간 분포를 함께 두면 좋겠습니다. DB 대기와 외부 API 오류, DLT 잔량, 이벤트 생성부터 업무 완료까지의 시간도 연결합니다. 한 지표의 숫자를 낮추는 것보다 사용자가 기다리는 결과가 정상적으로 나오는지 확인하기 위해서입니다.


마치며

Lag 알림을 받으면 Consumer부터 늘리고 싶어집니다. 하지만 먼저 한 파티션의 처리 흐름을 따라가며 어디서 기다리고, 언제 다시 poll하며, 무엇을 완료로 기록하는지를 확인하는 편이 낫습니다.

처리 시간 상한을 정하고, 부하에 맞게 묶음과 병렬성을 조정하고, 실패 이벤트의 회수 경로를 마련하는 것까지가 하나의 운영 설계입니다. 원인과 검증 조건을 함께 남겨두면 다음에 비슷한 지연이 생겼을 때 조사 범위를 더 빨리 좁힐 수 있습니다.

Comments