DevOps

Prometheus + Grafana 실전 구축 - Spring Boot 모니터링 완벽 가이드

백엔드 개발자 김승원 2026. 4. 9. 17:57

들어가며

"서버가 느린 것 같은데 원인을 모르겠어요", "장애가 발생했는데 언제부터인지 파악이 안 돼요 API Gateway 패턴의 Circuit Breaker와 장애 대응" - 모니터링이 없는 서비스에서 자주 듣는 말입니다. PrometheusGrafana는 오픈소스 모니터링의 사실상 표준(de facto standard)으로, CNCF 졸업 프로젝트이기도 합니다. 2026 백엔드 기술 스택에서의 모니터링 선택 이번 글에서는 Prometheus의 아키텍처와 PromQL, Grafana 대시보드 구축, Spring Boot Micrometer 연동, 커스텀 메트릭 작성, AlertManager 알림 설정까지 실전 수준으로 다루겠습니다.

1. Prometheus 아키텍처

Pull 모델

Prometheus는 모니터링 대상이 메트릭을 Push하는 것이 아니라, Prometheus 서버가 주기적으로 대상의 엔드포인트를 Pull(스크레이핑)하는 방식입니다.

┌──────────────────────────────────────────────────┐
│                  Prometheus Server                 │
│  ┌───────────┐  ┌────────┐  ┌──────────────────┐ │
│  │ Retrieval  │  │  TSDB  │  │   HTTP Server     │ │
│  │ (Scraper)  │→ │(Storage)│→ │  (PromQL API)    │ │
│  └─────┬─────┘  └────────┘  └────────┬─────────┘ │
│        │                              │           │
└────────┼──────────────────────────────┼───────────┘
         │ Pull (HTTP GET /metrics)      │ PromQL 쿼리
         ▼                              ▼
┌─────────────┐                 ┌──────────────┐
│ Spring Boot  │                 │   Grafana     │
│ /actuator/   │                 │  대시보드      │
│  prometheus  │                 └──────────────┘
└─────────────┘                         │
                                        ▼
                                ┌──────────────┐
                                │ AlertManager  │
                                │ (알림 전송)    │
                                └──────────────┘
                                   │   │   │
                                Slack Email PagerDuty

Pull vs Push 모델 비교

항목 Pull (Prometheus) Push (InfluxDB, Datadog)
데이터 수집 Prometheus가 대상을 스크레이핑 애플리케이션이 서버로 전송
장점 대상 상태 확인 가능, 중앙 제어 방화벽 뒤 대상 수집 가능
단점 방화벽 뒤 대상 수집 어려움 대상 장애 감지 어려움
서비스 디스커버리 필수 (자동 대상 감지) 불필요 (대상이 직접 전송)

TSDB (Time Series Database)

Prometheus는 자체 TSDB에 시계열 데이터를 저장합니다. 각 메트릭은 이름레이블의 조합으로 식별됩니다.

# 메트릭 형식: metric_name{label1="value1", label2="value2"} value timestamp
http_requests_total{method="GET", path="/api/users", status="200"} 1527 1617183600
http_requests_total{method="POST", path="/api/orders", status="201"} 342 1617183600
http_request_duration_seconds{method="GET", path="/api/users", quantile="0.99"} 0.45

메트릭 타입

  • Counter: 단조 증가 값 (요청 수, 에러 수). 절대 감소하지 않음. rate()와 함께 사용.
  • Gauge: 증감 가능한 값 (현재 온도, 메모리 사용량, 활성 스레드 수)
  • Histogram: 관측값의 분포 (응답 시간, 요청 크기). 버킷별 카운트 + 합계
  • Summary: 클라이언트에서 계산된 분위수. Histogram과 유사하나 서버측 집계 불가

2. PromQL 핵심 문법

PromQL은 Prometheus의 쿼리 언어로, 시계열 데이터를 필터링하고 집계하는 데 사용됩니다.

기본 쿼리

