Spring Micrometer 커스텀 메트릭

Micrometer 커스텀 메트릭이란?

Spring Boot Actuator는 JVM, HTTP, DB 커넥션 풀 등의 메트릭을 자동으로 수집합니다. 하지만 비즈니스 관점의 메트릭 — 주문 처리량, 결제 성공률, 장바구니 전환율 — 은 직접 정의해야 합니다. MicrometerMeterRegistry를 사용하면 Counter, Gauge, Timer, DistributionSummary 등 다양한 메트릭 타입을 생성하고 Prometheus, Grafana로 시각화할 수 있습니다.

메트릭 타입 비교

타입 용도 예시
Counter 단조 증가 카운터 주문 수, 에러 수
Gauge 현재 값 (증감 가능) 대기열 크기, 활성 사용자
Timer 시간 측정 + 호출 횟수 API 응답 시간, 처리 소요 시간
DistributionSummary 값 분포 (비시간) 주문 금액 분포, 파일 크기

Counter: 이벤트 카운팅

Counter는 단조롭게 증가하는 값입니다. 절대값보다 rate(변화율)로 분석합니다.

@Service
@RequiredArgsConstructor
public class OrderService {

    private final MeterRegistry meterRegistry;

    // 방법 1: 직접 Counter 생성
    public Order createOrder(OrderRequest request) {
        try {
            Order order = processOrder(request);

            // 성공 카운터 (태그로 세분화)
            meterRegistry.counter("orders.created",
                "status", "success",
                "payment_method", request.getPaymentMethod(),
                "category", request.getCategory()
            ).increment();

            return order;
        } catch (Exception e) {
            meterRegistry.counter("orders.created",
                "status", "failed",
                "error_type", e.getClass().getSimpleName()
            ).increment();
            throw e;
        }
    }

    // 방법 2: @Counted 어노테이션
    @Counted(value = "orders.cancelled", extraTags = {"source", "api"})
    public void cancelOrder(String orderId) {
        // 비즈니스 로직
    }
}
# Prometheus 쿼리 예시
# 분당 주문 생성 수
rate(orders_created_total{status="success"}[5m])

# 결제 수단별 주문 비율
sum by(payment_method) (rate(orders_created_total{status="success"}[1h]))

Gauge: 현재 상태 측정

Gauge는 현재 값을 나타냅니다. 큐 크기, 캐시 히트율, 활성 세션 수 등에 사용합니다.

@Component
public class BusinessMetrics implements MeterBinder {

    private final OrderRepository orderRepository;
    private final AtomicInteger activeUsers = new AtomicInteger(0);
    private final ConcurrentLinkedQueue<Order> pendingQueue;

    @Override
    public void bindTo(MeterRegistry registry) {
        // 컬렉션 크기를 Gauge로 등록
        Gauge.builder("orders.pending.count", pendingQueue, Queue::size)
            .description("처리 대기 중인 주문 수")
            .tag("priority", "normal")
            .register(registry);

        // AtomicInteger를 Gauge로 등록
        Gauge.builder("users.active", activeUsers, AtomicInteger::get)
            .description("현재 활성 사용자 수")
            .register(registry);

        // DB 쿼리 기반 Gauge (주의: 호출 빈도 고려)
        Gauge.builder("orders.unprocessed", orderRepository,
                repo -> repo.countByStatus(OrderStatus.PENDING))
            .description("미처리 주문 수")
            .register(registry);
    }

    public void userLoggedIn() { activeUsers.incrementAndGet(); }
    public void userLoggedOut() { activeUsers.decrementAndGet(); }
}

Timer: 성능 측정

