Spring Boot Micrometer와 Prometheus: 운영 환경의 Actuator 지표

Spring Boot 4에서의 Spring Boot Micrometer와 Prometheus 통합: Actuator 접근 모델, 스크래핑 설정, HTTP 지표, 헬스 프로브, 알림 규칙을 다룹니다.

Micrometer와 Prometheus로 구현하는 Spring Boot Actuator 모니터링

Spring Boot Actuator는 헬스, 지표, 진단 엔드포인트를 노출하며, 그 뒤에서 지표 파사드 역할을 하는 것이 Micrometer입니다. micrometer-registry-prometheus 의존성을 추가하면 /actuator/prometheus 엔드포인트가 생기고, Prometheus가 일정한 간격으로 이를 스크래핑합니다. 아래 예제는 Spring Boot 4.1(현재 GA, 4.2는 릴리스 후보 단계), Micrometer 1.16, Prometheus Java 클라이언트 1.x를 기준으로 합니다.

핵심 요약

Spring Boot Micrometer와 Prometheus 통합은 세 단계입니다. spring-boot-starter-actuator와 micrometer-registry-prometheus를 추가하고, management.endpoints.web.exposure.include에 prometheus를 넣은 뒤, Prometheus의 scrape_config가 /actuator/prometheus를 가리키게 합니다. JVM, HTTP, HikariCP, 시스템 지표는 추가 코드 없이 노출됩니다.

Spring Boot Micrometer 설정: Actuator와 Prometheus 레지스트리

필요한 Maven 의존성

Actuator 스타터는 엔드포인트를 활성화하고 Micrometer를 함께 가져옵니다. Prometheus 레지스트리는 모든 미터를 Prometheus exposition format으로 변환합니다. AspectJ 스타터는 @Timed와 @Counted 애노테이션을 쓸 때만 필요합니다.

xml
<!-- pom.xml -->
<!-- Actuator + Micrometer + Prometheus (Spring Boot 4.x) -->
<dependencies>
    <!-- Spring Boot Actuator: monitoring endpoints, includes Micrometer -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <!-- Micrometer Registry Prometheus (Prometheus Java client 1.x) -->
    <!-- Exposes metrics in Prometheus format at /actuator/prometheus -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>

    <!-- AspectJ for @Timed and @Counted -->
    <!-- Spring Boot 4 renamed spring-boot-starter-aop to spring-boot-starter-aspectj -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aspectj</artifactId>
    </dependency>
</dependencies>

오래된 튜토리얼에는 버전 관련 함정이 두 가지 있습니다. Micrometer 1.13(Spring Boot 3.3)부터 micrometer-registry-prometheus는 Prometheus Java 클라이언트 1.x 위에서 동작합니다. 이전 0.x 코드는 지원 중단된 micrometer-registry-prometheus-simpleclient 모듈에 남아 있으며, Micrometer Prometheus 문서는 이를 임시 방편으로만 소개합니다. 또한 Spring Boot 4에서는 spring-boot-starter-aop를 여전히 선언한 pom.xml이 의존성을 해석하지 못합니다. Spring Boot 4.0 마이그레이션 가이드에 따라 이름이 spring-boot-starter-aspectj로 바뀌었기 때문입니다.

Actuator 엔드포인트 설정

기본적으로 HTTP로 노출되는 엔드포인트는 health뿐입니다. /actuator/prometheus 엔드포인트는 레지스트리가 클래스패스에 있으면 존재하지만, 노출 목록에 추가하기 전까지는 404를 반환합니다.

Spring Boot 3.4는 켜기/끄기 스위치(management.endpoints.enabled-by-default, management.endpoint.<id>.enabled)를 접근 모델로 대체했고, 기존 속성은 지원 중단되었습니다. 옵트인 방식은 기본 접근을 none으로 두고 엔드포인트마다 read-only를 부여합니다.

yaml
# application.yml
# Actuator configuration for production (Spring Boot 3.4+ access model)
management:
  endpoints:
    # Every endpoint is inaccessible unless granted below
    access:
      default: none
    web:
      exposure:
        # Endpoints exposed over HTTP
        # health, info, prometheus are the minimum for monitoring
        include: health,info,prometheus,metrics,env,loggers
      base-path: /actuator
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      show-components: when-authorized
    info:
      access: read-only
    prometheus:
      access: read-only
    metrics:
      access: read-only
    env:
      access: read-only
      # Mask sensitive values for anonymous callers
      show-values: when-authorized
    loggers:
      # unrestricted allows POST to change a log level at runtime
      access: unrestricted

