본문 바로가기
DevOps, SRE

OTEL_METRICS_EXPORTER=none 인데 RED 그래프가 나온다 - Spanmetrics로 만든 CCP BE 관측성과, SRE 관점에서 보이는 빈틈

by Ramos 2026. 4. 26.

CCP BE 의 관측성 흐름은 내가 직접 깔았다. 솔루션 인프라에는 OTEL Collector / Prometheus / Jaeger / Loki / Grafana 같은 모니터링 도구 스택이 사전 셋업되어 있었고, 거기에 BE 측의 OTel javaagent 적용, OTLP 송신, spanmetrics 파생, Jaeger Monitor 연동까지를 내가 설계해 박았다. 그러다 멈칫한 지점이 있었다 — 환경변수에는 OTEL_METRICS_EXPORTER=none 이라고 박혀 있는데, Jaeger Monitor 탭에는 RED 그래프가 떠 있다. "그러면 메트릭은 어디서 나오지?" 답은 한 줄이다 — 메트릭은 BE가 보내지 않는다. 트레이스만 보내고, OTEL Collector의 spanmetrics connector가 그 트레이스에서 RED(Rate/Error/Duration)를 파생해 만든다. 이 글은 그 구조의 전체 그림과, 그 위에서 API 병목을 SRE 시각으로 분석할 때 지금 어디가 비어 있는지를 정리한 기록이다.

  • CCP BE는 opentelemetry-javaagent.jar 를 init container로 주입받아 트레이스/로그만 OTLP gRPC로 OTEL Collector에 보낸다. 메트릭은 안 보낸다 (OTEL_METRICS_EXPORTER=none).
  • 메트릭의 정체는 OTEL Collector의 spanmetrics connector 가 트레이스에서 파생한 traces_span_metrics_* 4종 (calls / duration histogram). Prometheus는 이 한 개의 scrape job (otel-spanmetrics) 만으로 BE 메트릭을 가져간다.
  • Jaeger UI의 Monitor 탭이 동작하는 이유도 같다. Jaeger가 PROMETHEUS_QUERY_NAMESPACE=traces_span_metrics 로 spanmetrics 메트릭을 직접 조회한다.
  • 이 구조는 앱에 Micrometer/Actuator를 강제하지 않으면서도 RED 지표를 통일된 라벨로 얻는다. 멀티 사이트 납품에 강하다는 게 진짜 이점이다.
  • 다만 SRE 관점의 4대 골든 시그널 중 Saturation(포화도)이 통째로 비어 있다. JVM Heap, GC, Hikari pool, Tomcat thread, OS load — actuator는 500 으로 막혀 있고 prometheus.io/scrape 어노테이션도 없다.
  • 결론: 현재 도구로 "느려졌다" 는 알 수 있지만 "왜 느려졌는지" 의 절반(자원 포화)은 알 수 없다. API 병목 분석은 spanmetrics로 라우트를 좁히고 → trace로 들어가 외부 호출/DB 구간을 보는 방식까지는 충분하지만, GC/Connection Pool 단의 가설은 추론으로 채워야 한다.
  • 그리고 더 중요한 사실: BE에는 spring-boot-starter-actuator 가 이미 들어 있고, alpha 환경에서는 https://<alpha-be-host>/kube-management/api/actuator/prometheus 로 메트릭이 실제로 나오고 있다. 즉 1순위 보강은 "Micrometer 도입"이 아니라 (a) profile yml 간 exposure 일관화 (alpha 에서는 노출, prod 누락), (b) Prometheus scrape 가 servlet context-path 포함 path 로 가게 보정, (c) 글로벌 응답 포맷터가 actuator path를 잡아먹지 않도록 우회 의 3가지 정렬 작업이다.

시점 컨텍스트
이 글이 다루는 환경은 현재 alpha 한 곳 이다. beta · prod 환경은 준비 단계이고, 모든 추가 개발과 QA 가 alpha 에서 진행되고 있다. 그래서 본문에 등장하는 "느려졌다 / 분석 / 알람" 같은 단어들은 prod 운영 사고 의 맥락이 아니라 alpha QA 단계의 병목 진단 작업 으로 읽어야 한다. 보강 로드맵의 우선순위도 같은 시점에 맞춰져 있다 — alpha 에서 깔끔히 검증한 다음 beta · prod 진입 전에 정렬을 끝내자 가 전제다.


1. 출발점 - "OTLP 다 보낸다" 와 "다 받는다" 사이의 진실

관측성을 깔기 시작하면서 가장 먼저 스스로에게 던진 질문은 단순했다.

"이 시스템이 지금 무엇을 보고 있고, 내가 새로 박아야 하는 건 무엇인가?"

자바 진영에서 OTel Java agent를 다는 건 흔하다. 보통은 "agent 매달면 trace/log/metric 다 알아서 OTLP로 보내요"라고 말하고 끝난다. 그래서 처음에는 그 가정으로 시작했는데, 차트와 ConfigMap 을 직접 짜면서 보니 그림이 달랐다.

# ccp-chart/ccp-be-kubemanagement/values-prod.yaml (발췌)
javaOpts: "-Xms512m -Xmx1536m -javaagent:/otel/opentelemetry-javaagent.jar"

env:
  OTEL_SERVICE_NAME: ccp-be-kubemanagement
  OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector.monitoring.svc.cluster.local:4317
  OTEL_EXPORTER_OTLP_PROTOCOL: grpc
  OTEL_TRACES_EXPORTER: otlp
  OTEL_LOGS_EXPORTER: otlp
  OTEL_METRICS_EXPORTER: none           # ← 메트릭은 안 보낸다
  OTEL_PROPAGATORS: "tracecontext,baggage"
  OTEL_RESOURCE_ATTRIBUTES: "service.namespace=<be-namespace>,deployment.environment=prod"

OTEL_METRICS_EXPORTER=none. 이 한 줄이 전체 관측성 구조의 성격을 정의하고 있었다.

