카프카를 쓰기만 하던 개발자가 Apache Kafka 코드를 고치기까지
업무에서 사용하던 오픈소스의 내부 코드를 직접 고치는 일은 처음부터 목표가 아니었습니다. 토픽을 설계하고 Consumer 동작을 확인하려고 KafkaConsumer.poll()부터 따라가다 보니, 작은 테스트 수정에서 시작한 작업이 태스크 할당과 장애 복구, 메트릭 호환성 검토까지 이어졌습니다.
이 글에서는 Apache Kafka 소스코드를 처음 읽기 시작한 시점부터 병합된 변경과 직접 닫은 PR까지, 기여 범위가 어떻게 넓어졌는지 정리해보려고 합니다.
Kafka를 사용하면서도 내부 동작은 잘 몰랐다#
입사 후 Kafka를 사용했지만 업무 범위는 Producer로 이벤트를 보내고 @KafkaListener로 처리하는 정도였습니다. 이미 구성된 토픽과 Consumer 설정을 사용하면 기능을 만드는 데는 문제가 없었고, 파티션 할당이나 리밸런싱이 실제로 어떤 코드에서 이루어지는지까지 알 필요는 없었습니다.
신규 프로젝트에서 토픽 설계와 파이프라인 구성을 직접 맡으면서 상황이 달라졌습니다. 파티션은 어떤 조건으로 나뉘고 Consumer Group은 왜 리밸런싱되는지 확인하려면 공식 문서만으로는 부족했습니다. 업무에서 사용하던 KafkaConsumer.poll()을 시작점으로 Apache Kafka 소스코드를 따라가기 시작했습니다.
소스코드를 읽는다고 바로 기여할 수 있었던 것은 아닙니다. 처음 찾은 동시성 문제는 이미 이슈와 이전 PR이 있었고, Kafka Streams의 메모리 누수는 재현과 수정까지 마친 시점에 다른 개발자가 하루 먼저 PR을 올렸습니다. 그래도 문제가 발생하는 조건을 만들고, 수정 전후를 같은 방법으로 비교하는 경험은 다음 기여에서 그대로 사용됐습니다.
시작은 간단한 테스트 수정부터#
오픈소스 기여는 Mockito 업그레이드로 깨진 테스트를 고친 PR #21857에서 시작했습니다. 반복 작업의 성공과 실패를 파티션별로 확인하는 테스트였지만, 스텁은 첫 실행에서만 유효한 offset에 맞춰져 있었습니다.
스텁 조건을 partition 기준으로 바꾸면서 테스트가 구분하려던 조건과 matcher가 다시 일치했습니다. 변경한 코드는 작았지만, 이때부터 기여를 수정량보다 테스트가 보장할 규칙을 정확히 설명하는 일로 보게 됐습니다. matcher와 Mockito 기본 응답의 관계는 Mockito 업그레이드 후 Kafka 테스트가 깨진 이유에 정리했습니다.
재현했지만 PR로 이어지지 않은 두 문제#
Kafka의 Sensor는 처리량이나 지연 시간 같은 측정값을 여러 통계 객체에 기록하고, 설정된 quota를 넘었는지도 확인합니다. 정상 흐름에서는 record()가 측정값을 반영한 뒤 같은 통계 목록을 사용해 quota를 검사합니다. 그런데 별도 호출인 checkQuotas()가 thread-safe하지 않은 LinkedHashMap을 순회하는 동안 record()가 같은 맵을 변경할 수 있었습니다. 두 메서드를 동시에 실행해 ConcurrentModificationException을 재현했고, 짧은 구간에서 스냅샷을 만든 뒤 lock 없이 순회하는 방식까지 검토했습니다. 하지만 KAFKA-15154에는 이미 담당자가 배정되어 있어 PR을 올리지는 않았습니다.
Kafka Streams는 처리 스레드에서 복구할 수 없는 예외가 발생했을 때 애플리케이션 전체를 종료하는 대신 REPLACE_THREAD로 해당 StreamThread만 교체할 수 있습니다. 교체가 끝나면 종료된 스레드가 사용하던 Consumer와 메트릭도 함께 해제되어야 합니다. KAFKA-20398에서는 이 교체를 반복할수록 종료된 StreamThread가 회수되지 않는 현상을 재현했습니다. 4.0부터 추가된 metrics reporter가 생성될 때는 전역 레지스트리에 등록되지만 종료될 때 제거되지 않아, reporter가 Consumer와 Sensor를 계속 참조하고 있었습니다. completeShutdown()에서 reporter를 제거한 뒤 인스턴스 수가 교체 횟수와 함께 증가하지 않는 것까지 확인했지만, PR을 준비하던 시점에 같은 수정이 PR #21973으로 먼저 올라왔습니다.
두 분석 모두 제 커밋으로 남지는 않았지만, 버전별 재현과 heap histogram, 참조 경로, 도입 커밋을 함께 보는 방식은 이후 문제를 좁히는 기준이 됐습니다.
다음에는 태스크 할당 계산을 고쳤다#
다음 기여는 Kafka Streams의 StickyTaskAssignor가 스케일 아웃 후 여러 번의 리밸런싱을 거쳐야 수렴하는 문제였습니다. 인스턴스별 태스크 상한을 계산하면서 정수 나눗셈이 너무 일찍 실행됐고, 공정하게 유지할 수 있는 기존 태스크까지 재할당 경로로 넘어갔습니다.
정수 나눗셈에서 나머지를 버린 뒤 ceil()을 호출해도 값은 복원되지 않습니다. 이 경우에는 스레드당 몫을 올리는 것도 아니라, 인스턴스의 용량에 비례한 몫을 직접 계산해야 했습니다.
상한을 인스턴스 단위로 다시 계산하고 실제 assignor의 이전 할당을 다음 입력으로 넘기며 수렴을 확인했습니다. 변경은 PR #22006으로 병합됐고, cooperative rebalance와 비례 몫 계산은 StickyTaskAssignor는 왜 리밸런싱을 다섯 번 반복했을까에서 다룹니다.
인증 장애 뒤 Consumer가 다시 동작하게 했다#
Classic Consumer에서는 OAuth 인증 서버의 일시 장애 뒤 heartbeat thread만 종료되고 group 상태는 그대로 남는 문제가 있었습니다. 예외는 전달됐지만 다음 poll()이 heartbeat를 다시 시작할 상태 전이는 빠져 있었습니다.
AuthenticationException에서 재가입을 요청하도록 바꾼 PR #22073이 trunk에 병합됐습니다. 이어서 4.3 백포트와 4.2 백포트를 각 브랜치의 상태 흐름에 맞춰 반영했습니다. poll(0)이 반복된 과정과 heartbeat 복구 범위는 인증 장애가 끝났는데 Kafka Consumer CPU는 왜 100%로 남았을까에 정리했습니다.
JMX 호환성 때문에 메트릭 PR을 닫았다#
Combined KRaft에서는 broker와 controller의 SocketServer가 같은 Yammer registry를 사용하면서 동일한 이름의 gauge가 충돌했습니다. nodeId와 listenerType tags로 메트릭을 구분하는 PR #22074를 올렸지만, 같은 tags가 외부 JMX MBean 이름도 바꾼다는 점을 확인했습니다.
비슷한 충돌을 다룬 KIP-1207이 기존 이름을 유지한 채 역할별 메트릭을 추가한 방식을 확인하고 PR을 직접 닫았습니다. registry key와 공개 관측 이름이 연결되는 원리는 Kafka 메트릭 충돌을 고치고도 PR을 닫은 이유에 남겼습니다.
기여는 수정 코드보다 검증 범위를 정하는 일이었다#
처음에는 오픈소스 기여를 저장소에서 버그를 찾아 코드를 보내는 일로 생각했습니다. 실제로는 재현 방법과 회귀 테스트, 공개 호환성, 지원 브랜치까지 검증 범위를 정해야 변경이 끝났습니다. 이후에는 코드를 고칠 수 있는지만 보기보다, 어떤 근거로 병합하고 어느 범위까지 책임질 수 있는지를 먼저 확인하게 됐습니다.