← 목록으로 돌아가기

Kafka Consumer Lag 운영 가이드: Rebalance, Offset Commit, Poison Message, Backpressure 다루기

Backend

Kafka Consumer Lag Rebalance Backpressure

Lag는 숫자가 아니라 사용자 지연이다

Kafka를 쓰는 시스템에서 consumer lag는 가장 먼저 보는 지표입니다. 하지만 lag를 단순히 "남은 메시지 수"로만 보면 중요한 맥락을 놓칩니다. 10만 건의 lag가 있어도 초당 5만 건을 처리한다면 곧 회복됩니다. 반대로 1,000건의 lag라도 가장 오래된 메시지가 30분 전이라면 특정 파티션이나 consumer가 막혀 있을 수 있습니다.

운영에서 중요한 것은 lag count, lag age, 처리량, rebalance 빈도, commit 지연을 함께 보는 것입니다. 메시지 기반 시스템은 API와 달리 장애가 즉시 사용자 에러로 보이지 않을 수 있습니다. 주문 이벤트 처리가 10분 밀리면 API는 200을 반환해도 정산, 알림, 추천, 재고 동기화가 늦어집니다. Lag는 내부 숫자가 아니라 비즈니스 지연입니다.

이 글에서는 Kafka consumer group 운영에서 자주 겪는 문제를 다룹니다. Rebalance가 왜 처리량을 떨어뜨리는지, offset commit을 언제 해야 하는지, poison message를 어떻게 격리하는지, backpressure를 어떻게 걸어야 downstream을 보호할 수 있는지 정리합니다.


1. Consumer Group과 Partition Ownership

Kafka consumer group에서는 하나의 partition이 같은 group 안의 consumer 하나에게만 할당됩니다. 이 구조 덕분에 partition 내부 순서를 유지하면서 병렬 처리가 가능합니다. Consumer 수가 partition 수보다 많으면 남는 consumer는 일을 받지 못합니다. 반대로 consumer 수가 너무 적으면 하나의 consumer가 여러 partition을 처리하며 lag가 쌓입니다.

Partition 수는 처리량의 상한과 관련됩니다. Topic partition이 6개라면 같은 group에서 동시에 처리할 수 있는 consumer도 최대 6개입니다. Consumer를 20개 띄워도 14개는 유휴 상태입니다. 대규모 확장을 기대한다면 topic 설계 시 partition 수를 미리 고려해야 합니다. 단, partition을 너무 많이 만들면 broker 메타데이터, file handle, rebalance 비용이 증가합니다.

Partition key도 중요합니다. 주문 ID를 key로 쓰면 같은 주문 이벤트의 순서는 보장됩니다. 하지만 특정 대형 고객이나 hot key에 이벤트가 몰리면 한 partition만 lag가 쌓입니다. 전체 lag 평균은 괜찮아 보여도 특정 partition lag가 계속 증가할 수 있습니다. Consumer lag는 topic 합계뿐 아니라 partition별로 봐야 합니다.


2. Rebalance는 처리 중단 비용이다

Rebalance는 partition ownership을 consumer들에게 다시 나누는 과정입니다. Consumer가 추가되거나 제거되거나, heartbeat가 끊기거나, poll 간격을 초과하면 rebalance가 발생합니다. Rebalance 중에는 일부 consumer가 처리를 멈추고 partition 할당을 다시 받습니다. 빈번한 rebalance는 처리량을 크게 떨어뜨립니다.

가장 흔한 원인은 max.poll.interval.ms 초과입니다. Consumer가 메시지를 가져온 뒤 처리가 너무 오래 걸리면 Kafka는 consumer가 죽었다고 판단하고 group에서 제외합니다. 그러면 rebalance가 발생하고, 처리 중이던 메시지는 다시 다른 consumer에게 할당될 수 있습니다. 긴 작업을 consumer poll loop 안에서 직접 처리하면 위험합니다.

해결 방법은 처리 시간을 제한하거나, poll과 processing을 분리하는 것입니다. Consumer thread는 계속 poll과 heartbeat를 유지하고, 실제 작업은 worker pool로 넘길 수 있습니다. 다만 이 경우 offset commit과 backpressure가 복잡해집니다. Worker queue가 가득 차면 poll을 잠시 멈추거나 pause해야 합니다. 무작정 계속 poll하면 메모리만 쌓입니다.