BE는 메트릭을 만들어 보내지 않는다. 그런데 Jaeger Monitor 탭에는 RED 그래프가 떠 있고, Prometheus에는 traces_span_metrics_calls_total 이 흐르고 있다. 메트릭은 누가 만드는 걸까?

답은 OTEL Collector의 spanmetrics connector 였다.

💡 결과를 원인처럼 설명하지 않기.
"BE에 javaagent 매달면 메트릭이 나옵니다" 는 문장은 결과를 원인으로 설명한 것이다. 진짜 원인은 "BE가 보낸 트레이스를 Collector가 받아 spanmetrics connector로 카운트/히스토그램을 파생한다"이고, BE에는 메트릭 송신 책임이 없다. 이걸 구분하지 않으면 다음 단계의 "그러면 JVM Heap은 어디서 나오나?" 같은 질문에 잘못 답하게 된다.


2. 설계 의도를 다시 읽기 - 왜 이렇게 짰을까

사내 인프라 문서에는 도구 스택의 *"현재 상태"* 만 적혀 있다. 그래서 그 위에 OTel 흐름을 박는 사람으로서 나는 한 단계 더 들어가 왜 이 구조가 합리적인지 부터 다시 짚어둘 필요가 있었다. 그래야 다음 보강 결정에서 의도가 흔들리지 않는다.

2.1 사업장 납품을 의식한 분리

CCP는 단일 클러스터 SaaS가 아니다. 사업장(Real)에 납품되는 솔루션이고, 사업장마다 Nexus/Harbor 주소가 다르다. OTel Java agent jar 를 BE 이미지에 굽지 않고, 별도의 side image (harbor.<site>/library/otel-javaagent:2.15.0) 로 분리한 이유가 여기 있다.

이 구조가 가져다주는 것

  • BE 앱 이미지는 전 사업장 공통. 사업장마다 BE 이미지를 다시 굽지 않아도 된다.
  • agent 버전 업그레이드는 otelAgent.image 값 한 줄 교체 + ArgoCD sync 로 끝난다. 롤백도 태그 되돌리기 한 번.
  • Nexus/Harbor 주소만 사업장별로 다르고, BE Helm 차트의 나머지는 그대로다.

이 분리는 별 거 아닌 것처럼 보이지만, 사업장 5곳에 납품되는 시점에 이미지 빌드 매트릭스를 5N에서 5+N으로 줄여준다.

2.2 BE 코드에 Micrometer를 강제하지 않는 선택

OTEL_METRICS_EXPORTER=none 의 더 본질적인 이유는 BE 코드 베이스를 관측 도구에 결합시키지 않는 것 이다. RED 지표(요청 수, 에러 수, 지연시간) 는 어차피 트레이스에 다 들어 있다. 그런데 BE에 또 한 번 Micrometer/micrometer-registry-prometheus 의존성을 박아 actuator로 노출하면 같은 정보를 두 번 만드는 셈이고, 의존성/엔드포인트 노출/스키마 일관성을 모두 BE가 책임져야 한다.

대신 spanmetrics connector가 단 한 군데에서 표준 라벨로 RED를 파생한다. APISIX 게이트웨이도, BE도, ArgoCD도 같은 메트릭 이름과 같은 라벨 체계로 들어온다. 이게 멀티 컴포넌트 RED 통합 대시보드를 이상하게 깨지지 않게 만드는 진짜 이유다.

한 줄 정리: "트레이스가 곧 RED 메트릭의 원본이다" 라는 OTel 진영 컨센서스를 정직하게 따랐다.

2.3 그러면 USE는 어떻게 채울 생각이었나

여기서 솔직해질 필요가 있다. 현재 구성은 RED는 통일된 방식으로 채웠지만, USE(Utilization/Saturation/Errors) 는 채울 자리만 비워두고 시작한 상태 다. 이건 디자인 결함이 아니라 순서를 뒤로 미룬 결정 으로 읽는 게 맞다. RED는 사용자에게 보이는 신호고, USE는 원인 추적용 신호다. 사용자에게 보이는 신호를 먼저 깔고 그 위에서 운영을 시작하는 건 자연스러운 순서다.

다만 다음 단계 — API 병목 분석을 SRE 관점에서 시작하는 시점 — 부터는 USE가 빈 상태로는 의사결정을 못 한다. 이게 이 글의 후반부 핵심이다.


3. 전체 시그널 흐름 - 트레이스가 메트릭이 되는 길

먼저 BE → Collector → Prometheus / Jaeger / Loki / Grafana 까지의 1차 흐름을 한 장에 그린다.

이 그림에서 가장 중요한 사실 두 개

  1. 트레이스는 두 번 쓰인다. Jaeger 저장용으로 한 번, spanmetrics 파생용으로 한 번. 같은 OTLP 입력을 fanout 한다.
  2. 메트릭은 BE → Collector 화살표가 없다. 그래서 BE Pod에 prometheus.io/scrape 어노테이션이 없어도 메트릭이 비어 보이지 않는 것이다. (없는 게 정상이다.)

이걸 모르면 다음 같은 잘못된 디버깅 가설을 세우게 된다 — "BE Pod의 actuator endpoint가 막혀 있어서 메트릭이 안 나오는 것 같다." 사실은 처음부터 actuator를 보지 않는 구조였다. 액추에이터가 막힌 건 별개의 문제고, 지금의 RED 그래프와는 무관하다.


4. spanmetrics connector — RED를 자동으로 만드는 한 줄 컴포넌트

OTEL Collector의 ConfigMap에서 핵심은 이 부분이다.

connectors:
  spanmetrics:
    dimensions:
      - name: http.route
      - name: http.request.method
      - name: http.response.status_code
    # histogram / namespace / metrics_flush_interval 은 기본값 사용

service:
  pipelines:
    traces:
      receivers:  [otlp]
      processors: [batch]
      exporters:  [otlp/jaeger, spanmetrics]   # ← trace 파이프라인의 exporter로 spanmetrics 를 끼움
    metrics/spanmetrics:
      receivers:  [spanmetrics]                # ← spanmetrics가 만들어낸 메트릭만 받는 별도 파이프라인
      exporters:  [prometheus]

