이 글의 출발점은 한 장의 빈 그래프입니다. Phase 3 부하 테스트 중 Grafana의 API Response Time p95/p99 패널이 “No data”로 떴습니다. scrape는 정상이었고 HTTP 메트릭 일부(_count/_sum)도 들어오고 있었는데 하필 p95 계산에 필요한 입력 series만 비어 있었다. 원인은 두 겹이었습니다. p95 계산에 필요한 histogram bucket을 켜는 설정이 처음부터 빠져 있었고, 그 설정을 YAML에 넣어도 프로파일 병합 때문에 런타임에 적용되지 않았습니다. 같은 설정을 커맨드라인 프로퍼티로 주면 동작했습니다. 이 사건이 이 글의 두 축 (YAML 설정 배치 원칙과 관측성 계약을 한 곳에서 관리하는 법)의 계기가 됐고 “관측성은 계약을 지키는 일”이라는 관점을 만들었습니다.

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이번 글은 (1) 애플리케이션 메트릭이 Grafana 알림까지 도달하는 경로를 끝까지 따라가고 (2) 그 경로의 각 지점이 어느 파일에 단일 출처(SSOT)로 자리잡고 있는지를 9개 지점으로 쪼개 정리하고 (3) 메트릭과 짝을 이루는 로그 추적(MDC) 경로가 Outbox, Kafka를 건너 어떻게 이어지는지를 봅니다. 마지막으로 이 계약이 Phase 4에서 서비스마다 복제될 때 무엇이 위험한지를 짚습니다.

이 글에서 쓰는 용어는 다음 뜻으로 읽으면 됩니다.