Cooperative rebalancing은 전체 partition을 한 번에 회수하는 stop-the-world 비용을 줄입니다. 가능한 클라이언트와 broker 설정에서 cooperative sticky assignor를 검토할 만합니다. 하지만 설정만 바꾼다고 모든 문제가 사라지지는 않습니다. 긴 처리, 불안정한 consumer, 과도한 배포가 있으면 rebalance는 계속 발생합니다.


3. Offset Commit은 처리 보장의 중심이다

Offset commit은 "여기까지 처리했다"는 선언입니다. 메시지를 받자마자 commit하면 처리 중 프로세스가 죽었을 때 메시지가 유실됩니다. 처리가 끝난 뒤 commit하면 중복 처리는 생길 수 있지만 유실은 줄어듭니다. 대부분의 비즈니스 이벤트에서는 at-least-once 처리가 기본이고, consumer 로직이 idempotent해야 합니다.

Auto commit은 단순하지만 위험합니다. 처리 완료와 무관하게 주기적으로 offset이 커밋될 수 있습니다. 결제, 포인트, 정산 같은 이벤트에서는 수동 commit을 권장합니다. 메시지 처리가 성공하고, side effect가 안전하게 반영된 뒤 commit합니다. Batch 처리라면 batch 내 일부 실패를 어떻게 다룰지 정책이 필요합니다.

Commit 순서도 중요합니다. 같은 partition에서 offset 10은 성공했고 11은 오래 걸리고 12는 성공한 경우, 12를 바로 commit하면 11을 건너뛰게 됩니다. Partition 단위 순서를 유지하려면 가장 낮은 미완료 offset을 기준으로 commit해야 합니다. 병렬 처리 모델에서는 offset tracking이 필수입니다.

중복 처리는 피할 수 없습니다. Consumer가 DB에 결과를 저장한 뒤 offset commit 전에 죽으면 같은 메시지를 다시 처리합니다. 따라서 idempotency key, processed event table, unique constraint가 필요합니다. Kafka의 exactly-once semantics는 특정 producer/transaction 시나리오에 도움을 주지만, 외부 DB side effect까지 마법처럼 한 번으로 만들어주지는 않습니다.


4. Poison Message는 빠르게 격리한다

Poison message는 계속 실패하는 메시지입니다. JSON 스키마가 깨졌거나, 필수 참조 데이터가 없거나, 비즈니스 규칙상 처리 불가능한 이벤트일 수 있습니다. Consumer가 이 메시지를 계속 재시도하면 해당 partition이 막히고 뒤의 정상 메시지도 처리되지 않습니다.

재시도는 두 종류로 나눠야 합니다. 일시 오류는 재시도 가치가 있습니다. DB deadlock, 외부 API timeout, 네트워크 오류는 backoff 후 다시 시도할 수 있습니다. 영구 오류는 재시도해도 성공하지 않습니다. 스키마 파싱 실패, 필수 필드 누락, 잘못된 enum 값은 dead letter topic으로 보내고 partition 진행을 계속해야 합니다.

Dead letter topic에는 원본 payload, 원본 topic/partition/offset, 실패 이유, consumer 버전, 발생 시각을 넣습니다. 그래야 나중에 재처리할 수 있습니다. 단, 개인정보와 민감 정보가 포함될 수 있으므로 보존 기간과 접근 권한을 제한해야 합니다. DLQ는 쓰레기통이 아니라 운영자가 복구할 수 있는 격리 공간입니다.

Poison message 알림은 volume과 age를 함께 봐야 합니다. DLQ가 1건 생겼다고 항상 장애는 아니지만, 같은 event type에서 반복되거나 특정 배포 이후 급증하면 즉시 봐야 합니다. Schema registry와 backward compatibility 검증을 도입하면 이런 사고를 많이 줄일 수 있습니다.


5. Backpressure로 Downstream을 보호한다