# 특정 메트릭의 현재 값
http_requests_total

# 레이블 필터링
http_requests_total{method="GET"}
http_requests_total{status=~"5.."} # 정규식: 5xx 에러
http_requests_total{path!="/health"} # 제외

# 시간 범위 선택 (Range Vector)
http_requests_total[5m] # 최근 5분간의 데이터

주요 함수

# rate(): Counter의 초당 변화율 (가장 많이 쓰는 함수)
rate(http_requests_total[5m])
# 결과: 초당 요청 수 (RPS)

# irate(): 마지막 두 데이터 포인트 기반 순간 변화율
irate(http_requests_total[5m])
# 순간적인 스파이크 감지에 유리

# increase(): 기간 내 총 증가량
increase(http_requests_total[1h])
# 결과: 1시간 동안의 총 요청 수

# histogram_quantile(): Histogram에서 분위수 계산
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
# 결과: P99 응답 시간

# sum(), avg(), max(), min(): 집계 함수
sum(rate(http_requests_total[5m])) by (method)
# 결과: HTTP 메서드별 초당 요청 수

avg(rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])) by (path)
# 결과: 경로별 평균 응답 시간

실전 유용 쿼리 모음

# 1. 전체 RPS (Requests Per Second)
sum(rate(http_server_requests_seconds_count[5m]))

# 2. 경로별 P99 응답 시간
histogram_quantile(0.99,
  sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri)
)

# 3. 5xx 에러율 (%)
100 * sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
    / sum(rate(http_server_requests_seconds_count[5m]))

# 4. JVM 힙 메모리 사용률 (%)
100 * jvm_memory_used_bytes{area="heap"}
    / jvm_memory_max_bytes{area="heap"}

# 5. HikariCP 커넥션 풀 사용률
hikaricp_connections_active / hikaricp_connections_max

# 6. GC 일시 정지 시간 (초/분)
rate(jvm_gc_pause_seconds_sum[5m])

# 7. 시스템 CPU 사용률
system_cpu_usage
process_cpu_usage

3. Spring Boot Micrometer 연동

의존성 추가

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus'
}

application.yml 설정

management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: ${spring.application.name}
    distribution:
      percentiles-histogram:
        http.server.requests: true  # 응답 시간 히스토그램 활성화
      sla:
        http.server.requests: 100ms, 300ms, 500ms, 1s  # SLA 버킷

spring:
  application:
    name: order-service

기본 제공 메트릭 확인

# Spring Boot Actuator Prometheus 엔드포인트 Spring Boot Actuator 완벽 활용으로 메트릭 노출하기
$ curl http://localhost:8080/actuator/prometheus

# 출력 예시
# HELP http_server_requests_seconds Duration of HTTP server request handling
# TYPE http_server_requests_seconds histogram
http_server_requests_seconds_bucket{method="GET",uri="/api/products",status="200",le="0.1"} 450
http_server_requests_seconds_bucket{method="GET",uri="/api/products",status="200",le="0.3"} 520
http_server_requests_seconds_bucket{method="GET",uri="/api/products",status="200",le="0.5"} 535
http_server_requests_seconds_bucket{method="GET",uri="/api/products",status="200",le="+Inf"} 540
http_server_requests_seconds_count{method="GET",uri="/api/products",status="200"} 540
http_server_requests_seconds_sum{method="GET",uri="/api/products",status="200"} 42.35

# JVM 메트릭
jvm_memory_used_bytes{area="heap",id="G1 Eden Space"} 52428800
jvm_threads_live_threads 45
jvm_gc_pause_seconds_count{action="end of minor GC",cause="G1 Evacuation Pause"} 12

# HikariCP 메트릭
hikaricp_connections_active{pool="HikariPool-1"} 3
hikaricp_connections_idle{pool="HikariPool-1"} 7
hikaricp_connections_max{pool="HikariPool-1"} 10

4. 커스텀 메트릭 작성

@Counted, @Timed 어노테이션

@Configuration
public class MetricsConfig {

