Skip to content

Monitoring

Sechang Jang edited this page Feb 20, 2026 · 1 revision

모니터링: Prometheus + Grafana

이 문서에서 다루는 것

  • 왜 모니터링이 필요했는가: k6만으로 알 수 없는 것
  • 기술 선택: Prometheus + Grafana를 선택한 이유
  • 수집 계층 설계: 어떤 메트릭을 왜 계층별로 나눴는가
  • Spring Boot → Prometheus → Grafana 연결 구조
  • 모니터링이 실제 의사결정에 연결된 사례

1. 왜 모니터링이 필요했는가

부하 테스트 1차 측정에서 VU 1000을 가했을 때 k6 결과는 이것만 보여줬습니다.

http_req_duration  avg=3.2s   p95=8.1s
http_req_failed    rate=41%

"느리다"는 사실은 알았지만 이유를 알 수 없었습니다. 가능한 원인만 해도 여럿이었습니다.

가능한 원인들
├── 쿼리가 느린가?     (N+1, 인덱스 누락)
├── Thread가 고갈됐는가? (Tomcat Accept Queue)
├── DB 커넥션이 부족한가? (HikariCP Pending)
├── GC가 애플리케이션을 멈추고 있는가? (Stop-the-World)
└── Nginx에서 병목이 생기는가? (upstream 대기)

k6는 클라이언트 측 도구입니다. "요청을 보냈더니 응답이 몇 초 걸렸다"는 End-to-End 시간만 측정합니다. 그 시간 안에서 서버 내부의 어떤 계층이 얼마나 걸렸는지는 보이지 않습니다.

서버 내부를 계층별로 실시간으로 볼 수 있어야 원인을 특정할 수 있습니다. 모니터링을 구축한 이유입니다.


2. 기술 선택: Prometheus + Grafana

Spring Boot Actuator + Micrometer

Spring Boot는 spring-boot-starter-actuatormicrometer-registry-prometheus 의존성만 추가하면 /actuator/prometheus 엔드포인트를 자동으로 노출합니다.

// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'

Micrometer는 애플리케이션 메트릭 수집의 추상화 레이어입니다. JVM, Tomcat, HikariCP, HTTP 요청 등 스프링 생태계의 주요 컴포넌트 메트릭을 자동 수집하고, 이를 Prometheus 포맷으로 변환합니다. 직접 메트릭 수집 코드를 작성하지 않아도 됩니다.

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus
  metrics:
    tags:
      application: ${spring.application.name}   # 모든 메트릭에 application=timefit 태그
    enable:
      tomcat: true
      jvm: true
      executor: true
    distribution:
      percentiles-histogram:
        http.server.requests: true              # p95, p99 히스토그램 활성화

percentiles-histogram: true 설정이 중요합니다. 이것을 활성화해야 Grafana에서 histogram_quantile() 함수로 p95, p99를 계산할 수 있습니다. 활성화하지 않으면 평균 응답 시간만 볼 수 있어 부하 테스트에서 의미 있는 분석이 어렵습니다.

Prometheus

Prometheus는 Pull 방식으로 메트릭을 수집합니다. Push 방식(애플리케이션이 메트릭 서버로 직접 전송)과 달리, Prometheus가 주기적으로 엔드포인트를 polling합니다.

# prometheus.yml
scrape_configs:
  - job_name: 'timefit-backend'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 5s             # 부하 테스트 시 5초 간격
    static_configs:
      - targets: ['timefit-backend:8080']
        labels:
          application: 'timefit'
          environment: 'prod'

  - job_name: 'nginx'
    scrape_interval: 5s
    static_configs:
      - targets: ['nginx-exporter:9113']   # nginx-prometheus-exporter
        labels:
          application: 'timefit'
          component: 'nginx'

scrape_interval: 5s는 일반 운영(15s)보다 짧습니다. 부하 테스트에서 실시간 변화를 추적하려면 5초 간격이 필요합니다. Nginx는 자체적으로 Prometheus 포맷 메트릭을 제공하지 않으므로 nginx-prometheus-exporter를 사이드카로 실행해 /nginx_status를 변환합니다.

Grafana

Prometheus가 수집한 데이터를 시각화합니다. Prometheus는 시계열 DB로 데이터를 저장하고, Grafana는 PromQL로 이 데이터를 쿼리해 대시보드를 구성합니다.

