Mockito 업그레이드 후 Kafka 테스트가 깨진 이유
일반 Consumer Group에서는 하나의 파티션을 한 시점에 한 Consumer가 맡습니다. Share Group은 같은 파티션의 레코드를 여러 Consumer가 나눠 처리할 수 있고, 처리 결과도 레코드별로 승인하거나 다시 전달할 수 있습니다. 그래서 broker는 어떤 레코드가 전달 중이고 처리가 끝났는지 별도의 상태로 관리해야 합니다.
ShareCoordinatorService는 이 전달 상태를 내부 share-group state topic에 기록하고, 더 이상 필요하지 않은 이전 구간을 파티션별로 주기적으로 정리합니다. 한 파티션의 정리가 실패하더라도 다른 파티션과 다음 주기의 정리 작업은 계속되어야 합니다.
Kafka의 Mockito 버전을 올리는 과정에서 이 동작을 확인하는 ShareCoordinatorServiceTest 한 건이 실패했습니다. 프로덕션 코드는 바뀌지 않았고 첫 번째 정리도 의도대로 동작했지만, 같은 작업이 다시 실행되면 파티션별 성공과 실패 조건이 달라졌습니다.
처음에는 Mockito의 반환 방식이 바뀌어서 생긴 호환성 문제로 보였습니다. 원인을 따라가며 라이브러리 업그레이드가 새로운 문제를 만든 것인지, 기존 테스트가 첫 실행의 값에 우연히 기대고 있었는지 확인해봤습니다.
레코드 정리 작업은 파티션별로 반복된다#
ShareCoordinatorService의 타이머 작업은 coordinator runtime에서 파티션마다 정리할 offset을 가져온 뒤 deleteRecords(partition, offset)을 호출합니다. 반환된 CompletableFuture가 완료되면 해당 주기의 성공과 실패를 처리하고, 설정된 시간이 지나면 새 offset으로 같은 작업을 반복합니다.
testRecordPruningTaskPeriodicityWithSomeFailures()는 이 주기 작업이 일부 실패에도 계속되는지 확인합니다. tp1은 성공하고 tp2는 실패하지만, 어느 한쪽의 결과 때문에 다음 실행 자체가 중단되면 안 됩니다. 테스트가 여러 주기에 걸쳐 유지해야 할 조건은 다음과 같았습니다.
tp1의 deleteRecords() → 성공
tp2의 deleteRecords() → 실패
다음 주기에도 같은 성공·실패 조건 유지파티션은 성공과 실패를 구분하는 작업 대상이고, offset은 정리가 진행될 때마다 달라지는 위치입니다. 하지만 기존 스텁은 작업 대상이 아니라 첫 실행에서 사용한 offset을 기준으로 결과를 나눴습니다.
when(writer.deleteRecords(any(), eq(10L)))
.thenReturn(CompletableFuture.completedFuture(null));
when(writer.deleteRecords(any(), eq(20L)))
.thenReturn(CompletableFuture.failedFuture(new Exception("bad stuff")));Mockito 스텁은 호출 인자가 matcher와 모두 일치할 때만 설정한 값을 반환합니다. 첫 번째 실행에서는 tp1의 offset이 10이고 tp2의 offset이 20이어서 두 스텁이 의도대로 선택됩니다. 다음 실행에서 offset이 11과 21로 바뀌면 어느 스텁도 일치하지 않고, 호출 결과는 Mockito의 기본 응답에 맡겨집니다.
매칭되지 않은 호출의 기본 응답이 달라졌다#
Mockito 5.20에서는 스텁과 일치하지 않는 CompletableFuture 반환 메서드의 기본값이 null이었습니다. 5.21부터는 이 기본값이 completedFuture(null)로 바뀌었습니다. 두 번째 주기의 deleteRecords()는 이전 버전에서도 스텁과 일치하지 않았지만, 반환값이 null이라 성공한 비동기 작업으로 처리되지 않았습니다. 업그레이드 후에는 이미 정상 완료된 Future가 반환되면서 같은 호출이 성공한 것처럼 보였습니다.
프로덕션 코드의 정리 방식이나 테스트가 확인하려던 시나리오는 바뀌지 않았습니다. 달라진 것은 매칭되지 않은 호출을 Mockito가 처리하는 방식이었습니다. 라이브러리 업그레이드는 고정 offset에 의존한 스텁을 새로 만들지 않았고, 기존 매칭 실패가 테스트 결과에 드러나게 했습니다.
offset 대신 partition으로 성공과 실패를 구분하다#
성공과 실패를 가르는 기준을 테스트의 의도와 맞췄습니다. offset은 이 테스트의 관심사가 아니므로 anyLong()으로 두고, partition만 명시했습니다.
when(writer.deleteRecords(
- any(),
- eq(10L)
+ eq(tp1),
+ anyLong()
)).thenReturn(CompletableFuture.completedFuture(null));
when(writer.deleteRecords(
- any(),
- eq(20L)
+ eq(tp2),
+ anyLong()
)).thenReturn(CompletableFuture.failedFuture(new Exception("bad stuff")));eq(tp1)과 eq(tp2)는 어떤 작업의 결과인지 구분하고, anyLong()은 이 테스트가 정리 위치의 정확성을 판단하지 않는다는 뜻을 드러냅니다. offset 값도 검증해야 한다면 별도 assertion으로 확인할 수 있지만, 파티션별 실패 복구를 다루는 스텁에 두 책임을 함께 넣을 필요는 없었습니다.
이제 다음 주기에 offset이 바뀌어도 tp1은 성공하고 tp2는 실패합니다. 스텁은 첫 실행의 offset이 아니라 주기가 바뀌어도 유지되는 partition을 기준으로 동작합니다.
Mockito 5.21과 5.23에서 반복 실행을 확인하다#
변경 후 Mockito 5.21과 PR에서 올린 5.23 환경에서 ShareCoordinatorServiceTest 전체를 실행했습니다. 두 버전 모두 통과했고, 리뷰에서도 partition과 anyLong()의 조합이 테스트의 실제 의도를 표현한다는 점을 확인받았습니다. 변경은 Apache Kafka PR #21857로 2026년 4월 10일 병합됐습니다.
반복 테스트의 스텁은 변하지 않는 값을 기준으로 삼아야 했다#
반복 작업의 입력에는 같은 대상을 나타내는 값과 실행할 때마다 변하는 값이 함께 들어갑니다. 이 테스트에서는 partition이 작업을 구분하는 식별자였고 offset은 정리 진행 상황이었습니다. 스텁은 시나리오를 나누는 식별자에 연결하고, 진행 값은 해당 테스트가 정말 검증해야 할 때 따로 확인하는 편이 의도를 더 정확하게 남깁니다.
Kafka 소스코드를 읽고 다른 문제로 기여 범위를 넓혀간 과정은 카프카를 쓰기만 하던 개발자가 Apache Kafka 코드를 고치기까지에서 이어서 정리했습니다.