    // @Timed 어노테이션 활성화
    @Bean
    public TimedAspect timedAspect(MeterRegistry registry) {
        return new TimedAspect(registry);
    }

    // @Counted 어노테이션 활성화
    @Bean
    public CountedAspect countedAspect(MeterRegistry registry) {
        return new CountedAspect(registry);
    }
}
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final MeterRegistry meterRegistry;

    // @Timed: 메서드 실행 시간 자동 측정
    @Timed(
        value = "order.create.duration",
        description = "주문 생성 소요 시간",
        percentiles = {0.5, 0.95, 0.99}
    )
    @Counted(
        value = "order.create.count",
        description = "주문 생성 횟수"
    )
    public OrderResponse createOrder(CreateOrderRequest request) {
        Order order = Order.create(request);
        orderRepository.save(order);
        
        // 주문 금액 메트릭 (커스텀)
        meterRegistry.counter("order.amount.total",
            "payment_method", request.paymentMethod()
        ).increment(order.getTotalAmount());
        
        return OrderResponse.from(order);
    }
}

프로그래밍 방식 커스텀 메트릭

@Component
@RequiredArgsConstructor
public class BusinessMetrics {

    private final MeterRegistry meterRegistry;
    private final AtomicInteger activeOrders = new AtomicInteger(0);

    @PostConstruct
    public void init() {
        // Gauge: 현재 처리 중인 주문 수
        Gauge.builder("orders.active", activeOrders, AtomicInteger::get)
            .description("현재 처리 중인 주문 수")
            .register(meterRegistry);

        // Gauge: 시스템 상태 (1=UP, 0=DOWN)
        Gauge.builder("app.health", this, self -> self.isHealthy() ? 1.0 : 0.0)
            .description("애플리케이션 상태")
            .register(meterRegistry);
    }

    // Counter: 비즈니스 이벤트 카운팅
    public void recordPaymentSuccess(String method, double amount) {
        meterRegistry.counter("payment.success",
            "method", method
        ).increment();

        // Distribution Summary: 결제 금액 분포
        DistributionSummary.builder("payment.amount")
            .description("결제 금액 분포")
            .baseUnit("won")
            .tag("method", method)
            .publishPercentiles(0.5, 0.95, 0.99)
            .register(meterRegistry)
            .record(amount);
    }

    public void recordPaymentFailure(String method, String reason) {
        meterRegistry.counter("payment.failure",
            "method", method,
            "reason", reason
        ).increment();
    }

    // Timer: 외부 API 호출 시간 측정
    public  T measureExternalApi(String apiName, Supplier action) {
        Timer timer = Timer.builder("external.api.duration")
            .description("외부 API 호출 시간")
            .tag("api", apiName)
            .publishPercentiles(0.5, 0.95, 0.99)
            .register(meterRegistry);

        return timer.record(action::get);
    }

    public void incrementActiveOrders() { activeOrders.incrementAndGet(); }
    public void decrementActiveOrders() { activeOrders.decrementAndGet(); }
    private boolean isHealthy() { return true; }
}

5. Prometheus 서버 설정

prometheus.yml

global:
  scrape_interval: 15s      # 스크레이핑 간격
  evaluation_interval: 15s  # 알림 규칙 평가 간격

rule_files:
  - "alert_rules.yml"

alerting:
  alertmanagers:
    - static_configs:
        - targets: ['alertmanager:9093']

scrape_configs:
  # Prometheus 자체 모니터링
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

  # Spring Boot 애플리케이션
  - job_name: 'spring-boot-app'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 10s
    static_configs:
      - targets: ['app1:8080', 'app2:8080']
        labels:
          env: 'production'

  # Kubernetes 서비스 디스커버리
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - 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: (.+)

  # Node Exporter (서버 시스템 메트릭)
  - job_name: 'node-exporter'
    static_configs:
      - targets: ['node-exporter:9100']

6. Grafana 대시보드 구축

주요 대시보드 패널