이 스택을 선택한 이유는 Spring Boot 생태계와의 통합 완성도입니다. Micrometer가 스프링 컴포넌트 메트릭을 자동 수집하고, Prometheus가 Pull 방식으로 가져가고, Grafana가 PromQL로 시각화하는 파이프라인이 별도의 커스텀 코드 없이 설정만으로 완성됩니다.


3. 수집 계층 설계

메트릭을 무작위로 수집하면 대시보드가 정보 과잉 상태가 됩니다. "어디서 문제가 생겼는지"를 계층 순서로 추적할 수 있도록 설계했습니다.

요청 흐름과 모니터링 계층 대응

Client → [Nginx] → [Tomcat] → [Service Layer] → [HikariCP] → [PostgreSQL]
             ↑           ↑                              ↑
         Nginx 계층   Application 계층            DB 계층

Application 계층: HTTP + JVM

HTTP 메트릭
├── http_server_requests_seconds (count, sum, histogram)
│   → Request Rate, 응답 시간 p95/p99 계산에 사용
└── Error Rate (5xx 비율)

JVM 메트릭
├── jvm_memory_used_bytes / jvm_memory_max_bytes
│   → Heap 사용량, OOM 위험도 감지
└── jvm_gc_pause_seconds (count, sum)
    → GC Stop-the-World 빈도와 지속 시간

HTTP p95/p99를 보는 이유: 평균 응답 시간은 느린 요청을 가립니다. 평균이 200ms여도 p99가 5초라면 100명 중 1명은 5초를 기다립니다. 부하 테스트에서 병목 징후는 평균보다 p95, p99에서 먼저 나타납니다.

JVM GC를 보는 이유: G1GC의 Stop-the-World가 발생하면 그 시간만큼 HTTP 응답이 지연됩니다. 1차 측정에서 응답 시간 스파이크와 GC Pause가 동시에 발생하는 것을 확인했습니다. GC 메트릭 없이는 이 연관성을 파악할 수 없습니다.

Tomcat 계층: Thread Pool

Tomcat 메트릭
├── tomcat_threads_busy_threads           → 현재 처리 중인 Thread 수
├── tomcat_threads_current_threads        → 생성된 Thread 수
├── tomcat_threads_config_max_threads     → 설정된 Max Thread 수
└── Busy / Max 비율 → Thread Usage %

Thread 사용률을 보는 이유: Thread가 고갈되면 새로운 요청이 Accept Queue에 쌓이고, 큐가 가득 차면 연결 거부(503)가 발생합니다. 2차 측정에서 Busy Threads가 200/200에 도달해 Thread Usage가 100%를 기록했고, 이것이 Nginx Rate Limiting + Thread 수 재설계의 근거가 됐습니다.

# Grafana PromQL: Thread 사용률
(sum(tomcat_threads_busy_threads{application="timefit"})
 / sum(tomcat_threads_config_max_threads{application="timefit"})) * 100

DB 계층: HikariCP

HikariCP 메트릭
├── hikaricp_connections_active    → 현재 사용 중인 커넥션
├── hikaricp_connections_idle      → 반납된 유휴 커넥션
├── hikaricp_connections_pending   → 커넥션 대기 중인 Thread 수
└── hikaricp_connections_max       → 설정된 Pool 최대 크기

Pending Connections를 핵심 경보 지표로 본 이유: Active가 Max에 도달해도 잠시라면 문제가 없습니다. 그러나 Pending이 발생한다는 것은 커넥션을 기다리는 Thread가 생겼다는 의미입니다. Pending이 지속되면 응답 시간이 커넥션 대기 시간만큼 늘어납니다. 1차 측정에서 Pending이 최대 269까지 치솟았고, 이것이 N+1 쿼리와 인덱스 최적화를 1순위로 결정한 근거였습니다.

Nginx 계층

Nginx는 Spring Boot 앞단에서 Rate Limiting과 Reverse Proxy를 담당합니다. nginx-prometheus-exporter/nginx_status를 Prometheus 포맷으로 변환합니다.

