Spring Kafka · merged · 2026-06-08

PR #4469 — GH-4465: Fix unbounded async retry re-delivery

Spring Kafka 4.1 시리즈에서 suspend @KafkaListener + DefaultErrorHandler 조합으로 항상 실패하는 레코드가 무한히 재전송되는 회귀를 추적해 원인이 되는 큐 중복을 제거했습니다. 회귀 테스트와 함께 머지.

증상

suspend 함수 형태의 @KafkaListener가 항상 예외를 던지고, 컨테이너에 DefaultErrorHandler(FixedBackOff(N, n))가 설정된 상태에서 같은 레코드가 정해진 attempt 횟수를 넘어 무한히 재배달됐습니다. RecordInRetryException이 던져진 시점에 ListenerConsumer#handleAsyncFailurefailedRecords 큐에 같은 튜플을 다시 넣고 있었던 것이 원인.

원인 흐름

비동기 리스너의 실패가 seekAfterHandling=trueCommonErrorHandler로 전달되도록 바뀐 변경(#4254) 이후, seek-and-retry 흐름은 정상이지만 handleAsyncFailure가 catch한 RecordInRetryException에 대해 동일한 FailedRecordTuple을 큐의 끝으로 다시 push 하는 경로가 남아 있었습니다. 결과:

  • 같은 레코드가 매 루프마다 두 번 처리 — 큐에서 한 번, seek-induced re-delivery로 한 번.
  • FailedRecordTracker attempt 카운터가 인플레이션돼 백오프 소진 시점이 어긋남.
  • recovery가 tracker entry를 비워도 큐에 남은 중복이 새 retry 사이클을 시작 → 무한 재배달.

수정

RecordInRetryException catch 블록에서 this.failedRecords.addLast(failedRecord) 한 줄을 제거. 에러 핸들러가 이미 컨슈머를 실패 offset으로 seek 했고, FailedRecordTracker 엔트리는 (topic, partition, offset)으로 키잉돼 루프 반복에 걸쳐 살아남기 때문에 attempt 카운터는 자연스러운 재배달 위에서 그대로 누적됩니다.

회귀 테스트

EnableKafkaKotlinCoroutinesTests에 always-failing suspend 리스너 + DefaultErrorHandler(FixedBackOff(100L, 2L)) 시나리오 추가. 리스너 호출이 정확히 3회 (initial + 2 retries) 일어나는지 확인. 픽스 없이는 expected: 3 but was: 6으로 실패, 픽스 후엔 통과.

관련 자료