용어이 글에서의 의미
ActuatorSpring Boot가 앱 상태(health), 메트릭(prometheus) 등을 HTTP 엔드포인트로 노출해 주는 모듈. /actuator/*
Micrometer앱 안에서 메트릭을 수집하는 계측 라이브러리. SLF4J가 로깅의 추상화이듯, Micrometer는 메트릭의 추상화
메트릭(metric)시간에 따라 변하는 수치 시계열. 예: http_server_requests_seconds_count(요청 수), _bucket(응답시간 분포)
histogram bucket응답시간을 구간(le=0.1s, 0.5s 등)별 누적 카운트로 쪼갠 시계열. p95/p99는 이 bucket이 있어야 계산된다
Prometheus메트릭을 주기적으로 긁어와(scrape) 시계열 DB에 저장하고 PromQL로 질의하게 해 주는 도구
scrapePrometheus가 대상의 /metrics(여기선 /actuator/prometheus)를 주기적으로 HTTP GET 해 수치를 가져오는 것
ServiceMonitorPrometheus Operator의 CRD. “이 Service를 이 주기와 경로로 scrape 하라”를 K8s 리소스로 선언(Kustomize 글)
GrafanaPrometheus의 시계열을 그래프로 그리고 *임계 초과 시 알림(Alert)*을 발화하는 시각화/경보 도구
PromQLPrometheus 질의 언어. histogram_quantile(0.95, ...)처럼 시계열을 가공해 p95 등을 계산
MDCMapped Diagnostic Context. SLF4J가 스레드마다 들고 다니는 키-값 가방. 로그 한 줄마다 traceId를 적어 두는 데 쓴다
SSOTSingle Source of Truth. “이 설정의 정본은 이 파일의 이 줄 하나뿐”이라는 단일 출처 원칙
surface관측성 계약을 이루는 개별 설정 지점. 이 글에서는 S1~S6.d 9개로 쪼개 다룬다

관측성이 처음이라면

이 글의 주제는 “돌고 있는 앱을 어떻게 들여다보는가”입니다. 그래서 먼저 등장하는 구성요소 몇 가지만 알면 나머지가 읽힙니다. 메트릭은 앱 안에서 만들어져 밖으로 노출되고, 긁혀가서 저장되고, 그려지는 다섯 단계를 거칩니다.

  • Micrometer (앱 안): 우리 앱이 요청을 처리할 때마다 “방금 요청, 200으로 21ms 걸림” 같은 사실을 숫자로 적어 두는 라이브러리. Spring Boot가 기본으로 http.server.requests 같은 메트릭을 Micrometer로 모읍니다. 개발자가 코드를 거의 안 써도 자동으로 쌓입니다.
  • Actuator (앱의 창구): Micrometer가 모은 숫자를 바깥에서 읽을 수 있게 HTTP로 내주는 창구. /actuator/prometheus로 GET 하면 Prometheus가 처리할 수 있는 텍스트 포맷으로 현재 메트릭을 반환합니다. 단, 아무 엔드포인트나 다 열면 위험하니(예: /actuator/env엔 비밀이 있을 수 있습니다) 무엇을 열지 화이트리스트로 고릅니다.
  • Prometheus (수집과 저장소): 그 창구를 주기적으로(우린 15초마다) 조회해 숫자를 수집하고 시계열로 쌓습니다. “시간에 따른 값의 변화”를 갖게 되므로 “최근 5분 에러율” 같은 걸 물을 수 있습니다.
  • Grafana (눈과 경보): Prometheus에 PromQL로 물어 그래프를 그리고 “에러율 5% 넘으면 알려라” 같은 규칙으로 알림을 보냅니다. 사람이 보는 면(대시보드)과 사람을 깨우는 면(Alert) 둘 다 Grafana의 일입니다.
  • MDC / traceId (로그 쪽): 위 네 가지가 메트릭(숫자 통계) 이야기라면, MDC는 로그 쪽 관측성입니다. “이 에러 로그가 어느 요청에서 났는가”를 잇기 위해 요청마다 16자리 traceId를 붙여 로그 줄마다 적습니다. 메트릭이 “전체가 얼마나 아픈가”라면 로그/trace는 “이 한 건이 어디서 막혔나”입니다. 둘은 다른 기둥이고 이 글은 둘 다 다룹니다.

메트릭과 로그를 굳이 한 글에 묶는 이유가 있습니다. 둘 다 “앱이 동작 규약으로서 외부에 약속하는 계약”이고 그 계약이 여러 파일에 흩어져 한 곳만 어긋나도 조용히 깨진다는 같은 문제가 있기 때문입니다. 앞서 말한 빈 그래프가 바로 그 문제의 첫 사례였습니다.


이번 학습에서 확인하고 싶은 질문은 다음과 같습니다.

  1. 메트릭이 정상 수집됐는데도 Grafana 그래프가 비는 게 어떻게 가능할까?
  2. 같은 management.* 설정을 YAML에 넣었는데 왜 base에서도 프로파일에서도 적용되지 않았을까? (YAML 병합)
  3. 애플리케이션 메트릭이 Grafana 알림까지 가는 경로의 각 지점은 어느 파일에 있을까?
  4. 관측성 계약을 “9개 surface”로 쪼개 SSOT를 명시하면 무엇이 좋아질까?
  5. up == 0과 absent(up)은 무엇이 다르길래 alert를 둘로 나눴을까?
  6. 메트릭과 별개인 로그 추적(MDC)은 Outbox, Kafka를 건너 어떻게 한 traceId로 이어질까?
  7. 이 계약이 Phase 4에서 서비스마다 복제될 때 무엇이 위험할까?

문제 상황: 수집은 됐는데 그래프가 비어 있다

Phase 3에서 kube-prometheus-stack을 띄우고(Kustomize 글) 대시보드를 붙였을 때, 대부분의 패널은 정상이었습니다. 그런데 API Response Time의 p95/p99 패널만 “No data” 였습니다. 직관과 어긋납니다. Prometheus를 열어 보면 http_server_requests_seconds_count, _sum은 분명히 들어와 있었습니다. scrape 대상이 막힌 것도, HTTP 메트릭 전체가 안 들어온 것도 아닙니다.

첫 번째 원인은 메트릭의 타입이었습니다. p95/p99는 “95%의 요청이 몇 초 안에 끝났나”인데, 이것을 계산하려면 응답시간을 구간별로 쪼갠 histogram bucket(..._seconds_bucket{le="0.1"}, le="0.5" 등)이 있어야 합니다. PromQL histogram_quantile()은 이 bucket을 입력으로 받아 분위수를 역산합니다. 그런데 우리 앱은 http_server_requests_seconds를 bucket 없는 summary 타입으로만 내보내고 있었습니다. 입력 시계열 자체가 없으니 histogram_quantile()은 NaN을 반환하고 Grafana는 그것을 “No data”로 표시합니다.

histogram bucket을 켜는 설정은 한 줄입니다. management.metrics.distribution.percentiles-histogram[http.server.requests]: true. 두 번째 원인은 이 한 줄을 넣는 자리였습니다. YAML 어디에 넣어도 동작하지 않았습니다.

시도 1) application-k8s.yml 에 추가      → 동작 안 함  
시도 2) application.yml(base) 에 추가     → 동작 안 함  
시도 3) 커맨드라인 프로퍼티로 주입         → 동작함 (!)

3번이 동작했다는 건 설정 값은 맞고 YAML 바인딩/병합 경로에 문제가 있다는 뜻입니다. 즉 같은 설정이 커맨드라인에선 적용되고 YAML(base든 프로파일이든)에선 런타임에 적용되지 않는 상태가 재현됐습니다. Spring Boot 내부의 어느 단계에서 값이 가려지는지까지는 추적하지 않고, 우리는 이 경로를 YAML에서 들어내 Java Config로 옮겨 회피했습니다. management.metrics 트리가 base(application.yml)와 환경 프로파일(application-k8s.yml)에 동시에 선언돼 있던 점이 의심 지점이었고(최상위 키 기준으로 맵이 병합될 때 프로파일 쪽이 우선해 base의 하위 키가 가려질 수 있습니다), 어느 쪽이든 동작 규약을 YAML에 두는 한 재발 위험이 남는다는 게 핵심이었습니다. 설정은 파일에 분명히 적혀 있는데 런타임엔 없는 상태, 디버깅이 가장 괴로운 종류의 버그입니다.

Spring Boot 공식 문서는 프로파일 프로퍼티가 같은 키를 override하고 Map은 여러 property source에서 바인딩될 수 있다고만 설명한다. 그러니 tags.application이 distribution.*을 가린다는 건 이 특정 YAML 구성에서 관찰한 현상이다. 일반 규칙으로 확인된 것은 아니다. 우리가 택한 해법(Java Config 이전)은 그 원인 규명과 무관하게 재발 경로 자체를 없앤다.

여기서 한 발 물러서 보면, 이 사건은 구조적 신호였습니다. 관측성을 켜는 설정이 application.yml, application-k8s.yml, SecurityConfig.java, servicemonitor.yml, grafana-alerts.yml 다섯 군데에 흩어져 있었고, 그중 한 곳의 병합 한 번이 어긋나자 p95/p99 그래프가 표시되지 않았습니다. 메트릭은 여러 파일에 걸친 계약이 전부 맞아야 비로소 그래프가 그려지는 연결 구조의 문제입니다. 이 연결 구조를 누가 어디서 책임지는가: 그것이 이 글의 핵심 설계 질문입니다.


선택한 설계

1. 메트릭이 알림까지 가는 경로: 다섯 지점, 다섯 파일

먼저 “수집됐는데 안 보인다”를 다시 안 겪으려면, 메트릭이 거치는 경로를 지점별로 명확히 그려야 합니다. PeekCart에서 한 번의 HTTP 요청이 Grafana 알림이 되기까지는 다섯 단계입니다.

flowchart LR  
    A["① 앱: Micrometer 계측<br/>http.server.requests<br/>(+ histogram bucket)"] --> B["② Actuator 노출<br/>/actuator/prometheus<br/>(exposure + 보안 허용)"]  
    B --> C["③ ServiceMonitor<br/>15s scrape<br/>(selector/namespace)"]
    C --> D["④ Prometheus<br/>시계열 저장<br/>application=peekcart"]  
    D --> E["⑤ Grafana<br/>대시보드 + Alert<br/>(PromQL)"]  

각 지점은 서로 다른 파일에 SSOT로 자리잡고 있고, 하나라도 어긋나면 연결이 끊깁니다. 지점별로 “무엇이, 어디에” 있는지를 정리한 게 아래 surface 표입니다. 빈 그래프를 고친 뒤의 위치라서, bucket 설정은 MetricsConfig.java에 있습니다. surface는 앱과 매니페스트 쪽 설정 S1~S5 다섯 개와 Grafana alert 룰 S6.a~d 네 개, 모두 9개입니다. 아래 표는 alert 넷을 S6 한 행으로 묶었습니다.

지점무엇을 보장하나SSOT 위치 (파일)surface
(1) histogram bucketp95/p99 계산 가능성MetricsConfig.java (MeterFilter)S1
(1) application 태그모든 series에 application="peekcart" 라벨application.yml:38-40S2
(2) 노출 화이트리스트/actuator/prometheus가 scrape 가능application.yml:33-37S3
(2) 보안 허용actuator가 401 없이 200 응답SecurityConfig.java:47-48S4
(3) scrape 설정15초마다 긁어감, 대상 매칭servicemonitor.yml:6-20S5
(5) alert 규칙임계 초과 시 발화grafana-alerts.ymlS6.a~d

이 표가 말하는 바는 간단합니다: “메트릭이 안 보인다”는 신고가 들어오면 이 표의 행을 순서대로 짚으면 된다. bucket이 없습니까(S1)? 태그가 빠졌습니까(S2)? 노출이 막혔습니까(S3)? 보안이 401입니까(S4)? scrape 대상이 안 잡혔습니까(S5)? alert PromQL이 틀렸습니까(S6)? 앞의 빈 그래프는 S1의 실패였고 그 진단을 다음에 5분 만에 끝내려고 이 표를 결정 문서로 남겼습니다.

2. YAML에서 Java Config로: 빈 그래프를 고친 방법

빈 그래프를 고치는 값은 한 줄이지만 어디에 두느냐가 결정이었습니다. base와 프로파일이 같은 management.metrics 트리를 나눠 갖는 한 YAML에서는 다시 가려질 수 있어서, 해결은 Java Config로 이동이었습니다(MetricsConfig.java).

// com.peekcart.global.config.MetricsConfig (S1의 SSOT)
@Configuration  
public class MetricsConfig {  
    @Bean
    public MeterRegistryCustomizer<MeterRegistry> httpHistogramCustomizer() {
        return registry -> registry.config().meterFilter(new MeterFilter() {
            @Override
            public DistributionStatisticConfig configure(
                    Meter.Id id,
                    DistributionStatisticConfig config
            ) {
                if (id.getName().equals("http.server.requests")) {
                    return DistributionStatisticConfig.builder()
                            .percentilesHistogram(true)   // ← bucket 활성화
                            .build()
                            .merge(config);
                }
                return config;
            }
        });
    }
}  

이 위치 이동은 이후 원칙이 됐습니다. 이 사건을 일반화해 우리는 “환경마다 달라지는 연결 정보와 자격증명만 application-{profile}.yml에 두고, 런타임 동작을 바꾸는 정책은 base나 Java Config로 관리한다” 는 규칙을 세웠습니다. 기준 한 줄: “환경마다 달라야 하는 값인가(그러면 프로파일), 아니면 동작 규약인가(그러면 base/Java Config)?”

histogram bucket은 모든 환경에서 똑같이 켜져야 하는 동작 규약입니다. minikube든 GKE든 p95는 같은 방식으로 계산돼야 합니다. 그러니 환경별 프로파일에 둘 이유가 없고 오히려 거기 두면 병합 충돌에 노출됩니다. application=peekcart 태그(S2)와 노출 화이트리스트(S3)도 동작 규약이지만 이 둘은 base application.yml에 두고, 프로파일엔 재선언하지 않습니다. base에 넣은 시도 2가 실패한 것은 application-k8s.yml에도 management.metrics 트리가 있었기 때문입니다. 그래서 빈 그래프를 고친 뒤 프로파일에 있던 태그와 노출 설정을 base로 옮겨, management.* 트리가 base 한 곳에만 있게 했습니다.

특히 이 원칙은 *“최상위 키 트리(management.*)에 base/프로파일 병합 충돌 가능성이 있으면 Java Config로 가라”*를 명시 케이스로 둡니다.

3. 관측성을 “계약”으로 본다: 9 surface를 한 표로

YAML 원칙은 값을 어디에 두나를 풀었지만 더 큰 질문이 남았습니다. 9개 지점이 흩어져 있다는 사실 자체를 어떻게 관리할까? 그것이 이 절의 주제입니다.

결정은 의외로 “통합하지 않는다”였습니다. 모든 설정을 한 ObservabilityConfig 클래스로 모으는 길도 검토했지만 코드 통합 자체가 위험과 검증 비용을 동반하는 별도 변경이라 결정과 코드 이동을 한 작업에 묶으면 둘 다 품질이 깎인다고 봤습니다. 대신 “각 surface가 지금 어디에 있고, Phase 4에서 어느 모듈로 가고, 무엇을 검증하는가”를 표로 분명히 정해 두었습니다. 9 surface를 요약하면 다음과 같습니다.

surface무엇의존깨지면Phase 4 위치
S1histogram bucket없음p95 패널과 latency alert만 NaN (error-rate는 무관)공통 관측성 모듈 한 곳
S2application= 태그없음alert의 {application="peekcart"} 필터가 시계열을 못 잡음각 서비스의 base application.yml(값은 서비스 이름)
S3exposure 화이트리스트없음/actuator/prometheus scrape 불가공통 관측성 모듈이 제공하는 base 설정
S4actuator 보안 허용없음scrape가 401, Probe 실패공통 관측성 모듈의 actuator 보안 설정
S5scrape 설정없음S6.a~c는 입력 시계열이 없어 발화하지 못하고, S6.d만 발화서비스마다 자기 ServiceMonitor
S6.aerror-rate alert (5xx>5%)S2, S5_count 사용, S1 무관monitoring 공용 alert 파일 한 곳
S6.blatency alert (p95>2s)S1, S2, S5_bucket 사용, S1 직접 의존위와 같음
S6.ctarget-down (up==0)S5scrape label만 의존위와 같음
S6.dscrape-absent (absent(up))S5selector 미스매치/리소스 삭제위와 같음

이 표의 가치는 의존 방향에 있습니다. 예를 들어 “error-rate alert는 멀쩡한데 latency alert만 NaN”이면 S1(bucket)만 의심하면 됩니다. error-rate는 _count를 쓰므로 S1과 무관하기 때문입니다. 반대로 다른 alert는 조용한데 scrape-absent만 뜬다면 공통 의존인 S5(scrape)를 먼저 봅니다. 흩어진 계약을 통합하지 않고도 “어디를 보면 되는지”를 결정론적으로 만든 게 이 표입니다.

이 SSOT 표는 앞의 YAML 원칙을 대체하는 게 아니라 확장합니다. YAML 원칙이 “동작 정책은 Java Config로”라는 일반 규칙이라면, 이 표는 그 규칙을 관측성의 9 surface에 구체화하고 특히 일반 규칙만으론 도출되지 않는 manifest 측 surface(S5 ServiceMonitor, S6 Grafana alert)의 위치까지 정합니다.

4. 메트릭의 짝: 로그 추적(MDC)이 Outbox, Kafka를 건너는 법

여기까지가 메트릭(전체 통계)입니다. 그런데 “5xx 에러율이 올랐다”는 alert가 떴을 때, 운영자는 곧장 “어느 요청이 터졌나”를 알고 싶어 합니다. 그것이 로그/trace 쪽 관측성이고 PeekCart는 이를 **MDC traceId**로 풉니다.

시작은 단순합니다. MdcFilter가 16자리 traceId와(SecurityContext가 있으면) userId를 MDC에 적습니다. 이 필터는 JwtFilter 뒤에 등록돼(SecurityConfig의 addFilterAfter(new MdcFilter(), JwtFilter.class)), userId를 채우려면 JWT 인증이 먼저 끝나 있어야 하기 때문입니다. 그래서 traceId가 붙는 범위는 정확히는 JwtFilter 이후의 애플리케이션 처리 구간이고 JwtFilter 내부 로그나 그 전에 끝나는 보안 실패 로그는 이 traceId 밖입니다.

// global/filter/MdcFilter.java  
MDC.put("traceId", UUID.randomUUID().toString().replace("-", "").substring(0, 16));  
Authentication auth = SecurityContextHolder.getContext().getAuthentication();  
if (auth != null && auth.getPrincipal() instanceof Long userId) {  
    MDC.put("userId", userId.toString());
}  

이러면 그 요청이 (필터 통과 이후) 동기적으로 찍는 로그 줄에 같은 traceId가 붙어, 로그를 traceId로 grep 하면 한 요청의 처리 흐름이 모입니다. 문제는 비동기 경계다. PeekCart의 핵심 흐름(주문, 결제, 알림)은 Outbox + Kafka로 도메인을 건넙니다(Outbox, Consumer 멱등성 글). 그런데:

  1. OutboxPollingService는 @Scheduled 별도 스레드에서 실행됩니다. 그 스레드의 MDC는 비어 있습니다(MDC.get("traceId")는 항상 null).
  2. Consumer가 메시지를 받을 땐 원본 HTTP 요청의 MDC가 이미 사라진 지 오래됐습니다.

그대로 두면 “HTTP 요청, Outbox 저장, Kafka publish, Consumer 처리, DLQ”의 5단계가 서로 다른 traceId로 흩어져, 정작 장애 분석이 필요한 비동기 경로에서 추적이 끊깁니다. 그래서 택한 방법은 traceId를 메시지에 실어 보내는 것입니다.

flowchart LR  
    H["HTTP 요청<br/>MdcFilter: traceId 생성"] --> P["Publisher<br/>MdcSnapshot.current()"]  
    P -->|"trace_id 컬럼 영속"| O["outbox_events<br/>(같은 TX)"]
    O --> Poll["OutboxPollingService<br/>buildRecord()"]
    Poll -->|"X-Trace-Id 헤더 주입"| K["Kafka"]  
    K --> C["Consumer<br/>MdcRecordInterceptor<br/>헤더 → MDC 복원"]  

폴링 스레드엔 MDC가 없으므로 MDC를 캡처하는 시점을 앞당겨, 원본 HTTP 요청 스레드에서 Outbox 이벤트를 저장할 때 MdcSnapshot.current()로 traceId/userId를 떠서 엔티티 컬럼(outbox_events.trace_id, user_id)에 같은 트랜잭션으로 영속합니다(V4__outbox_trace_context.sql). 나중에 폴링 스레드는 MDC 대신 컬럼에 저장된 값을 읽어 Kafka 헤더에 싣습니다.

// global/outbox/OutboxPollingService.java: 폴링 스레드는 컬럼에서 읽어 헤더로  
private ProducerRecord<String, String> buildRecord(OutboxEvent event) {  
    ProducerRecord<String, String> record = new ProducerRecord<>(...);
    addHeaderIfPresent(record, KafkaTraceHeaders.TRACE_ID, event.getTraceId()); // X-Trace-Id
    addHeaderIfPresent(record, KafkaTraceHeaders.USER_ID, event.getUserId());   // X-User-Id
    return record;
}  

Consumer 쪽 MdcRecordInterceptor는 이미 헤더 우선, 다음 payload eventId, 마지막 신규 UUID의 fallback 순서가 있어(Consumer 측 trace 복원은 이 글보다 앞선 단계에서 먼저 만들어졌습니다), 헤더가 오면 그대로 복원하고 없으면 eventId로 묶습니다.

// global/kafka/MdcRecordInterceptor.java  
String traceId = headerValue(record, KafkaTraceHeaders.TRACE_ID);  // ① 헤더 (Producer가 실어 보냄)  
if (traceId == null) traceId = extracted.eventId();                // ② payload eventId (재시도/DLQ까지 동일)  
if (traceId == null) traceId = UUID.randomUUID()...;               // ③ 방어적 fallback
MDC.put(MDC_TRACE_ID, traceId);  

이 설계에서 눈여겨볼 결정 두 가지. (1) 헤더가 null이면 헤더 자체를 안 넣는다(빈 문자열 헤더 금지), Consumer의 value.isBlank() ? null 분기와 정합시켜, “있다”와 “비었다”가 같은 의미가 되게 했습니다. (2) payload envelope을 안 건드렸다, trace를 메시지 본문(JSON)에 넣었으면 Consumer 파서를 고쳐야 하고 기존 메시지 호환이 깨집니다. 헤더 경로를 택해 스키마 불변 + 기존 Consumer 인프라 재활용을 동시에 얻었습니다. 그리고 이것이 Phase 4 OpenTelemetry 도입 시 표준 헤더 traceparent를 한 단계 더 추가하기만 하면 되는 forward-compatible 구조입니다.

짚을 점: 이 MDC 추적은 로그 상관관계다. Prometheus/Grafana엔 안 보인다. 진짜 분산 추적(span, sampling, 서비스 간 전파)은 Phase 4에서 micrometer-tracing/OpenTelemetry로 별도 도입한다. 지금은 “모놀리스 안에서 로그를 한 traceId로 묶는” 최소 추적이고, 위 작업은 그 컬럼과 헤더 인프라를 미리 깔아 둔 것이다.


구현 구조: 네 개의 알림과 한 개의 회귀 테스트

네 개의 Grafana Alert: 무엇이 침묵을 깨우나

경로의 종착점은 Grafana Alert입니다. grafana-alerts.yml은 ConfigMap 하나에 4개 룰을 담습니다(S6.a~d). 넷이 서로 다른 실패를 감지하도록 설계됐습니다.

uid감지PromQL 핵심severity의존 surface
peekcart-high-error-rate5xx 비율 > 5%rate(..._count{status=~"5.."}) / rate(..._count)criticalS2, S5
peekcart-slow-responsep95 > 2shistogram_quantile(0.95, rate(..._bucket[5m]))warningS1, S2, S5
peekcart-target-downup == 0count(up{...} == 0) or on() vector(0)criticalS5
peekcart-scrape-absentabsent(up{...})absent(up{...}) or on() vector(0)criticalS5

여기서 가장 배울 게 많은 건 target-down(S6.c)과 scrape-absent(S6.d)를 왜 둘로 나눴나입니다. 둘 다 “메트릭이 안 들어온다”처럼 보이지만 원인이 다릅니다.

  • up == 0 (target-down): Prometheus가 scrape 대상을 알고는 있는데(ServiceMonitor 매칭 성공) 막상 수집을 시도하니 실패했습니다. 네트워크 단절, 401, /actuator/prometheus가 500 등. up 시계열은 존재하되 값이 0입니다.
  • absent(up) (scrape-absent): up 시계열이 아예 없다. ServiceMonitor selector가 미스매치거나, 네임스페이스와 서비스가 삭제됐거나, scrape 대상으로 등록된 적이 없습니다.

이 구분이 실무적으로 중요한 이유: up == 0은 “서비스가 떠 있는데 응답을 못 한다”(앱/네트워크 문제)고 absent(up)은 “그 서비스를 감시하고 있다는 사실 자체가 사라졌다”(설정/배포 문제)입니다. 전자는 앱을 보고 후자는 매니페스트(S5)를 봅니다. 알림 하나로 뭉뚱그렸으면 둘을 구분하느라 새벽에 헤맸을 것입니다. grafana-alerts.yml의 description이 *“up 시계열은 존재하되 0 … Error Rate 패널의 강제 0% 표시와 구분하는 신호”*라고 그 의도를 적어 둡니다.

또 하나의 디테일, error-rate는 _count를, latency는 _bucket을 씁니다. 그래서 histogram bucket(S1)이 깨져도 error-rate alert는 영향을 받지 않습니다. surface 의존 표(S6.a는 S1 무관, S6.b는 S1 의존)가 이 동작을 예측합니다. 다만 빈 그래프 사건 때 대시보드의 Error Rate 패널도 “No data”였는데, 원인은 S1이 아니었습니다. 5xx 요청이 없는 구간에서 분자 시계열이 비어 계산 결과가 비었고, 이것은 분모가 0보다 클 때만 나누고 그 밖에는 0%를 표시하도록 PromQL을 고쳐 따로 해결했습니다.

모든 룰은 datasourceUid: prometheus로 datasource를 명시 참조합니다. 여기엔 함정이 하나 있습니다: 원래 이 UID는 kube-prometheus-stack Helm 차트가 자동 프로비저닝하는 기본값에 의존하고 있어, 차트 업그레이드로 UID가 바뀌면 그 UID를 참조하는 대시보드와 alert가 한꺼번에 datasource를 잃을 수 있었습니다(uid: prometheus 참조가 대시보드 JSON 14곳, alert 룰 4개의 쿼리 5곳). 그래서 values에 grafana.sidecar.datasources.uid: prometheus를 명시 pin해 차트 기본값 의존을 끊었습니다(dashboard/alert YAML은 0건 변경, 기존 uid: prometheus 참조를 그대로 살림).

한 개의 회귀 테스트: 계약이 깨지는 걸 CI에서 잡는다

surface 표로 SSOT를 정리해 둬도, 표는 사람이 안 보면 그만입니다. 그래서 동작 회귀만이라도 자동으로 잡으려고 ObservabilityMetricsIntegrationTest를 둡니다.

@SpringBootTest(webEnvironment = RANDOM_PORT)  
@AutoConfigureObservability   // ← 이게 없으면 테스트에서 메트릭이 안 켜진다  
class ObservabilityMetricsIntegrationTest extends AbstractIntegrationTest {  
  
    @Test // S1 + S2 동시 검증  
    void prometheusEndpoint_exposesHistogramBucketAndApplicationTag() {  
        restTemplate.getForEntity("/api/v1/products", String.class);          // 비즈니스 호출로 메트릭 유발  
        String body = restTemplate.getForEntity("/actuator/prometheus", ...).getBody();  
  
        assertThat(body).contains("application=\"peekcart\"");                // S2: 태그  
        assertThat(body).containsPattern(                                      // S1: bucket
            "http_server_requests_seconds_bucket\\{[^}]*uri=\"/api/v1/products\"[^}]*le=\"[^\"]+\"[^}]*\\}"
        );  
    }  
}  

@AutoConfigureObservability가 핵심 장치입니다. Spring Boot는 테스트에서 노이즈를 줄이려 기본적으로 메트릭 export를 끕니다. 이 어노테이션으로 명시적으로 켜야 실제 운영과 같은 메트릭 노출을 검증할 수 있습니다. 테스트는 비즈니스 엔드포인트를 한 번 호출해 http.server.requests를 유발한 뒤, /actuator/prometheus 응답에 (S1) _bucket{...le=...}과 (S2) application="peekcart"가 둘 다 있는지 봅니다. bucket이 사라지거나 태그가 빠지면 이 테스트가 실패합니다: 즉 빈 그래프와 태그 누락의 재발을 CI에서 막는다. 여기에 exposure 화이트리스트 정확도(info, env가 노출되지 않는지)와 /actuator/health/** 무인증 접근까지 검증 범위가 넓어졌습니다.

다만 이 테스트가 잡는 건 동작 회귀이지 위치 위반이 아닙니다. 누가 application= 태그를 base가 아닌 프로파일에 복제해도, 런타임 동작이 같으면 이 테스트는 통과합니다. 그 위치/복제 위반은 별도의 정적 lint(scripts/observability-ssot-lint.sh)가 CI에서 잡습니다. base application.yml이 SSOT인 키가 다른 프로파일에 재선언됐는지, MeterFilter나 application= 태그가 둘 이상 파일에 중복 선언됐는지를 grep/yaml 파싱으로 검사합니다. 즉 동작은 통합 테스트가, 위치/복제는 정적 lint가 나눠 맡습니다.


한계와 트레이드오프

SSOT 강제는 lint로 구현됐다: 다만 정적 검증의 한계가 남는다

9 surface 표는 결정이고 그 결정을 코드로 강제하는 lint와 테스트가 이미 CI에서 돈다. 이 글을 쓰는 시점의 실제 상태는 다음과 같습니다.

검증 대상강제 수단검사 내용
SSOT 위치와 복제scripts/observability-ssot-lint.sh (CI)SSOT 키의 타 프로파일 재선언과 MeterFilter/태그 중복 선언 정적 검출
exposure 정확도ObservabilityMetricsIntegrationTestexposure가 정확히 health,prometheus만 노출(info, env는 200 아님)
health 무인증ObservabilityMetricsIntegrationTest/actuator/health/** 무인증 200
ServiceMonitor selectorscripts/servicemonitor-selector-lint.sh (CI)두 overlay의 kubectl kustomize 산출물에서 ServiceMonitor selector와 Service label/port 매칭
alert PromQL 라벨scripts/observability-promql-lint.sh (CI)alert uid별 required-label + S2/S5 실제 설정값 일치

즉 “위치 위반은 수동 리뷰에 의존”하던 단계는 지났습니다. 다만 이 강제는 전부 정적이다. 세 lint는 YAML, kustomize 산출물을 파싱해 구조, 라벨, 배선을 검사할 뿐, Prometheus를 실제로 띄워 런타임 의미를 검증하지는 않습니다.

정적 lint가 못 보는 것: 런타임 의미는 여전히 사각

Phase 3 통합 테스트엔 Prometheus가 없습니다(Testcontainers로 MySQL/Redis/Kafka는 띄우지만 Prometheus는 아닙니다). 그래서 다음은 lint, 테스트를 통과해도 실제로는 실패할 수 있습니다.

  • PromQL이 문법상과 라벨상 맞아도 결과가 틀릴 수 있다. observability-promql-lint.sh는 alert expr의 required-label presence와 S2/S5 ground truth 값 일치까지 봅니다. 하지만 histogram_quantile이 실제 bucket 분포에서 말이 되는 수치를 내는지, rate 윈도우가 적절한지 같은 의미는 Prometheus가 돌아야 압니다.
  • selector가 매칭돼도 scrape가 실패할 수 있다. servicemonitor-selector-lint.sh는 label/port가 문서상 맞는지 보지만 실제 Pod가 그 포트로 메트릭을 응답하는지는 클러스터에서만 확인됩니다. 그건 런타임의 peekcart-target-down(S6.c) alert가 맡습니다.
  • happy-path 회귀 테스트의 범위. exposure 정확도와 health 무인증 테스트는 “정확히 health/prometheus만 노출”, “health 무인증 200”을 보지만 모든 endpoint, 모든 경로의 조합을 망라하지는 않습니다.

정리하면 위치/복제/배선은 CI에서 해소됐고 남은 사각은 Prometheus를 실제로 띄워야 보이는 런타임 의미입니다. 이것은 단위/정적 검증의 본질적 경계이지 미구현 부채가 아닙니다.

MDC는 분산 추적이 아니다. span도 sampling도 없다

이 trace 인프라는 “로그를 한 traceId로 묶는” 최소 상관관계지, OpenTelemetry 같은 분산 추적이 아닙니다. span(구간별 소요시간), sampling(부하 시 일부만 추적), baggage(컨텍스트 전파) 같은 게 전부 없습니다. 모놀리스 단일 컨텍스트에선 그 인프라 비용이 가치 대비 과해서 의도적으로 미뤘습니다. 대신 헤더 우선순위를 forward-compatible하게 만들어 둬서, Phase 4에서 traceparent 한 단계만 추가하면 되도록 했습니다. 즉 지금의 한계는 Phase 4의 작업으로 명시적으로 이연돼 있습니다.

관측성에도 사각이 남아 있다. 메트릭이 없는 영역

지금 메트릭은 HTTP(http.server.requests)와 JVM 중심이고 비즈니스/파이프라인 내부 메트릭이 비어 있다. 학습 부채 후보로 이미 등록된 것만:

  • 캐시 hit rate 부재: RedisCacheManager가 CacheMeterBinder에 안 붙어 cache.gets{result=hit/miss}가 없습니다. 캐시 효과를 부하 TPS로 간접 측정할 뿐 운영 중 실시간 hit rate 가 안 보입니다(상품 캐시 글).
  • Outbox 발행 메트릭 부재: OutboxPollingService에 Micrometer 계측이 전무해 PENDING backlog 크기, 발행 지연, 실패율이 메트릭으로 안 나옵니다. 운영자의 유일한 신호는 MAX_RETRY 소진 시 Slack 단발 알림뿐(Outbox 글).
  • Slack 발송 가시성 부재: 운영 알림 3종이 같은 Slack Webhook에 의존하는데 SlackNotificationClient.send()가 예외를 log.error로만 기록하고 전파하지 않아 발송 실패가 메트릭/알람으로 안 뜹니다(알림 실패 격리 글).

셋 다 surface 표에 cache, outbox에 해당하는 행이 아예 없다는 같은 구조입니다. “관측성 옵션 가치” 형 부채, 지금은 문제가 드러나지 않지만 Phase 4에서 서비스 비교가 필요할 때 비로소 비용이 드러납니다.


이 설계가 보장한 것 (과 못한 것)

보장하는 것

  • 메트릭 경로의 결정론적 진단. “안 보인다”는 신고가 들어오면 S1~S6을 의존 순서대로 짚어 원인을 좁힙니다. error-rate는 멀쩡한데 latency만 NaN이면 S1, 다른 alert는 조용하고 scrape-absent만 뜨면 S5. surface 표가 추측을 진단으로 바꿉니다.
  • 빈 그래프와 태그 누락 재발의 CI 차단. ObservabilityMetricsIntegrationTest가 histogram bucket(S1), application 태그(S2), exposure(S3), health 무인증(S4)의 동작 회귀를 매 빌드 검사합니다.
  • 병합 충돌 경로 제거. bucket 설정을 Java Config로 옮겨 YAML 병합의 영향을 받지 않게 했고, 나머지 management.* 키는 base 한 곳에만 두고 정적 lint로 프로파일 재선언을 막습니다.
  • 두 실패의 분리 감지. up==0(앱/네트워크)과 absent(up)(설정/배포)을 별 alert로 나눠, 새벽 호출 시 어디를 볼지가 알림 자체에 담겨 있습니다.
  • 비동기 경계를 건너는 traceId. HTTP, Outbox, Kafka, Consumer, DLQ 5단계가 한 traceId로 묶여 로그 grep으로 한 요청을 따라갈 수 있습니다.

보장하지 못하는 것

  • 런타임 PromQL 의미: 정적 lint(selector, PromQL 라벨)는 매칭, 라벨, 설정값을 보지만 Prometheus를 띄워 실제 쿼리 결과가 맞는지는 검증하지 않습니다.
  • 분산 추적: MDC는 로그 상관관계지 span, sampling이 있는 OpenTelemetry가 아닙니다.
  • 비즈니스/파이프라인 가시성: 캐시 hit rate, Outbox backlog, Slack 발송 실패가 메트릭에 없습니다.
  • surface의 물리적 이전: 9 surface는 아직 모놀리스 한 곳에 모여 있습니다. Phase 4 모듈/서비스로의 이전은 surface 표의 “Phase 4 위치” 열대로 그때 수행됩니다.

Kustomize 글에서 본 패턴이 여기서도 반복됩니다. 핵심 동작은 단단히 검증하고, 일부 보장은 공백으로 남겨 둡니다. 위치, 복제, 배선의 공백은 lint/테스트로 이미 해소됐고 남은 공백은 “런타임 의미 검증, 분산 추적, 파이프라인 메트릭, Phase 4 물리 이전” 넷입니다.


Phase 4 MSA에서는 어떻게 바뀌는가

지금 관측성 계약은 모놀리스 한 덩어리의 것입니다. 9 surface가 한 앱, 한 ServiceMonitor, 한 alert 파일에 모여 있습니다. Phase 4에서 서비스가 N개로 갈라지면 이 계약이 서비스 수만큼 복제되는데 여기서 앞서 본 “계약 분산”이 모듈 경계를 따라 더 깊어질 위험이 있습니다. surface 표의 “Phase 4 위치” 열은 바로 이 복제를 미리 설계해 둔 것입니다.

flowchart LR  
    M1["Phase 3 Monolith: MetricsConfig, application.yml, SecurityConfig"] --> SM1["ServiceMonitor: one scrape target"]
    SM1 --> GA1["Grafana alerts: application equals peekcart"]
    GA1 --> COMMON["Phase 4 MSA: common observability module"]
    COMMON --> SVC["Each service: own application name and selector"]
    SVC --> ALERTS["Shared Grafana alerts: regex application matching"]

그대로 가는 것

  • 9 surface의 의존 구조. “S6은 S5에 의존, latency는 S1에 의존” 같은 의존 방향은 서비스가 늘어도 동일합니다. 진단 표가 서비스마다 한 장씩 늘 뿐 논리는 같습니다.
  • 모니터링 스택의 위치 분담. S5(ServiceMonitor)는 앱 레포 base/services/<svc>/, S6(alert)는 monitoring/shared/로 가는 분담이 유지됩니다. ServiceMonitor를 서비스 디렉토리에 동봉하는 Kustomize 글의 패턴이 그대로 N배로 확장됩니다.
  • 헤더 기반 trace 사다리. Consumer 측 헤더 우선순위와 Outbox Producer 측 헤더 주입의 헤더 → eventId → UUID 우선순위가 cross-service에서 traceparent를 맨 위에 추가하는 것만으로 확장됩니다.

바뀌는 것

  • 공통 surface는 모듈로, 식별 surface는 서비스로. S1(histogram), S3(exposure), S4(보안 허용)는 모든 서비스가 똑같이 필요하므로 peekcart-common-observability 모듈에 두고 각 서비스가 import-only로 가져갑니다. MeterFilter를 서비스마다 재선언하면 계약 분산이 재발합니다. 반면 S2(application=)는 서비스마다 자기 이름(order-service, payment-service 등)이어야 하므로 각 서비스 application.yml에 두되 공통 모듈에 default를 넣지 않습니다.
  • alert가 cross-service 정규식으로. 지금 application="peekcart"인 alert 필터가 Phase 4에선 application=~"order-service|payment-service|..." 정규식이 됩니다. alert는 여전히 monitoring/shared/ 한 곳, 서비스별로 복제하지 않습니다.
  • MDC가 진짜 분산 추적으로. 모놀리스의 “로그 상관관계”가 micrometer-tracing/OpenTelemetry의 span 기반 분산 추적으로 확장됩니다. MdcFilter를 tracer와 결합하고 traceparent를 헤더 우선순위에 추가하는 작업이 그 시점에 더해집니다.

그래서 Phase 4 진입 전 짚을 점

  • 이미 있는 SSOT lint를 서비스별로 확장해야 한다. 지금의 정적 lint는 모놀리스 한 곳을 검사하도록 짜여 있습니다. 서비스 N개에서 “MeterFilter가 공통 모듈 1개소에만 있는가”, “application=가 각 서비스 base 외에 재선언 안 됐는가”를 보려면 lint의 검사 범위를 모듈 트리에 맞춰 넓혀야 합니다. 과제는 기존 lint의 확장입니다.
  • 공통 관측성 모듈의 경계를 surface 표대로 그어야 한다. 무엇이 peekcart-common-observability로 가고(S1/S3/S4) 무엇이 서비스에 남는가(S2/S5)는 이미 표에 정해져 있습니다: Phase 4 계획은 이 모듈을 자기 결정 항목에서 제외하고 표를 그대로 따릅니다. 이것은 CI 글과 부채 점검 글의 “common 모듈에 무엇을 둘 것인가”와 같은 결의 질문입니다.
  • 정적 lint를 넘어 런타임 검증으로 갈지 결정해야 한다. 서비스가 늘수록 selector 미스매치와 PromQL 결과 오류가 생길 범위가 N배가 됩니다. 지금의 정적 lint가 구조는 막지만 Prometheus를 띄워 실제 쿼리 결과까지 보는 통합 검증을 어디까지 도입할지가 부채 점검 글에서 정할 항목입니다.

자료는 어떤 질문에 연결해서 읽을까

질문같이 읽을 자료이 글에서 연결되는 지점
Micrometer/Actuator가 메트릭을 노출하는 원리Spring Boot Reference, Actuator / Metrics경로 (1), (2), MetricsConfig
histogram bucket과 histogram_quantile()의 관계Prometheus 문서, Histograms and summaries빈 그래프, S1
Spring Boot YAML 프로파일 병합 규칙Spring Boot Reference, Externalized Configuration: Merging YAML문제 상황, YAML 원칙
ServiceMonitor가 scrape 대상을 매칭하는 방식Prometheus Operator 문서, ServiceMonitor경로 (3), S5
up 메트릭과 absent()의 의미Prometheus 문서, Querying / uptarget-down vs scrape-absent
MDC 기반 로그 상관관계와 분산 추적의 차이Micrometer Tracing 문서, IntroductionMDC 절, Phase 4 OTel
SSOT/계약을 한 곳에서 강제하는 패턴Google SRE Book, Monitoring Distributed Systems9 surface 표