노출과 접근은 별개의 제어입니다. exposure.include는 엔드포인트를 HTTP에 매핑할지 결정하고, access는 매핑된 엔드포인트가 무엇을 허용할지 결정합니다. enabled: true를 그대로 쓰는 속성 파일은 지원 중단 경고와 함께 계속 동작하며, 이 경고가 마이그레이션 시점입니다.

show-details: when-authorized 옵션은 적절한 권한을 가진 인증 사용자에게만 헬스 상세 정보를 노출합니다. 엔드포인트 자체는 전용 SecurityFilterChain으로 보호합니다.

ActuatorSecurityConfig.javajava
// Securing Actuator endpoints (Spring Boot 4, Spring Security 7)
package com.example.monitoring.config;

import static org.springframework.security.web.access.IpAddressAuthorizationManager.hasIpAddress;

import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration(proxyBeanMethods = false)
public class ActuatorSecurityConfig {

    @Bean
    SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                // Health and info public for load balancers
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // Prometheus accessible from the internal network only
                .requestMatchers(EndpointRequest.to("prometheus")).access(hasIpAddress("10.0.0.0/8"))
                // Other endpoints restricted to admins
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            )
            .httpBasic(basic -> {})
            .build();
    }
}

Spring Boot 4에서 EndpointRequest는 org.springframework.boot.security.autoconfigure.actuate.web.servlet으로 이동했습니다. Actuator 엔드포인트 레퍼런스에 나와 있는 그대로입니다. authorizeHttpRequests의 Java AuthorizedUrl DSL에는 hasIpAddress 메서드가 없습니다. IP 규칙은 Spring Security 6.3부터 제공되는 IpAddressAuthorizationManager.hasIpAddress로 지정합니다.

Micrometer로 만드는 커스텀 지표

애플리케이션 카운터, 게이지, 타이머

Micrometer는 사용 사례에 맞는 다양한 지표 유형을 제공합니다. Counter는 누적 이벤트, Gauge는 순간 값, Timer는 작업의 소요 시간을 측정합니다.

OrderMetricsService.javajava
// Custom business metrics service
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Service;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

@Service
public class OrderMetricsService {

    // Counter for orders created
    private final Counter ordersCreatedCounter;
    // Timer to measure processing duration
    private final Timer orderProcessingTimer;
    // Atomic value for pending orders gauge
    private final AtomicInteger pendingOrdersCount = new AtomicInteger(0);

    public OrderMetricsService(MeterRegistry registry) {
        // Exported as orders_created_total: the _total suffix is added by the registry
        this.ordersCreatedCounter = Counter.builder("orders.created")
            .description("Total number of orders created")
            .tag("channel", "web")
            .register(registry);

        // Timer with histogram buckets: percentiles computed server-side in PromQL
        this.orderProcessingTimer = Timer.builder("orders.processing.duration")
            .description("Order processing duration")
            .publishPercentileHistogram()
            .register(registry);

        // Gauge linked to atomic value
        // Read on each scrape
        Gauge.builder("orders.pending.count", pendingOrdersCount, AtomicInteger::get)
            .description("Number of orders pending processing")
            .register(registry);
    }

    public void recordOrderCreated() {
        ordersCreatedCounter.increment();
        pendingOrdersCount.incrementAndGet();
    }

    public void recordOrderProcessed(Runnable processingLogic) {
        // Measures execution duration
        orderProcessingTimer.record(processingLogic);
        pendingOrdersCount.decrementAndGet();
    }

    public <T> T recordOrderProcessedWithResult(Supplier<T> processingLogic) {
        return orderProcessingTimer.record(processingLogic);
    }
}

Prometheus 레지스트리에서는 이름 규칙의 세부 사항 두 가지가 중요합니다. Micrometer 미터 이름은 점으로 구분하며 스네이크 케이스로 변환됩니다(orders.processing.duration은 orders_processing_duration_seconds가 됩니다). Counter에는 _total 접미사가 자동으로 붙으므로, Counter 이름을 orders.created.total로 지으면 잡음만 늘어납니다.

publishPercentiles(0.5, 0.95, 0.99)는 각 JVM 내부에서 백분위수를 계산하며, 이 값은 인스턴스 간에 집계할 수 없습니다. publishPercentileHistogram()은 버킷을 노출하고 histogram_quantile()이 이를 전체 인스턴스에 걸쳐 집계합니다. Prometheus 환경에서는 이쪽을 선택합니다.

