Skip to content

feat: 서비스 도메인 메트릭 추가 - #98

Merged
HI-JIN2 merged 1 commit into
mainfrom
feat/service-domain-metrics
Jun 22, 2026
Merged

feat: 서비스 도메인 메트릭 추가#98
HI-JIN2 merged 1 commit into
mainfrom
feat/service-domain-metrics

Conversation

@HI-JIN2

@HI-JIN2 HI-JIN2 commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

요약

  • control-service telemetry/Kafka/SSE 메트릭 추가
  • dispatch-service 차량 상태/Redis GEO/후보 탐색 도메인 메트릭 추가
  • Prometheus actuator endpoint 노출 설정 추가

검증

  • ./gradlew :control-service:test :dispatch-service:test

@HI-JIN2
HI-JIN2 merged commit c148f04 into main Jun 22, 2026
7 checks passed
@HI-JIN2
HI-JIN2 deleted the feat/service-domain-metrics branch June 22, 2026 07:20

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates Prometheus metrics monitoring into both control-service and dispatch-service using Micrometer, adding counters and gauges to track telemetry reception, Kafka publishing, SSE broadcasting, and vehicle status counts. The review feedback highlights a critical issue with local ConcurrentHashMap state management in CarStateService which can cause inconsistency in clustered environments, memory leaks, and metrics distortion. Additionally, improvements are suggested to pre-register counters to avoid dynamic creation overhead, fix a potential garbage collection issue with Micrometer's Gauge weak reference, simplify Kotlin constructor properties, and use higher-level Spring Data Redis APIs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +26 to +27
private val currentStatusCounts = ConcurrentHashMap<String, AtomicInteger>()
private val carStatuses = ConcurrentHashMap<Long, String>()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

이 로컬 ConcurrentHashMap 기반의 상태 관리 방식은 운영 환경에서 심각한 문제를 일으킬 수 있습니다.

  1. 다중 인스턴스(Clustered) 환경에서의 정합성 불일치: dispatch-service가 여러 인스턴스로 로드밸런싱되어 실행되는 경우, 각 인스턴스는 자신이 처리한 차량의 상태만 메모리에 유지하므로 프로메테우스 게이지(baro_dispatch_vehicle_status_current)가 인스턴스마다 서로 다르고 부정확한 값을 수집하게 됩니다.
  2. 애플리케이션 재시작 시 메트릭 왜곡: 서비스가 재시작되면 메모리 맵이 초기화됩니다. 이후 기존 차량이 상태 업데이트를 보낼 때 previousStatusnull이 되므로, 이전 상태에 대한 차감 없이 새로운 상태의 카운트만 증가하여 전체 메트릭 수치가 실제보다 비정상적으로 비대해집니다.
  3. 메모리 누수(Memory Leak): carStatuses 맵에 한 번 등록된 차량 ID는 영구히 메모리에 유지되며, 만료(TTL)나 정리 메커니즘이 없어 시간이 지남에 따라 메모리 사용량이 계속 증가합니다.

해결 방안:
차량의 현재 상태를 공유 저장소(예: Redis Hash 또는 DB)에 저장하고, 게이지 등록 시 해당 저장소의 상태별 카운트를 동적으로 조회하도록 설계하는 것을 강력히 권장합니다.

class TelemetryService(
private val kafkaTemplate: KafkaTemplate<String, Any>,
private val stateStore: VehicleStateStore,
meterRegistry: MeterRegistry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

meterRegistry를 생성자 매개변수에서 직접 private val 프로퍼티로 선언하면, 클래스 본문에서 private val meterRegistry = meterRegistry와 같이 별도로 대입할 필요가 없어 코드가 더 간결하고 코틀린답게 바뀝니다.

Suggested change
meterRegistry: MeterRegistry,
private val meterRegistry: MeterRegistry,

private val knownStatuses = setOf("idle", "moving_to_pickup", "driving", "relocating")
}

private val meterRegistry = meterRegistry

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

생성자에서 private val meterRegistry로 직접 선언하면 이 중복 대입 선언은 제거할 수 있습니다.