이 작은 yaml이 다음을 자동으로 산출한다.

메트릭 이름 타입 의미
traces_span_metrics_calls_total Counter span 호출 횟수
traces_span_metrics_duration_milliseconds_bucket Histogram span 지연시간 버킷
traces_span_metrics_duration_milliseconds_count Histogram 지연시간 카운트 (=calls_total 과 같음)
traces_span_metrics_duration_milliseconds_sum Histogram 지연시간 합 (평균은 sum/count)

라벨은 다음과 같이 구성된다.

라벨 의미 / 예시
service_name OTLP resource의 service.name (예: ccp-be-kubemanagement, ccp-be-member, APISIX, argocd-server)
span_kind SPAN_KIND_SERVER / SPAN_KIND_CLIENT / SPAN_KIND_INTERNAL
span_name OTLP span name (GET, POST, SQL 쿼리, gRPC 경로)
status_code STATUS_CODE_UNSET / STATUS_CODE_OK / STATUS_CODE_ERROR
http_route, http_request_method, http_response_status_code dimensions로 추가한 HTTP 서버 span 한정 라벨

여기서 또 한 번 멈칫할 부분이 있다.

주의: ConfigMap의 dimensions에 있는 건 신규 시멘틱 컨벤션 표기 (http.request.method, http.response.status_code) 다. 일부 자료/문서에서 보이는 구표기 (http.method, http.status_code) 는 여기엔 없다. 두 표기가 혼재되면 PromQL 라벨 셀렉터가 조용히 어긋난다. 추가 dimension을 넣을 때는 신규 표기로만 통일하는 게 좋다.


5. Jaeger Monitor 탭이 통하는 이유

Jaeger 1.58 의 Deployment env에 다음 네 줄이 들어 있다.

METRICS_STORAGE_TYPE=prometheus
PROMETHEUS_SERVER_URL=http://prometheus-server
PROMETHEUS_QUERY_SUPPORT_SPANMETRICS_CONNECTOR=true
PROMETHEUS_QUERY_NAMESPACE=traces_span_metrics
PROMETHEUS_QUERY_NORMALIZE_CALLS=true
PROMETHEUS_QUERY_NORMALIZE_DURATION=true

이 중 PROMETHEUS_QUERY_NAMESPACE=traces_span_metrics 가 핵심이다. spanmetrics connector가 prefix로 붙이는 이름과 정확히 일치해야 Jaeger UI의 "Monitor" 탭이 메트릭을 그릴 수 있다.

Jaeger Monitor가 보여주는 그림의 출처를 다시 따라가 보면

  • "Calls" 그래프 → traces_span_metrics_calls_total 의 rate
  • "Latency P95" 그래프 → traces_span_metrics_duration_milliseconds_buckethistogram_quantile(0.95, ...)
  • "Errors" 그래프 → status_code="STATUS_CODE_ERROR" 의 비율

즉 Jaeger Monitor와 Grafana RED 패널은 같은 메트릭의 다른 UI다. 한쪽이 끊기면 다른 쪽도 끊긴다. 한쪽이 비어 있다면 BE → Collector 트레이스 송신부터 끊겼다고 봐야 한다.

이 의존 관계를 머릿속에 그려두면 장애 트리아지가 빨라진다.

빨갛게 칠한 BE가 트레이스를 못 보내면 Monitor 탭과 RED 그래프와 Trace 검색이 동시에 죽는다. 이는 동시 장애처럼 보이지만 단일 원인 사고다. 알람을 짤 때 이 상관관계를 미리 알고 짜야 노이즈가 안 생긴다.


6. Prometheus는 BE Pod를 직접 보지 않는다

문서를 정리하면서 한 번 더 확인이 필요했던 사실.

# prometheus-server ConfigMap (요지)
- job_name: otel-spanmetrics
  static_configs:
    - targets: ['otel-collector:8889']
  • BE 메트릭의 유일한 수집 경로 가 이 한 줄이다.
  • BE Pod에는 prometheus.io/scrape 어노테이션이 없고, kubernetes-pods job의 relabel 조건도 만족하지 않는다.
  • 그래서 처음에는 "actuator 도 막혀 있나?" 부터 의심하게 되는데, 여기서 한 번 미끄러진다.
# 이 명령이 처음에 만든 오해 — servlet context-path 가 빠져 있다
$ kubectl exec <ccp-be-pod> -- curl -s localhost:8080/actuator/prometheus
{"isSuccessful":false,"resultCode":107,"message":"internal server error"}

이 응답을 보고 "actuator path 가 500 으로 막혀 있다" 고 결론짓는 건 잘못된 추론이다. CCP BE 의 application.yml 에는 다음이 박혀 있다.

spring:
  mvc:
    servlet:
      path: /kube-management/api    # ← 모듈마다 /{모듈}/api 로 prefix 됨

모든 endpoint 는 servlet context-path 아래로 옮겨져 있다. actuator path 도 예외가 아니다. 외부 ingress 에서도 동일한 path 가 그대로 노출된다.

# ✅ 정확한 path — servlet context-path 포함
# alpha 환경에서는 외부에서도 동작 확인 가능
$ curl -s https://<alpha-be-host>/kube-management/api/actuator/prometheus | head -3
# HELP jvm_memory_used_bytes The amount of used memory ...
# TYPE jvm_memory_used_bytes gauge
jvm_memory_used_bytes{area="heap",id="G1 Eden Space",...} ...

여기까지 와서야 진짜 그림이 보인다.

환경 현재 상태 exposure 설정 결과
application-alpha.yml / application-beta.yml alpha 활성 (현재 개발/QA 중) / beta 준비 includeprometheus 포함 actuator/prometheus 노출됨
application-prod.yml 준비 단계 (사업장 납품 직전 표준 베이스) include: health,info,startup prometheus 누락

즉 정확히는 두 가지 별개의 문제가 겹쳐 있었다.

  1. 검증 path 자체가 틀렸다. servlet context-path 를 넣지 않으면 사내 글로벌 응답 포맷터/예외 핸들러가 unknown path 로 분류해 표준 응답(resultCode 107) 으로 변환해 버린다. 이건 actuator 가 막힌 게 아니라 그 path 에 actuator 가 없어서 인 것에 가깝다.
  2. prod profile 의 management.endpoints.web.exposure.includehealth,info,startup 으로만 잡혀 있어서, path 를 맞춰도 prod profile 로 뜬 인스턴스에서는 prometheus 메트릭이 노출되지 않는다. alpha 에서는 나오고 prod 에서만 빠져 있다.

그리고 또 한 가지 — BE 의존성 자체에는 spring-boot-starter-actuator 가 모듈 3개 모두에 들어 있다. alpha 에서 metrics 가 흐른다는 사실이 그 증거다. 처음에 가졌던 *"BE 가 Micrometer 자체를 안 매달고 있다"* 라는 가설은 틀렸다.

그래서 현재 상태 의 정확한 표현은 이렇다.

"BE 에 메트릭 노출 능력은 이미 있다. 다만 prod profile yml 에 exposure 한 줄이 빠져 있고, Prometheus scrape 설정이 그 path 를 모르며, 글로벌 응답 포맷터가 path mismatch 케이스를 표준 에러로 감싸 디버깅을 헷갈리게 만들었다."

여기서 다행인 점 하나 - prod 는 아직 활성화되어 운영 중인 상태가 아니다. 사업장 납품 직전 표준 베이스로서 준비 중인 단계이고, 추가 개발과 QA 는 모두 alpha 에서 돌고 있다. 그래서 이 정렬 작업은 지금 발생 중인 사고를 막는 일 이 아니라 prod 진입 전 표준 베이스에 한 줄 미리 박아두는 일 이다. 비용 측면에서 *"코드 베이스 의존성을 새로 추가해야 함"* 과 *"yml 한 줄 + scrape 설정 한 블럭"* 은 자릿수가 다르고, 시점 측면에서도 가장 싼 타이밍이다.

그럼에도 spanmetrics 가 채우지 못하는 영역은 그대로 남아 있다. 현재 alpha 에서 수집되고 있는 메트릭 기준 으로 보면 USE 영역은 Prometheus scrape 설정이 actuator path 를 알지 못해 비어 있는 상태다 - alpha BE 가 그 path 로 메트릭을 노출하긴 하지만, Prometheus 가 그 곳을 보러 가지 않는다. 다음 표가 *"지금 Prometheus 안에 흐르고 있는 시계열"* 기준이다.

카테고리 메트릭 예시 현재 상태 영향
JVM Memory jvm_memory_used_bytes, jvm_memory_max_bytes 없음 OOM 직전 알람 불가
GC jvm_gc_pause_seconds_*, jvm_gc_overhead_percent 없음 GC stall 인한 P99 ramp-up 원인 추적 불가
Threads jvm_threads_live_threads, tomcat_threads_busy 없음 Tomcat thread starvation 추적 불가
HTTP server http_server_requests_seconds_* 없음 (spanmetrics가 대체) 라우트별 Micrometer 표준 라벨 부재
DataSource hikaricp_connections_*, hikaricp_connections_pending 없음 DB 풀 포화/대기 알람 불가
HTTP client http_client_requests_* 없음 (spanmetrics CLIENT span으로 일부 대체) retry/timeout 분포 불가
Logback / Custom logback_events_total, business KPI 없음 ERROR log spike 알람, 도메인 SLI 불가

이걸 보고 나면 비로소, 처음 떠올린 기우와 정반대 결론에 도달한다.

"지금은 RED는 보이는데 USE는 안 보인다. 따라서 왜 느려졌는가 를 절반밖에 못 본다."


7. SRE 관점에서 다시 - 4대 골든 시그널 매트릭스

Google SRE Book 의 4대 골든 시그널을 기준으로 현재 상태를 다시 채점하면 그림이 명확해진다.

골든 시그널 정의 현재 도구 측정 가능 여부
Latency 요청 처리에 걸린 시간 traces_span_metrics_duration_milliseconds_bucket + Trace 라우트별 P50/P95/P99 가능
Traffic 단위 시간당 요청량 traces_span_metrics_calls_total service / route 별 RPS
Errors 실패한 요청의 비율 traces_span_metrics_calls_total{http_response_status_code=~"5.."} HTTP 기준 가능, 5xx 만
Saturation 시스템이 얼마나 꽉 찼는가 JVM/Pool/Thread 없음

3 of 4. 그리고 비어 있는 한 칸이 정확히 "병목 분석을 시작하기 위한 가설"의 절반을 차지한다는 게 함정이다. Latency가 ramp-up 했을 때 우리가 보통 던지는 가설들이 이렇다.

  1. 다운스트림(외부 API/DB) 지연 → trace 의 CLIENT span으로 보임
  2. GC stall, JVM heap 압박 → X
  3. Tomcat worker thread 고갈 → X
  4. Hikari connection pool wait → X
  5. 캐시 hit ratio 변화 → 별도 Valkey 메트릭 필요
  6. CPU throttling → kube-state-metrics + cAdvisor (일반 K8s 지표는 있음)

3개 중 절반을 trace만으로 확정 못 한다. trace에 안 찍히는 시간 (BE 코드 안의 GC pause, queue 대기, pool wait pre-acquire) 은 클라이언트 입장에서는 똑같이 느려진 것 으로 보이지만, 원인 분류는 완전히 다르다.


8. 그래도 지금 도구로 어디까지 갈 수 있나 - 병목 분석 표준 레인

USE 한 칸이 비어 있다고 해서 분석을 못 하는 건 아니다. 가용한 도구로 만든 표준 분석 레인을 그려본다.

