관측성(Observability) — 분산 시스템에서 무슨 일이 일어났나
모놀리스 시절엔 문제가 생기면 로그 파일 하나를 열어 보면 됐다. 한 요청이 처음부터 끝까지 같은 프로세스 안에서 처리됐으니까. 그런데 마이크로서비스로 쪼개고 나면 사정이 달라진다. 사용자의 주문 한 번이 게이트웨이 → 주문 → 결제 → 재고 → 알림, 다섯 개 서비스를 가로지른다. 이때 "어디서 느려졌지?", "어느 단계에서 터졌지?"를 로그 한 파일로는 도저히 알 수가 없다. 관측성은 바로 이 질문에 답할 수 있게, 시스템 안에서 벌어진 일을 추적 가능하게 만드는 기술이다. MSA 편에서 미뤄 둔 "어디서 터졌나"에 대한 답이 여기에 있다.
세 가지 눈 — 메트릭·로그·트레이스
관측성은 흔히 세 가지를 함께 본다. 각자 답하는 질문이 다르다. 메트릭은 얼마나에 답한다 — 요청 수·지연·에러율·CPU 같은 수치와 추세다. 로그는 무슨 일이에 답한다 — 개별 사건의 기록이라 원인을 파고들 때 본다. 트레이스는 요청이 어디를 거쳤나에 답한다 — 한 요청의 여정 전체를 서비스 경계를 넘어 추적하는, 분산 디버깅의 핵심이다.
중요한 건 이 셋이 서로를 가리켜야 빠르다는 점이다 — 메트릭에서 에러율 급증을 알아채고, 그 시점의 로그로 원인을 좁히고, 문제의 그 요청의 트레이스를 펼쳐 어느 서비스에서 무너졌는지를 본다. 흥미롭게도 Spring Boot 3은 이 셋을 **하나의 계측(Micrometer Observation API)**에서 뽑아낸다 — 한 번 관측(Observation)을 심으면 거기서 메트릭과 트레이스가 함께 나온다. 이제 각각을 보자.
운영 정보의 창구 — Actuator
가장 먼저, 살아있는 애플리케이션에서 운영에 필요한 정보를 꺼내는 창구가 Actuator다. /actuator/health로 살아있는지를, /actuator/metrics·/actuator/prometheus로 메트릭을 노출한다. health에는 알아 둘 구분이 있다 — 쿠버네티스는 "죽었나"(liveness, 실패하면 재시작)와 "트래픽 받을 준비가 됐나"(readiness, 실패하면 재시작 없이 로드밸런서에서 빼기)를 다르게 취급한다. 부팅 중·의존성 미준비면 readiness만 실패시켜 멀쩡한 프로세스를 죽이지 않으면서 트래픽만 잠시 막는다.
메트릭의 공통어 — Micrometer, 그리고 카디널리티 폭발
메트릭을 코드에서 직접 Prometheus·Datadog API로 기록하면 백엔드를 갈아탈 때 코드를 전부 고쳐야 한다. 그래서 그 사이에 Micrometer라는 벤더 중립 파사드(메트릭계의 SLF4J, jdp의 퍼사드 패턴)를 둔다.
meterRegistry.counter("orders.placed", "type", "premium").increment();
여기서 반드시 조심할 게 카디널리티 폭발이다. 메트릭에 붙이는 *태그(라벨)*의 고유 조합 수만큼 시계열이 생기는데, 만약 태그에 사용자 id·주문 id 같은 고유값을 넣으면 시계열이 폭증한다 — 한 메트릭이라도 5개 메서드 × 10개 상태코드 × 100개 엔드포인트 × 50개 인스턴스 = 25만 개 시계열이 되고, 이게 더 늘면 Prometheus TSDB가 크래시하고 Grafana가 느려진다. (출처: High cardinality in Prometheus.) 그래서 태그에는 값의 종류가 적은 차원(상태·메서드·엔드포인트 정도)만 넣고, 고유 식별자는 로그나 트레이스 쪽에 둔다.
메트릭 종류도 알아야 한다. 카운터(증가만), 게이지(현재값), 그리고 지연 분포를 보는 히스토그램이다. 평균 지연은 거짓말을 하기 쉬워(p99가 나쁜데 평균은 멀쩡할 수 있다), 보통 p95·p99 백분위를 본다. SLO가 "99%가 300ms 안에"라면 히스토그램 버킷을 200·300·400ms 근처에 두고 histogram_quantile(0.99, …)로 p99를 동적으로 계산해 SLO 준수를 평가한다. (출처: Histogram buckets in Prometheus.)
흩어진 로그를 하나의 여정으로 — 분산 추적
이제 가장 분산다운 부분이다. 다섯 서비스를 가로지르는 요청을 어떻게 하나로 묶을까? 요청이 처음 들어올 때 **traceId**를 하나 발급하고, 서비스를 호출할 때마다 그 id를 헤더로 함께 넘긴다(각 구간은 spanId). Spring Boot 3은 기본 전파 형식으로 W3C traceparent 헤더를 쓰고, 부가 메타데이터를 baggage(서비스 경계를 넘는 키-값)로 함께 나른다. 그러면 다섯 서비스에 흩어져 찍힌 로그를 traceId로 모아, "이 요청이 거친 전체 여정"을 한 화면에서 펼쳐 볼 수 있다(Zipkin·Tempo). 계측은 Micrometer Tracing이 하고, micrometer-tracing-bridge-otel 의존을 더하면 그게 OpenTelemetry로 연결된다 — 업계가 수렴하는 벤더 중립 표준이다. (출처: Observability with Spring Boot 3.)
로그와 트레이스를 잇는 비결은 MDC(Mapped Diagnostic Context)다. MDC에 traceId를 넣어 두면 그 요청의 모든 로그 줄에 traceId가 자동으로 따라붙는다. 그런데 이 MDC가 내부적으로 ThreadLocal(jcode·시큐리티의 그것)이라 — 작업이 @Async나 스레드 풀로 넘어가 스레드가 바뀌면 전파가 끊겨 traceId가 사라진다. 컨텍스트 전파(context-propagation) 설정으로 이어 줘야 한다. 한 가지 더 — 트래픽이 많으면 모든 요청을 추적하면 비싸므로, management.tracing.sampling.probability로 표본만 추적한다(예: 10%).
실무에서 챙기는 것들
로그는 사람이 읽는 텍스트보다 JSON 같은 구조적 로깅이 검색·집계에 유리하다(Spring Boot 3.4+ 기본 지원). 모든 로그·메트릭에 traceId와 서비스명을 박아 세 기둥이 서로를 가리키게 한다. 알람은 CPU 같은 내부 수치가 아니라 사용자가 실제로 느끼는 것에 건다 — 흔히 RED(Rate 요청률·Errors 에러율·Duration 지연)나 골든 시그널에 SLO를 걸고 p99로 본다. 그리고 표준은 OpenTelemetry로 수렴 중이라, 새로 짤 땐 그쪽에 맞춰 두면 갈아탈 일이 적다.
정리하면, 관측성은 분산 시스템에서 "무슨 일이 일어났나"를 추적 가능하게 만드는 일이다. 메트릭(추세)·로그(사건)·트레이스(여정)라는 세 눈을 서로 가리키게 하고(Spring Boot 3은 Micrometer Observation 하나에서 메트릭·트레이스를 함께 뽑는다), Actuator로 상태를(liveness/readiness), Micrometer로 메트릭을 벤더 중립으로 모으되 카디널리티 폭발을 피하고 히스토그램 p99로 SLO를 보며, traceId를 W3C traceparent로 경계 너머 전파해(MDC=ThreadLocal, 비동기 유실·샘플링 주의) 흩어진 기록을 하나의 여정으로 꿴다 — 그리고 OpenTelemetry·구조적 로깅·증상 기반 알람으로 운영에 쓸 만하게 만든다.