포스트

Observability: Micrometer와 분산 추적

Observability: Micrometer와 분산 추적

장애가 났는데 어디서 느린지 모르겠다

서비스가 커지고 호출 경로가 길어지면, “느리다”는 건 알겠는데 어디서 느린지를 모르는 상황이 옵니다. 로그를 grep해도 한 요청이 게이트웨이→주문→결제→재고를 거치는 흐름이 흩어져 있어 따라가기 어렵죠.

핵심은 단순히 메트릭·로그·추적을 수집하는 게 아닙니다. 세 신호를 같은 요청 단위로 잇는 것이 관측성의 본질입니다. “이 느린 요청(trace)의 그 순간 로그가 이거고, 그때 CPU 메트릭이 이랬다”가 한 번에 연결돼야 합니다. 이 글은 Spring Boot가 그 연결을 어떻게 만들어내는지 — Observation 한 번이 어떻게 메트릭과 추적을 동시에 낳는지 — 그 메커니즘까지 내려갑니다.

신호답하는 질문대표 도구카디널리티
메트릭(Metrics)“얼마나? 몇 번? 평균/p99는?”Micrometer → Prometheus낮아야 함(집계값)
추적(Tracing)“이 요청이 어디서 시간을 썼나?”Micrometer Tracing → OTel/Zipkin높음(요청별)
로그(Logs)“그 순간 정확히 무슨 일이?”SLF4J/Logback + MDC높음(이벤트별)

세 줄을 잇는 끈이 traceId입니다. 아래는 하나의 요청에 traceId가 부여돼 서비스를 지나며 span으로 이어지고, 같은 id가 로그·메트릭에까지 박히는 모습입니다.

게이트웨이 span A 주문 서비스 span B 결제 서비스 span C trace 3f9a… 분산 추적 타임라인 (한 trace = 여러 span) A: 전체 B: 주문 C: 결제 로그 [traceId=3f9a, spanId=…] payment timeout 메트릭 http.server.requests · exemplar→3f9a

Observation API: “한 번 계측 → 메트릭·추적 동시 생성”

예전엔 메트릭(Timer)과 추적(Span)을 따로 계측했습니다. 같은 구간을 두 번 감싸야 했고, 둘이 어긋나기 일쑤였죠. Spring Boot 3 / Micrometer 1.10부터 들어온 Observation API는 이걸 하나로 합칩니다 — 구간을 한 번 “관측”으로 선언하면, 등록된 핸들러들이 그 하나의 이벤트로 메트릭도, span도, 로그 컨텍스트도 만들어 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
@Service
@RequiredArgsConstructor
public class OrderService {
    private final ObservationRegistry registry;

    public Order place(OrderRequest req) {
        return Observation.createNotStarted("order.place", registry)
            .lowCardinalityKeyValue("order.type", req.type())   // 메트릭 태그 + span 태그
            .highCardinalityKeyValue("order.id", req.id())       // span에만 (메트릭 X)
            .observe(() -> doPlace(req));   // 이 구간이 Timer + Span으로 동시 기록
    }
}

왜 하나가 둘이 되나 — ObservationHandler

이 마법의 정체는 ObservationRegistry에 등록된 ObservationHandler 목록입니다. observe()가 호출되면 라이프사이클 이벤트(onStart → onStop 등)가 발생하고, 등록된 모든 핸들러가 그 이벤트를 받습니다.

  • DefaultMeterObservationHandler → 이 관측을 Timer/LongTaskTimer 메트릭으로 만든다.
  • TracingObservationHandler (Micrometer Tracing) → 이 관측을 Span으로 만든다(start=span 시작, stop=span 종료).
flowchart LR
    O["Observation.observe()"] --> R["ObservationRegistry<br/>이벤트 디스패치"]
    R --> H1["MeterObservationHandler<br/>→ Timer 메트릭"]
    R --> H2["TracingObservationHandler<br/>→ Span (trace)"]
    R --> H3["기타 핸들러<br/>→ 로그 컨텍스트(MDC) 등"]

여기서 핵심 설계 결정이 보입니다. 계측 코드는 “무엇을 관측하는가”(order.place)만 선언하고, “그걸 메트릭으로 낼지 추적으로 낼지”는 핸들러가 정합니다. 그래서 추적 의존성을 빼면 자동으로 span은 안 생기고 메트릭만 남습니다 — 계측 코드는 한 줄도 안 바뀝니다. 이게 자동 구성(관련 글)과 만나는 지점입니다. ObservationAutoConfiguration이 클래스패스에 있는 것에 맞춰 핸들러를 조건부로 등록합니다.

@Observed는 공짜가 아니다

메서드 단위라면 애너테이션이 더 깔끔합니다.

1
2
@Observed(name = "order.place", contextualName = "place-order")
public Order place(OrderRequest req) { ... }

⚠️ 단, @Observed는 AOP 프록시(ObservedAspect)로 동작하므로 그 Bean을 직접 등록해야 작동합니다. 안 그러면 애너테이션이 조용히 무시됩니다(자주 빠지는 함정).

1
2
3
4
@Bean
ObservedAspect observedAspect(ObservationRegistry registry) {
    return new ObservedAspect(registry);
}

그리고 AOP 프록시라서 self-invocation(같은 클래스 내부 호출)엔 안 걸립니다 — @Transactional의 그 함정과 똑같은 원리입니다.

분산 추적: traceId를 서비스 경계로 전파하기

참고: Spring Boot 2.x의 Spring Cloud Sleuth는 Boot 3부터 단종되고 Micrometer Tracing으로 대체됐습니다. 지금 글은 Boot 3/4 기준입니다.

Micrometer Tracing은 추적 API의 얇은 추상화이고, 실제 구현은 브리지로 갈아끼웁니다.