핵심은 단계 E2의 *"SERVER span 자체 지연"* 이다. trace는 자기가 본 시간만 안다. trace span에 안 찍히는 BE 내부 시간은 그냥 "그 사이의 빈 구간"으로 보인다. 여기를 채워주는 게 USE 메트릭이다. 지금은 Loki 로그로 GC stop-the-world나 Hikari 경고를 사후 추정해야 한다 - 추적이지 진단이 아니다.

8.1 alpha QA 중 자주 쓰는 PromQL 모음

부하 시나리오/회귀 테스트를 돌리면서 자주 꺼내 쓰는 형태로 정리해두면 좋다.

# 서비스별 RPS (1분 윈도우)
sum by (service_name) (
  rate(traces_span_metrics_calls_total[1m])
)

# kubemanagement 의 라우트별 P95 latency (5분)
histogram_quantile(0.95,
  sum by (le, http_route) (
    rate(traces_span_metrics_duration_milliseconds_bucket{
          service_name="ccp-be-kubemanagement",
          span_kind="SPAN_KIND_SERVER"
        }[5m])
  )
)

# 라우트별 5xx 비율 (5분)
sum by (service_name, http_route) (
  rate(traces_span_metrics_calls_total{
        http_response_status_code=~"5.."
      }[5m])
)
/
sum by (service_name, http_route) (
  rate(traces_span_metrics_calls_total[5m])
)

# CLIENT span (외부 호출) latency P95 — 다운스트림 의심 시
histogram_quantile(0.95,
  sum by (le, service_name, span_name) (
    rate(traces_span_metrics_duration_milliseconds_bucket{
          span_kind="SPAN_KIND_CLIENT"
        }[5m])
  )
)

8.2 이 쿼리들이 답하지 못하는 질문

같은 쿼리들로 답을 못 하는 질문 도 명확히 적어두는 게 미래의 나에게 친절한 행위다.

  • "지금 Heap 사용률이 몇 %인가?" → X
  • "Hikari pool 대기 큐 길이가 얼마인가?" → X
  • "Tomcat worker thread 가 몇 개 살아 있나?" → X
  • "직전 1분간 GC stop-the-world 누적이 몇 ms인가?" → X
  • "라우트별 레이턴시 분포가 thread starvation 패턴(P50은 평이한데 P99만 폭주) 인가?" → 부분 완료, 하지만 원인은 아님.

이 질문 리스트가 그대로 다음 단계의 보강 우선순위가 된다.


9. 카디널리티의 함정 - spanmetrics는 공짜가 아니다

이 구조의 비용은 단 한 곳에 집중된다 — Prometheus의 메모리/스토리지.

spanmetrics가 만드는 모든 시계열은 라벨 카디널리티의 곱이다.

cardinality ≈ |service_name| × |span_kind| × |span_name| × |http_route| × |http_request_method| × |http_response_status_code|

여기서 위험한 두 라벨은 span_namehttp_route 다. 다음 같은 구현 실수를 만나면 카디널리티가 폭증한다.

안티패턴 카디널리티 영향
GET /users/{id} 를 라우트 정규화 없이 /users/12345 로 spanname 처리 id 만큼 시계열 폭발
SQL span name이 파라미터 바인딩 전 raw SQL 그대로 노출 쿼리 변형 수만큼 폭발
외부 호출 span이 querystring 포함된 path 를 그대로 span name에 사용 요청 단위 폭발
FE가 random correlation id를 path에 넣는 RPC 스타일 요청 단위 폭발

OTel auto-instrumentation은 보통 /users/{id} 형태로 정규화된 라우트(http.route) 를 잘 채우지만, 서드파티 라이브러리가 손으로 span을 만들면 정규화가 깨질 수 있다. 새로운 라이브러리를 도입할 때마다 한 번씩 traces_span_metrics_calls_total 의 series count를 점검하는 게 운영 습관이 되어야 한다.

# 라벨별 시계열 폭증 모니터링
count by (service_name) (
  count by (service_name, http_route) (
    traces_span_metrics_calls_total
  )
)

이 값이 어느 날 갑자기 1.5배 이상 뛰면, 그 날 머지된 코드를 의심해야 한다.

💡 Prometheus 관점의 한 줄 원칙
메트릭 이름이 늘어나는 건 추적 가능하지만, 라벨 값이 늘어나는 건 침묵 속에 일어난다. spanmetrics는 라벨 값 폭증의 단일 진원지가 될 수 있는 컴포넌트다.


10. 보강 로드맵 - 무엇을 어떤 순서로 채울 것인가

여기까지 정리하고 나면, 보강 우선순위는 자연스럽게 줄을 선다. "비용 대비 운영 의사결정 품질을 가장 많이 올리는 항목" 부터 위로 끌어올린다.

우선순위 1 - alpha 에서 scrape 보정해 USE 채우고, beta · prod 진입 전 exposure 정렬 + 응답 포맷터 우회

비용: yml 한 줄 + Helm values 의 podAnnotations 블록 + 응답 포맷터에 path exclusion 한 줄.
이득: alpha 에서 USE 영역의 골든 시그널 4번째 칸이 한 번에 채워진다. 이 시점이 alpha QA 의 병목 진단 품질을 결정짓는 단일 변수 이고, 동시에 beta · prod 진입 전 표준 베이스를 정렬해두는 가장 싼 타이밍이기도 하다.

여기서 강조할 점 — 새 의존성을 추가하는 게 아니다. spring-boot-starter-actuator 는 이미 모듈 3개에 다 들어 있고, alpha 환경에서는 https://<alpha-be-host>/kube-management/api/actuator/prometheus 로 메트릭이 실제로 흐른다. 누락된 건 (alpha 의) 수집 측 path 보정 과 (beta · prod 진입 전의) 환경별 균일화 다.

1.1 prod (·beta-net) profile yml exposure 정렬 — beta · prod 진입 전

# kubeManagement/src/main/resources/application-prod.yml (기존)
management:
  endpoint:
    startup:
      enabled: true
  endpoints:
    web:
      exposure:
        include: health,info,startup       # ← prometheus 빠져 있음