@Timed와 @Counted 애노테이션

반복적인 코드를 줄이기 위해 Micrometer는 AspectJ를 통해 메서드를 계측하는 애노테이션을 제공합니다.

PaymentService.javajava
// Automatic instrumentation with annotations
package com.example.monitoring.service;

import io.micrometer.core.annotation.Counted;
import io.micrometer.core.annotation.Timed;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final PaymentValidator paymentValidator;

    public PaymentService(PaymentValidator paymentValidator) {
        this.paymentValidator = paymentValidator;
    }

    // @Timed creates a Timer
    // Measures each call and publishes count, sum, max and histogram buckets
    @Timed(
        value = "payment.process.duration",
        description = "Payment processing duration",
        histogram = true
    )
    public PaymentResult processPayment(PaymentRequest request) {
        // Call through another bean: the proxy intercepts it
        paymentValidator.validate(request);
        return executePayment(request);
    }

    // @Counted increments a counter on each call
    // Useful for discrete events
    @Counted(
        value = "payment.refunds",
        description = "Total number of refunds"
    )
    public void refundPayment(String transactionId) {
        // Refund logic
    }
}
yaml
# application.yml
# Enables TimedAspect, CountedAspect and the other Micrometer annotation aspects
management:
  observations:
    annotations:
      enabled: true

Spring Boot 3.2부터는 TimedAspect와 CountedAspect 빈을 직접 선언할 필요가 없습니다. 클래스패스에 spring-boot-starter-aspectj가 있는 상태에서 management.observations.annotations.enabled=true를 설정하면 자동 구성됩니다. 이 속성의 기본값은 false이며, "@Timed가 아무것도 하지 않는다"는 문의 대부분이 여기서 비롯됩니다.

AOP 제약

@Timed와 @Counted 애노테이션은 프록시를 거쳐 호출되는 Spring 빈의 public 메서드에서만 동작합니다. 같은 클래스 안에서 한 메서드가 다른 메서드를 호출하면 프록시를 우회하므로 계측되지 않으며, private 메서드에 붙인 애노테이션은 무시됩니다. 이미 계측되고 있는 Spring MVC 컨트롤러에 애노테이션을 붙이면 옵저베이션이 중복됩니다.

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

커스텀 헬스 엔드포인트

비즈니스 Health Indicator

Health Indicator는 외부 의존성과 핵심 비즈니스 컴포넌트의 상태를 점검합니다. Spring Boot는 데이터베이스, Redis 등 자주 사용되는 서비스에 대한 기본 인디케이터를 제공합니다. Spring Boot 4에서는 헬스 API가 별도의 spring-boot-health 모듈로 분리되었고, Health와 HealthIndicator는 org.springframework.boot.actuate.health에서 org.springframework.boot.health.contributor로 이동했습니다.

PaymentGatewayHealthIndicator.javajava
// Health indicator for payment gateway (Spring Boot 4 packages)
package com.example.monitoring.health;

import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

import java.time.Duration;
import java.time.Instant;

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final RestClient restClient;
    private final String gatewayHealthUrl;

    public PaymentGatewayHealthIndicator(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
        this.gatewayHealthUrl = "https://api.payment-gateway.com/health";
    }

    @Override
    public Health health() {
        Instant start = Instant.now();

        try {
            // Call gateway health endpoint
            var response = restClient.get()
                .uri(gatewayHealthUrl)
                .retrieve()
                .toBodilessEntity();

            Duration responseTime = Duration.between(start, Instant.now());

            if (response.getStatusCode().is2xxSuccessful()) {
                return Health.up()
                    .withDetail("responseTime", responseTime.toMillis() + "ms")
                    .withDetail("statusCode", response.getStatusCode().value())
                    .build();
            } else {
                return Health.down()
                    .withDetail("statusCode", response.getStatusCode().value())
                    .withDetail("reason", "Unexpected status code")
                    .build();
            }
        } catch (Exception e) {
            Duration responseTime = Duration.between(start, Instant.now());

            return Health.down()
                .withDetail("error", e.getClass().getSimpleName())
                .withDetail("message", e.getMessage())
                .withDetail("responseTime", responseTime.toMillis() + "ms")
                .build();
        }
    }
}

이 인디케이터는 /actuator/health 아래 paymentGateway라는 이름으로 노출됩니다. 빈 이름에서 HealthIndicator 접미사를 뺀 이름입니다.

Kubernetes를 위한 헬스 그룹

