메트릭과 SLO — 무엇을 재고, 어디까지 괜찮은가
2025-10-20
8편에서 분산추적을 붙여, 주문 하나가 일곱 서비스를 지나며 어디서 느렸는지 를 볼 수 있게 됐습니다. 그런데 만들어 놓고 보니 빈 곳이 있었습니다. 추적은 개별 사건 입니다. "이 주문이 왜 느렸나"는 답하지만, "지금 시스템 전체가 괜찮은가?" 는 한눈에 안 보입니다. 그걸 보려면 개별 사건이 아니라 집계된 시계열 — 메트릭이 필요합니다.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-24태그에 있습니다.
관찰성의 세 기둥, 그리고 메트릭의 자리
- 로그: 무슨 일이 있었나(개별 사건, 상세하지만 비쌈).
- 추적: 한 요청이 서비스들을 어떻게 지났나(인과, 개별).
- 메트릭: 전체가 시간에 따라 어떤가(집계, 싸고 상시).
메트릭은 싸서 늘 켜 둘 수 있고, 그래서 "건강 검진" 과 알림 의 토대가 됩니다. 이상을 메트릭으로 감지하고, 원인은 추적으로 파고듭니다.
RED — 요청 기반 서비스의 3대 지표
무엇을 재야 할까요. 요청을 처리하는 서비스라면 RED 가 정석입니다.
- Rate(요청률): 초당 몇 건인가.
- Errors(에러율): 그중 몇 %가 실패하나.
- Duration(지연): 얼마나 걸리나.
앞선 편들에서 http_requests_total(카운터)은 있었는데 지연이 없었습니다. 히스토그램 을 더합니다 — 히스토그램이라야 나중에 p95·p99 를 쿼리로 뽑을 수 있습니다(평균은 꼬리 지연을 숨깁니다).
httpDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
}, []string{"method", "route"})
그리고 미들웨어에서 요청마다 관측합니다.
RecordHTTPDuration(r.Method, route, elapsed.Seconds())
카디널리티가 생명입니다. 라벨로
route(패턴POST /orders/{id})를 쓰지, URL 경로를 쓰면 안 됩니다. 주문 ID 마다 새 시계열이 생겨 Prometheus 가 메모리로 터집니다 — 관찰성 시스템을 죽이는 1번 원인입니다. 상태도 텍스트("Internal Server Error") 대신 숫자 코드("500")로 바꿔,status=~"5.."같은 SLO 쿼리가 깔끔해지게 했습니다.
기술 지표만 보면 "장사"를 놓칩니다
RED 가 다 초록이어도, 아무도 물건을 안 사고 있을 수 있습니다. 모든 요청이 200 이지만 주문이 0 인 상황을, 기술 지표는 못 잡습니다. 그래서 비즈니스 메트릭 을 나란히 둡니다.
func RecordOrderPlaced(channel string, amountWon int64) {
ordersPlaced.WithLabelValues(channel).Inc()
orderRevenue.WithLabelValues(channel).Add(float64(amountWon))
}
여기서 15편에서 더한 channel 이 빛을 봅니다 — 유입 경로별 주문·매출 을 나눠 보면, "앱 결제 전환이 갑자기 꺾였다" 같은 걸 그래프로 잡습니다. 그리고 14편의 죽은 편지함도 지표로 끌어옵니다.
func RecordDeadLettered(subject string) { dlqMessages.WithLabelValues(subject).Inc() }
DLQ 유입률(rate(dlq_messages_total))이 0 이 아니면, 어딘가 조용히 썩고 있다 는 신호입니다. 14편에서 "DLQ 는 지켜봐야 한다"고 했는데, 그 눈이 바로 이 지표입니다.
Prometheus 가 긁고, Grafana 가 그린다
Prometheus 는 pull 모델입니다 — 각 파드의 /metrics 를 주기적으로 긁습니다. 파드에 붙여 둔 prometheus.io/scrape 애너테이션을 보고 대상을 자동 발견 하니, 서비스를 늘려도 설정을 안 고쳐도 됩니다.
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true" # scrape=true 인 파드만 긁는다
Grafana 는 그 시계열을 그립니다. 데이터소스와 대시보드를 코드로 프로비저닝 해서(ConfigMap), 클러스터를 새로 띄워도 "Shop — RED · 비즈니스 · SLO" 대시보드가 자동으로 뜨게 했습니다. 대시보드에 담은 것: 요청률·에러율·p50/p95/p99 지연, 채널별 주문·매출, DLQ 유입, 그리고 SLO 두 개.
SLO — "어디까지가 괜찮은가"를 숫자로
지표를 그리는 것만으론 부족합니다. 얼마면 괜찮고, 얼마부터 문제인가 를 정해야 알림도 걸고 판단도 합니다. 그게 SLO(서비스 수준 목표) 입니다.
- SLI(지표): 예) 성공 요청 비율, p95 지연.
- SLO(목표): 예) 30일 가용성 99.9%, p95 < 500ms.
- 에러 예산(error budget):
100% − SLO. 99.9% 면 30일에 약 43분 의 실패가 허용됩니다.
# 가용성 SLI: 5xx 가 아닌 요청 비율
1 - (sum(rate(http_requests_total{status=~"5.."}[30m]))
/ clamp_min(sum(rate(http_requests_total[30m])), 1))
여기서 중요한 발상의 전환이 있습니다. 왜 목표가 100% 가 아닌가? 100% 신뢰성은 불가능할뿐더러 지독하게 비쌉니다. 에러 예산이 있어야 배포 속도와 안정성을 저울질 할 수 있습니다. 예산이 남으면 과감히 배포하고, 예산을 빠르게 태우면(번 레이트↑) 배포를 멈추고 안정화 에 집중합니다 — 이게 SRE 의 규율입니다.
알림은 증상에, 원인에 걸지 마세요. "CPU 80%" 에 알림을 걸면 밤마다 깨지만 정작 사용자는 멀쩡할 수 있습니다. "에러 예산을 빠르게 태우는 중"(번 레이트) 처럼 사용자가 겪는 증상 에 걸어야, 울리는 알림이 곧 진짜 문제입니다.
정직하게 — 메트릭의 한계와 짝
- 메트릭은 "무엇이" 를 알려주고, "왜" 는 모릅니다. 대시보드에서 p99 가 튀는 걸 보면, 그 시각의 추적(8편) 으로 파고들어 원인을 찾습니다. 메트릭과 추적은 짝입니다.
- 히스토그램 버킷은 미리 정해야 합니다. 나중에 못 바꾸니(과거 데이터엔 소급 안 됨), 관심 있는 임계값(500ms 근처)에 버킷을 촘촘히 둡니다.
- 카디널리티는 계속 감시 해야 합니다. 라벨 하나 잘못 늘리면 Prometheus 가 조용히 무거워집니다.
- SLO 는 사용자 관점으로. 내부 지표(큐 길이)가 아니라 사용자가 겪는 것(성공률·지연)으로 잡아야 의미가 있습니다.
정리 — 재지 않으면 모른다
- RED(요청률·에러·지연)로 서비스 건강을, 비즈니스 지표(주문·매출·채널)로 장사를, 신뢰성 지표(DLQ·소비 에러)로 곪는 곳을 봅니다.
- Prometheus(자동 발견 pull) + Grafana(프로비저닝된 대시보드)로 상시 관찰합니다.
- SLO·에러 예산·번 레이트 로 "어디까지 괜찮은가" 를 숫자로 정하고, 알림은 증상 에 겁니다.
- 메트릭으로 이상을 감지 하고, 추적으로 원인을 파고듭니다.
핵심 한 줄 — 재지 않으면 모릅니다. 그리고 재기만 하고 기준(SLO)이 없으면, 숫자가 많아도 "괜찮은지" 는 여전히 모릅니다. 무엇을 재고, 어디까지가 괜찮은지를 함께 정해야 관찰성이 완성됩니다.
이번 편 전체 코드는 리포의
part-24태그에 있습니다.