dev notes

Kafka 메트릭 충돌을 고치고도 PR을 닫은 이유

2026-06-299 min read
공유

KRaft 모드에서 broker는 클라이언트의 데이터 요청을 처리하고, controller는 클러스터 메타데이터를 관리하는 quorum에 참여합니다. 두 역할은 별도 서버로 실행할 수도 있지만 process.roles=broker,controller로 설정한 Combined KRaft에서는 한 프로세스가 모두 담당합니다. Kafka의 KRaft 문서에 설명된 이 구성에서는 역할별로 SocketServer가 생성되지만 두 서버가 같은 JVM 자원을 사용합니다.

Combined KRaft 테스트에서는 broker의 메모리 풀 메트릭을 조회해도 controller의 값이 보이는 경우가 있었습니다. 서로 다른 SocketServer가 존재하는데도 메트릭을 조회하면 한쪽 값만 보이는 이유를 Yammer registry의 등록 과정에서 확인했습니다.

처음에는 역할과 node를 태그로 추가하면 끝나는 문제라고 생각했고 실제 코드와 테스트도 그 방식으로 동작했습니다. 하지만 외부에 노출되는 JMX 이름까지 확인하자 값의 정확성을 높이는 변경이 기존 모니터링을 끊을 수 있었습니다. 메트릭 충돌을 고친 PR을 왜 그대로 병합하지 않았는지 그 판단 과정을 정리해보려고 합니다.

하나의 JVM에서는 메트릭 이름도 공유된다#

Kafka server는 Yammer Metrics로 내부 측정값을 수집하고 이를 JMX로 노출합니다. Kafka 모니터링 문서는 JMX에서 MBean 이름을 찾아 broker 메트릭을 조회하는 방식을 안내합니다. 대시보드와 모니터링 에이전트도 이 이름을 기준으로 값을 수집할 수 있습니다.

Yammer 메트릭은 JVM 단위의 KafkaYammerMetrics.defaultRegistry에 등록됩니다. registry는 gauge 객체의 참조가 아니라 MetricName을 키로 사용합니다. Kafka의 KafkaMetricsGroup은 group, type, name과 tags로 registry의 식별자를 만들고, 같은 정보로 JMX에 노출할 MBean 이름도 구성합니다. 따라서 한 JVM에서 서로 다른 객체가 같은 이름을 사용하면 registry 입장에서는 같은 메트릭입니다.

SocketServer는 네트워크 처리 여유율과 메모리 풀 상태를 다음 네 gauge로 제공합니다.

  • NetworkProcessorAvgIdlePercent
  • MemoryPoolAvailable
  • MemoryPoolUsed
  • ExpiredConnectionsKilledCount

네 gauge에는 역할이나 node를 구분하는 tag가 없었습니다. controller SocketServer가 먼저 등록한 뒤 broker SocketServer가 같은 이름으로 newGauge()를 호출하면 새 gauge가 추가되지 않고 기존 gauge가 반환됩니다. 두 SocketServer는 모두 존재하지만 관측 경로는 먼저 등록된 controller 값만 가리키는 상태가 됐습니다.

node와 역할을 registry key에 추가하다#

처음에는 각 SocketServernodeIdlistenerType을 메트릭 태그에 추가했습니다.

scala
private val metricsTags = MetricsUtils.getTags(
  "nodeId", nodeId.toString,
  "listenerType", apiVersionManager.listenerType.name
)

nodeIdlistenerType을 tags에 넣으면 MetricName의 scope가 달라져 broker와 controller가 registry 안에서 별도 key를 갖습니다. 등록과 조회에 같은 tags를 사용했고, shutdown()에서도 정확히 그 이름을 제거하도록 정리 로직을 추가했습니다. 생성할 때만 이름을 구분하고 종료할 때 기존 이름으로 삭제하면 registry에 gauge가 남기 때문입니다.

테스트에서는 각 gauge의 MBean 이름에 nodeId=0listenerType=BROKER가 포함되는지, SocketServer 종료 후 registry에서 네 gauge가 모두 제거되는지 확인했습니다. reflection으로 내부 메모리 풀을 읽던 통합 테스트도 실제 메트릭을 조회하는 방식으로 바꿨습니다.

이 변경을 Apache Kafka PR #22074로 올렸습니다. registry 안에서 네 gauge를 구분하고 수명 주기까지 맞추는 범위에서는 의도한 결과가 나왔습니다.

같은 태그가 JMX MBean 이름에도 추가됐다#

KafkaMetricsGroup.metricName()은 tags를 registry 내부 scope와 JMX MBean 이름에 함께 사용합니다. 내부 충돌을 해결하려고 넣은 nodeIdlistenerType은 외부에서 조회하는 ObjectName에도 추가됩니다. 기존 이름을 정확히 지정해 수집하던 대시보드와 alert, 모니터링 에이전트는 값이 계속 존재해도 변경된 MBean을 찾지 못할 수 있습니다.

메트릭의 내부 식별자와 외부 조회 이름이 같은 정보에서 만들어지기 때문에, tags 추가는 구현 내부의 수정으로 끝나지 않았습니다. 새 인스턴스를 구분하는 문제는 해결했지만 Kafka가 공개해온 관측 인터페이스를 유지하는 조건은 충족하지 못했습니다.

JMX 조회 경로의 호환성을 확보하지 못한 변경#

PR #22074는 nodeIdlistenerType tag로 메트릭 인스턴스를 구분해 registry 내부의 충돌을 해결했습니다. 그러나 같은 tag가 JMX ObjectName에도 반영되면서 기존 MBean 이름을 사용하는 조회 경로가 달라졌습니다. 충돌은 해결했지만 기존 JMX 사용자가 같은 이름으로 메트릭을 계속 수집할 수 있는 호환성 경로는 제공하지 못했습니다.

KIP-1207RequestHandlerAvgIdlePercent에서 발생한 비슷한 Combined KRaft 충돌을 다루면서 기존 MBean에 역할 tag를 추가하지 않았습니다. 대신 broker와 controller를 구분하는 별도 메트릭 이름을 제공해 기존 수집 경로를 유지하면서 역할별 값도 조회할 수 있도록 설계했습니다.

두 변경의 차이는 충돌을 없애는 방법보다 기존 이름을 사용하는 수집 경로를 보존했는지에 있었습니다. PR #22074에는 기존 이름을 유지할 기간이나 새 이름으로 이전하는 절차가 없었기 때문에, 이 호환성 문제를 남기고 PR을 올린 당일 직접 닫았습니다.

이후 같은 문제를 다시 다룬다면 기존 MBean을 유지하고 역할별 메트릭을 별도로 제공하거나, 공개 이름을 바꿔야 한다면 KIP 절차에서 마이그레이션 경로까지 함께 논의해야 합니다.

JMX ObjectName도 공개 호환성의 일부였다#

메트릭 수정은 값이 정확한지만 확인해서 끝나지 않습니다. registry key, JMX ObjectName, 대시보드의 조회 조건이 같은 이름에 연결돼 있다면 그 이름도 호환성을 검토해야 하는 공개 인터페이스입니다. 내부 충돌을 없앤 변경이 외부 관측을 끊는다면, 기존 이름을 유지하는 새 경로까지 설계 범위에 포함해야 합니다.

병합된 변경과 병합하지 않은 판단을 함께 정리한 과정은 카프카를 쓰기만 하던 개발자가 Apache Kafka 코드를 고치기까지에서 이어집니다.

Connected Notes