Timer는 실행 시간과 호출 횟수를 동시에 측정합니다. 퍼센타일을 활성화하면 P50, P95, P99 응답 시간을 추적할 수 있습니다.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final MeterRegistry meterRegistry;

    public PaymentResult processPayment(PaymentRequest request) {
        // Timer.Sample로 시작/종료 분리
        Timer.Sample sample = Timer.start(meterRegistry);

        try {
            PaymentResult result = callPaymentGateway(request);

            sample.stop(Timer.builder("payment.processing.time")
                .description("결제 처리 소요 시간")
                .tag("gateway", request.getGateway())
                .tag("status", result.isSuccess() ? "success" : "failed")
                .publishPercentiles(0.5, 0.95, 0.99)       // P50, P95, P99
                .publishPercentileHistogram()                // 히스토그램 버킷
                .serviceLevelObjectives(
                    Duration.ofMillis(100),
                    Duration.ofMillis(500),
                    Duration.ofSeconds(1))                   // SLO 버킷
                .register(meterRegistry));

            return result;
        } catch (Exception e) {
            sample.stop(Timer.builder("payment.processing.time")
                .tag("gateway", request.getGateway())
                .tag("status", "error")
                .register(meterRegistry));
            throw e;
        }
    }

    // @Timed 어노테이션 방식
    @Timed(value = "payment.refund.time",
           percentiles = {0.5, 0.95, 0.99},
           histogram = true)
    public void refund(String transactionId) {
        // 환불 로직
    }
}
# Prometheus 쿼리
# P99 결제 처리 시간
histogram_quantile(0.99, rate(payment_processing_time_seconds_bucket[5m]))

# 1초 이상 걸린 요청 비율 (SLO 위반율)
1 - (rate(payment_processing_time_seconds_bucket{le="1.0"}[5m])
  / rate(payment_processing_time_seconds_count[5m]))

DistributionSummary: 값 분포

시간이 아닌 값의 분포를 추적합니다. 주문 금액, 파일 크기, 배치 사이즈 등에 사용합니다.

@Service
@RequiredArgsConstructor
public class OrderAnalyticsService {

    private final MeterRegistry meterRegistry;

    public void recordOrderAmount(Order order) {
        DistributionSummary.builder("orders.amount")
            .description("주문 금액 분포")
            .baseUnit("KRW")
            .tag("category", order.getCategory())
            .publishPercentiles(0.5, 0.75, 0.95)
            .scale(1.0)                           // 단위 스케일링
            .minimumExpectedValue(1000.0)          // 최소 기대값
            .maximumExpectedValue(10_000_000.0)    // 최대 기대값
            .register(meterRegistry)
            .record(order.getTotalAmount());
    }
}

태그 설계 전략

태그(label)는 메트릭을 세분화하는 핵심입니다. 하지만 카디널리티 폭발을 주의해야 합니다.

// ✅ 좋은 태그: 낮은 카디널리티
meterRegistry.counter("api.requests",
    "method", "GET",           // ~5 종류
    "status", "200",           // ~10 종류
    "endpoint", "/api/orders"  // ~50 종류
);

// ❌ 나쁜 태그: 높은 카디널리티 → 메모리 폭발
meterRegistry.counter("api.requests",
    "user_id", userId,         // 수백만 종류!
    "request_id", requestId    // 무한!
);
// MeterFilter로 태그 정규화
@Bean
public MeterFilter tagNormalizationFilter() {
    return new MeterFilter() {
        @Override
        public Meter.Id map(Meter.Id id) {
            // URI 패스 변수를 정규화
            if (id.getName().startsWith("http.server")) {
                String uri = id.getTag("uri");
                if (uri != null && uri.matches("/api/orders/[0-9]+")) {
                    return id.replaceTags(
                        Tag.of("uri", "/api/orders/{id}"));
                }
            }
            return id;
        }
    };
}

MeterFilter: 메트릭 필터링

불필요한 메트릭을 제거하거나 이름을 변환할 수 있습니다.

@Configuration
public class MetricsConfig {

    @Bean
    public MeterFilter customFilter() {
        return MeterFilter.deny(id ->
            id.getName().startsWith("jvm.memory.pool")
            && id.getTag("id") != null
            && id.getTag("id").contains("survivor"));
    }