# 변경
        include: health,info,startup,prometheus

member, portalManagement 의 prod yml 도 함께 점검한다 — alpha 와 prod 가 동일 형태가 되는 게 1차 목표다. 어차피 prod 는 아직 활성화되지 않은 표준 베이스이므로, 이 정렬은 지금이 가장 싼 시점이다.

1.2 alpha 에서 Pod annotation 으로 servlet context-path 포함된 path 알려주기

기존 Prometheus 의 kubernetes-pods job 은 보통 prometheus.io/path 를 그대로 받아 scrape 한다. 그러므로 BE 모듈마다 그 path 만 정확히 박아주면 된다. alpha values 부터 적용해 검증하고, 검증 끝나면 동일 블록을 prod·beta values 표준 베이스에 같이 박는다.

# 1순위 적용: ccp-chart/ccp-be-kubemanagement/values-alpha.yaml
podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/path: "/kube-management/api/actuator/prometheus"
  prometheus.io/port: "8080"

# alpha 검증 후 동일 블록을 values-beta.yaml / values-prod.yaml 에도 정렬
# ccp-chart/ccp-be-member/values-alpha.yaml
podAnnotations:
  prometheus.io/path: "/member/api/actuator/prometheus"
  ...
# ccp-chart/ccp-be-portalmanagement/values-alpha.yaml
podAnnotations:
  prometheus.io/path: "/portal-management/api/actuator/prometheus"
  ...

여기서 함정 한 번. ConfigMap (prometheus-server) 의 kubernetes-pods job relabel 조건이 prometheus.io/path annotation 을 honor 하는지 한 번 더 확인해야 한다. 하지 않는다면 relabel 한 줄을 추가하거나, scrape job 을 BE 전용으로 하나 분리하는 게 깔끔하다.

# 별도 scrape job 으로 분리한 형태 (대안)
- job_name: ccp-be-actuator
  kubernetes_sd_configs:
    - role: pod
      namespaces:
        names: [<be-namespace>]
  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: (.+)
    - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
      action: replace
      regex: ([^:]+)(?::\d+)?;(\d+)
      replacement: $1:$2
      target_label: __address__

1.3 글로벌 응답 포맷터의 actuator path 우회

이건 별개의, 그러나 이번 디버깅을 한 번 더 헛되게 만든 단일 원인이다. 사내 표준 응답 포맷터(resultCode 107 변환기) 가 unknown path 까지 잡아 변환하면, 실제로는 path 가 잘못된 케이스마치 endpoint 가 500 인 것처럼 보인다. 직접 디버깅하거나 QA 가 actuator/prometheus 에 직접 curl 을 칠 일이 종종 있으므로, actuator path 만큼은 포맷터 체인에서 제외 하는 게 디버깅 비용을 한 자릿수 줄여준다.

// 예: ServletPath/RequestMatcher 기반 exclusion
@Configuration
class ResponseFormatterExclusion {
    @Bean
    fun actuatorBypassFilter(): FilterRegistrationBean<ActuatorPathFilter> {
        ...
    }
}

(구체적 구현은 사내 포맷터 구조에 의존하므로 형태만 적는다.)

1.4 ServiceMonitor 는 지금 도입하지 않는다

ServiceMonitor 방식은 Prometheus Operator 가 전제다. 현재 모니터링 스택은 Operator 미설치이고, 단지 메트릭 한 종을 더 받기 위해 Operator 를 들이는 비용이 너무 크다. 위 1.2 안 (annotation + relabel) 으로 해결하면 충분하다. 둘을 동시에 운영할 이유는 없다.

우선순위 2 - http.route 정규화 검증과 dimensions 정리

비용: ConfigMap 한 번 검토 + 회귀 테스트.
이득: 카디널리티 폭발 사전 차단. 미래의 Prometheus 메모리 사고 한 건을 사전에 막는다.

  • spanmetrics dimensions 에 신규 시멘틱 컨벤션 표기로만 통일 (http.request.method, http.response.status_code).
  • 추가로 service.namespace, deployment.environment 를 dimensions 로 올리면 prod/beta 분리 대시보드가 단순해진다 — 단, 카디널리티가 service_name 의 N배가 되므로 Prometheus 메모리 추이를 같이 본다.
  • 새 라이브러리/내부 RPC 추가 시 http.route 가 정규화되어 들어오는지 PR 체크리스트에 박는다.

우선순위 3 - Jaeger 저장소 마이그레이션 검토

비용: ES/Tempo 도입 결정 + 운영 부담.
이득: Badger 단일 디스크 의존 탈출, retention 360h(15일) 한계 해소.

현재 Jaeger all-in-one + Badger는 단일 Pod 디스크에 매여 있다. 사고 시점이 retention 윈도우를 벗어나면 trace가 사라진다. SRE 분석에 trace가 자료의 절반인 만큼, 장기 보관 전략 부재는 분석 품질의 상한 이 된다. 단, 이 작업은 비용이 가장 크고 다른 항목보다 절박도가 낮으므로 의도적으로 3순위에 둔다.

우선순위 4 - Collector HA / 외부 중앙 수집기

비용: Collector replicas + scrape 설정 정리 + (멀티 클러스터일 경우) otlp/central 활성화.
이득: SPOF 완화, 멀티 클러스터 통합 관측성.

otlp/central: { endpoint: otel.cone-chain.net:443 } 가 ConfigMap에 주석 상태로 있다. 멀티 클러스터로 가는 시점에 활성화하면 된다. 지금 단계에서는 인증/네트워크 정책이 같이 정비되어야 하므로 우선순위를 아래로 둔다.

flowchart LR
  P1["1. Micrometer + actuator<br/>= USE 칸 채움"]:::p1 --> P2["2. http.route 정규화 + dimensions<br/>= 카디널리티 사전 차단"]:::p2 --> P3["3. Jaeger 저장소<br/>= retention 한계 해소"]:::p3 --> P4["4. Collector HA / otlp/central<br/>= SPOF/멀티클러스터"]:::p4
  classDef p1 fill:#cfc,stroke:#393;
  classDef p2 fill:#ffc,stroke:#a80;
  classDef p3 fill:#fec,stroke:#a52;
  classDef p4 fill:#eef,stroke:#779;

