모니터링 완전 정복 — Prometheus·Grafana·ELK부터 알림 파이프라인까지
시스템이 지금 이 순간 무엇을 하고 있는지 알지 못한 채 운영하는 것은, 계기판 없이 비행기를 모는 것과 같다. 메트릭 수집, 저장, 시각화, 알림까지 이어지는 전체 파이프라인을 처음부터 끝까지 해부한다.
비유: 비행기 조종석의 계기판과 같다. 고도계, 속도계, 연료계, 엔진 온도계 수십 개가 있고, 이상 징후가 발생하면 경보음이 울린다. 계기판 없이 비행하면 이미 추락 중에야 문제를 알게 된다.
1. Observability 3대 요소
관측 가능성(Observability)은 세 기둥으로 구성된다.
graph LR
OBS["Observability"]
OBS --> METRICS["Metrics"]
OBS --> LOGS["Logs"]
OBS --> TRACES["Traces"]
METRICS --> PROM["Prometheus + Grafana"]
LOGS --> ELK["ELK Stack / Loki"]
TRACES --> JAEGER["Jaeger / Zipkin"]
| 요소 | 핵심 질문 | 대표 도구 |
|---|---|---|
| Metrics | 지금 시스템이 얼마나 바쁜가? 느린가? | Prometheus, Datadog |
| Logs | 에러가 왜 발생했나? 무슨 일이 있었나? | ELK Stack, Loki |
| Traces | 어느 서비스에서 지연이 발생했나? | Jaeger, Zipkin, Tempo |
- 메트릭: “지금 초당 요청이 1200개다” — 숫자, 시계열, 압축 저장 가능
- 로그: “2026-05-15 13:42:01 ERROR NullPointerException at line 42” — 이벤트, 텍스트, 용량 큼
- 트레이스: 하나의 요청이 어떤 서비스를 어떤 순서로 거쳤는가 — 분산 추적
메트릭이 특별한 이유는 저비용 고밀도다. 초당 100만 요청이 들어와도 메트릭은 단 몇 개의 숫자만 증가시키면 된다. 로그는 100만 줄이 되지만, Counter 하나는 여전히 숫자 하나다.
세 요소는 서로 보완한다. Metrics로 이상을 감지하고, Logs로 원인을 파악하며, Traces로 어느 서비스가 문제인지 추적한다.
2. Prometheus 아키텍처 — Pull 모델의 철학
가장 널리 쓰이는 오픈소스 메트릭 모니터링 스택이다.
2-1. Pull vs Push
대부분의 모니터링 시스템은 에이전트가 서버로 데이터를 밀어넣는(Push) 방식을 사용한다. Prometheus는 반대다. 각 서비스가 메트릭 엔드포인트를 노출하면 Prometheus가 주기적으로 가져온다(Pull/Scrape).
graph LR
PROM["Prometheus"] -->|"Scrape 15초"| APP["SpringApp"]
APP -->|"메트릭 반환"| PROM
GRAF["Grafana"] -->|"PromQL 쿼리"| PROM
PROM -->|"데이터 반환"| GRAF
Pull 모델의 장점:
- 장애 격리: Prometheus가 죽어도 서비스에는 영향 없음
- 중단 감지: 스크랩 실패 시 즉시 알 수 있음. Push 방식은 에이전트가 죽어도 서버가 모름
- 네트워크 방향 제어: 모니터링 서버만 아웃바운드를 열면 됨
- 설정 중앙화: 무엇을 수집할지 Prometheus 쪽에서 결정
단점은 배치 작업처럼 수명이 짧은 프로세스는 스크랩 타이밍을 맞추기 어렵다는 것이다. 이를 위해 Pushgateway가 존재한다.
2-2. TSDB — 시계열 데이터베이스 내부
Prometheus는 자체 TSDB(Time Series Database)를 내장한다. 일반 RDB나 NoSQL과는 완전히 다른 설계다.
데이터 모델의 핵심: 메트릭 이름 + 레이블 집합 = 하나의 시계열
http_requests_total{method="GET", status="200", path="/api/users"} 1523
http_requests_total{method="POST", status="500", path="/api/orders"} 7
같은 메트릭 이름이라도 레이블이 다르면 별개의 시계열이다. 위 예시에서 http_requests_total이라는 메트릭은 레이블 조합 수만큼의 시계열을 생성한다.
저장 구조는 2시간 단위 블록(chunk)으로 메모리에 쌓다가 압축해서 디스크에 내린다. WAL(Write-Ahead Log)로 충돌 복구를 보장한다.
data/
01GXYZ.../ # 블록 디렉토리 (ULID 기반)
chunks/
000001 # 실제 시계열 데이터 (압축)
index # 레이블→시계열 역인덱스
meta.json # 블록 메타정보
tombstones # 삭제 표시
wal/ # Write-Ahead Log
00000001
00000002
기본 보존 기간은 15일이다. --storage.tsdb.retention.time=30d로 조정한다.
2-3. Prometheus 설정
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- "alert_rules.yml"
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
scrape_configs:
- job_name: 'spring-app'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['app1:8080', 'app2:8080']
relabel_configs:
- source_labels: [__address__]
target_label: instance
# Kubernetes 환경: Pod 어노테이션 기반 자동 디스커버리
- 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__
3. 메트릭 4종 — 도구를 골라 쓰는 법
3-1. Counter — 단조 증가
한 번 올라가면 절대 내려가지 않는 값. 프로세스 재시작 시 0으로 리셋된다.
// Micrometer
Counter requestCounter = Counter.builder("http.requests.total")
.tag("method", "GET")
.tag("status", "200")
.register(registry);
requestCounter.increment();
Counter 자체를 쿼리하면 누적값이라 분석이 어렵다. rate()로 초당 변화율을 구한다.
# 5분 평균 초당 요청률
rate(http_requests_total{status="200"}[5m])
3-2. Gauge — 현재 상태
오르내리는 값. 메모리 사용량, 큐 깊이, 활성 연결 수 등.
Gauge.builder("jvm.memory.used", runtime, r -> r.totalMemory() - r.freeMemory())
.tag("area", "heap")
.register(registry);
Gauge는 현재 스냅샷이므로 rate() 대신 바로 사용하거나 delta()를 쓴다.
3-3. Histogram — 분포 측정
요청 지연시간처럼 분포가 중요한 값. 미리 정의한 버킷에 관측값을 분류해 저장한다.
Timer timer = Timer.builder("http.request.duration")
.tag("path", "/api/orders")
.publishPercentileHistogram()
.sla(Duration.ofMillis(100), Duration.ofMillis(500), Duration.ofSeconds(1))
.register(registry);
timer.record(() -> processRequest());
이 코드가 생성하는 메트릭:
http_request_duration_seconds_bucket{le="0.1"} 8432
http_request_duration_seconds_bucket{le="0.5"} 9891
http_request_duration_seconds_bucket{le="1.0"} 9998
http_request_duration_seconds_bucket{le="+Inf"} 10000
http_request_duration_seconds_count 10000
http_request_duration_seconds_sum 812.4
histogram_quantile로 백분위수를 계산한다:
# 99번째 백분위수 지연시간 (P99)
histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))
3-4. Summary — 클라이언트 계산 백분위
Histogram과 달리 클라이언트 쪽에서 백분위수를 계산한다. 서버 부하는 낮지만 여러 인스턴스를 집계할 수 없다는 치명적 단점이 있다. 분산 환경에서는 Histogram을 선택해야 한다.
| 항목 | Histogram | Summary |
|---|---|---|
| 백분위 계산 | 서버(PromQL) | 클라이언트 |
| 인스턴스 집계 | 가능 | 불가 |
| 정확도 | 버킷 경계에 의존 | 높음 |
| 적합 환경 | 분산 시스템 | 단일 프로세스 |
4. Spring Boot + Micrometer 연동
Micrometer는 메트릭 수집의 파사드(Facade) 라이브러리다. Prometheus, Datadog, CloudWatch 등 다양한 백엔드에 동일한 API로 메트릭을 전송할 수 있다.
4-1. 의존성과 설정
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
probes:
enabled: true # liveness, readiness 엔드포인트 분리 (K8s probe용)
prometheus:
enabled: true
metrics:
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active}
distribution:
percentiles-histogram:
http.server.requests: true # 히스토그램 활성화 (P95 계산에 필요)
slo:
http.server.requests: 50ms, 100ms, 200ms, 500ms, 1s, 2s
/actuator/prometheus에 접근하면 Prometheus 포맷의 텍스트가 노출된다.
4-2. 커스텀 메트릭
@Service
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry registry;
// Counter: 단조증가 카운터 (주문 수, 에러 수)
public void trackOrderCreated(String type) {
Counter.builder("order.created.total")
.description("생성된 주문 수")
.tag("type", type)
.register(registry)
.increment();
}
// Timer: 처리 시간 측정 (분포 포함)
public Order processOrder(OrderRequest request) {
return Timer.builder("order.processing.duration")
.tag("type", request.getType())
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry)
.recordCallable(() -> doProcess(request));
}
// Gauge: 현재 상태를 반영하는 값 (람다로 실시간 제공)
@PostConstruct
void registerGauges() {
Gauge.builder("order.queue.size", orderQueue, Queue::size)
.description("현재 대기 중인 주문 수")
.register(registry);
}
}
Counter는 단조 증가만 하므로 rate()로 초당 변화량을 계산한다. Gauge는 현재값을 바로 사용한다.
5. PromQL 핵심 — 쿼리 언어 완전 정복
PromQL은 시계열 데이터를 위해 설계된 함수형 쿼리 언어다. SQL과 달리 시간 축이 내재되어 있다.
비유: 엑셀 함수와 비슷하다. 엑셀에서
=SUM(A1:A10)으로 셀 범위를 집계하듯, PromQL에서sum(rate(http_requests_total[5m]))은 “지난 5분간의 시계열 데이터 범위를 집계”한다. 차이는 엑셀이 공간 범위를 다루는 반면 PromQL은 시간 범위를 다룬다는 점이다.
5-1. 선택자와 필터
# 기본 선택
http_requests_total
# 레이블 필터
http_requests_total{job="api-server", status="500"}
# 정규식 필터
http_requests_total{path=~"/api/.*", status!~"2.."}
# 범위 벡터 (5분간 데이터)
http_requests_total[5m]
5-2. rate vs irate vs increase
# rate: 초당 평균 변화율 (연속 함수로 처리, 부드럽게)
rate(http_requests_total[5m])
# increase: 구간 총 증가량 (= rate * 구간초)
increase(http_requests_total[1h])
# irate: 마지막 두 샘플의 순간 변화율 (급격한 스파이크 감지)
irate(http_requests_total[5m])
rate는 스크랩 누락에 강하고, irate는 반응이 빠르다. 실무 권장: 알림 규칙은 rate(), 실시간 대시보드는 irate().
5-3. 집계 연산자
# 전체 합산
sum(rate(http_requests_total[5m]))
# 상태 코드별 합산
sum by (status) (rate(http_requests_total[5m]))
# 특정 레이블 제외하고 집계
sum without (instance) (rate(http_requests_total[5m]))
# 상위 5개 인스턴스
topk(5, rate(http_requests_total[5m]))
5-4. 실용 쿼리 모음
# 초당 요청 수 (TPS)
rate(http_server_requests_seconds_count[5m])
# P95 응답시간 (상위 5% 느린 요청의 응답시간)
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))
# P99 지연시간
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# 에러율 (5xx 응답 비율, %)
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) * 100
# JVM Heap 사용률 (%)
jvm_memory_used_bytes{area="heap"} /
jvm_memory_max_bytes{area="heap"} * 100
# DB 커넥션 풀 사용률
hikaricp_connections_active / hikaricp_connections_max
# CPU 사용률 (컨테이너)
rate(container_cpu_usage_seconds_total[5m]) * 100
# CPU 사용률 (노드)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
# 서비스 가용성 (업타임 비율)
avg_over_time(up{job="api-server"}[24h]) * 100
6. Grafana 대시보드 설계
6-1. 데이터 소스 연결
Configuration -> Data Sources -> Add data source -> Prometheus
URL: http://prometheus:9090
6-2. 대시보드 구조 설계 원칙
좋은 대시보드는 USE 방법론을 따른다.
- Utilization: 자원 사용률 (CPU, 메모리, 디스크)
- Saturation: 포화도 (큐 깊이, 대기 시간)
- Errors: 오류율
혹은 웹 서비스에 특화된 RED 방법론:
- Rate: 요청률
- Errors: 에러율
- Duration: 지연시간
6-3. 패널 구성 예시
{
"title": "HTTP 요청률 (초당)",
"type": "timeseries",
"targets": [
{
"expr": "sum by (status) (rate(http_requests_total[5m]))",
"legendFormat": "{{status}}"
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"thresholds": {
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 800},
{"color": "red", "value": 1000}
]
}
}
}
}
6-4. 변수(Variable) 활용
인스턴스나 잡을 드롭다운으로 선택할 수 있게 만들면 대시보드 하나로 여러 환경을 커버한다.
Dashboard Settings -> Variables -> New Variable
Name: instance
Type: Query
Query: label_values(up{job="api-server"}, instance)
이후 쿼리에서 $instance로 참조한다:
rate(http_requests_total{instance="$instance"}[5m])
7. ELK Stack
Elasticsearch + Logstash + Kibana. 로그 수집, 저장, 시각화 스택이다.
graph LR
APP["Spring App"] -->|"JSON 로그"| FB["Filebeat"]
FB -->|"전송"| LS["Logstash"]
LS -->|"저장"| ES[("Elasticsearch")]
ES -->|"쿼리"| KI["Kibana"]
7-1. Spring Logback JSON 설정
로그를 JSON으로 출력하면 Elasticsearch에 구조화된 형태로 저장되어 traceId, userId 등으로 필터링이 가능하다.
<configuration>
<appender name="JSON_CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<customFields>{"application":"myapp","environment":"prod"}</customFields>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON_CONSOLE"/>
</root>
</configuration>
출력 결과:
{
"@timestamp": "2026-05-01T10:00:00.000Z",
"level": "ERROR",
"message": "주문 처리 실패",
"logger": "com.example.OrderService",
"traceId": "abc123def456",
"spanId": "789xyz",
"userId": "user-42",
"application": "myapp",
"environment": "prod",
"exception": "com.example.OrderNotFoundException: Order not found: 12345"
}
traceId가 있으면 ELK에서 검색해 해당 요청이 거친 모든 서비스의 로그를 한 화면에서 조회할 수 있다.
7-2. Logstash 파이프라인
input {
beats { port => 5044 }
}
filter {
json { source => "message" }
if [level] == "ERROR" {
mutate { add_tag => ["error"] }
}
geoip { source => "clientIp" } # IP 지역 정보 추가
}
output {
elasticsearch {
hosts => ["elasticsearch:9200"]
index => "myapp-logs-%{+YYYY.MM.dd}"
ilm_enabled => true
ilm_rollover_alias => "myapp-logs"
ilm_policy => "myapp-logs-policy" # 오래된 인덱스 자동 삭제
}
}
8. Datadog
SaaS 형태의 통합 모니터링 플랫폼이다. Metrics/Logs/Traces를 하나의 플랫폼에서 제공한다.
장점:
- 설치가 매우 간단 (Agent 하나로 모든 것)
- UI/UX가 우수함
- APM(Application Performance Monitoring) 내장
- 이상 감지(Anomaly Detection) AI 내장
단점:
- 비용이 높음 (Host당 월 $15~$35)
- 데이터가 외부로 나감 (금융권 제약 가능성)
// dd-java-agent 사용 — JVM 옵션만으로 자동 계측, 코드 변경 불필요
// -javaagent:/path/to/dd-java-agent.jar
// -Ddd.service=myapp
// -Ddd.env=production
// -Ddd.version=1.0.0
// -Ddd.logs.injection=true // 로그에 traceId 자동 주입
9. Alertmanager — 알림 라우팅과 억제
9-1. 알림 규칙 정의
# alert_rules.yml
groups:
- name: api-alerts
interval: 1m
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) > 0.05
for: 2m # 2분 지속 시 발화
labels:
severity: critical
team: backend
annotations:
summary: "에러율 {{ $value | humanizePercentage }} 초과"
description: "{{ $labels.instance }} 인스턴스에서 5% 이상 에러 발생"
- alert: HighP99Latency
expr: |
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
) > 1.0
for: 5m
labels:
severity: warning
team: backend
annotations:
summary: "P99 지연시간 {{ $value }}초 초과"
- alert: PodCrashLooping
expr: rate(kube_pod_container_status_restarts_total[15m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Pod 재시작 감지: {{ $labels.pod }}"
for 절이 중요하다. 조건이 참이 되는 즉시 발화하면 일시적 스파이크에도 알림이 울린다. for: 2m은 2분 동안 지속될 때만 알림을 보낸다.
9-2. Alertmanager 라우팅
# alertmanager.yml
global:
smtp_smarthost: 'smtp.company.com:587'
slack_api_url: 'https://hooks.slack.com/services/...'
route:
receiver: 'default'
group_by: ['alertname', 'team'] # 그룹핑 기준
group_wait: 30s # 첫 알림 대기 (같은 그룹 묶기)
group_interval: 5m # 그룹 갱신 간격
repeat_interval: 4h # 재알림 간격
routes:
- match:
severity: critical
receiver: pagerduty
continue: false
- match:
severity: warning
team: backend
receiver: slack-backend
receivers:
- name: 'default'
email_configs:
- to: 'ops@company.com'
- name: 'pagerduty'
pagerduty_configs:
- service_key: '<key>'
- name: 'slack-backend'
slack_configs:
- channel: '#backend-alerts'
text: '{{ range .Alerts }}{{ .Annotations.summary }}{{ end }}'
9-3. 억제(Inhibit) 규칙
인프라 전체가 다운됐을 때 수백 개의 앱 알림이 쏟아지는 상황을 막는다.
inhibit_rules:
# 클러스터 다운 알림이 있으면 개별 인스턴스 알림 억제
- source_match:
alertname: ClusterDown
severity: critical
target_match:
severity: warning
equal: ['cluster']
# critical 이 있으면 동일 서비스의 warning 억제
- source_match:
severity: critical
target_match:
severity: warning
equal: ['alertname', 'service']
9-4. Silence — 계획된 점검
배포나 점검 시간에 알림을 임시 차단할 수 있다.
# amtool로 silence 등록
amtool silence add \
--alertmanager.url http://alertmanager:9093 \
--author "kim" \
--comment "정기 배포 점검" \
--duration 1h \
severity=warning team=backend
10. Recording Rules — 쿼리 성능 최적화
비유: 요리사가 매일 아침 손님이 오기 전에 육수를 미리 끓여두는 것과 같다. 주문이 들어올 때마다 육수를 처음부터 끓이면 시간이 너무 걸린다. Recording Rules는 자주 쓰는 복잡한 계산을 미리 해두는 “육수 스톡”이다. 대시보드가 열릴 때마다 무거운 집계를 반복 계산하는 대신, 이미 준비된 결과를 바로 꺼낸다.
groups:
- name: api_aggregations
interval: 1m
rules:
# 미리 계산된 에러율
- record: job:http_error_rate:rate5m
expr: |
sum by (job) (rate(http_requests_total{status=~"5.."}[5m])) /
sum by (job) (rate(http_requests_total[5m]))
# 미리 계산된 P99
- record: job:http_request_duration_p99:rate5m
expr: |
histogram_quantile(0.99,
sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
)
대시보드에서는 이 규칙을 참조한다:
job:http_error_rate:rate5m{job="api-server"}
Recording Rules 네이밍 컨벤션: level:metric:operations
11. Federation과 확장 아키텍처
11-1. Federation
대규모 환경에서 단일 Prometheus는 한계에 부딪힌다. Federation으로 계층 구조를 만든다.
비유: 지역 신문사와 전국 신문사의 관계와 같다. 서울 지역 신문사(DC1 Prometheus)는 서울 소식만 수집하고, 부산 지역 신문사(DC2 Prometheus)는 부산 소식만 수집한다. 전국 신문사(Global Prometheus)는 각 지역사로부터 핵심 기사(집계된 메트릭)만 받아 전국 단위 뷰를 제공한다.
# 상위 Prometheus 설정 — 하위에서 집계된 메트릭만 수집
scrape_configs:
- job_name: 'federate'
honor_labels: true
metrics_path: '/federate'
params:
match[]:
- '{job="api-server"}'
- 'job:http_error_rate:rate5m'
static_configs:
- targets:
- 'dc1-prometheus:9090'
- 'dc2-prometheus:9090'
graph LR
A["Global Prometheus"] -->|federate| B["DC1 Prometheus"]
A -->|federate| C["DC2 Prometheus"]
B -->|scrape| D["DC1 Apps"]
C -->|scrape| E["DC2 Apps"]
11-2. Thanos — 장기 보존과 글로벌 뷰
Thanos는 Prometheus 위에서 동작하는 오픈소스 확장이다.
graph LR
A["Thanos Query"] --> B["Thanos Sidecar"]
A --> C["Thanos Store"]
B --> D["Prometheus"]
C --> E["Object Storage"]
핵심 컴포넌트:
- Sidecar: Prometheus 옆에 배치, 블록을 오브젝트 스토리지(S3 등)로 업로드
- Store Gateway: 오브젝트 스토리지의 히스토리 데이터를 쿼리 가능하게 노출
- Query: 여러 Prometheus와 Store를 통합해 단일 쿼리 엔드포인트 제공
- Compactor: 오브젝트 스토리지의 블록을 압축/다운샘플링
장점:
- 보존 기간 제한 없음 (스토리지 비용만)
- 여러 데이터센터의 메트릭을 단일 뷰로
- Prometheus HA (같은 타겟을 수집하는 두 인스턴스의 중복 제거)
12. 4 Golden Signals (SRE 기준)
비유: 4 Golden Signals는 사람의 활력 징후(혈압/맥박/체온/호흡수)와 같다. 의사가 환자를 처음 볼 때 이 4가지만 봐도 위험 여부를 판단한다. 시스템도 마찬가지다.
graph LR
SVC["서비스"] --> LAT["Latency"]
SVC --> TRAF["Traffic"]
SVC --> ERR["Errors"]
SVC --> SAT["Saturation"]
LAT -->|"p99 임계값 초과"| ALERT["알림 발생"]
ERR -->|"5xx 1% 초과"| ALERT
Google SRE에서 정의한 서비스 품질의 핵심 4가지 신호다. 이 네 가지를 모니터링하면 대부분의 장애를 조기에 감지할 수 있다.
| 신호 | 설명 | 예시 메트릭 |
|---|---|---|
| Latency | 요청 처리 시간 | P50, P95, P99 응답시간 |
| Traffic | 요청량 | TPS (초당 트랜잭션 수) |
| Errors | 오류율 | 5xx 응답 비율 |
| Saturation | 자원 포화도 | CPU, 메모리, 커넥션 풀 |
SLI / SLO / SLA
SLI (Service Level Indicator): 측정 지표
예) 지난 30일간 가용성 = 성공 요청 수 / 전체 요청 수 = 99.95%
SLO (Service Level Objective): 내부 목표
예) 가용성 >= 99.9%, P95 응답시간 <= 500ms
SLA (Service Level Agreement): 고객과의 계약
예) 가용성 < 99.9% 시 서비스 크레딧 제공
SLO는 SLA보다 약간 엄격하게 설정해야 한다. SLO를 위반하지 않아야 SLA 위반을 방지할 수 있다.
알림 전략 (Alert Fatigue 방지)
P0 (즉시 대응, 24시간):
- 서비스 완전 다운
- 에러율 > 5%
-> PagerDuty / 전화
P1 (1시간 내 대응):
- P95 응답시간 > 2초
- DB 커넥션 풀 90% 이상
-> Slack #incidents
P2 (다음 근무일):
- 디스크 80% 초과
- 배포 실패
-> Slack #alerts
알림이 너무 많으면 중요한 알림도 무시하게 된다(Alert Fatigue). P0~P2로 분류해 각 수준에 맞는 채널과 대응 시간을 정해야 한다.
13. 카디널리티 관리 — 가장 중요한 운영 원칙
비유: 도서관 분류 시스템과 같다. “소설/비소설”처럼 큰 카테고리로 분류하면 책장 몇 개로 충분하다(낮은 카디널리티). 그런데 “저자 이름 + 출판 날짜 + ISBN”으로 각 책마다 고유 서랍을 만들면 책장이 책 수만큼 필요하다(카디널리티 폭발). Prometheus는 레이블 조합마다 별도 시계열을 저장하므로, UUID나 userId처럼 무한히 늘어나는 값을 레이블로 쓰면 메모리가 무한히 늘어난다.
카디널리티(Cardinality)는 하나의 메트릭이 가지는 고유 레이블 조합 수다. 카디널리티를 폭발시키면 Prometheus가 OOM으로 죽는다.
나쁜 예시
// 절대 하지 말 것 — userId는 수백만 가지
Counter.builder("api.requests")
.tag("userId", request.getUserId()) // 카디널리티 폭탄
.tag("requestId", UUID.randomUUID()) // 더 나쁨
.register(registry)
.increment();
이 코드 하나가 Prometheus TSDB를 수억 개의 시계열로 채울 수 있다.
좋은 예시
// 범주화 — 카디널리티를 제한된 값으로
Counter.builder("api.requests")
.tag("endpoint", classifyEndpoint(request.getPath())) // /api/users, /api/orders 등
.tag("status_class", statusClass(response.getStatus())) // 2xx, 4xx, 5xx
.register(registry)
.increment();
레이블 값은 항상 유한한 집합이어야 한다. 규칙:
- 레이블 하나의 고유값이 100개를 넘으면 설계를 재검토
- UUID, 사용자 ID, IP 주소는 레이블 금지
- 전체 카디널리티 = 각 레이블의 고유값 수의 곱
14. VisualVM — JVM 실시간 모니터링의 기본
비유: VisualVM은 JVM의 건강검진 도구다. 혈압(CPU), 체온(메모리), 심박수(스레드)를 실시간으로 보여준다.
왜 VisualVM인가:
- JDK에 기본 포함 (무료), 별도 설치 불필요
- 실시간 CPU/메모리/스레드/GC 모니터링
- 힙 덤프 분석, 스레드 덤프 분석
- 프로파일링 (CPU/메모리 핫스팟 탐지)
- 원격 JVM 연결 (JMX)
핵심 기능:
| 기능 | 용도 | 장애 대응 시 |
|---|---|---|
| Monitor 탭 | CPU/메모리/GC 실시간 그래프 | 메모리 누수 패턴 확인 |
| Threads 탭 | 스레드 상태 (Running/Waiting/Blocked) | 데드락 탐지, 스레드 풀 고갈 확인 |
| Heap Dump | 메모리 스냅샷 분석 | OOM 원인 객체 특정 |
| Thread Dump | 스레드 스택 트레이스 | 블로킹 지점 특정 |
| Sampler/Profiler | CPU/메모리 핫스팟 | 느린 메서드 특정 |
실전 장애 대응 흐름:
- OOM 발생 -> VisualVM으로 힙 덤프 -> 가장 큰 객체 -> 누수 원인 특정
- CPU 100% -> Sampler로 CPU 핫스팟 -> 무한 루프 메서드 특정
- 응답 지연 -> Thread Dump -> BLOCKED 스레드 -> 락 경합 지점 특정
VisualVM vs 다른 도구 비교:
| 도구 | 비용 | 원격 | 실시간 | 프로파일링 | 적합 |
|---|---|---|---|---|---|
| VisualVM | 무료 | O (JMX) | O | O | 개발/스테이징 |
| JConsole | 무료 | O (JMX) | O | X | 간단 확인 |
| Arthas (알리바바) | 무료 | O | O | O | 프로덕션 디버깅 |
| YourKit | 유료 | O | O | O (정밀) | 성능 튜닝 전문 |
| IntelliJ Profiler | 유료 | X | O | O | IDE 통합 |
| Datadog APM | 유료 | O | O | O | 분산 환경 |
15. 왜 Prometheus + Grafana인가? (vs 대안 비교)
| 도구 | 특징 | 비용 | 선택 기준 |
|---|---|---|---|
| Prometheus + Grafana | 오픈소스, Pull 방식, PromQL | 무료 (인프라 비용만) | 쿠버네티스 환경, 커스터마이징 중요할 때 |
| Datadog | SaaS, 에이전트 Push, 전방위 모니터링 | 호스트당 $23~+ /월 | 빠른 도입, 팀 규모 작을 때 |
| New Relic | APM 강점, 코드 레벨 분석 | 사용량 기반 | Java 애플리케이션 심층 분석 |
| Grafana Cloud | Prometheus + Loki + Tempo 통합 SaaS | 무료 티어 있음 | 자체 운영 부담 줄이면서 OSS 스택 유지 |
| AWS CloudWatch | AWS 네이티브, 별도 설치 불필요 | 메트릭당 과금 | AWS 단일 클라우드 환경 |
Prometheus를 선택하는 핵심 이유:
- Pull 방식 -> 서비스가 죽어도 Prometheus는 살아있음 (장애 격리)
- PromQL -> 유연한 집계 (rate, histogram_quantile, by 절)
- Kubernetes 네이티브 (ServiceMonitor, PodMonitor CRD)
- 오픈소스 생태계: Alertmanager, Thanos, Cortex
16. 극한 시나리오
시나리오 1: 메트릭은 정상인데 사용자 불만 폭발
HTTP 200 응답률 99.9%이지만 결제 성공률이 60%인 경우다. 기술 메트릭만 보고 비즈니스 메트릭을 놓친 것이다.
# 기술 메트릭 (정상처럼 보임): HTTP 200 응답률 99.9%
rate(http_requests_total{status="200"}[5m]) / rate(http_requests_total[5m])
# 비즈니스 메트릭 (실제 문제): 결제 성공률 60%
rate(payment_success_total[5m]) / rate(payment_attempt_total[5m])
HTTP 200이어도 결제 로직 내부에서 비즈니스 실패가 발생할 수 있다. 비즈니스 메트릭을 반드시 커스텀 메트릭으로 추가해야 한다.
시나리오 2: 카디널리티 폭발로 Prometheus OOM
상황: 새 배포 후 개발자가 request_id 레이블을 메트릭에 추가. 초당 1만 요청 * 24시간 = 8억 6400만 개의 시계열 생성. Prometheus 메모리 사용량이 수십 GB로 폭증하다 OOM Killed.
진단:
# 카디널리티 높은 메트릭 찾기
curl -s 'http://prometheus:9090/api/v1/label/__name__/values'
# TSDB 상태 API
curl http://prometheus:9090/api/v1/status/tsdb | jq '.data.seriesCountByMetricName[:10]'
대응:
- 즉시: 문제 메트릭 삭제
curl -X POST 'http://prometheus:9090/api/v1/admin/tsdb/delete_series' \ --data 'match[]={__name__=~"bad_metric.*"}' - 단기: 해당 메트릭의 레이블 제거 후 재배포
- 장기: 메트릭 리뷰 프로세스 도입 (PR에서 카디널리티 검토)
시나리오 3: 로그 스토리지 폭발 — 한 달 3TB
원인: 모든 요청에 DEBUG 로그 출력, 로그 보존 정책 없음
해결:
1. 로그 레벨 조정: 프로덕션은 WARN 이상만
2. 샘플링: 정상 요청의 1%만 DEBUG 로깅
3. ILM (Index Lifecycle Management):
- 7일: Hot (자주 조회)
- 30일: Cold (드물게 조회)
- 30일 후: 삭제
4. 중요도별 보존 기간 분리:
- ERROR 로그: 90일
- INFO 로그: 7일
시나리오 4: TSDB 디스크 가득 참
상황: 에러 관련 메트릭 폭증으로 TSDB 디스크 사용량이 100%에 도달.
대응 순서:
# 1. 현재 디스크 상태
df -h /prometheus-data
# 2. 오래된 블록 강제 정리
curl -X POST http://prometheus:9090/api/v1/admin/tsdb/clean_tombstones
# 3. 보존 기간 임시 축소 (재시작 필요)
# --storage.tsdb.retention.time=7d 으로 변경
# 4. 스냅샷 생성 후 정리
curl -X POST http://prometheus:9090/api/v1/admin/tsdb/snapshot
장기 저장이 필요하면 Thanos 또는 Cortex로 원격 스토리지에 적재한다.
시나리오 5: 알림 폭풍 (Alert Storm)
상황: 데이터센터 네트워크 장애로 300개 서비스 알림이 동시에 발화. 팀원들의 슬랙이 1분에 수백 개의 알림으로 마비.
사전 방어 (이것이 핵심):
# Alertmanager에서 그룹핑으로 폭풍 방지
route:
group_by: ['alertname', 'datacenter'] # 데이터센터 단위로 묶음
group_wait: 30s # 30초 기다려서 같은 그룹 알림 묶기
group_interval: 5m # 5분마다 한 번만 발송
# Inhibit으로 상위 알림이 하위를 억제
inhibit_rules:
- source_match:
alertname: DatacenterDown
target_match_re:
alertname: '.+'
equal: ['datacenter']
사후 대응: Silence로 복구 시간 동안 알림 차단, 원인 해결 후 해제.
17. 실무에서 자주 하는 실수
실수 1: 모든 로그에 INFO 레벨 사용
// 나쁜 예 — 정상 흐름에 INFO 남발
log.info("getUser 호출됨: userId={}", userId);
log.info("DB 조회 완료: {}", user);
log.info("응답 반환: {}", response);
// -> 트래픽 많으면 초당 수천 줄 -> ELK 스토리지 폭발
// 좋은 예 — 레벨 구분
log.debug("getUser 호출: userId={}", userId); // 개발 환경에서만
log.warn("캐시 미스 빈번: {}회/분", missCount); // 이상 징후
log.error("결제 처리 실패: orderId={}", orderId, e); // 반드시 알림
실수 2: 알림 임계값을 너무 낮게 설정 (Alert Fatigue)
# 나쁜 예 — 1초라도 에러 나면 알림
- alert: AnyError
expr: rate(http_errors_total[1m]) > 0
# -> 새벽 3시에도 알림 -> 담당자가 알림을 무시하기 시작
# 좋은 예 — 의미있는 임계값 + 지속 시간
- alert: HighErrorRate
expr: rate(http_errors_total[5m]) / rate(http_requests_total[5m]) > 0.01
for: 2m # 2분간 지속될 때만 알림
# -> 일시적 스파이크 무시, 실제 장애만 감지
실수 3: 분산 추적 없이 MSA 운영
증상: "주문 API가 가끔 3초 걸리는데 어디가 문제인지 모르겠다"
원인: 각 서비스 로그를 따로 뒤져야 하고 연결할 방법이 없음
해결: Spring Sleuth + Zipkin 또는 OpenTelemetry 도입
-> 모든 서비스에 TraceId 자동 전파
-> 단일 TraceId로 전체 요청 흐름 추적
-> 어느 서비스에서 얼마나 걸렸는지 시각화
실수 4: 로그에 민감정보 포함
// 나쁜 예
log.info("결제 요청: cardNumber={}, cvv={}", cardNumber, cvv);
log.info("로그인: userId={}, password={}", userId, password);
// 좋은 예 — 마스킹 처리
log.info("결제 요청: cardNumber=****{}", cardNumber.substring(cardNumber.length()-4));
log.info("로그인 시도: userId={}", userId); // 비밀번호 절대 로깅 금지
댓글