Comment on lines +25 to +28
private fun receivedCounter(status: String) = Counter.builder("baro_control_telemetry_received_total")
.description("Telemetry messages received")
.tag("status", status)
.register(this.meterRegistry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

매번 telemetry 메시지를 수신할 때마다 Counter.builder를 호출하고 register하는 것은 불필요한 객체 생성 및 레지스트리 조회 오버헤드를 발생시킵니다. knownStatuses"unknown" 상태에 대한 카운터를 미리 생성하여 맵으로 관리하고, 메시지 수신 시에는 맵에서 조회하여 increment()만 호출하는 방식이 훨씬 효율적입니다.

Suggested change
private fun receivedCounter(status: String) = Counter.builder("baro_control_telemetry_received_total")
.description("Telemetry messages received")
.tag("status", status)
.register(this.meterRegistry)
private val receivedCounters = (knownStatuses + "unknown").associateWith { status ->
Counter.builder("baro_control_telemetry_received_total")
.description("Telemetry messages received")
.tag("status", status)
.register(meterRegistry)
}

Comment on lines +40 to +41
val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown"
receivedCounter(normalizedStatus).increment()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

미리 정의된 receivedCounters 맵을 사용하여 카운터를 조회하고 증가시키도록 변경합니다.

Suggested change
val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown"
receivedCounter(normalizedStatus).increment()
val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown"
receivedCounters[normalizedStatus]?.increment()

Comment on lines +21 to +23
private fun stateEventCounter(status: String) = Counter.builder("baro_dispatch_vehicle_state_events_total")
.tag("status", status)
.register(meterRegistry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

매번 차량 상태 이벤트를 처리할 때마다 Counter.builder를 통해 카운터를 동적으로 생성하고 등록하는 것은 성능상 비효율적입니다. CarStatus enum의 모든 값에 대해 카운터를 미리 생성하여 맵으로 관리하는 것이 좋습니다.

Suggested change
private fun stateEventCounter(status: String) = Counter.builder("baro_dispatch_vehicle_state_events_total")
.tag("status", status)
.register(meterRegistry)
private val stateEventCounters = CarStatus.entries.associateWith { status ->
Counter.builder("baro_dispatch_vehicle_state_events_total")
.tag("status", status.value)
.register(meterRegistry)
}


open fun handle(command: CarStateCommand) {
log.debug("차량 상태 데이터를 수신했습니다. carId={}, status={}, timestamp={}", command.carId, command.status.value, command.timestamp)
stateEventCounter(command.status.value).increment()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

미리 생성해 둔 stateEventCounters 맵을 사용하여 카운터를 증가시킵니다.

Suggested change
stateEventCounter(command.status.value).increment()
stateEventCounters[command.status]?.increment()

Comment on lines +34 to +35
Gauge.builder("baro_dispatch_idle_geo_count") { readIdleGeoCount() }
.register(meterRegistry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Micrometer의 Gauge는 기본적으로 상태 객체에 대해 **약한 참조(Weak Reference)**를 유지합니다. 이처럼 인자 없는 람다 { readIdleGeoCount() }를 직접 전달하면, 해당 람다 객체가 가비지 컬렉션(GC) 대상이 되어 게이지가 수집 도중 NaN을 반환하거나 작동을 멈출 수 있습니다.

안전하게 수집하기 위해 Spring 빈 객체(this)를 상태 객체로 전달하고, 이를 참조하여 값을 구하도록 설정하는 것이 좋습니다.

Suggested change
Gauge.builder("baro_dispatch_idle_geo_count") { readIdleGeoCount() }
.register(meterRegistry)
Gauge.builder("baro_dispatch_idle_geo_count", this) { it.readIdleGeoCount() }
.register(meterRegistry)

Comment on lines +160 to +164
private fun readIdleGeoCount(): Double = try {
(redisTemplate.execute { connection -> connection.zCard(properties.idleCarGeoKey.toByteArray()) } ?: 0L).toDouble()
} catch (_: Exception) {
Double.NaN
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

낮은 수준의 redisTemplate.execute와 바이트 배열 변환 대신, Spring Data Redis에서 제공하는 고수준 API인 opsForZSet().size()를 사용하면 코드가 훨씬 직관적이고 안전해집니다. Redis GEO 데이터는 내부적으로 Sorted Set(ZSET)으로 저장되므로 opsForZSet().size()로 크기를 조회할 수 있습니다.

Suggested change
private fun readIdleGeoCount(): Double = try {
(redisTemplate.execute { connection -> connection.zCard(properties.idleCarGeoKey.toByteArray()) } ?: 0L).toDouble()
} catch (_: Exception) {
Double.NaN
}
private fun readIdleGeoCount(): Double = try {
(redisTemplate.opsForZSet().size(properties.idleCarGeoKey) ?: 0L).toDouble()
} catch (_: Exception) {
Double.NaN
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant