StickyTaskAssignor는 왜 리밸런싱을 다섯 번 반복했을까
Kafka Streams는 처리 토폴로지와 입력 파티션을 기준으로 작업을 태스크 단위로 나눕니다. active 태스크는 레코드를 처리하는 실행 단위이며 집계나 조인에 사용하는 로컬 상태도 가질 수 있습니다. 여러 인스턴스와 처리 스레드는 이 태스크들을 나눠 실행합니다.
인스턴스를 늘리면 처리 용량은 커지지만 기존 태스크 일부를 새 인스턴스로 옮겨야 합니다. 상태가 있는 태스크는 새 위치에서 복구 과정이 필요하므로 StickyTaskAssignor는 부하를 고르게 나누면서도 이전 할당을 가능한 한 유지합니다.
그런데 KAFKA-20198에 제보된 조건에서는 450개 태스크를 20개 처리 스레드에 나누는 할당이 안정되기까지 다섯 번의 리밸런싱을 거쳤습니다.
스케일 아웃으로 태스크가 한 번 이동하는 것과 할당 결과가 여러 라운드 동안 계속 달라지는 것은 다른 문제였습니다. 실제 StickyTaskAssignor가 이전 할당을 유지하는 순서부터 따라가며, 정수 나눗셈에서 버려진 나머지가 기존 태스크의 유지 범위와 다음 리밸런싱 결과를 어떻게 바꾸는지 확인했습니다.
Cooperative 리밸런싱의 단계적 소유권 이전#
인스턴스가 추가되면 각 인스턴스는 자신이 현재 소유한 태스크 정보를 포함해 리밸런싱에 참여합니다. cooperative 방식에서는 이미 소유한 태스크를 곧바로 다른 인스턴스에 중복 할당하지 않고, 필요한 태스크만 반납한 뒤 다음 리밸런싱에서 새 소유자에게 넘깁니다. 한 라운드의 결과가 다음 라운드의 이전 할당이 되므로, assignor가 매번 다른 목표를 만들면 태스크 이동과 리밸런싱도 계속 이어집니다. 따라서 리밸런싱 횟수보다 먼저 기존 태스크를 어느 범위까지 유지하는지 확인할 필요가 있었습니다.
기존 할당은 인스턴스별 상한 안에서 유지된다#
StickyTaskAssignor는 전체 태스크를 미할당 집합에 넣은 뒤, 이전 active 태스크부터 원래 인스턴스에 다시 배치합니다. 이때 인스턴스가 공정한 몫을 넘지 않았는지 hasRoomForActiveTask()로 확인합니다. 상한 때문에 유지하지 못한 태스크는 미할당 집합에 남고, 이후 findBestClientForTask()가 처리할 인스턴스를 다시 선택합니다.
여기서 상한은 단순한 성능 최적화 값이 아닙니다. 너무 크면 새 인스턴스로 태스크가 충분히 이동하지 않고, 너무 작으면 유지할 수 있는 태스크까지 미할당 경로로 밀려납니다.
기존 코드는 전체 태스크 수를 전체 스레드 수로 먼저 나눈 뒤, 인스턴스가 가진 스레드 수를 곱했습니다.
int activeTasksPerThread = taskCount / totalCapacity;
int instanceLimit = capacity * activeTasksPerThread;태스크 450개를 처리 스레드 10개씩 가진 인스턴스 두 개에 나누면 전체 용량은 20입니다. 첫 계산은 스레드 한 개의 몫을 정수로 구하므로 나머지 10개를 표현하지 못합니다.
450 / 20 = 22
10 × 22 = 인스턴스당 220개실제 인스턴스별 비례 몫은 225개지만 계산된 상한은 220개입니다. 두 인스턴스의 상한을 합쳐도 440개에 불과하므로, 공정하게 유지할 수 있었던 10개 태스크가 미할당 경로로 넘어갑니다. findBestClientForTask()는 HashMap 순회 순서에 따라 이 태스크들을 라운드마다 다르게 배치했고, 새 할당이 다시 다음 라운드의 입력이 되면서 수렴이 늦어졌습니다.
정수 나눗셈에서는 버림이 일어난 뒤 ceil()을 호출해도 나머지를 복원할 수 없습니다. Java에서 Math.ceil(taskCount / totalCapacity)라고 작성하면 괄호 안의 정수 나눗셈이 먼저 끝나므로 450 / 20은 이미 22입니다. Math.ceil(22)의 결과도 22입니다.
나눗셈 전에 실수로 변환해 스레드당 몫을 올리는 방식도 이 분배 규칙에는 맞지 않습니다. ceil(450 / 20) × 10은 인스턴스 상한을 230으로 만들어 반대 방향으로 오차가 생깁니다. 올림해야 하는 값은 스레드당 태스크 수가 아니라 인스턴스가 받을 비례 몫인 ceil(450 × 10 / 20)입니다.
인스턴스 비례 몫으로 수정한 상한 계산#
중간의 스레드당 몫을 없애고, 전체 태스크에서 해당 인스턴스의 용량이 차지하는 비율을 직접 계산했습니다. 양의 정수에서 ceil(a / b)는 (a + b - 1) / b로 구할 수 있으므로 다음 식은 실수 변환 없이 인스턴스 몫을 올림합니다.
int instanceLimit =
(taskCount * capacity + totalCapacity - 1) / totalCapacity;같은 조건에서는 다음과 같이 계산됩니다.
ceil(450 × 10 / 20) = 225이 계산은 AbstractStickyAssignor.maxQuota에서 사용하던 비례 할당 방식과도 일치했습니다. 각 인스턴스의 상한을 올림하면 상한의 합이 전체 태스크 수보다 커질 수 있지만, 상한은 실제 할당량이 아니라 기존 태스크를 유지할 수 있는 최대치입니다. 최종 할당 태스크 수는 여전히 전체 태스크 수를 넘지 않습니다.
hasRoomForActiveTask()는 이제 스레드당 몫을 확대해서 추정하지 않고, 해당 인스턴스가 받을 비례 몫과 현재 active 태스크 수를 직접 비교합니다.
반복 할당으로 검증한 수렴 라운드#
공식의 반환값만 확인하면 225가 계산된다는 사실까지만 알 수 있습니다. 변경 목적은 리밸런싱 결과를 안정시키는 것이므로, 회귀 테스트는 실제 StickyTaskAssignor를 실행하고 그 결과를 다음 라운드의 이전 할당으로 다시 전달했습니다. 연속한 두 결과가 같아질 때까지 이 과정을 반복해 수렴 라운드를 비교했습니다.
| 태스크 수 | 인스턴스별 스레드 | 변경 전 | 변경 후 |
|---|---|---|---|
| 450 | 10 | 5회 | 2회 |
| 100 | 4 | 4회 | 2회 |
| 200 | 8 | 6회 | 2회 |
| 1,000 | 16 | 9회 | 5회 |
| 500 | 12 | 9회 | 3회 |
PR의 회귀 테스트와 시뮬레이션에서 assignor를 반복한 결과인데, 나누어떨어지는 기존 조건은 변경 전후 모두 2회로 유지됐고, 450개 태스크 조건에서는 기존 인스턴스가 공정한 몫인 225개를 유지하는지도 별도로 확인했습니다.
변경과 회귀 테스트는 Apache Kafka PR #22006에 포함됐습니다. 병합 전 시스템 테스트에서는 Streams smoke test 3건이 한 차례 실패했습니다. 실패한 실행이 rebase 이전 head를 사용했고 같은 시기의 trunk에서도 동일한 실패가 발생한 점을 확인한 뒤, rebased head에서 세 시나리오를 다시 실행했습니다. 재실행과 이후 branch system test가 모두 통과한 뒤 PR은 2026년 7월 23일 trunk에 병합됐습니다.
분배 단위와 상한 계산 단위는 일치해야 했다#
분배 규칙이 인스턴스의 용량에 비례한 몫을 요구한다면 상한도 같은 단위에서 계산해야 합니다. 더 작은 스레드 단위에서 나머지를 버린 뒤 다시 곱하면 한 번의 계산 오차가 다음 할당의 입력으로 누적될 수 있습니다. 반복 알고리즘에서는 개별 결과의 오차뿐 아니라 그 결과가 다음 라운드의 상태를 어떻게 바꾸는지까지 확인해야 했습니다.
Kafka 소스 분석에서 이 변경까지 이어진 과정은 카프카를 쓰기만 하던 개발자가 Apache Kafka 코드를 고치기까지에서 정리했습니다.