Spring Boot Micrometer

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는 메트릭과 트레이싱의 경계를 허물어, lowCardinalityKeyValuehighCardinalityKeyValue로 한 번의 계측으로 두 가지 관측 데이터를 생성한다. 새 프로젝트라면 @Observed + Observation API를 적극 도입하고, 기존 프로젝트는 @Timed·@Counted로 점진적으로 커스텀 메트릭을 확장해 나가자.

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