Spring Boot 모니터링 대시보드에 꼭 포함해야 할 패널입니다.

// Grafana 대시보드 JSON 패널 예시 (RPS 패널)
{
  "title": "Requests Per Second",
  "type": "timeseries",
  "targets": [
    {
      "expr": "sum(rate(http_server_requests_seconds_count{application=\"$application\"}[5m])) by (uri)",
      "legendFormat": "{{uri}}"
    }
  ],
  "fieldConfig": {
    "defaults": {
      "unit": "reqps"
    }
  }
}

추천 대시보드 구성

패널 PromQL
요약 총 RPS sum(rate(http_server_requests_seconds_count[5m]))
요약 에러율 (%) 100 * sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))
요약 P99 응답시간 histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket[5m])) by (le))
HTTP 경로별 RPS sum(rate(http_server_requests_seconds_count[5m])) by (uri)
HTTP 상태코드 분포 sum(rate(http_server_requests_seconds_count[5m])) by (status)
JVM 힙 메모리 jvm_memory_used_bytes{area="heap"}
JVM GC 일시정지 rate(jvm_gc_pause_seconds_sum[5m])
DB 커넥션 풀 hikaricp_connections_active

Grafana 프로비저닝 (코드로 대시보드 관리)

# grafana/provisioning/datasources/prometheus.yml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    editable: false

7. AlertManager 알림 설정

알림 규칙 정의 (alert_rules.yml)

groups:
  - name: spring-boot-alerts
    rules:
      # 1. 높은 에러율 경고
      - alert: HighErrorRate
        expr: |
          100 * sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
          / sum(rate(http_server_requests_seconds_count[5m])) > 5
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "높은 에러율 감지 ({{ $value | printf \"%.1f\" }}%)"
          description: "5xx 에러율이 5%를 초과했습니다. 현재 {{ $value | printf \"%.1f\" }}%"

      # 2. 느린 응답 시간
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_server_requests_seconds_bucket[5m])) by (le)
          ) > 2
        for: 3m
        labels:
          severity: warning
        annotations:
          summary: "P99 응답 시간 초과 ({{ $value | printf \"%.2f\" }}s)"
          description: "P99 응답 시간이 2초를 초과했습니다."

      # 3. 높은 메모리 사용률
      - alert: HighHeapUsage
        expr: |
          100 * jvm_memory_used_bytes{area="heap"}
          / jvm_memory_max_bytes{area="heap"} > 85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "JVM 힙 메모리 사용률 높음 ({{ $value | printf \"%.0f\" }}%)"

      # 4. 커넥션 풀 고갈 위험
      - alert: ConnectionPoolExhaustion
        expr: |
          hikaricp_connections_active
          / hikaricp_connections_max > 0.8
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "DB 커넥션 풀 사용률 80% 초과"

      # 5. 인스턴스 다운
      - alert: InstanceDown
        expr: up == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "인스턴스 다운: {{ $labels.instance }}"
          description: "{{ $labels.job }}의 {{ $labels.instance }}가 1분 이상 응답하지 않습니다."

AlertManager 설정 (alertmanager.yml)

global:
  slack_api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'

route:
  group_by: ['alertname', 'severity']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'slack-critical'
  routes:
    - match:
        severity: critical
      receiver: 'slack-critical'
    - match:
        severity: warning
      receiver: 'slack-warning'

receivers:
  - name: 'slack-critical'
    slack_configs:
      - channel: '#alerts-critical'
        title: '{{ .GroupLabels.alertname }}'
        text: |
          {{ range .Alerts }}
          *Alert:* {{ .Annotations.summary }}
          *Description:* {{ .Annotations.description }}
          *Severity:* {{ .Labels.severity }}
          {{ end }}
        send_resolved: true

  - name: 'slack-warning'
    slack_configs:
      - channel: '#alerts-warning'
        title: '{{ .GroupLabels.alertname }}'
        text: |
          {{ range .Alerts }}
          *Alert:* {{ .Annotations.summary }}
          {{ end }}
        send_resolved: true

