성능 최적화 25편 - 분산 트레이싱(Distributed Tracing)으로 여러 서비스에 걸친 병목 찾기
8편에서는 APM으로 하나의 서버 안에서 요청이 어느 구간에서 시간을 쓰는지 추적하는 방법을 다뤘습니다. 하지만 21~24편에서 다룬 것처럼 시스템이 여러 서비스(주문, 결제, 알림 등)로 나뉘어 있다면, 하나의 사용자 요청이 여러 서버를 거쳐가며 처리됩니다. 이번 글에서는 이렇게 여러 서비스에 걸친 요청의 흐름을 추적하는 분산 트레이싱을 정리합니다.
1. 단일 서버 APM만으로는 안 보이는 것
주문 완료 API 하나가 내부적으로 여러 서비스를 호출한다고 가정해보겠습니다.
클라이언트 -> 주문 서비스 -> 결제 서비스 -> 알림 서비스
각 서비스에 8편에서 다룬 APM을 개별적으로 붙였다고 해도, 다음과 같은 질문에는 답하기 어렵습니다.
- 전체 요청이 850ms 걸렸는데, 이 중 주문/결제/알림 서비스가 각각 얼마나 차지하는가
- 결제 서비스에서 발생한 에러가, 정확히 어느 주문 서비스의 요청에서 시작됐는가
각 서비스의 로그와 APM 데이터가 따로 놀기 때문에, 서비스 하나하나는 보이지만 요청 전체의 여정은 보이지 않습니다.
2. 분산 트레이싱의 핵심 개념 - Trace와 Span
분산 트레이싱은 하나의 요청에 고유한 식별자를 부여하고, 이 식별자가 서비스 경계를 넘나들 때도 계속 전달되도록 만듭니다.
- Trace ID : 하나의 요청 전체를 식별하는 고유 ID. 클라이언트 요청이 시작될 때 생성되어, 이후 호출되는 모든 서비스에 전파됩니다.
- Span : 그 요청 안에서 발생하는 개별 작업 단위. "주문 서비스에서 결제 서비스를 호출한 구간", "결제 서비스가 DB를 조회한 구간"처럼 세분화된 조각입니다.
Trace ID: abc-123
├─ Span: OrderController.completeOrder() [주문 서비스] 850ms
│ ├─ Span: OrderService.validateOrder() [주문 서비스] 15ms
│ ├─ Span: PaymentClient.requestPayment() [주문 서비스 -> 결제 서비스] 780ms
│ │ └─ Span: PaymentService.process() [결제 서비스] 770ms
│ │ └─ Span: PG사 연동 호출 [결제 서비스] 700ms
│ └─ Span: NotificationClient.send() [주문 서비스 -> 알림 서비스] 35ms
이렇게 하나의 Trace ID 아래 모든 Span이 계층 구조로 모이면, 전체 요청의 흐름과 각 구간의 소요 시간이 한눈에 드러납니다. 이번 예시에서는 결제 서비스가 PG사와 통신하는 구간(700ms)이 전체 지연의 대부분을 차지한다는 것이 명확해집니다.
3. Spring Boot에 적용하기 - Micrometer Tracing
implementation 'io.micrometer:micrometer-tracing-bridge-brave'
implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
management:
tracing:
sampling:
probability: 1.0 # 모든 요청을 추적 (운영에서는 트래픽에 맞게 낮춰서 사용)
zipkin:
tracing:
endpoint: http://zipkin-server:9411/api/v2/spans
별도의 코드 수정 없이, HTTP 요청과 주요 컴포넌트 호출에 자동으로 Trace ID와 Span이 부여되고 Zipkin 같은 수집 서버로 전송됩니다.
4. 서비스 간 Trace ID 전파하기
서비스 A가 서비스 B를 호출할 때, HTTP 헤더로 Trace 정보가 함께 전달되어야 이어진 흐름으로 인식됩니다.
서비스 A -> 서비스 B 호출 시 헤더
traceparent: 00-abc123def456-789xyz-01
Spring Boot에서 RestTemplate이나 WebClient를 사용하면, Micrometer Tracing이 이 헤더 전파를 자동으로 처리해줍니다. 다만 Kafka(11편)처럼 메시지 큐를 통한 비동기 흐름에서는, Trace 정보를 메시지 헤더에 직접 담아 전파하도록 별도 설정이 필요한 경우가 많습니다.
@KafkaListener(topics = "order-completed")
public void handleOrderCompleted(
@Header(KafkaHeaders.RECEIVED_MESSAGE_KEY) String key,
ConsumerRecord<String, OrderCompletedEvent> record) {
// 메시지 헤더에 포함된 Trace 정보로 컨슈머 처리도 같은 Trace로 연결
}
5. 커스텀 Span 추가하기
자동 계측만으로 부족한 구간은 직접 Span을 추가해서 세밀하게 추적할 수 있습니다.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final Tracer tracer;
private final PgClient pgClient;
public PaymentResult process(PaymentRequest request) {
Span span = tracer.nextSpan().name("pg-interface-call").start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
return pgClient.requestPayment(request);
} finally {
span.end();
}
}
}
6. Zipkin/Jaeger로 시각화하기
수집된 Trace 데이터는 Zipkin이나 Jaeger 같은 도구의 UI에서 타임라인 형태로 확인할 수 있습니다. 어느 서비스, 어느 구간이 전체 응답 시간에서 얼마의 비중을 차지하는지 막대그래프 형태로 바로 파악할 수 있어, 여러 서비스 로그를 일일이 대조하며 원인을 찾던 방식보다 훨씬 빠르게 병목을 특정할 수 있습니다.
7. 샘플링 비율 조정하기
모든 요청을 100% 추적하면 데이터 수집 자체의 오버헤드가 커지고, 저장 비용도 급격히 늘어납니다. 트래픽이 많은 운영 환경에서는 샘플링 비율을 조정해서, 일부 요청만 추적 대상으로 삼는 것이 일반적입니다.
management:
tracing:
sampling:
probability: 0.1 # 전체 요청의 10%만 추적
에러가 발생한 요청은 샘플링 비율과 무관하게 항상 추적되도록 설정해서, 정작 문제가 된 요청이 누락되지 않도록 하는 것도 중요한 실무 팁입니다.
8. 정리
- 서비스가 여러 개로 나뉜 구조에서는 각 서비스의 APM만으로 요청 전체의 흐름을 파악하기 어렵다
- 분산 트레이싱은 Trace ID로 하나의 요청을 식별하고, Span으로 세부 구간을 나눠 서비스 경계를 넘어 하나의 흐름으로 재구성한다
- Micrometer Tracing과 Zipkin/Jaeger 조합으로 HTTP 호출 대부분은 자동 계측되며, Kafka 같은 비동기 흐름은 별도의 전파 설정이 필요하다
- 운영 환경에서는 샘플링 비율을 조정하되, 에러가 발생한 요청은 항상 추적되도록 예외를 둔다
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 27 - CDN으로 정적 자원 전송 속도 개선하기 (0) | 2026.07.24 |
|---|---|
| 성능 최적화 STEP 26 - 가상 스레드(Virtual Threads) (0) | 2026.07.23 |
| 성능 최적화 STEP 24 - Rate Limiting (0) | 2026.07.21 |
| 성능 최적화 STEP 23 - Resilience4j (0) | 2026.07.21 |
| 성능 최적화 STEP 22 - 샤딩(Sharding) (1) | 2026.07.20 |