Micrometer 커스텀 메트릭이란?
Spring Boot Actuator는 JVM, HTTP, DB 커넥션 풀 등의 메트릭을 자동으로 수집합니다. 하지만 비즈니스 관점의 메트릭 — 주문 처리량, 결제 성공률, 장바구니 전환율 — 은 직접 정의해야 합니다. Micrometer의 MeterRegistry를 사용하면 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 네 가지 타입을 적재적소에 사용하고, 태그 설계를 신중히 하면 비즈니스와 기술 양 측면에서 실행 가능한 인사이트를 얻을 수 있습니다.