Spring Boot Micrometer란? — 애플리케이션 메트릭의 표준 추상화
운영 환경에서 “CPU/메모리는 정상인데 왜 느리지?”라는 질문을 받았다면, 애플리케이션 레벨 메트릭이 부족한 것이다. Spring Boot Actuator가 제공하는 기본 메트릭(JVM, HTTP 요청 수)만으로는 비즈니스 병목을 찾기 어렵다. Spring Boot Profiles & Configuration 심화에서 다룬 환경 설정 위에, Micrometer로 커스텀 메트릭을 설계하면 주문 처리량, 외부 API 레이턴시, 재고 변동 같은 도메인 지표를 실시간으로 관측할 수 있다.
Micrometer는 SLF4J가 로깅의 파사드(facade)인 것처럼, 메트릭의 파사드다. Prometheus, Datadog, CloudWatch 등 백엔드가 바뀌어도 코드는 그대로 유지된다. Spring Boot 2.x 이후 spring-boot-starter-actuator에 기본 포함되어 있으며, Spring Boot 3.x에서는 Observation API와 통합되어 메트릭과 트레이싱을 하나의 계측 포인트로 관리할 수 있다.
1. 4가지 핵심 Meter 타입 — Counter·Gauge·Timer·Distribution Summary
Micrometer가 제공하는 Meter 타입은 네 가지이며, 각각의 용도가 명확히 다르다. 잘못 선택하면 Prometheus에서 의미 없는 그래프가 나온다.
1-1. Counter — 단조 증가하는 누적값
Counter는 오직 증가만 하는 값이다. 주문 건수, 에러 발생 횟수, 메시지 전송 수 등 “지금까지 총 몇 번” 류의 지표에 쓴다. Prometheus에서 rate() 함수로 초당 변화율을 계산하는 것이 핵심 활용법이다.
@Component
@RequiredArgsConstructor
public class OrderMetrics {
private final MeterRegistry registry;
private Counter orderCounter;
private Counter orderFailCounter;
@PostConstruct
void init() {
orderCounter = Counter.builder("order.created")
.description("Total orders created")
.tag("channel", "web") // 차원(dimension) 추가
.register(registry);
orderFailCounter = Counter.builder("order.failed")
.description("Total orders failed")
.tag("reason", "payment")
.register(registry);
}
public void onOrderCreated(String channel) {
// 동적 태그가 필요하면 builder 대신 registry.counter() 사용
registry.counter("order.created", "channel", channel).increment();
}
}
주의: Counter를 재고 수량처럼 오르내리는 값에 쓰면 안 된다. Prometheus의 rate()는 단조 증가를 전제로 동작하며, 값이 줄면 “리셋”으로 간주해 잘못된 결과를 낸다.
1-2. Gauge — 현재 상태의 스냅샷
Gauge는 올라가기도, 내려가기도 하는 현재값이다. 커넥션 풀 활성 수, 큐에 쌓인 메시지 수, 캐시 사이즈 등 “지금 얼마” 류의 지표에 쓴다.
@Component
public class QueueMetrics {
public QueueMetrics(MeterRegistry registry, TaskQueue taskQueue) {
// 강한 참조(strong reference) 방식 — 객체가 GC되지 않을 때
Gauge.builder("queue.size", taskQueue, TaskQueue::size)
.description("Current task queue depth")
.tag("queue", "order-processing")
.register(registry);
}
}
// 약한 참조 방식 — 단명 객체용
Gauge.builder("cache.entries", cache, c -> c.estimatedSize())
.strongReference(false) // WeakReference로 등록
.register(registry);
함정: Gauge.builder()의 두 번째 인자(상태 객체)가 GC되면 Gauge는 NaN을 반환한다. 서비스 빈처럼 장수(long-lived) 객체를 넘기거나, strongReference(true)(기본값)를 확인하라.
1-3. Timer — 레이턴시 + 호출 횟수를 동시에
Timer는 소요 시간을 측정하면서 호출 횟수와 총 소요 시간도 함께 기록한다. HTTP 요청 처리 시간, DB 쿼리 시간, 외부 API 호출 시간이 대표적이다.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final MeterRegistry registry;
public PaymentResult processPayment(PaymentRequest req) {
// Timer.record(Supplier) — 가장 깔끔한 패턴
return Timer.builder("payment.process")
.description("Payment processing latency")
.tag("provider", req.getProvider())
.tag("currency", req.getCurrency())
.publishPercentiles(0.5, 0.95, 0.99) // 클라이언트 측 백분위
.publishPercentileHistogram() // Prometheus histogram 버킷
.minimumExpectedValue(Duration.ofMillis(1)) // 버킷 범위 조정
.maximumExpectedValue(Duration.ofSeconds(10))
.register(registry)
.record(() -> doProcess(req)); // 자동으로 시간 측정
}
}
// 수동 제어가 필요한 경우 (비동기, try-catch)
Timer.Sample sample = Timer.start(registry);
try {
result = callExternalApi();
sample.stop(Timer.builder("external.api")
.tag("status", "success")
.register(registry));
} catch (Exception e) {
sample.stop(Timer.builder("external.api")
.tag("status", "error")
.register(registry));
throw e;
}
publishPercentiles()은 애플리케이션 인스턴스 내에서 계산하므로 여러 인스턴스를 합산(aggregate)할 수 없다. 클러스터 전체 p99를 보려면 publishPercentileHistogram()으로 히스토그램 버킷을 Prometheus에 보내고, histogram_quantile(0.99, ...)로 서버 측에서 계산해야 한다.
1-4. Distribution Summary — 비시간 분포
Timer와 구조가 같지만 시간이 아닌 값의 분포를 측정한다. 요청/응답 페이로드 크기, 배치 처리 건수, 주문 금액 분포 등에 쓴다.
DistributionSummary summary = DistributionSummary.builder("order.amount")
.description("Order amount distribution")
.baseUnit("krw")
.tag("type", "purchase")
.publishPercentileHistogram()
.minimumExpectedValue(1_000.0)
.maximumExpectedValue(10_000_000.0)
.scale(1.0) // 단위 변환 계수
.register(registry);
summary.record(order.getTotalAmount()); // 금액 기록
| Meter 타입 | 증감 | 대표 용도 | Prometheus 타입 |
|---|---|---|---|
| Counter | 증가만 | 총 주문 수, 에러 횟수 | counter |
| Gauge | 증감 모두 | 큐 사이즈, 활성 커넥션 | gauge |
| Timer | 증가만(누적) | API 레이턴시, 쿼리 시간 | histogram/summary |
| DistributionSummary | 증가만(누적) | 주문 금액, 페이로드 크기 | histogram/summary |
2. @Timed·@Counted — 선언적 메트릭 계측
매번 Timer.builder()를 쓰는 것은 번거롭다. Micrometer는 @Timed와 @Counted 어노테이션으로 선언적 계측을 지원한다. 단, Spring AOP 기반이므로 TimedAspect 빈 등록이 필수다.
@Configuration
public class MetricsConfig {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry); // @Timed 활성화
}
@Bean
public CountedAspect countedAspect(MeterRegistry registry) {
return new CountedAspect(registry); // @Counted 활성화
}
}
@Service
public class ProductService {
@Timed(value = "product.search",
description = "Product search latency",
extraTags = {"source", "catalog"},
percentiles = {0.5, 0.95, 0.99},
histogram = true)
public List<Product> search(String keyword) {
return productRepository.findByKeyword(keyword);
}
@Counted(value = "product.view",
description = "Product page views",
extraTags = {"page", "detail"})
public Product getById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new NotFoundException(id));
}
}
self-invocation 함정: Spring AOP 심화에서 다룬 것처럼, 같은 클래스 내부에서 호출하면 프록시를 거치지 않아 @Timed가 동작하지 않는다. 반드시 외부 빈에서 호출해야 한다.
3. Tag 설계 전략 — 카디널리티 폭발 방지
태그(label)는 메트릭의 차원(dimension)을 결정한다. "http.server.requests"에 method, uri, status 태그를 붙이면 GET·/api/users·200 조합별로 시계열(time series)이 생긴다.
카디널리티 폭발이란?
태그 값의 조합 수가 곧 시계열 수다. userId를 태그로 넣으면 사용자 100만 명 × 메트릭 수만큼 시계열이 폭증한다. Prometheus의 메모리가 터지고, Grafana 쿼리가 타임아웃된다.
// ❌ 절대 하면 안 되는 패턴 — 카디널리티 폭발
registry.counter("api.call", "userId", userId); // 사용자 수만큼 시계열
registry.counter("api.call", "orderId", orderId); // 주문 수만큼 시계열
registry.timer("api.latency", "uri", "/users/" + userId); // 경로 변수가 태그에 포함
// ✅ 안전한 패턴 — 바운딩된 태그
registry.counter("api.call", "endpoint", "/users", "method", "GET");
registry.counter("api.call", "endpoint", "/orders", "status", "success");
// URI 템플릿 사용: /users/{id} → /users/{id} (Spring 기본 동작)
태그 설계 규칙:
- 태그 값은 유한하고 예측 가능해야 한다 (HTTP method, status code, 서비스명 등)
- ID, 이메일, IP 같은 고유 식별자는 절대 태그로 쓰지 않는다
- URI 경로 변수(
/users/123)는 템플릿(/users/{id})으로 정규화한다 - 태그 조합이 1,000개 이하로 유지되는지 주기적으로 점검한다
4. MeterFilter — 전역 태그·이름 변환·비활성화
MeterFilter는 모든 Meter의 등록·설정을 중앙에서 제어하는 메커니즘이다. 공통 태그 추가, 메트릭 이름 변환, 특정 메트릭 비활성화, 히스토그램 설정 오버라이드 등을 코드 한 곳에서 관리할 수 있다.
@Configuration
public class MeterFilterConfig {
// 1. 공통 태그 — 모든 메트릭에 app, env, instance 추가
@Bean
public MeterFilter commonTags(@Value("${spring.application.name}") String app,
@Value("${app.env:local}") String env) {
return MeterFilter.commonTags(Tags.of(
"app", app,
"env", env,
"instance", getHostName()
));
}
// 2. 메트릭 이름 변환 — prefix 통일
@Bean
public MeterFilter renameFilter() {
return new MeterFilter() {
@Override
public Meter.Id map(Meter.Id id) {
if (id.getName().startsWith("custom.")) {
return id;
}
// 사내 메트릭은 biz. prefix
if (id.getName().startsWith("order.") || id.getName().startsWith("payment.")) {
return id.withName("biz." + id.getName());
}
return id;
}
};
}
// 3. 불필요한 메트릭 비활성화 — 시계열 수 절약
@Bean
public MeterFilter denyFilter() {
return MeterFilter.deny(id ->
id.getName().startsWith("jvm.buffer.") // 거의 안 쓰는 JVM 버퍼 메트릭
|| id.getName().startsWith("process.files") // 파일 디스크립터
);
}
// 4. URI 카디널리티 제한 — 상위 100개만 허용
@Bean
public MeterFilter uriTagFilter() {
return MeterFilter.maximumAllowableTags(
"http.server.requests", "uri", 100,
MeterFilter.deny() // 100개 초과 시 해당 시계열 거부
);
}
// 5. 특정 Timer에 히스토그램 버킷 오버라이드
@Bean
public MeterFilter histogramFilter() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id,
DistributionStatisticConfig config) {
if (id.getName().equals("payment.process")) {
return DistributionStatisticConfig.builder()
.percentilesHistogram(true)
.minimumExpectedValue(Duration.ofMillis(10).toNanos() * 1.0)
.maximumExpectedValue(Duration.ofSeconds(30).toNanos() * 1.0)
.build()
.merge(config); // 기존 설정과 병합
}
return config;
}
};
}
}
필터 적용 순서: MeterFilter는 빈 등록 순서대로 체이닝된다. @Order로 우선순위를 명시하면 더 안전하다. deny 필터가 먼저 실행되면 이후 필터는 해당 메트릭을 아예 보지 못한다.
5. Prometheus 연동 — /actuator/prometheus 엔드포인트 설정
Spring Boot + Prometheus 연동은 의존성 하나로 완성된다.
<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.yml
management:
endpoints:
web:
exposure:
include: health, info, prometheus # prometheus 엔드포인트 노출
metrics:
tags:
application: ${spring.application.name} # 전역 태그
distribution:
percentiles-histogram:
http.server.requests: true # HTTP 요청 히스토그램
slo:
http.server.requests: 100ms, 500ms, 1s # SLO 버킷 추가
minimum-expected-value:
http.server.requests: 1ms
maximum-expected-value:
http.server.requests: 10s
prometheus:
metrics:
export:
enabled: true
step: 30s # 스크레이프 주기와 맞춤
애플리케이션 실행 후 GET /actuator/prometheus를 호출하면 Prometheus 텍스트 포맷으로 모든 메트릭이 출력된다:
# HELP order_created_total Total orders created
# TYPE order_created_total counter
order_created_total{app="order-service",channel="web",env="prod"} 1542.0
# HELP payment_process_seconds Payment processing latency
# TYPE payment_process_seconds histogram
payment_process_seconds_bucket{provider="toss",le="0.1"} 890.0
payment_process_seconds_bucket{provider="toss",le="0.5"} 1203.0
payment_process_seconds_bucket{provider="toss",le="1.0"} 1298.0
payment_process_seconds_bucket{provider="toss",le="+Inf"} 1305.0
payment_process_seconds_count{provider="toss"} 1305.0
payment_process_seconds_sum{provider="toss"} 487.32
Kubernetes ServiceMonitor 설정
Prometheus + Grafana Kubernetes 모니터링 가이드에서 다룬 것처럼, kube-prometheus-stack 환경에서는 ServiceMonitor로 스크레이핑을 자동화한다:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: order-service
labels:
release: kube-prometheus-stack # Prometheus Operator 셀렉터
spec:
selector:
matchLabels:
app: order-service
endpoints:
- port: management # Service의 management 포트
path: /actuator/prometheus
interval: 30s
scrapeTimeout: 10s
6. Observation API (Spring Boot 3.x) — 메트릭 + 트레이싱 통합 계측
Spring Boot 3.x / Spring Framework 6에서 도입된 Observation API는 하나의 계측 포인트로 메트릭과 분산 트레이싱을 동시에 생성한다. Micrometer Tracing(구 Spring Cloud Sleuth)과 결합하면 Timer 메트릭 + Span이 자동으로 만들어진다.
@Service
@RequiredArgsConstructor
public class InventoryService {
private final ObservationRegistry observationRegistry;
public Stock checkStock(String sku) {
return Observation.createNotStarted("inventory.check", observationRegistry)
.lowCardinalityKeyValue("warehouse", "main") // 태그 (메트릭용)
.highCardinalityKeyValue("sku", sku) // 트레이스 속성만 (메트릭 제외)
.observe(() -> doCheckStock(sku)); // 자동 계측
}
}
// @Observed 어노테이션 사용 (ObservedAspect 빈 필요)
@Configuration
public class ObservationConfig {
@Bean
public ObservedAspect observedAspect(ObservationRegistry registry) {
return new ObservedAspect(registry);
}
}
@Service
public class ShippingService {
@Observed(name = "shipping.process",
contextualName = "process-shipping",
lowCardinalityKeyValues = {"type", "domestic"})
public TrackingInfo ship(ShipmentRequest req) {
return doShip(req);
}
}
핵심 구분: lowCardinalityKeyValue는 메트릭 태그 + 트레이스 속성 모두에 포함되고, highCardinalityKeyValue는 트레이스 속성에만 포함된다. 이것이 카디널리티 폭발 없이 상세 디버깅 정보를 트레이스에 남기는 핵심 설계다.
7. 실무 커스텀 메트릭 설계 예제 — 주문 서비스
실제 주문 서비스에서 어떤 메트릭을 어떻게 설계하는지 전체 패턴을 정리한다.
@Component
@RequiredArgsConstructor
public class OrderObservability {
private final MeterRegistry registry;
private final AtomicInteger activeOrders = new AtomicInteger(0);
@PostConstruct
void init() {
// Gauge — 현재 처리 중인 주문 수
Gauge.builder("order.active", activeOrders, AtomicInteger::get)
.description("Currently processing orders")
.register(registry);
// Gauge — 비즈니스 상태 (DB 조회 기반, 주기적 갱신)
Gauge.builder("order.pending.count", this, self ->
self.countPendingOrders())
.description("Orders awaiting fulfillment")
.register(registry);
}
// Counter — 주문 생성 (상태별)
public void recordOrderCreated(OrderType type) {
registry.counter("order.created",
"type", type.name().toLowerCase())
.increment();
activeOrders.incrementAndGet();
}
// Counter — 주문 완료/실패
public void recordOrderCompleted(OrderStatus status, String failReason) {
registry.counter("order.completed",
"status", status.name().toLowerCase(),
"reason", failReason != null ? failReason : "none")
.increment();
activeOrders.decrementAndGet();
}
// Timer — 주문 처리 전체 소요 시간
public Timer.Sample startOrderTimer() {
return Timer.start(registry);
}
public void stopOrderTimer(Timer.Sample sample, OrderType type, boolean success) {
sample.stop(Timer.builder("order.process.duration")
.tag("type", type.name().toLowerCase())
.tag("success", String.valueOf(success))
.publishPercentileHistogram()
.register(registry));
}
// Distribution Summary — 주문 금액 분포
public void recordOrderAmount(double amount, String currency) {
DistributionSummary.builder("order.amount")
.baseUnit(currency.toLowerCase())
.tag("currency", currency)
.publishPercentileHistogram()
.minimumExpectedValue(1_000.0)
.maximumExpectedValue(50_000_000.0)
.register(registry)
.record(amount);
}
}
PromQL 쿼리 예시
# 초당 주문 생성률 (최근 5분)
rate(order_created_total{env="prod"}[5m])
# 주문 처리 p99 레이턴시
histogram_quantile(0.99,
rate(order_process_duration_seconds_bucket{env="prod"}[5m])
)
# 주문 실패율
rate(order_completed_total{status="failed"}[5m])
/ rate(order_completed_total[5m]) * 100
# 평균 주문 금액 (최근 1시간)
rate(order_amount_sum[1h]) / rate(order_amount_count[1h])
# SLO: 99%의 요청이 500ms 이내
histogram_quantile(0.99,
rate(http_server_requests_seconds_bucket{uri="/api/orders"}[5m])
) < 0.5
8. 테스트에서 메트릭 검증하기
커스텀 메트릭이 제대로 기록되는지 단위 테스트로 검증할 수 있다. Micrometer는 테스트용 SimpleMeterRegistry를 제공한다.
@SpringBootTest
class OrderObservabilityTest {
// SimpleMeterRegistry를 주입하거나 직접 생성
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private OrderObservability sut;
@BeforeEach
void setUp() {
sut = new OrderObservability(registry);
sut.init();
}
@Test
void 주문_생성_시_카운터_증가() {
sut.recordOrderCreated(OrderType.STANDARD);
sut.recordOrderCreated(OrderType.STANDARD);
sut.recordOrderCreated(OrderType.EXPRESS);
assertThat(registry.counter("order.created", "type", "standard").count())
.isEqualTo(2.0);
assertThat(registry.counter("order.created", "type", "express").count())
.isEqualTo(1.0);
}
@Test
void 주문_처리_시간_기록() {
Timer.Sample sample = sut.startOrderTimer();
// ... 처리 로직 시뮬레이션
sut.stopOrderTimer(sample, OrderType.STANDARD, true);
Timer timer = registry.find("order.process.duration")
.tag("type", "standard")
.tag("success", "true")
.timer();
assertThat(timer).isNotNull();
assertThat(timer.count()).isEqualTo(1);
assertThat(timer.totalTime(TimeUnit.MILLISECONDS)).isGreaterThan(0);
}
@Test
void 활성_주문_게이지_증감() {
sut.recordOrderCreated(OrderType.STANDARD);
sut.recordOrderCreated(OrderType.STANDARD);
assertThat(registry.get("order.active").gauge().value()).isEqualTo(2.0);
sut.recordOrderCompleted(OrderStatus.COMPLETED, null);
assertThat(registry.get("order.active").gauge().value()).isEqualTo(1.0);
}
}
9. 운영 베스트 프랙티스 체크리스트
| 항목 | 권장 사항 | 위반 시 증상 |
|---|---|---|
| 태그 카디널리티 | 태그 값 조합 < 1,000 | Prometheus OOM, 쿼리 타임아웃 |
| 네이밍 컨벤션 | 소문자 + 점(.) 구분자, 단위 suffix | Grafana 대시보드 혼란 |
| 히스토그램 범위 | min/max ExpectedValue 설정 | 불필요한 버킷으로 시계열 낭비 |
| 불필요 메트릭 제거 | MeterFilter.deny()로 비활성화 | 스크레이프 시간 증가, 저장 비용 |
| 테스트 검증 | SimpleMeterRegistry로 단위 테스트 | 메트릭 누락을 운영에서 발견 |
| 공통 태그 | app, env, instance를 MeterFilter로 | 멀티 서비스 환경에서 구분 불가 |
마무리 — 메트릭은 설계의 영역이다
Micrometer를 쓰는 것 자체는 쉽다. 어려운 것은 “무엇을 측정할 것인가”의 설계다. Counter·Gauge·Timer·Distribution Summary 네 가지 Meter 타입을 정확히 이해하고, 태그 카디널리티를 통제하며, MeterFilter로 전역 정책을 관리하는 것이 운영 수준의 메트릭 시스템을 만드는 핵심이다.
특히 Spring Boot 3.x의 Observation API는 메트릭과 트레이싱의 경계를 허물어, lowCardinalityKeyValue와 highCardinalityKeyValue로 한 번의 계측으로 두 가지 관측 데이터를 생성한다. 새 프로젝트라면 @Observed + Observation API를 적극 도입하고, 기존 프로젝트는 @Timed·@Counted로 점진적으로 커스텀 메트릭을 확장해 나가자.