메트릭과 SLO — 무엇을 재고, 어디까지 괜찮은가

2025-10-20

8편에서 분산추적을 붙여, 주문 하나가 일곱 서비스를 지나며 어디서 느렸는지 를 볼 수 있게 됐습니다. 그런데 만들어 놓고 보니 빈 곳이 있었습니다. 추적은 개별 사건 입니다. "이 주문이 왜 느렸나"는 답하지만, "지금 시스템 전체가 괜찮은가?" 는 한눈에 안 보입니다. 그걸 보려면 개별 사건이 아니라 집계된 시계열 — 메트릭이 필요합니다.

💻 이 편의 코드는 github.com/kahnco/go-ddd-shoppart-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 태그에 있습니다.