Nginx 메트릭
├── nginx_http_requests_total    → 초당 유입 요청 수 (클라이언트 발 전체)
└── nginx_connections_*
    ├── active   → 현재 활성 연결 수
    ├── waiting  → keepalive 대기 연결 (요청 없이 연결만 유지)
    ├── reading  → 요청 헤더 읽는 중
    └── writing  → 응답 전송 중

Nginx 메트릭이 필요한 이유: Rate Limiting을 적용하면 Grafana(서버)와 k6(클라이언트)의 수치가 달라집니다. Nginx 메트릭이 없으면 이 차이의 원인을 설명할 수 없습니다. 3차 측정에서 Nginx Request Rate ~1,000 req/s vs Spring Boot HTTP Request Rate ~100 req/s를 나란히 보여줌으로써 Rate Limiting이 의도대로 동작했음을 확인했습니다.


4. 파이프라인 구성

Docker Compose 통합

모니터링 스택 전체를 Docker Compose로 통합했습니다.

# docker-compose.prod.yml (모니터링 관련 서비스)
services:
  prometheus:
    image: prom/prometheus:latest
    volumes:
      - ./prometheus/prometheus.prod.yml:/etc/prometheus/prometheus.yml:ro
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.retention.time=30d'    # 30일 보관
      - '--web.enable-lifecycle'
    networks:
      - timefit-network

  nginx-exporter:
    image: nginx/nginx-prometheus-exporter:1.1.0
    command:
      - '-nginx.scrape-uri=http://nginx:80/nginx_status'
    networks:
      - timefit-network

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3200:3000"
    volumes:
      - grafana_data:/var/lib/grafana
      - ./grafana/provisioning:/etc/grafana/provisioning:ro
    networks:
      - timefit-network

Spring Boot, Nginx, Prometheus, Grafana, nginx-exporter가 같은 Docker 네트워크(timefit-network) 안에서 서비스 이름으로 통신합니다. Prometheus는 timefit-backend:8080으로, nginx-exporter는 nginx:80으로 접근합니다. 외부에 IP를 노출하지 않고도 컨테이너 간 통신이 가능합니다.

Grafana Provisioning

Grafana 대시보드와 데이터소스를 provisioning 디렉터리에 JSON으로 정의하면, 컨테이너 재시작 후에도 설정이 자동으로 복원됩니다. 수동으로 UI에서 설정하면 볼륨이 초기화될 때 사라지는 문제가 있었습니다.

grafana/
└── provisioning/
    ├── datasources/
    │   └── prometheus.yml    # Prometheus 연결 설정
    └── dashboards/
        ├── dashboard.yml     # 대시보드 파일 경로 지정
        └── timefit.json      # 실제 대시보드 패널 정의

5. 모니터링이 의사결정에 연결된 사례

모니터링은 대시보드를 보기 위한 것이 아닙니다. "지금 어디가 문제인가"를 특정하고, "어떤 최적화가 효과를 냈는가"를 확인하는 도구입니다.

사례 1: 1차 측정 — HikariCP가 먼저 고갈됐다

k6 결과만 봤을 때 가장 유력한 가설은 "Tomcat Thread 부족"이었습니다. VU 1000이면 Thread가 먼저 소진될 것이라고 예상했습니다.

그러나 Grafana를 보니 다른 계층이 먼저 터졌습니다.

1차 측정 Grafana 관찰
├── HikariCP Pending: 최대 269 스파이크 (Pool 고갈)
├── HTTP p95: 2초 이상 (스파이크 반복)
└── JVM GC: G1 Evacuation Pause와 응답 시간 스파이크가 겹침

HikariCP Pending이 269까지 치솟았습니다. Pool의 커넥션을 기다리는 Thread가 269개였다는 뜻입니다. 쿼리가 느려서 커넥션을 오래 물고 있었고, 그것이 Pool 고갈로 이어진 것이었습니다.

이 관찰이 1단계 최적화 방향을 결정했습니다. 예상과 달리 Thread가 아니라 쿼리가 문제였습니다. N+1 제거와 인덱스 설계를 1순위로 진행했습니다.

GC 스파이크도 응답 시간에 직접 영향을 줬습니다. G1 GC Evacuation Pause가 발생하는 시점에 HTTP p95가 함께 튀었습니다. GC 메트릭 없이는 "쿼리를 최적화했는데 왜 여전히 간헐적으로 느린가"를 설명할 수 없었을 것입니다.