Spring Boot 4는 Kubernetes를 감지했을 때뿐 아니라 모든 플랫폼에서 liveness와 readiness 프로브를 기본으로 활성화합니다. /actuator/health/liveness와 /actuator/health/readiness 그룹은 별도 설정 없이 존재하며, LivenessStateHealthIndicator나 ReadinessStateHealthIndicator 빈을 직접 선언할 필요가 없습니다. 설정은 그룹에 컴포넌트를 추가하거나 커스텀 그룹을 정의할 때만 필요합니다.

yaml
# application.yml
# Health groups configuration for Kubernetes
management:
  endpoint:
    health:
      probes:
        # true by default in Spring Boot 4
        enabled: true
        # Also serve the groups at /livez and /readyz on the main server port
        add-additional-paths: true
      group:
        # Readiness: can the application receive traffic?
        readiness:
          include: readinessState,db
        # Custom group for critical dependencies
        critical:
          include: db,paymentGateway
          show-details: when-authorized

liveness 그룹은 livenessState만 포함해야 합니다. 데이터베이스나 외부 게이트웨이를 liveness에 넣으면 해당 의존성이 다운될 때 Kubernetes가 모든 파드를 재시작하므로, 부분 장애가 전면 장애로 번집니다. 외부 의존성은 readiness에 두되, 애플리케이션이 그 의존성 없이 어떤 요청도 처리할 수 없는 경우에만 넣습니다.

Kubernetes 프로브는 전용 엔드포인트를 가리킵니다.

yaml
# kubernetes-deployment.yml
# Kubernetes probes configuration
spec:
  containers:
    - name: order-service
      livenessProbe:
        httpGet:
          path: /livez
          port: 8080
        initialDelaySeconds: 30
        periodSeconds: 10
        failureThreshold: 3
      readinessProbe:
        httpGet:
          path: /readyz
          port: 8080
        initialDelaySeconds: 10
        periodSeconds: 5
        failureThreshold: 3

add-additional-paths: true를 설정하면 Actuator가 별도의 관리 포트에서 실행되더라도 프로브는 애플리케이션 포트에 남으므로, 메인 서버가 고장 나면 프로브가 이를 감지합니다.

Spring Boot Prometheus 통합과 Grafana

Prometheus 스크래핑 설정

Prometheus는 /actuator/prometheus 엔드포인트를 주기적으로 호출하여 지표를 수집합니다. Prometheus의 기본 경로는 /metrics이므로, 스크래핑 설정에서 대상과 지표 경로를 정의합니다.

yaml
# prometheus.yml
# Prometheus configuration for Spring Boot
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-apps'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 10s
    static_configs:
      - targets:
          - 'order-service:9090'
          - 'payment-service:9090'
          - 'inventory-service:9090'
    # Relabeling to strip the port from the instance label
    relabel_configs:
      - source_labels: [__address__]
        target_label: instance
        regex: '([^:]+):\d+'
        replacement: '${1}'

  # Kubernetes service discovery
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      # Only scrape pods with annotation
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)

대상이 9090 포트를 쓰는 이유는 뒤에 나오는 운영 설정이 Actuator를 별도의 관리 포트로 옮기기 때문입니다. management.server.port를 설정하지 않으면 스크래핑 대상은 애플리케이션 포트입니다.

Spring Boot Actuator가 기본으로 노출하는 지표

Micrometer와 결합된 Actuator는 JVM, 프로세스, HikariCP, HTTP 지표를 코드 없이 노출합니다. 운영 환경에서 가장 먼저 살펴보는 지표는 다음 PromQL 쿼리로 확인합니다.

promql
# PromQL queries for JVM monitoring

# Heap memory usage, summed across heap pools
sum by (instance) (jvm_memory_used_bytes{area="heap"})

# Heap usage percentage
sum by (instance) (jvm_memory_used_bytes{area="heap"})
  / sum by (instance) (jvm_memory_max_bytes{area="heap"}) * 100

# Live threads
jvm_threads_live_threads

# Garbage collection: seconds spent paused per second
rate(jvm_gc_pause_seconds_sum[5m])

# GC pauses per minute
rate(jvm_gc_pause_seconds_count[1m]) * 60

# CPU used by the JVM process
process_cpu_usage

# Active database connections
hikaricp_connections_active

# Connection pool utilization
hikaricp_connections_active / hikaricp_connections_max * 100

jvm_memory_used_bytes{area="heap"}는 메모리 풀마다(G1 기준 Eden, Survivor, Old Gen) 시계열 하나를 반환합니다. sum by (instance) 없이 최댓값으로 나누면 풀별 비율이 나오며, 대시보드에서는 잘못된 값처럼 보입니다.