이 순서를 지키지 않으면, 비싼 작업(3, 4) 을 먼저 해놓고 *"그래서 결국 GC가 문제였던 건가?"* 를 여전히 답하지 못하는 상태에 갇힌다. 1번이 압도적인 ROI 항목이다.


11. 점검 체크리스트 (alpha QA · beta · prod 진입 전)

이 글에서 얻은 항목들을 SRE 점검 체크리스트로 압축한다. 현재 시점은 alpha 활성 / beta · prod 준비 단계이므로, 점검 주기도 부하 시나리오 / 회귀 테스트 / 환경 승급 전 중심으로 잡는다.

11.1 alpha QA 사이클 점검 (부하·회귀 테스트 전후)

  • traces_span_metrics_* 의 시계열 카운트가 직전 사이클 대비 ±20% 이내인가? (count by (service_name)(traces_span_metrics_calls_total))
  • OTEL Collector Pod restart 회수가 0인가? (memory pressure 의심)
  • Prometheus targets 페이지에서 otel-spanmetrics job이 UP 인가?
  • Jaeger Monitor 탭에 BE service가 모두 표시되는가?
  • (보강 후) BE actuator scrape job 의 target 이 모듈 3개 모두 UP 인가?

11.2 변경 시 점검 (PR/릴리스)

  • 새 라이브러리/RPC 도입 시 http.route 가 정규화 형태인가? (id/uuid path가 그대로 들어가지 않는가)
  • OTEL_RESOURCE_ATTRIBUTESservice.namespace/deployment.environment 를 모두 포함하는가?
  • javaagent 버전 업그레이드 시 단일 manifest 로 푸시했는가? (MediaType: application/vnd.docker.distribution.manifest.v2+json)
  • 모듈별 application-{env}.ymlmanagement.endpoints.web.exposure.include 가 환경 간 일관되어 있는가? (특히 prod 에 prometheus 누락 여부)
  • Helm values 의 podAnnotations.prometheus.io/path 가 servlet context-path 포함 형태인가? (/kube-management/api/actuator/prometheus 등)
  • (보강 후) actuator path 가 글로벌 응답 포맷터에 잡히지 않도록 exclusion 처리되었는가?

11.3 환경 승급 전 점검 (alpha → beta / beta → prod)

  • 모듈별 application-{env}.ymlmanagement.endpoints.web.exposure.include 가 alpha 와 동일한가? (특히 prometheus 포함 여부)
  • values-{env}.yamlpodAnnotations.prometheus.io/path 가 servlet context-path 포함 형태로 정렬되어 있는가?
  • alpha 에서 검증된 RED + USE 대시보드/알람 정의가 새 환경에서 동일 라벨로 동작하는가? (service_name, http_route, deployment.environment)
  • javaagent side image 가 사업장(또는 새 환경) Harbor 에 푸시되어 있고 단일 manifest 인가?

11.4 트리아지 레인 (alpha QA 단계)

  • 라우트 P95 ramp-up 관찰 → Grafana RED 패널 (P95 by service/route) 확인
  • 특정 라우트 ramp-up → Jaeger Search → 대표 trace 진입
  • CLIENT span 지연 → 다운스트림 (Argo/Gitea/Harbor/DB) 응답 시간/오류 확인
  • SERVER span 자체 지연 → (현재) Loki에서 ERROR/GC log 추적 → (보강 후) Heap/Pool/Thread Micrometer로 직진단

12. 회고 - 사전 깔린 도구 위에 직접 흐름을 짠다는 것

이 흐름을 직접 깔고, 깐 사람으로서 다시 정리하면서 가장 크게 배운 두 가지.

(1) "무엇이 비어 있는지" 를 정확히 적는 게, "무엇이 갖춰져 있는지" 를 잘 정리하는 것보다 훨씬 더 다음 단계를 살린다.

(2) "endpoint 가 막혔다" 와 "path 가 다르다" 는 같은 화면을 만든다. 사내 표준 응답 포맷터가 unknown path 를 표준 에러로 변환하는 환경에서는, path 오류서버 에러처럼 보인다. 이번 케이스에서 한 번 가졌던 가설 — "actuator 가 통째로 막혔다" — 은 정확히 이 함정에 걸려 있었다. servlet context-path 한 칸 차이가 코드 베이스 전체의 의존성을 의심하게 만들었다. 검증 명령은 반드시 외부에서 통하는 path 를 우선 적어두는 게 안전하다.

(1) 을 한 번 더 풀어본다. 솔루션 인프라에 사전 셋업된 모니터링 도구 스택은 "이미 다 갖춰진 환경" 처럼 보이기 쉽다. OTEL Collector도 있고, Prometheus·Jaeger·Loki·Grafana도 떠있다. 그런데 그 위에 BE 측 OTel 흐름을 직접 박아본 입장에서 보면, "갖춰진 도구""채워진 시그널" 은 다른 이야기다. 도구 스택은 SRE 분석에 필요한 4대 골든 시그널 중 어느 한 칸도 자동으로 채워주지 않는다. 채워지는 건 내가 어떤 송신을 켜고, 어떤 path 를 어떤 라벨로 정규화했는가 의 결과다.

그래서 글로 남기는 가치는 단순하다. "OTEL Collector spanmetrics로 RED 메트릭이 나옵니다" 까지만 적어두면, 다음에 이걸 다시 들여다볼 미래의 나조차 "이건 다 갖춰진 거구나" 하고 USE 보강 의제를 잊어버릴 수 있다. 빈 칸을 명시한 글이 다음 결정의 출발점을 보존한다. 이게 이 글의 진짜 효용이다.