1
2
3
4
5
// 택1: OpenTelemetry 브리지 (요즘 표준)
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
// 또는 Brave(Zipkin) 브리지
// implementation 'io.micrometer:micrometer-tracing-bridge-brave'

서비스 간 전파는 W3C traceparent 헤더(또는 B3) 표준으로 이뤄집니다. 보내는 쪽 RestClient/WebClient와 받는 쪽 서버 필터에 자동 계측이 붙어, Propagator가 헤더를 주입·추출합니다.

flowchart LR
    A["게이트웨이<br/>trace abc · span 1"] -->|"traceparent: 00-abc-1-01"| B["주문<br/>trace abc · span 2"]
    B -->|전파| C["결제<br/>trace abc · span 3"]
    B -->|전파| D["재고<br/>trace abc · span 4"]

수집된 trace는 Zipkin/Tempo/Jaeger에서 요청 하나가 각 서비스에서 얼마나 걸렸는지 타임라인(위 애니메이션의 span 막대들)으로 보여줍니다. “어디서 느린가”가 그래프로 답해집니다.

로그 상관관계: MDC 자동 주입

추적과 로그를 잇는 마지막 끈. Micrometer Tracing이 현재 trace/span id를 MDC(SLF4J MDC)에 자동으로 넣어주므로, 로그 패턴에 끼우기만 하면 됩니다.

1
2
3
logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

이제 Loki/ELK에서 traceId=3f9a…로 검색하면 네 서비스에 흩어진 그 요청의 로그가 한 줄에 모입니다. 추적 UI에서 본 느린 span의 그 순간 로그를 바로 펼쳐볼 수 있죠.

프로덕션 함정 ①: 컨텍스트가 스레드를 건너면 끊긴다

trace 컨텍스트는 본질적으로 ThreadLocal 기반입니다. 그래서 작업이 다른 스레드로 넘어가면(비동기 @Async, CompletableFuture, 리액티브 Flux, 가상 스레드 경계) traceId가 유실되어 span이 끊기고 로그의 %X{traceId}가 비어버립니다.

해법은 컨텍스트 전파입니다.

1
2
3
4
// 명령형 비동기: 현재 컨텍스트를 캡처해 다른 스레드에서 복원
ExecutorService traced = ContextExecutorService.wrap(
        Executors.newVirtualThreadPerTaskExecutor(),
        ContextSnapshotFactory.builder().build()::captureAll);

리액터에서는 Hooks.enableAutomaticContextPropagation()(Reactor 3.5+)을 켜면 됩니다. 핵심은 “스레드 경계마다 컨텍스트를 명시적으로 옮겨야 한다”는 것.

프로덕션 함정 ②: 카디널리티 폭발

메트릭 태그에 요청마다 다른 값(userId, orderId, UUID, 전체 URL의 path variable)을 넣으면, Prometheus의 시계열(time series) 수가 폭증해 메모리·쿼리가 무너집니다. 이게 모니터링 시스템을 죽이는 1순위 사고입니다.

  • 메트릭 태그(lowCardinalityKeyValue)에는 유한한 값만(상태코드, 메서드, 라우트 템플릿 "/orders/{id}").
  • 요청별 고유값은 highCardinalityKeyValue 로 — 이건 span(추적)에만 들어가고 메트릭 태그는 안 만든다.
  • URI 태깅은 path(실제 경로)가 아니라 uri(템플릿) 로 기록되는지 확인. /orders/1, /orders/2가 각각 시계열이 되면 끝장입니다.

디버깅 / 운영 체크리스트

  • /actuator/metrics/http.server.requests — 특정 메트릭의 태그·값 확인. 엔드포인트 전반은 Actuator 글 참고.
  • /actuator/prometheus — Prometheus가 긁어가는 raw 출력. 태그 카디널리티를 눈으로 점검.
  • management.tracing.sampling.probability — 기본 0.1(10%만 추적). 운영에선 비용 때문에 전수 추적을 안 한다. “로컬에선 trace가 보이는데 운영에선 안 보임”의 흔한 원인.
  • Exemplar: 메트릭 그래프의 한 점에서 그 순간의 trace로 점프하는 기능. 메트릭↔추적 연결의 끝판왕.

면접/리뷰 단골 질문

  • Q. Observation 하나가 메트릭과 trace를 동시에 만드는 원리는? → ObservationRegistry에 등록된 ObservationHandler들(Meter용·Tracing용)이 같은 라이프사이클 이벤트를 각자 소비하기 때문. 계측 코드는 신호 종류를 모른다.
  • Q. @Observed를 붙였는데 아무 일도 안 일어난다. → ObservedAspect Bean 미등록, 또는 self-invocation(프록시 우회).
  • Q. 운영에서 일부 요청만 trace가 보인다. → 샘플링(sampling.probability 기본 0.1). 카디널리티/비용 때문에 의도된 동작.

정리

  • 관측성의 본질은 수집이 아니라 메트릭·추적·로그를 같은 요청(traceId)으로 잇는 것.
  • Observation API: 한 번 계측 → 등록된 ObservationHandler들이 메트릭(Timer)과 추적(Span)을 동시 생성. 계측 코드는 신호 종류를 모른다.
  • 분산 추적은 Micrometer Tracing(구 Sleuth) + OTel/Brave 브리지, traceparent 헤더로 전파. MDC로 로그에 trace id 자동 주입.
  • 함정 1: 스레드 경계(비동기/리액티브/가상스레드)에서 컨텍스트 유실 → 컨텍스트 전파로 해결.
  • 함정 2: 메트릭 태그 카디널리티 폭발 → low/high cardinality 구분, URI는 템플릿으로.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.