기본 지표가 다루지 않는 애플리케이션 수준의 게이지는 MeterBinder 빈으로 추가합니다.

CustomJvmMetrics.javajava
// Additional JVM metrics
package com.example.monitoring.metrics;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.stereotype.Component;

import java.lang.management.ManagementFactory;
import java.lang.management.OperatingSystemMXBean;

@Component
public class CustomJvmMetrics implements MeterBinder {

    @Override
    public void bindTo(MeterRegistry registry) {
        OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean();

        // System load average
        Gauge.builder("system.load.average", osBean, OperatingSystemMXBean::getSystemLoadAverage)
            .description("System load average over 1 minute")
            .register(registry);

        // Available processors count
        Gauge.builder("system.cpu.count", osBean, OperatingSystemMXBean::getAvailableProcessors)
            .description("Number of available processors")
            .register(registry);

        // Application uptime
        Gauge.builder("application.uptime.seconds",
                ManagementFactory.getRuntimeMXBean(),
                bean -> bean.getUptime() / 1000.0)
            .description("Application uptime in seconds")
            .register(registry);
    }
}

system.cpu.count와 process.uptime은 Micrometer의 내장 바인더가 이미 노출합니다. 이 예제는 패턴을 보여 주기 위한 것이며, 실제 바인더에는 기본 지표에 없는 항목만 등록합니다.

즉시 활용 가능한 Grafana 대시보드

Grafana에는 표준 Actuator 지표로 동작하는 Spring Boot용 커뮤니티 대시보드가 있습니다. 직접 작성하는 대시보드는 timeseries와 stat 패널 타입을 사용합니다.

json
{
  "annotations": {
    "list": []
  },
  "panels": [
    {
      "title": "Request Rate",
      "type": "timeseries",
      "targets": [
        {
          "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{application=\"$application\"}[5m]))",
          "legendFormat": "{{method}} {{uri}} - {{status}}"
        }
      ]
    },
    {
      "title": "Response Time P99",
      "type": "timeseries",
      "targets": [
        {
          "expr": "histogram_quantile(0.99, sum by (le, method, uri) (rate(http_server_requests_seconds_bucket{application=\"$application\"}[5m])))",
          "legendFormat": "{{method}} {{uri}}"
        }
      ]
    },
    {
      "title": "Error Rate",
      "type": "stat",
      "targets": [
        {
          "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\",status=~\"5..\"}[5m])) / sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m])) * 100"
        }
      ]
    }
  ]
}

오래된 튜토리얼에서 복사한 대시보드 상당수는 여전히 graph와 singlestat 패널 타입을 사용합니다. Singlestat은 Grafana 8에서 제거되었고, Grafana 12는 AngularJS 지원을 완전히 제거했으므로, 이 패널들은 timeseries와 stat으로 마이그레이션해야만 렌더링됩니다.

Grafana 가져오기

커뮤니티 대시보드 가져오기: Grafana > Dashboards > New > Import에서 ID 4701 (JVM Micrometer) 또는 12900 (SpringBoot APM)을 입력합니다. 가져온 뒤에는 패널 타입을 확인해야 합니다. 커뮤니티 대시보드의 오래된 리비전에는 Grafana 12가 마이그레이션해야 하는 Angular 패널이 남아 있을 수 있습니다.

Prometheus 알림 구성

핵심 알림 규칙

Prometheus 알림 규칙은 지표가 임곗값을 넘는 순간 알림을 발송합니다.

yaml
# alerting-rules.yml
# Alert rules for Spring Boot applications
groups:
  - name: spring-boot-alerts
    rules:
      # Alert if application is down
      - alert: ApplicationDown
        expr: up{job="spring-boot-apps"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Application {{ $labels.instance }} is down"
          description: "{{ $labels.instance }} has been down for more than 1 minute"

      # Alert on HTTP error rate
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count[5m])) by (application)
          > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High error rate on {{ $labels.application }}"
          description: "Error rate is {{ $value | humanizePercentage }}"

      # Alert on P99 latency, aggregated per application
      - alert: HighLatency
        expr: |
          histogram_quantile(0.99,
            sum by (le, application) (rate(http_server_requests_seconds_bucket[5m]))
          ) > 2
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High latency on {{ $labels.application }}"
          description: "P99 latency is {{ $value | humanizeDuration }}"

      # Heap memory alert
      - alert: HighHeapUsage
        expr: |
          sum by (instance) (jvm_memory_used_bytes{area="heap"})
          / sum by (instance) (jvm_memory_max_bytes{area="heap"})
          > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High heap memory usage on {{ $labels.instance }}"
          description: "Heap usage is at {{ $value | humanizePercentage }}"

      # Database connection pool exhausted alert
      - alert: DatabaseConnectionPoolExhausted
        expr: |
          hikaricp_connections_active
          / hikaricp_connections_max
          > 0.9
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Database connection pool nearly exhausted"
          description: "{{ $value | humanizePercentage }} of connections in use"

      # Excessive GC alert
      - alert: HighGCPause
        expr: |
          rate(jvm_gc_pause_seconds_sum[5m])
          / rate(jvm_gc_pause_seconds_count[5m])
          > 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High GC pause time"
          description: "Average GC pause is {{ $value | humanizeDuration }}"