8. Docker Compose로 모니터링 스택 구축 Docker 실전 가이드로 컨테이너 이해하기

# docker-compose.monitoring.yml
services:
  prometheus:
    image: prom/prometheus:v2.51.0
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
      - ./prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml
      - prometheus-data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=30d'
      - '--web.enable-lifecycle'  # 설정 핫 리로드
    restart: unless-stopped

  grafana:
    image: grafana/grafana:10.4.0
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_USER=admin
      - GF_SECURITY_ADMIN_PASSWORD=admin123
      - GF_USERS_ALLOW_SIGN_UP=false
    volumes:
      - grafana-data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning
    depends_on:
      - prometheus
    restart: unless-stopped

  alertmanager:
    image: prom/alertmanager:v0.27.0
    container_name: alertmanager
    ports:
      - "9093:9093"
    volumes:
      - ./alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml
    restart: unless-stopped

  node-exporter:
    image: prom/node-exporter:v1.7.0
    container_name: node-exporter
    ports:
      - "9100:9100"
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro
    command:
      - '--path.procfs=/host/proc'
      - '--path.sysfs=/host/sys'
      - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
    restart: unless-stopped

volumes:
  prometheus-data:
  grafana-data:

실행 및 확인

# 모니터링 스택 시작
$ docker-compose -f docker-compose.monitoring.yml up -d

# 상태 확인
$ docker-compose -f docker-compose.monitoring.yml ps

# Prometheus UI: http://localhost:9090
# - Status > Targets 에서 스크레이핑 대상 상태 확인
# - Graph 탭에서 PromQL 쿼리 테스트

# Grafana UI: http://localhost:3000 (admin/admin123)
# - Data Sources에서 Prometheus 연결 확인
# - Dashboard > Import > ID 4701 (JVM Micrometer)
# - Dashboard > Import > ID 11378 (Spring Boot Statistics)

# AlertManager UI: http://localhost:9093
# - 활성 알림 확인

# Prometheus 설정 리로드 (변경 시)
$ curl -X POST http://localhost:9090/-/reload

추천 Grafana 대시보드 ID

  • 4701: JVM (Micrometer) - JVM 상세 모니터링
  • 11378: Spring Boot Statistics - HTTP, DB, 캐시 통합
  • 1860: Node Exporter Full - 서버 시스템 모니터링
  • 13587: Spring Boot Observability - 최신 대시보드

마치며

Prometheus + Grafana 모니터링 스택은 서비스 안정성의 근간입니다. MSA 관측 가능성 완벽 가이드 핵심 포인트를 정리합니다.

  • Prometheus Pull 모델: 서버가 대상을 스크레이핑하는 방식으로, 대상의 상태(UP/DOWN)도 자동으로 감지할 수 있습니다.
  • PromQL: rate(), histogram_quantile(), sum() by () 세 가지만 잘 사용하면 대부분의 모니터링 쿼리를 작성할 수 있습니다.
  • Micrometer: Spring Boot의 메트릭 파사드로, @Timed, @Counted 어노테이션으로 간편하게 메트릭을 수집합니다.
  • 커스텀 메트릭: 비즈니스 메트릭(주문 수, 결제 금액 등)을 추가하면 기술 메트릭만으로는 보이지 않는 인사이트를 얻을 수 있습니다.
  • AlertManager: 임계값 기반 알림으로 장애를 조기에 감지합니다. severity 기반 라우팅으로 알림 피로를 줄이세요.
  • Docker Compose: 전체 모니터링 스택을 코드로 관리하면 환경 구축이 쉽고 재현 가능합니다.

모니터링은 장애 발생 후가 아니라 서비스 출시 전에 구축해야 합니다. "측정하지 않으면 개선할 수 없다"는 격언을 기억하며, 모니터링 체계를 먼저 갖추고 서비스를 운영하시길 권장합니다.