feat: 서비스 도메인 메트릭 추가 - #98
Conversation
There was a problem hiding this comment.
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.
| private val currentStatusCounts = ConcurrentHashMap<String, AtomicInteger>() | ||
| private val carStatuses = ConcurrentHashMap<Long, String>() |
There was a problem hiding this comment.
이 로컬 ConcurrentHashMap 기반의 상태 관리 방식은 운영 환경에서 심각한 문제를 일으킬 수 있습니다.
- 다중 인스턴스(Clustered) 환경에서의 정합성 불일치:
dispatch-service가 여러 인스턴스로 로드밸런싱되어 실행되는 경우, 각 인스턴스는 자신이 처리한 차량의 상태만 메모리에 유지하므로 프로메테우스 게이지(baro_dispatch_vehicle_status_current)가 인스턴스마다 서로 다르고 부정확한 값을 수집하게 됩니다. - 애플리케이션 재시작 시 메트릭 왜곡: 서비스가 재시작되면 메모리 맵이 초기화됩니다. 이후 기존 차량이 상태 업데이트를 보낼 때
previousStatus가null이 되므로, 이전 상태에 대한 차감 없이 새로운 상태의 카운트만 증가하여 전체 메트릭 수치가 실제보다 비정상적으로 비대해집니다. - 메모리 누수(Memory Leak):
carStatuses맵에 한 번 등록된 차량 ID는 영구히 메모리에 유지되며, 만료(TTL)나 정리 메커니즘이 없어 시간이 지남에 따라 메모리 사용량이 계속 증가합니다.
해결 방안:
차량의 현재 상태를 공유 저장소(예: Redis Hash 또는 DB)에 저장하고, 게이지 등록 시 해당 저장소의 상태별 카운트를 동적으로 조회하도록 설계하는 것을 강력히 권장합니다.
| class TelemetryService( | ||
| private val kafkaTemplate: KafkaTemplate<String, Any>, | ||
| private val stateStore: VehicleStateStore, | ||
| meterRegistry: MeterRegistry, |
| private val knownStatuses = setOf("idle", "moving_to_pickup", "driving", "relocating") | ||
| } | ||
|
|
||
| private val meterRegistry = meterRegistry |
| private fun receivedCounter(status: String) = Counter.builder("baro_control_telemetry_received_total") | ||
| .description("Telemetry messages received") | ||
| .tag("status", status) | ||
| .register(this.meterRegistry) |
There was a problem hiding this comment.
매번 telemetry 메시지를 수신할 때마다 Counter.builder를 호출하고 register하는 것은 불필요한 객체 생성 및 레지스트리 조회 오버헤드를 발생시킵니다. knownStatuses와 "unknown" 상태에 대한 카운터를 미리 생성하여 맵으로 관리하고, 메시지 수신 시에는 맵에서 조회하여 increment()만 호출하는 방식이 훨씬 효율적입니다.
| 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) | |
| } |
| val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown" | ||
| receivedCounter(normalizedStatus).increment() |
There was a problem hiding this comment.
미리 정의된 receivedCounters 맵을 사용하여 카운터를 조회하고 증가시키도록 변경합니다.
| val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown" | |
| receivedCounter(normalizedStatus).increment() | |
| val normalizedStatus = p.status.takeIf { it in knownStatuses } ?: "unknown" | |
| receivedCounters[normalizedStatus]?.increment() |
| private fun stateEventCounter(status: String) = Counter.builder("baro_dispatch_vehicle_state_events_total") | ||
| .tag("status", status) | ||
| .register(meterRegistry) |
There was a problem hiding this comment.
매번 차량 상태 이벤트를 처리할 때마다 Counter.builder를 통해 카운터를 동적으로 생성하고 등록하는 것은 성능상 비효율적입니다. CarStatus enum의 모든 값에 대해 카운터를 미리 생성하여 맵으로 관리하는 것이 좋습니다.
| 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() |
| Gauge.builder("baro_dispatch_idle_geo_count") { readIdleGeoCount() } | ||
| .register(meterRegistry) |
There was a problem hiding this comment.
Micrometer의 Gauge는 기본적으로 상태 객체에 대해 **약한 참조(Weak Reference)**를 유지합니다. 이처럼 인자 없는 람다 { readIdleGeoCount() }를 직접 전달하면, 해당 람다 객체가 가비지 컬렉션(GC) 대상이 되어 게이지가 수집 도중 NaN을 반환하거나 작동을 멈출 수 있습니다.
안전하게 수집하기 위해 Spring 빈 객체(this)를 상태 객체로 전달하고, 이를 참조하여 값을 구하도록 설정하는 것이 좋습니다.
| Gauge.builder("baro_dispatch_idle_geo_count") { readIdleGeoCount() } | |
| .register(meterRegistry) | |
| Gauge.builder("baro_dispatch_idle_geo_count", this) { it.readIdleGeoCount() } | |
| .register(meterRegistry) |
| private fun readIdleGeoCount(): Double = try { | ||
| (redisTemplate.execute { connection -> connection.zCard(properties.idleCarGeoKey.toByteArray()) } ?: 0L).toDouble() | ||
| } catch (_: Exception) { | ||
| Double.NaN | ||
| } |
There was a problem hiding this comment.
낮은 수준의 redisTemplate.execute와 바이트 배열 변환 대신, Spring Data Redis에서 제공하는 고수준 API인 opsForZSet().size()를 사용하면 코드가 훨씬 직관적이고 안전해집니다. Redis GEO 데이터는 내부적으로 Sorted Set(ZSET)으로 저장되므로 opsForZSet().size()로 크기를 조회할 수 있습니다.
| 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 | |
| } |
요약
검증