Consumer는 Kafka에서 빨리 읽을 수 있지만 downstream이 항상 그 속도를 감당하는 것은 아닙니다. DB connection pool, 외부 API rate limit, 검색 색인 cluster가 병목일 수 있습니다. Consumer가 계속 메시지를 가져와 내부 큐에 쌓으면 메모리 사용량이 증가하고, 처리 지연이 커집니다. Backpressure가 필요합니다.

Kafka consumer에는 pause/resume 패턴이 있습니다. Worker queue가 임계값을 넘으면 특정 partition의 poll을 pause하고, 처리량이 회복되면 resume합니다. 이 방식은 메시지를 애플리케이션 메모리에 무한히 쌓지 않고 Kafka에 남겨둡니다. Kafka는 durable buffer이므로, downstream이 느릴 때는 Kafka에 지연을 남기는 편이 안전합니다.

Rate limit도 사용할 수 있습니다. 외부 API가 초당 100건만 허용한다면 consumer 수를 늘려도 처리량은 100건을 넘을 수 없습니다. 오히려 retry와 429가 늘어납니다. Consumer scale out은 downstream capacity와 함께 설계해야 합니다. Lag가 쌓인다고 무조건 consumer를 늘리면 다른 시스템을 무너뜨릴 수 있습니다.


실무 체크리스트

  • Lag count뿐 아니라 oldest lag age와 partition별 lag를 보고 있는가
  • Rebalance rate와 rebalance duration을 알림으로 연결했는가
  • 긴 처리가 max.poll.interval.ms를 초과하지 않도록 설계했는가
  • Offset commit이 처리 성공 이후에 일어나는가
  • Partition 내 병렬 처리 시 commit 가능한 offset을 정확히 추적하는가
  • Consumer side effect가 idempotent하게 설계되어 있는가
  • Poison message를 DLQ로 격리하고 정상 partition 진행을 막지 않는가
  • Worker queue와 downstream 상태에 따라 pause/resume backpressure를 적용하는가
  • Consumer scale out 전에 partition 수와 downstream capacity를 확인하는가

6. Consumer 배포도 Lag를 만든다

Kafka consumer는 코드가 좋아도 배포 방식이 거칠면 lag가 쌓입니다. 모든 consumer를 동시에 내리면 partition 소유권이 한꺼번에 흔들리고, rebalance가 반복되며, 처리 중 메시지가 다시 실행될 수 있습니다. Rolling deployment에서도 readiness와 graceful shutdown이 없으면 같은 문제가 생깁니다. Consumer는 HTTP 서버보다 종료 절차가 더 중요합니다.

종료 시에는 새 poll을 멈추고, 처리 중인 메시지를 마무리하고, 가능한 offset을 commit한 뒤 group을 떠나는 순서가 필요합니다. 작업 시간이 긴 consumer라면 shutdown timeout을 충분히 잡아야 합니다. 반대로 timeout이 너무 길면 배포가 멈추고, 너무 짧으면 중복 처리가 늘어납니다. 처리 로직이 idempotent해야 하는 이유가 여기서도 드러납니다.

배포 중에는 lag와 rebalance rate를 배포 지표와 함께 봐야 합니다. 특정 버전 배포 직후 lag age가 증가한다면 코드 성능 문제일 수도 있고, 단순히 rebalance가 잦은 배포 방식 때문일 수도 있습니다. Consumer 운영에서는 배포 이벤트, rebalance 이벤트, lag 지표를 한 화면에서 보는 것이 원인 파악에 도움이 됩니다.


결론: Kafka 운영은 지연을 어디에 둘지 결정하는 일이다

Kafka는 강력한 버퍼입니다. 하지만 버퍼가 있다는 사실이 처리가 안전하다는 뜻은 아닙니다. Lag가 어디에서 생기는지, rebalance가 왜 발생하는지, offset을 언제 commit하는지, 실패 메시지를 어떻게 격리하는지에 따라 시스템의 신뢰성이 달라집니다.

좋은 consumer는 메시지를 빨리 읽는 consumer가 아닙니다. 처리 가능한 만큼 읽고, 실패를 분리하고, downstream이 버틸 수 있게 속도를 조절하며, 중복 실행에도 안전한 consumer입니다. Kafka는 이벤트를 저장해주지만, 운영 신뢰성은 consumer 설계에서 완성됩니다.