관측 가능성(Observability) 시리즈의 마지막 퍼즐이자, 마이크로서비스 아키텍처(MSA) 환경에서 병목 지점을 찾아내는 최고의 해결사인 “분산 추적(Distributed Tracing)“에 대한 포스팅입니다.
Spring Boot 분산 추적 완벽 가이드: Jaeger와 OpenTelemetry로 MSA 성능 병목 해결하기
1. 서론
우리는 지금까지 ELK Stack을 통해 “과거에 발생한 에러 로그”를 통합 관리하는 법을 배웠고, Prometheus와 Grafana를 통해 “현재 서버의 리소스 상태”를 모니터링하는 체계를 갖추었습니다. 이 두 가지 시스템만으로도 모놀리식(Monolithic) 아키텍처나 서버 개수가 적은 환경에서는 충분한 관제 능력을 발휘할 수 있습니다. 하지만 현대의 백엔드 시스템이 거대한 단일 서버에서 수십, 수백 개의 작은 서비스가 서로 통신하는 마이크로서비스 아키텍처(MSA)로 진화하면서 새로운 형태의 악몽이 시작되었습니다.
사용자가 “주문이 너무 느려요!”라고 불만을 토로했을 때를 상상해 보십시오. 개발자는 ELK를 뒤져보지만 에러 로그는 없습니다. Grafana를 봐도 CPU나 메모리는 평온합니다. 문제는 서비스 A가 서비스 B를 호출하고, B가 다시 C를 호출하는 복잡한 연쇄 과정 어딘가에서 발생한 지연(Latency) 때문입니다. 로그는 개별 서비스 단위로 남기 때문에, 하나의 요청이 여러 서비스를 넘나드는 전체 경로(Flow)를 파악하기가 매우 어렵습니다. 마치 범인이 10개의 방을 통과하며 도망갔는데, CCTV는 각 방의 내부만 비출 뿐 방과 방 사이의 복도는 비추지 못하는 상황과 같습니다.
이 난제를 해결하기 위해 등장한 기술이 바로 분산 추적(Distributed Tracing)입니다. 하나의 요청에 고유한 식별자(ID)를 부여하여, 그 요청이 시스템에 들어와서 나갈 때까지 거쳐 간 모든 경로와 시간을 추적하는 기술입니다. 그리고 이 분야에서 클라우드 네이티브 컴퓨팅 재단(CNCF)의 졸업 프로젝트로서 가장 널리 사랑받는 오픈소스가 바로 Jaeger(예거)입니다. 오늘은 관측 가능성(Observability)의 마지막 핵심 기둥인 분산 추적 시스템을 Spring Boot와 Jaeger를 연동하여 구축하고, 보이지 않던 성능 병목 구간을 시각화하는 방법에 대해 깊이 있게 다뤄보겠습니다.
2. 본론
1: 분산 추적의 핵심 원리와 OpenTelemetry
분산 추적 시스템을 구축하기 전에, 그 근간이 되는 핵심 개념과 표준 기술에 대한 이해가 선행되어야 합니다.
1. Trace와 Span
분산 추적 데이터 모델의 기본 단위는 Span과 Trace입니다.
- Span(스팬): 작업의 기본 단위입니다. 예를 들어 ‘서비스 A가 DB를 조회함’, ‘서비스 A가 서비스 B를 호출함’과 같은 개별적인 작업 하나하나가 Span이 됩니다. 각 Span은 작업의 이름, 시작 시간, 종료 시간, 그리고 어떤 작업의 하위 작업인지에 대한 정보를 가집니다.
- Trace(트레이스): 하나의 요청이 처리되는 전체 과정을 나타내는 Span들의 집합입니다. 사용자가 ‘주문 버튼’을 누르는 순간 생성되어, 주문 서비스, 결제 서비스, 재고 서비스를 거쳐 응답이 반환될 때까지의 전체 여정을 하나의 Trace로 봅니다. 모든 Span은 하나의 Trace ID를 공유하며 이를 통해 서로 연결됩니다.
2. 컨텍스트 전파 (Context Propagation)
그렇다면 서로 다른 서버에 있는 서비스들은 어떻게 동일한 Trace ID를 공유할까요? 서비스 A가 서비스 B를 HTTP로 호출할 때, 헤더(Header)에 Trace ID를 몰래 실어서 보내기 때문입니다. 이를 컨텍스트 전파라고 합니다. 과거에는 X-B3-TraceId 같은 독자적인 헤더를 썼지만, 최근에는 W3C 표준인 traceparent 헤더를 사용합니다. Spring Boot는 내부적으로 이 과정을 자동으로 처리해 줍니다.
3. Spring Cloud Sleuth의 종료와 Micrometer Tracing의 등장 (중요)
많은 개발자가 Spring Boot 2.x 시절에 사용하던 Spring Cloud Sleuth 라이브러리에 익숙할 것입니다. 하지만 Spring Boot 3.x로 넘어오면서 Sleuth 프로젝트는 공식적으로 종료되었고, 그 기능이 Micrometer Tracing으로 이관되었습니다. 또한 추적 데이터의 표준이 OpenTelemetry(OTel)로 통합되면서, 이제 우리는 Micrometer Tracing과 OpenTelemetry 브릿지를 사용하여 Jaeger와 통신해야 합니다. 구글링을 통해 나오는 예전 자료를 그대로 따라 하면 동작하지 않으므로 주의가 필요합니다.
2: Jaeger 아키텍처와 Docker Compose 구축
Jaeger는 우버(Uber)에서 개발하여 오픈소스로 공개한 분산 추적 시스템입니다. 복잡한 마이크로서비스 간의 의존성 관계를 시각화해주고, 각 구간별 소요 시간을 워터폴(Waterfall) 차트로 보여주어 성능 튜닝의 결정적인 단서를 제공합니다.
Jaeger의 아키텍처는 클라이언트(Agent), 수집기(Collector), 저장소(Storage), UI로 나뉘지만, 개발 및 테스트 환경에서는 이 모든 것을 하나의 이미지로 합친 all-in-one 이미지를 사용하는 것이 효율적입니다.
프로젝트 루트에 docker-compose.yml을 작성하여 Jaeger를 실행해 보겠습니다.
YAML
version: '3.8'
services:
jaeger:
image: jaegertracing/all-in-one:latest
container_name: jaeger
environment:
- COLLECTOR_OTLP_ENABLED=true # OTLP 프로토콜 활성화
ports:
- "16686:16686" # Jaeger UI 포트
- "4317:4317" # OpenTelemetry gRPC 수신 포트
- "4318:4318" # OpenTelemetry HTTP 수신 포트
위 설정에서 중요한 점은 4317, 4318 포트를 열어두는 것입니다. 과거에는 Jaeger 전용 프로토콜(Thrift)을 사용했지만, 이제는 표준인 OTLP(OpenTelemetry Protocol)를 사용하여 데이터를 전송하는 것이 권장됩니다. docker-compose up -d로 실행 후 localhost:16686에 접속하면 Jaeger의 UI를 볼 수 있습니다.
3. Spring Boot 3.x 프로젝트 설정 (Micrometer + OTel)
이제 스프링 부트 애플리케이션이 요청을 받을 때마다 Trace ID를 생성하고, 작업이 끝나면 그 정보를 Jaeger 서버로 전송하도록 설정해야 합니다.
1. 의존성 추가 (build.gradle)
Spring Boot 3 이상에서는 다음과 같은 의존성 조합이 필요합니다.
Groovy
dependencies {
// 1. Actuator: 메트릭 및 추적 기능의 기반
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// 2. Micrometer Tracing: 추적 파사드(Facade)
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
// 3. OpenTelemetry Exporter: 데이터를 Jaeger(OTLP)로 전송
implementation 'io.opentelemetry:opentelemetry-exporter-otlp'
}
과거 Sleuth 시절에는 spring-cloud-starter-sleuth 하나만 넣으면 되었지만, 이제는 아키텍처가 유연해진 대신 설정해야 할 라이브러리가 명확하게 분리되었습니다. micrometer-tracing-bridge-otel은 스프링의 추적 기능을 OpenTelemetry 구현체와 연결하는 다리 역할을 하고, opentelemetry-exporter-otlp는 수집된 데이터를 OTLP 프로토콜을 통해 외부(Jaeger)로 내보내는 역할을 합니다.
2. application.yml 설정
설정 파일에서 가장 중요한 부분은 샘플링 비율(Sampling Probability)과 전송 경로입니다.
YAML
spring:
application:
name: order-service # Jaeger UI에 표시될 서비스 이름
management:
tracing:
sampling:
probability: 1.0 # 1.0은 100% 수집 (개발용), 운영에선 0.1(10%) 권장
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces # Jaeger OTLP HTTP 포트
endpoints:
web:
exposure:
include: prometheus, health, info
운영 환경에서는 모든 요청을 추적하면 성능 오버헤드가 발생하고 저장소 용량이 급증할 수 있으므로, 보통 0.1(10%) 정도로 샘플링 비율을 낮춰서 설정합니다. 하지만 개발 환경이나 트러블슈팅 중에는 1.0으로 설정하여 모든 요청을 분석하는 것이 좋습니다.
4: 분산 추적을 통한 성능 병목 분석 실전
설정이 완료되었다면, 실제로 API를 호출해 보고 Jaeger UI에서 어떻게 보이는지 확인해 보겠습니다.
예를 들어, 사용자가 ‘주문 생성’ API를 호출했을 때 내부적으로 ‘재고 확인’, ‘결제 승인’, ‘배송 요청’ 로직이 순차적으로 실행된다고 가정해 봅시다. Jaeger UI의 ‘Search’ 탭에서 해당 서비스를 선택하고 ‘Find Traces’를 누르면, 발생한 트랜잭션 목록이 나타납니다. 그중 하나를 클릭하면 간트 차트(Gantt Chart) 형태의 상세 뷰가 펼쳐집니다.
이 뷰가 제공하는 인사이트는 실로 놀랍습니다.
- 전체 소요 시간 파악: 주문 처리에 총 2초가 걸렸다는 것을 바로 알 수 있습니다.
- 구간별 소요 시간 파악: ‘재고 확인’은 50ms, ‘배송 요청’은 50ms가 걸렸는데, 유독 ‘결제 승인’ 구간인 Span이 1.8초를 차지하며 길게 늘어쳐져 있는 것을 볼 수 있습니다.
- 병목 원인 추론: 로그만 봤을 때는 전체가 느리다는 것만 알았지만, 이제는 정확히 ‘결제 승인’ 외부 API 호출 구간이 범인임을 지목할 수 있습니다. 개발자는 즉시 결제 모듈의 코드를 열어 타임아웃 설정을 확인하거나 PG사와의 네트워크 상태를 점검하는 등 구체적인 조치를 취할 수 있게 됩니다.
또한, 트랜잭션 도중 예외가 발생했다면 해당 Span에 빨간색 느낌표 아이콘이 표시되며, 클릭 시 상세한 에러 메시지와 스택 트레이스까지 확인할 수 있습니다. 로그 파일에서 Trace ID를 검색해서 찾아다니던 수고가 완전히 사라지는 것입니다.
3. 결론
오늘 우리는 Spring Boot와 Jaeger, 그리고 차세대 표준인 OpenTelemetry를 결합하여 분산 추적 시스템을 구축하는 방법을 심도 있게 알아보았습니다.
분산 추적은 단순히 “멋진 그래프를 보는 도구”가 아닙니다. 마이크로서비스라는 복잡한 미로 속에서 길을 잃지 않게 해주는 네비게이션이자, 시스템의 성능 저하가 어디서 기인하는지를 명확하게 짚어주는 MRI 검사와 같습니다. 로그(Log), 메트릭(Metric), 그리고 오늘 배운 추적(Tracing)까지, 이 세 가지 관측 가능성(Observability)의 기둥을 모두 세운다면, 여러분은 그 어떤 복잡한 장애 상황에서도 당황하지 않고 원인을 찾아내는 진정한 백엔드 엔지니어링 전문가로 거듭날 것입니다.
이제 여러분의 시스템은 더 이상 ‘블랙박스’가 아닙니다. 모든 요청의 흐름이 투명하게 보이는 유리 상자가 되었습니다. 이 강력한 도구를 활용하여 시스템의 숨은 병목을 찾아내고, 사용자에게 더 빠르고 안정적인 서비스를 제공하는 쾌감을 느껴보시기 바랍니다.
관측 가능성 시리즈를 완주하신 것을 축하드립니다. 다음 단계로는 이렇게 잘 구축된 시스템을 더욱 견고하게 만들기 위한 “Spring Boot 테스트 전략: JUnit5와 Mockito를 활용한 단위 테스트부터 TestContainers를 이용한 통합 테스트까지“에 대한 포스팅을 통해 코드의 품질 자체를 높이는 방법을 다뤄보는 것은 어떨까요?