이러한 알림은 운영에서 자주 마주치는 가용성, 성능, 자원 관련 문제를 폭넓게 다룹니다. HighLatency 규칙에는 sum by (le, ...)가 필요합니다. 이것이 없으면 histogram_quantile이 시계열마다(URI, 메서드, 상태 코드별로) P99를 계산하므로, 호출이 세 번뿐인 느린 엔드포인트 하나 때문에 알림이 발생합니다.

Spring Boot HTTP 지표와 데이터베이스 지표

HTTP 요청의 자동 계측

Spring Boot는 Observation API를 통해 들어오는 모든 Spring MVC 및 WebFlux 요청을 계측하고, method, uri, status, outcome, exception 태그가 붙은 http.server.requests 타이머를 노출합니다.

yaml
# application.yml
# HTTP metrics configuration
management:
  metrics:
    distribution:
      # Histogram buckets for server-side percentiles
      percentiles-histogram:
        http.server.requests: true
      # SLO buckets: count requests faster than each boundary
      slo:
        http.server.requests: 100ms, 500ms, 1s, 2s
    tags:
      # Common tags added to every meter
      application: ${spring.application.name}
      environment: ${spring.profiles.active:default}

많은 예제에 등장하는 WebMvcTagsContributor와 WebMvcTagsProvider 인터페이스는 Spring Boot 3.0에서 제거되었습니다. 이제는 Spring Boot 지표 레퍼런스에 설명된 대로 DefaultServerRequestObservationConvention을 상속해 커스텀 태그를 추가합니다.

TenantObservationConvention.javajava
// Adds custom tags to http.server.requests (Spring Boot 3.0+)
package com.example.monitoring.config;

import io.micrometer.common.KeyValue;
import io.micrometer.common.KeyValues;
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
import org.springframework.http.server.observation.ServerRequestObservationContext;
import org.springframework.stereotype.Component;

import jakarta.servlet.http.HttpServletRequest;

@Component
public class TenantObservationConvention extends DefaultServerRequestObservationConvention {

    @Override
    public KeyValues getLowCardinalityKeyValues(ServerRequestObservationContext context) {
        HttpServletRequest request = context.getCarrier();
        String userId = request.getHeader("X-User-Id");
        String tenantId = request.getHeader("X-Tenant-Id");

        // Keep the default tags and add two bounded ones
        return super.getLowCardinalityKeyValues(context).and(
            KeyValue.of("user.type", userId != null ? "authenticated" : "anonymous"),
            KeyValue.of("tenant", tenantId != null ? tenantId : "default")
        );
    }
}

Prometheus 레이블이 되는 것은 카디널리티가 낮은 키-값뿐입니다. tenant 태그는 테넌트가 수십 개라면 괜찮지만 수천 개가 되면 카디널리티 문제가 됩니다. 카디널리티가 높은 값(사용자 ID, 주문 ID)은 트레이스에만 전달되는 getHighCardinalityKeyValues에 넣어야 합니다. Observation API의 트레이싱 측면은 OpenTelemetry를 활용한 Spring Boot 옵저버빌리티에서 자세히 다룹니다.

HikariCP와 SQL 쿼리 지표

HikariCP 풀 지표는 hikaricp 접두사와 풀 이름 태그를 달고 자동으로 노출되며, 범용 jdbc.connections 게이지도 함께 노출됩니다. 풀 이름을 지정하면 태그를 읽기 쉬워집니다.

yaml
# application.yml
# HikariCP pool configuration
spring:
  datasource:
    hikari:
      # Becomes the "pool" tag on hikaricp_* metrics
      pool-name: OrderServicePool
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000

