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차 흐름을 한 장에 그린다.

이 그림에서 가장 중요한 사실 두 개
- 트레이스는 두 번 쓰인다. Jaeger 저장용으로 한 번, spanmetrics 파생용으로 한 번. 같은 OTLP 입력을 fanout 한다.
- 메트릭은 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_bucket의histogram_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-podsjob의 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 준비 | include 에 prometheus 포함 |
actuator/prometheus 노출됨 |
application-prod.yml |
준비 단계 (사업장 납품 직전 표준 베이스) | include: health,info,startup |
prometheus 누락 |
즉 정확히는 두 가지 별개의 문제가 겹쳐 있었다.
- 검증 path 자체가 틀렸다. servlet context-path 를 넣지 않으면 사내 글로벌 응답 포맷터/예외 핸들러가 unknown path 로 분류해 표준 응답(
resultCode 107) 으로 변환해 버린다. 이건 actuator 가 막힌 게 아니라 그 path 에 actuator 가 없어서 인 것에 가깝다. - prod profile 의
management.endpoints.web.exposure.include가health,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 했을 때 우리가 보통 던지는 가설들이 이렇다.
- 다운스트림(외부 API/DB) 지연 → trace 의 CLIENT span으로 보임
- GC stall, JVM heap 압박 → X
- Tomcat worker thread 고갈 → X
- Hikari connection pool wait → X
- 캐시 hit ratio 변화 → 별도 Valkey 메트릭 필요
- 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_name 과 http_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-spanmetricsjob이 UP 인가? - Jaeger Monitor 탭에 BE service가 모두 표시되는가?
- (보강 후) BE actuator scrape job 의 target 이 모듈 3개 모두 UP 인가?
11.2 변경 시 점검 (PR/릴리스)
- 새 라이브러리/RPC 도입 시
http.route가 정규화 형태인가? (id/uuid path가 그대로 들어가지 않는가) -
OTEL_RESOURCE_ATTRIBUTES가service.namespace/deployment.environment를 모두 포함하는가? - javaagent 버전 업그레이드 시 단일 manifest 로 푸시했는가? (
MediaType: application/vnd.docker.distribution.manifest.v2+json) - 모듈별
application-{env}.yml의management.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}.yml의management.endpoints.web.exposure.include가 alpha 와 동일한가? (특히prometheus포함 여부) -
values-{env}.yaml의podAnnotations.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 에서 채우는 게 가장 싸다.