사례 2: 2차 측정 — 예상과 다른 병목 위치

N+1을 제거하고 인덱스를 적용한 뒤 2차 측정을 했습니다. HikariCP는 안정됐는데 새로운 문제가 생겼습니다.

2차 측정 Grafana 관찰
├── Tomcat Busy Threads: 200/200 (Thread Usage 100%)
├── HikariCP Pending: 최대 99 (간헐적, 1차보다 크게 감소)
└── HTTP p95: 400ms ~ 1.6s (스파이크 여전히 간헐적 발생)

쿼리가 빨라지자 이번에는 Tomcat Thread가 한계에 도달했습니다. Busy Threads가 200/200을 기록하고 Thread Usage가 100%였습니다.

이 관찰이 Nginx Rate Limiting 도입의 직접적 근거가 됐습니다. 쿼리를 더 최적화해도 서버로 들어오는 요청량 자체를 제어하지 않으면 Thread는 계속 고갈됩니다. Application Layer 최적화로 해결할 수 있는 범위를 넘었다는 판단이었습니다.

HikariCP Pending이 완전히 사라지지 않은 이유도 보였습니다. Thread가 100%이면 새로운 요청이 Accept Queue에 쌓이고, 대기 중인 Thread가 결국 커넥션을 요청합니다. Thread와 Connection Pool 문제가 연쇄적으로 발생하고 있었습니다.

사례 3: 3차 측정 — Rate Limiting 효과 확인

Rate Limiting을 적용한 후 Grafana에서 두 가지를 확인해야 했습니다. "Rate Limiting이 실제로 작동하는가"와 "서버 내부가 안정화됐는가"입니다.

3차 측정 Grafana 관찰
├── Nginx Request Rate: 최대 1,000 req/s 유입
├── Spring Boot HTTP Request Rate: ~100 req/s  ← 필터링 확인
├── Tomcat Busy Threads: 평균 3.54, 최대 27 (Max 400, 사용률 6.75%)
├── HikariCP Pending: 0 (완전 해소)
└── HTTP p95: mean 120ms, max 303ms (안정적)

Nginx 메트릭과 Spring Boot HTTP 메트릭을 나란히 봄으로써 Rate Limiting이 의도대로 1,000 req/s를 100 req/s로 필터링하고 있음을 확인했습니다. 그 결과 서버 내부 지표가 전부 안정됐습니다.

이 측정 결과가 prod 설정값 산정의 근거가 됐습니다. Rate Limiting 환경에서 Tomcat 400개 중 최대 27개, HikariCP 150개 중 최대 20개만 사용됐습니다. prod에서 Tomcat 100개, HikariCP 50개로 설정해도 이 부하를 충분히 처리할 수 있다는 판단이 수치로 뒷받침됩니다.


6. k6와 Grafana: 무엇이 다른가

모니터링을 구축하고 나서 알게 된 중요한 사실이 있습니다. 같은 부하 테스트를 보는데 k6와 Grafana의 수치가 크게 다릅니다.

측정 지점 비교

Client → [k6 측정 범위: 전체 E2E]
                ↓
           [Nginx]
           ├── Pass: ~100 req/s  ──→  [Spring Boot]
           │                              ↑
           │                    [Grafana 측정 범위: 서버 내부]
           └── Block: ~900 req/s ──→  429 즉시 반환

k6는 클라이언트 입장에서 전체 왕복 시간을 측정합니다. 차단된 429 응답도 http_req_failed로 집계됩니다. Grafana는 서버에 도달한 요청만 측정합니다.

3차 측정에서 k6 실패율이 84.7%였지만 서버가 84.7%의 요청을 처리하지 못한 것이 아닙니다. Nginx가 약 85%를 의도적으로 차단했고, 서버로 들어온 ~15%의 요청은 p95 303ms 이내로 정상 처리됐습니다.

두 도구는 서로 다른 관점을 측정합니다. k6 없이는 사용자 체감 시간을 알 수 없고, Grafana 없이는 서버 내부에서 어떤 계층이 병목인지 알 수 없습니다. 두 도구를 함께 사용해야 "클라이언트가 느리다"는 증상에서 "어떤 계층에서 왜 느린가"까지 추적할 수 있습니다.

Clone this wiki locally