쿼리 단위의 실행 시간은 Spring Boot가 다루지 않습니다. datasource-micrometer 프로젝트는 DataSource를 감싸 커넥션, 쿼리, 결과 집합마다 옵저베이션을 생성하므로 지표와 트레이스 스팬을 모두 얻을 수 있습니다. 2.x 라인은 Spring Boot 4, 1.x 라인은 Spring Boot 3을 대상으로 합니다.

xml
<!-- pom.xml -->
<!-- JDBC observations: 2.x for Spring Boot 4, 1.x for Spring Boot 3 -->
<dependency>
    <groupId>net.ttddyy.observation</groupId>
    <artifactId>datasource-micrometer-spring-boot</artifactId>
    <version>2.0.1</version>
</dependency>

이 방식은 DataSource를 ProxyDataSourceBuilder와 직접 작성한 리스너로 감싸던 수동 방식을 대체합니다. 수동 방식은 Spring Boot의 DataSource 자동 구성을 우회했고, Boot를 업그레이드할 때마다 맞춰 고쳐야 했습니다. 느린 쿼리는 대개 N+1 패턴을 가리키며, 이는 Spring Data JPA N+1 쿼리 해결 방법에서 다룹니다.

운영을 위한 모범 사례

지표 카디널리티

카디널리티가 지나치게 높으면 Prometheus 성능이 떨어집니다. 태그의 모든 고유 조합이 별개의 시계열을 만들어 내기 때문입니다.

AntiPatternHighCardinality.javajava
// AVOID: unbounded cardinality
package com.example.monitoring.antipattern;

@Service
public class AntiPatternHighCardinality {

    private final MeterRegistry registry;

    // BAD: userId creates one series per user
    public void trackUserAction(String userId, String action) {
        Counter.builder("user.actions")
            .tag("userId", userId)  // Millions of possible values
            .tag("action", action)
            .register(registry)
            .increment();
    }
}
GoodPracticeCardinality.javajava
// Controlled cardinality
package com.example.monitoring.bestpractice;

@Service
public class GoodPracticeCardinality {

    private final MeterRegistry registry;

    // GOOD: user category instead of ID
    public void trackUserAction(User user, String action) {
        Counter.builder("user.actions")
            .tag("userType", user.getSubscriptionType())  // FREE, PREMIUM, ENTERPRISE
            .tag("action", action)
            .register(registry)
            .increment();
    }

    // GOOD: let a timer histogram bucket durations
    public void trackResponseTime(long responseTimeMs) {
        Timer.builder("response.time")
            .publishPercentileHistogram()
            .register(registry)
            .record(Duration.ofMillis(responseTimeMs));
    }
}

지연 시간에 관한 질문에는 직접 만든 bucket 태그를 단 Counter보다 히스토그램 버킷을 가진 Timer가 더 적합합니다. PromQL은 버킷으로부터 어떤 백분위수든 계산할 수 있고, 버킷 경계도 서비스 간에 일관되게 유지됩니다.

운영용 설정

yaml
# application-production.yml
# Production configuration (Spring Boot 4)
management:
  server:
    # Separate port for management endpoints, not exposed publicly
    port: 9090
  endpoints:
    access:
      default: none
    web:
      exposure:
        include: health,info,prometheus
  endpoint:
    health:
      access: read-only
      show-details: when-authorized
      probes:
        add-additional-paths: true
    info:
      access: read-only
    prometheus:
      access: read-only
  observations:
    annotations:
      enabled: true
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
      minimum-expected-value:
        http.server.requests: 1ms
      maximum-expected-value:
        http.server.requests: 30s
    tags:
      application: ${spring.application.name}
      environment: production
      version: ${app.version:unknown}

access.default: none을 설정하면 env, beans, configprops, mappings 같은 엔드포인트를 하나씩 나열하지 않아도 접근할 수 없게 됩니다. management.prometheus.metrics.export.enabled의 기본값은 true이며, step 설정은 Prometheus에 아무 효과가 없습니다. Prometheus는 풀 방식 레지스트리이고, 스크래핑 간격은 prometheus.yml에서 정하기 때문입니다.

Actuator와 Prometheus에서 자주 발생하는 오류