그래서 이 글은 "메트릭 4종이 spanmetrics에서 나온다" 가 결론이 아니다. 그건 시작점일 뿐이고, 진짜 결론은

  • 이 구조가 의도적으로 RED만 채우고 USE를 비워둔 것이며,
  • 그 빈칸이 SRE 4대 골든 시그널 중 1칸을 잡아먹고 있고,
  • 다음 단계의 API 병목 분석을 시작하기 전에 그 1칸을 먼저 채워야 한다,

는 세 줄짜리 행동 결론이다.


부록 A - 검증 명령 (alpha 기준)

다음 명령은 모두 alpha 클러스터 기준이다. beta · prod 는 준비 단계이므로 동일 명령을 환경 승급 시점에 그대로 재사용하는 그림이다.

목적 명령
spanmetrics가 살아 있는지 kubectl -n monitoring exec deploy/otel-collector -- wget -qO- localhost:8889/metrics | grep '^traces_span_metrics' | head
Prometheus scrape job 존재 kubectl -n monitoring get cm prometheus-server -o yaml | grep -A2 otel-spanmetrics
Jaeger ↔ Prometheus 환경변수 kubectl -n monitoring get deploy jaeger -o yaml | grep -A1 PROMETHEUS_QUERY
BE Pod 의 javaagent 적용 확인 kubectl -n <be-namespace> exec <pod> -- ls -l /otel/opentelemetry-javaagent.jar
BE Pod 의 OTEL env 확인 kubectl -n <be-namespace> exec <pod> -- env | grep '^OTEL_'
BE Pod 의 actuator path 정확히 확인 (alpha) kubectl -n <be-namespace> exec <pod> -- curl -s localhost:8080/kube-management/api/actuator/prometheus | head -3
잘못된 path — 디버깅 헷갈림 재현용 kubectl -n <be-namespace> exec <pod> -- curl -s localhost:8080/actuator/prometheus | head -c 200 (사내 표준 포맷으로 변환되어 나옴 — actuator 가 막힌 게 아니라 path 가 다른 케이스)
alpha 외부 ingress 에서 동작 확인 curl -s https://<alpha-be-host>/kube-management/api/actuator/prometheus | head -3
모듈별 환경 프로필 exposure 비교 for env in alpha beta prod; do echo "== $env =="; grep -A2 "exposure" be-ccp/<module>/src/main/resources/application-$env.yml; done

부록 B - 자주 묻는 질문 (스스로에게)

Q. spanmetrics 가 만든 메트릭은 Micrometer 의 http_server_requests_* 와 호환되나?
A. 호환되지 않는다. 메트릭 이름과 라벨 체계가 다르다. 둘 다 RED를 표현하지만, 알람/대시보드는 둘 중 하나의 표준에 맞춰 짜야 한다. 보강 시 두 시계열이 동시에 들어오기 시작하므로, 어느 쪽을 primary RED 출처 로 둘지 선택해야 한다. 대시보드 일관성, 라벨 풍부함, 멀티 컴포넌트(APISIX 등 비-자바 컴포넌트) 일관성을 보면 spanmetrics 를 primary로 두는 게 자연스럽다.

Q. OTEL_METRICS_EXPORTER=otlp 로 켜고 spanmetrics 도 같이 쓰면 안 되나?
A. 가능하다. 단, 두 출처가 같은 메트릭 이름 공간을 침범하면 라벨 셀렉터가 어긋난다. spanmetrics 는 trace 기반의 RED만, BE의 OTLP 메트릭은 USE만 (JVM/Pool/Thread) 보내도록 의식적으로 분리해야 한다. SDK 레벨 필터를 잘못 설정하면 같은 RED를 두 번 만들어 비용만 늘어난다.

Q. Trace sampling 은 얼마로 잡혀 있나?
A. ConfigMap 의 명시적 sampler 설정이 보이지 않으므로 javaagent 기본값(parentbased_always_on) 으로 추정된다. 현재 트래픽이 비교적 낮은 사업장 환경에서는 큰 문제가 없지만, 트래픽이 늘면 카디널리티가 아닌 trace 저장 비용 이 다음 병목이 된다. 그 시점에 OTEL_TRACES_SAMPLER=parentbased_traceidratio, OTEL_TRACES_SAMPLER_ARG=0.1 같은 형태로 head sampling 을 들이는 결정을 해야 한다. 단, sampling 을 낮추면 spanmetrics 에 들어가는 base가 같이 줄어 RED 가 노이즈에 취약해진다는 점도 같이 봐야 한다.


마무리

이렇게 짠 흐름은 깔아둔 입장에서 만족스럽다. "BE 에 도구를 강제하지 않는다" 라는 OTel 진영의 정신을 정직하게 따랐고, 사업장 납품을 의식한 사이드 이미지 분리는 사업장별 환경 차이를 깔끔히 흡수해준다. 다만 친절하게 구성된 흐름이 곧 분석 가능한 흐름은 아니다. alpha 에서 API 병목을 SRE 시각으로 진단하려면, 비어 있는 골든 시그널 한 칸을 정직하게 채우는 것. 그게 가장 단순하고 가장 효과 큰 한 수다.

그리고 그 한 수는 지금 이 가장 싸다. alpha 에서 검증한 형태가 그대로 beta · prod 표준 베이스에 박힌다. prod 가 활성 운영에 들어간 뒤에 같은 정렬을 하는 것보다 자릿수 단위로 비용이 다르고, prod 진입 시점부터 RED + USE 가 동시에 갖춰진 상태로 시작할 수 있다. 이게 이 정렬을 alpha 단계의 1순위로 두는 진짜 이유다.

그 한 수를 넣고 나면, 비로소 "왜 느려졌는지" 의 절반이 trace에서, 나머지 절반이 USE 메트릭에서 동시에 보이기 시작한다. 그 다음에 비로소 SLO를 진지하게 정의할 수 있다.

한 줄로: 트레이스로 RED를 만들 수는 있어도, 트레이스가 USE를 대신할 수는 없다. 그리고 그 빈 칸은 alpha 에서 채우는 게 가장 싸다.