인증 장애가 끝났는데 Kafka Consumer CPU는 왜 100%로 남았을까
Kafka Consumer의 CPU 문제를 설명하는 글인데 OAuth 인증 서버와 heartbeat thread부터 나오면 둘이 왜 연결되는지 바로 이해하기가 쉽지않아 문제를 따라가기 전에 Kafka에서 OAuth가 어디에 사용되고, 인증된 연결 위에서 Consumer Group이 어떻게 유지되는지부터 잠깐 짚어보겠습니다.
Kafka에서 OAuth는 broker 연결을 인증할 때 사용한다#
Kafka에 접속하는 Producer와 Consumer는 데이터를 보내거나 가져오기 전에 broker에 자신의 신원을 증명합니다. SASL은 이 인증 절차에 여러 방식을 사용할 수 있게 하는 체계이고, OAUTHBEARER는 OAuth 2.0의 access token을 이용하는 방식입니다. 계정 정보를 Kafka client마다 직접 관리하는 대신 OAuth 인증 서버가 token을 발급하고 Kafka가 그 token으로 client의 신원을 확인합니다.
Consumer를 기준으로 보면 각 구성 요소의 관계는 다음과 같습니다.
Consumer ── token 발급·갱신 ──> OAuth 인증 서버
Consumer ── SASL/OAUTHBEARER 인증 ──> Kafka broker
Consumer ── fetch·heartbeat 요청 ──> Kafka brokerConsumer Group의 heartbeat가 OAuth 서버를 매번 직접 호출하는 것은 아닙니다. heartbeat는 coordinator 역할을 하는 Kafka broker로 전송됩니다. 다만 이 요청이 오가는 broker 연결에는 인증이 필요하고, Kafka의 SASL/OAUTHBEARER 문서에 설명된 것처럼 client는 token이 만료되기 전에 갱신합니다. 이 시점에 인증 서버가 응답하지 않으면 재인증이 실패하고 heartbeat 처리 경로에서도 AuthenticationException이 발생할 수 있습니다.
KAFKA-20253의 재현 환경에서는 Keycloak을 중단해 재인증을 실패시킨 뒤 다시 실행했습니다. 이후 token 발급과 broker 인증은 다시 가능해졌지만 Consumer 프로세스의 CPU 사용률은 100%에 머물렀습니다. 이슈에 첨부된 재현 프로젝트를 실행하고 heartbeat thread의 예외 처리부터 다음 poll()까지 따라가며, 외부 장애가 끝난 뒤에도 복구되지 않은 내부 상태를 확인해봤습니다.
인증된 연결 위에서 poll()과 heartbeat가 group을 유지한다#
인증을 마친 Classic Kafka Consumer가 group에 가입하면 coordinator로부터 member id와 generation, 파티션 할당을 받습니다. 이후 애플리케이션 스레드는 poll()로 레코드와 네트워크 응답을 처리하고, 별도의 heartbeat thread는 주기적으로 heartbeat 요청을 보내 멤버십을 유지합니다. coordinator가 heartbeat를 계속 받는 동안에는 같은 generation과 할당을 사용할 수 있습니다.
두 실행 경로는 독립적이지만 같은 coordinator 상태를 사용합니다. poll()은 group 가입이 필요한지 확인하고 heartbeat thread가 없으면 시작합니다. 네트워크를 기다릴 때는 timeToNextHeartbeat()를 참고해 다음 heartbeat 시점을 넘기지 않는 timeout을 선택합니다. heartbeat가 전송되면 timer가 다시 설정되므로, 평소에는 이 값이 0에 고정되지 않습니다.
인증 예외가 heartbeat thread만 종료했다#
기존 코드는 heartbeat thread에서 AuthenticationException을 받으면 오류를 저장하고 thread를 종료했습니다.
catch (AuthenticationException e) {
log.error("An authentication error occurred in the heartbeat thread", e);
setFailureCause(e);
}setFailureCause(e)는 애플리케이션 스레드가 오류를 확인하게 하지만 group 상태를 변경하지는 않습니다. heartbeat thread는 종료되면서 참조가 null로 정리됐고, 다음과 같은 값이 함께 남았습니다.
state = STABLE
rejoinNeeded = false
heartbeatThread = null
heartbeatTimer.remainingMs() = 0STABLE은 마지막 group 가입 결과가 유효하다는 뜻이지만 heartbeat가 실행 중이라는 뜻은 아닙니다. rejoinNeeded도 false라서 다음 poll()의 ensureActiveGroup()은 가입 절차를 건너뛰었고, 가입 과정에서 호출되는 heartbeat 초기화도 실행되지 않았습니다.
동시에 timeToNextHeartbeat()는 만료된 timer의 0을 반환했습니다. NetworkClient.poll(0) 자체는 오류가 아닙니다. 바로 처리해야 할 heartbeat나 네트워크 이벤트가 있을 때는 기다리지 않고 selector를 확인하는 정상적인 경로입니다. 다만 timer를 갱신할 heartbeat thread가 사라진 상태에서는 다음 호출도 계속 0을 받습니다. 내부 selector가 대기하지 않는 selectNow()를 반복하면서 인증 장애가 끝난 뒤에도 CPU를 사용하는 busy spin이 남았습니다.
인증 예외 뒤 다음 poll()에서 재가입하도록 바꾸다#
AuthenticationException을 처리할 때 group 재가입을 함께 요청하도록 변경했습니다.
catch (AuthenticationException e) {
log.error("An authentication error occurred in the heartbeat thread", e);
setFailureCause(e);
+ requestRejoin("authentication error in heartbeat thread");
}requestRejoin()은 heartbeat thread 안에서 즉시 가입을 시도하지 않고, 다음 애플리케이션 poll()에서 재가입하도록 coordinator 상태를 바꿉니다. 다음 ensureActiveGroup()은 join과 sync를 수행하고 startHeartbeatThreadIfNeeded()로 heartbeat thread를 다시 만듭니다. 이 과정에서 heartbeat timer도 초기화되므로 네트워크 대기 시간이 다시 정상적으로 계산됩니다.
GroupAuthorizationException에는 같은 처리를 적용하지 않았습니다. 일시적인 인증 실패와 달리 ACL 거부는 group에 다시 가입해도 같은 실패가 반복되므로, 재가입이 복구 조건이 아니기 때문입니다.
requestRejoin()은 heartbeat만 다시 시작하는 기능이 아니므로 리밸런싱이 발생할 수 있습니다. 리뷰 과정에서도 인증 장애가 session timeout보다 짧다면 전용 경로로 heartbeat thread만 다시 만드는 편이 낫지 않겠느냐는 가능성을 검토했습니다. 하지만 Consumer는 인증이 복구된 시점에 기존 멤버십이 아직 유효한지 바로 알 수 없습니다. 전용 경로를 추가하면 heartbeat를 먼저 재시작한 뒤 session이 만료된 경우 다시 재가입하는 복구 흐름까지 별도로 관리해야 합니다.
이 경로가 불필요한 리밸런싱을 줄일 수 있는 경우는 인증 장애가 session timeout보다 짧을 때입니다. 반대로 session이 이미 만료됐다면 결국 재가입해야 합니다. 이번 변경은 새로운 부분 복구 경로를 추가하기보다 기존 ensureActiveGroup()을 사용해 group 상태와 heartbeat를 함께 초기화하는 쪽을 택했습니다.
인증 복구 후 heartbeat 요청과 응답까지 확인하다#
단위 테스트는 첫 heartbeat에서만 인증 예외를 발생시켰습니다. 예외가 호출부로 전달되고 rejoinNeededOrPending()이 true로 바뀐 뒤, 다음 ensureActiveGroup()에 join·sync 응답을 제공했습니다. 새 generation으로 가입한 다음 heartbeat 요청과 응답까지 완료되는지를 확인했습니다.
초기 테스트는 rejoinNeededOrPending()이 true로 바뀌는지만 확인했습니다. 리뷰에서는 이 테스트가 선택한 구현을 확인할 뿐, 인증 문제가 사라진 뒤 Consumer가 실제로 복구되는지는 보여주지 못한다는 의견이 있었습니다. 첫 heartbeat에서 인증 예외를 발생시키고, 다음 ensureActiveGroup()이 JoinGroup과 SyncGroup을 마친 뒤 다시 heartbeat 요청과 응답을 처리하는 데까지 검증 범위를 넓혔습니다.
마지막 assertion이 heartbeat를 보내지 않고도 통과하지 않도록 응답 matcher는 HeartbeatRequest만 받게 했습니다. 준비한 heartbeat 응답이 실제로 소비되고 진행 중인 요청이 남지 않은 상태까지 확인한 뒤 승인을 받았습니다.
재현 프로젝트에서는 다음 결과를 확인했습니다.
| 조건 | CPU 사용률 | Consumer generation |
|---|---|---|
| 변경 전 | 약 101%로 지속 | 5에서 멈춤 |
| 변경 후 | 약 0.3% | 5에서 15로 변경되며 재가입 |
이 수치는 운영 환경의 성능 측정값이 아니라 이슈 제보자의 재현 프로젝트에서 수정 전후를 비교한 결과입니다. 제보자와 리뷰어도 같은 재현 방식으로 busy spin이 다시 발생하지 않는 것을 확인했습니다. 변경은 Apache Kafka PR #22073으로 2026년 7월 27일 trunk에 병합됐습니다.
Classic과 Async·Share Consumer의 busy spin 수정을 4.3과 4.2에 백포트하다#
PR #22073은 전용 heartbeat thread를 사용하는 Classic Consumer를 수정했습니다. 별도로 진행된 PR #22836은 RequestManager를 사용하는 Async·Share Consumer의 두 실행 경로를 다뤘습니다.
AbstractHeartbeatRequestManager.maximumTimeToWait()는 coordinator를 찾지 못했거나 heartbeat를 보내지 않는 상태에서도 만료된 timer의 0을 반환할 수 있었습니다. CommitRequestManager도 auto-commit timer가 만료됐지만 이전 commit이 진행 중이면 새 commit을 시작할 수 없는데 0을 반환했습니다. #22836은 각각 heartbeat interval과 auto-commit interval을 반환해 애플리케이션 thread와 Consumer network thread가 0ms 대기를 반복하지 않도록 바꿨습니다. 리뷰에서 Share Consumer도 같은 network thread를 사용한다는 점이 확인되면서 ShareHeartbeatRequestManagerTest에도 busy spin 회귀 테스트가 추가됐습니다.
두 수정이 trunk에 병합된 뒤 백포트 여부를 논의했습니다. KAFKA-20253에는 4.0.0과 4.2.0이 영향 버전으로 기록되어 있었고, busy spin은 운영에 직접 영향을 주는 문제였습니다. 답변하기 전에 두 PR을 4.3과 4.2에 각각 cherry-pick해봤습니다. #22073은 두 브랜치에 그대로 적용됐지만 #22836은 4.3의 테스트 import와 4.2의 AbstractHeartbeatRequestManager에서 충돌했습니다. 이 결과를 공유한 뒤 백포트 PR을 따로 여는 방향으로 정리됐습니다.
4.3 백포트 PR #22964에서는 ShareHeartbeatRequestManagerTest가 이미 anyBoolean()을 사용하고 있어 import 충돌만 정리했습니다. 나머지 코드와 회귀 테스트는 trunk 변경을 그대로 적용할 수 있었습니다.
4.2 백포트 PR #22965에는 trunk의 UNSUBSCRIBED 조기 반환이 없었습니다. 이 조건은 별도 변경인 PR #21239에서 추가됐기 때문에 KAFKA-20253 백포트에 함께 넣지 않았습니다. 4.2의 shouldSkipHeartbeat()가 이미 UNSUBSCRIBED를 처리하므로, 해당 상태에서는 trunk의 Long.MAX_VALUE 대신 heartbeatIntervalMs()를 반환합니다. 반환값은 다르지만 0ms 대기를 반복하지 않으며 4.2의 기존 상태 흐름도 유지할 수 있었습니다.
두 백포트에서는 AbstractCoordinatorTest, ConsumerHeartbeatRequestManagerTest, CoordinatorRequestManagerTest, ShareHeartbeatRequestManagerTest를 각 브랜치에서 실행했습니다. 두 PR은 각각 두 명의 승인을 받은 뒤 2026년 7월 30일 병합됐습니다. 같은 diff를 적용하는 것보다 유지보수 브랜치에서 같은 busy spin 방지 조건이 성립하는지를 확인하는 일이 백포트의 중심이었습니다.
예외를 전달한 뒤 종료된 heartbeat도 다시 시작해야 했다#
비동기 구성 요소의 오류 처리는 예외를 호출부에 전달하는 것으로 끝나지 않습니다. 현재 상태가 다음 정상 동작을 시작할 수 있는지, 종료된 실행 주체를 어느 경로에서 다시 만들지, 만료된 timer를 누가 초기화할지까지 이어져야 합니다. 이번 문제에서는 STABLE이라는 group 상태만 보면 보이지 않던 heartbeat 실행 상태와 timer를 함께 확인해야 복구 경로가 완성됐습니다.
Kafka 소스코드를 읽고 이 기여까지 이어진 과정은 카프카를 쓰기만 하던 개발자가 Apache Kafka 코드를 고치기까지에서 정리했습니다.