증상원인해결
/actuator/prometheus가 404를 반환엔드포인트가 노출 목록에 없거나 레지스트리가 없음management.endpoints.web.exposure.include에 prometheus를 추가하고 micrometer-registry-prometheus가 클래스패스에 있는지 확인
Prometheus 대상에 "server returned HTTP status 401" 표시Spring Security가 health를 제외한 모든 엔드포인트를 보호스크래퍼의 네트워크에 prometheus를 열거나 스크래핑 설정에 Basic 인증을 구성
@Timed가 지표를 만들지 않음management.observations.annotations.enabled의 기본값이 false이거나 AspectJ가 없음속성을 true로 설정하고 spring-boot-starter-aspectj를 추가
업그레이드 후 spring-boot-starter-aop를 찾을 수 없음Spring Boot 4에서 이름 변경spring-boot-starter-aspectj 사용
Boot 4에서 Health 또는 HealthIndicator import 실패패키지가 org.springframework.boot.health.contributor로 이동import 수정
management.endpoint.*.enabled에 지원 중단 경고켜기/끄기 모델이 Spring Boot 3.4에서 대체됨management.endpoints.access.default와 management.endpoint.<id>.access 사용
대시보드의 백분위수가 실제와 크게 다름publishPercentiles 값이 파드 간에 평균됨publishPercentileHistogram()과 histogram_quantile() 사용

Spring Boot Micrometer 면접 질문

Actuator는 미드레벨과 시니어 포지션의 Spring Boot 면접 대부분에서 다뤄집니다. 경력자와 주니어를 가르는 답변은 다음과 같습니다.

  • liveness와 readiness의 차이: liveness는 Kubernetes에 파드 재시작을 지시하고, readiness는 트래픽 라우팅 중단을 지시합니다. 데이터베이스를 liveness 그룹에 넣는 지원자는 장애 상황에서 이를 운영해 본 적이 없는 것입니다.
  • Prometheus 클라이언트를 직접 쓰지 않고 Micrometer를 쓰는 이유: Micrometer는 파사드입니다. 같은 Counter와 Timer 코드가 의존성만 바꾸면 Prometheus, Datadog, OTLP로 내보내지며, Spring 자체의 계측(HTTP, JDBC 풀, 캐시)도 Micrometer를 기준으로 작성되어 있습니다.
  • 클라이언트 측 백분위수와 히스토그램의 차이: 클라이언트 측 백분위수는 인스턴스 간에 집계할 수 없고, 히스토그램은 집계할 수 있습니다. 이 주제에서 가장 흔한 함정 질문입니다.
  • 카디널리티의 의미와 Prometheus를 망가뜨리는 방식: 레이블의 고유 조합마다 시계열이 하나씩 생깁니다. 사용자 ID 태그는 메모리 사용량을 사용자 수만큼 곱합니다.
  • Actuator 보안 방법: 노출 범위를 제한하고, 접근 수준을 설정하고, 관리 엔드포인트를 별도 포트에서 실행하고, 전용 SecurityFilterChain으로 보호합니다.

이런 유형의 질문은 Spring Boot Actuator 면접 질문 모듈에 모여 있습니다.

참고 자료

Spring Boot Micrometer와 Prometheus 핵심 정리

  • 스크래핑이 동작하기까지 세 단계: Actuator 스타터, micrometer-registry-prometheus, 노출 목록에 prometheus 추가
  • 3.4 이후의 접근 모델: management.endpoints.access.default: none과 엔드포인트별 read-only가 지원 중단된 enabled 플래그를 대체
  • Spring Boot 4의 변경 사항: spring-boot-starter-aspectj, 헬스 API는 org.springframework.boot.health.contributor, EndpointRequest는 org.springframework.boot.security.autoconfigure.actuate.web.servlet, 프로브는 기본 활성화
  • 애노테이션에는 스위치가 필요: management.observations.annotations.enabled=true, TimedAspect 빈 직접 선언은 불필요
  • HTTP 태그는 Observation API로: DefaultServerRequestObservationConvention을 상속하고 레이블은 낮은 카디널리티로 유지
  • 클라이언트 백분위수보다 히스토그램: publishPercentileHistogram()을 쓰고 모든 histogram_quantile에 sum by (le) 적용
  • liveness는 로컬에 한정: 외부 의존성은 readiness나 커스텀 그룹에 두고, liveness에는 절대 넣지 않음

이 기본값을 적용한 Spring Boot 4 서비스는 파드 간에 올바르게 집계되는 지표, 공개 네트워크에 아무것도 노출하지 않는 엔드포인트, 느린 URI 하나가 아니라 실제 장애에서 발생하는 알림을 갖추게 됩니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

오늘의 챌린지

Spring Boot 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 10월 26일 업데이트

태그

#spring boot actuator
#micrometer
#prometheus
#monitoring
#observability

공유

관련 기사