    // 특정 메트릭만 퍼센타일 활성화
    @Bean
    public MeterFilter histogramFilter() {
        return new MeterFilter() {
            @Override
            public DistributionStatisticConfig configure(
                    Meter.Id id, DistributionStatisticConfig config) {
                if (id.getName().startsWith("payment.")) {
                    return DistributionStatisticConfig.builder()
                        .percentiles(0.5, 0.95, 0.99)
                        .percentilesHistogram(true)
                        .build()
                        .merge(config);
                }
                return config;
            }
        };
    }
}

커스텀 MeterBinder

MeterBinder를 구현하면 메트릭 등록을 모듈화할 수 있습니다.

@Component
public class CacheMetrics implements MeterBinder {

    private final CacheManager cacheManager;

    @Override
    public void bindTo(MeterRegistry registry) {
        cacheManager.getCacheNames().forEach(name -> {
            Cache cache = cacheManager.getCache(name);

            Gauge.builder("cache.size", cache,
                    c -> getCacheSize(c))
                .tag("cache", name)
                .register(registry);

            // FunctionCounter: 캐시 히트/미스
            FunctionCounter.builder("cache.hits", cache,
                    c -> getCacheHits(c))
                .tag("cache", name)
                .register(registry);

            FunctionCounter.builder("cache.misses", cache,
                    c -> getCacheMisses(c))
                .tag("cache", name)
                .register(registry);
        });
    }
}

Grafana 대시보드 설계

커스텀 메트릭을 Grafana에서 시각화하는 패널 예시입니다.

# 비즈니스 대시보드 패널 쿼리

# 1. 실시간 주문 처리율 (분당)
sum(rate(orders_created_total{status="success"}[5m])) * 60

# 2. 결제 성공률 (%)
sum(rate(orders_created_total{status="success"}[1h]))
/ sum(rate(orders_created_total[1h])) * 100

# 3. P99 결제 응답 시간
histogram_quantile(0.99,
  sum by(le) (rate(payment_processing_time_seconds_bucket[5m])))

# 4. 시간대별 주문 금액 평균
rate(orders_amount_sum[1h]) / rate(orders_amount_count[1h])

# 5. 미처리 주문 알림 (50건 초과 시)
orders_unprocessed > 50

@Observed: Spring Boot 3.x 통합

Spring Boot 3.x의 @Observed 어노테이션은 메트릭과 트레이싱을 동시에 생성합니다.

@Service
public class ProductService {

    @Observed(name = "product.search",
              contextualName = "search-products",
              lowCardinalityKeyValues = {"source", "api"})
    public List<Product> search(String keyword) {
        // 자동으로 Timer + Span 생성
        return productRepository.searchByKeyword(keyword);
    }
}

// ObservationHandler 등록
@Bean
public ObservationHandler<Observation.Context> customHandler() {
    return new ObservationHandler<>() {
        @Override
        public void onStart(Observation.Context context) {
            log.info("관측 시작: {}", context.getName());
        }

        @Override
        public boolean supportsContext(Observation.Context context) {
            return true;
        }
    };
}

실전 팁

  • 카디널리티 제한: 태그 값의 고유 조합 수를 모니터링합니다. Prometheus는 고카디널리티에서 메모리를 많이 소모합니다
  • 네이밍 컨벤션: 도메인.행위.단위 형식으로 통일합니다. orders.created.total, payment.processing.seconds
  • 메트릭 테스트: SimpleMeterRegistry를 주입하여 메트릭이 올바르게 기록되는지 단위 테스트합니다
  • 비즈니스 + 기술 분리: 비즈니스 메트릭(주문, 결제)과 기술 메트릭(응답 시간, 에러율)을 별도 대시보드로 관리합니다
  • SLO 기반 알림: 절대값(P99 > 1s)보다 번 레이트(burn rate) 기반 알림이 노이즈가 적습니다

마무리

Micrometer 커스텀 메트릭은 “측정하지 않으면 개선할 수 없다”는 원칙을 코드 레벨에서 구현합니다. Counter, Gauge, Timer, DistributionSummary 네 가지 타입을 적재적소에 사용하고, 태그 설계를 신중히 하면 비즈니스와 기술 양 측면에서 실행 가능한 인사이트를 얻을 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux