Spring BootMonitoringObservabilityOpenTelemetry

Sentry만으로 부족했던 이유 — Spring Boot에 OpenTelemetry + Jaeger 분산 추적을 도입한 과정

Sentry의 에러 추적과 Actuator의 메트릭 모니터링만으로는 서비스 간 호출 흐름을 파악하기 어려웠습니다. OpenTelemetry와 Jaeger를 도입해 분산 추적 체계를 구축하고, 각 도구의 역할을 분리한 경험을 공유합니다.

Srue2026년 5월 4일
Sentry만으로 부족했던 이유 — Spring Boot에 OpenTelemetry + Jaeger 분산 추적을 도입한 과정

어느 날 오후, 결제 완료 후 포인트가 적립되지 않는다는 제보가 들어왔습니다.

Sentry를 도입한 이후로 에러 추적은 훨씬 수월해졌기에, 습관처럼 Sentry 대시보드를 열었습니다. 그런데 에러가 없었습니다. Actuator + Grafana 대시보드를 확인해봐도 메모리, CPU, 커넥션 풀 전부 정상 범위였습니다. 서버는 건강한데 기능이 안 되는, 가장 곤혹스러운 유형의 장애였습니다.

결국 원인은 결제 서비스 → 포인트 서비스로 이어지는 내부 API 호출에서 타임아웃이 발생했는데, 포인트 서비스 쪽에서 이를 정상 응답(빈 배열)으로 처리해버리고 있었던 것이었습니다. 에러가 아니니 Sentry에 안 잡히고, 인프라 지표는 멀쩡하니 Actuator에도 안 걸렸습니다.

이 경험이 분산 추적(Distributed Tracing) 시스템을 진지하게 고민하게 된 계기였습니다.

기존 모니터링 도구들의 한계

돌이켜 보면, 기존에 구축해둔 모니터링 체계는 각각 명확한 강점이 있었지만, 커버하지 못하는 영역도 뚜렷했습니다.

도구잘하는 것못하는 것
Sentry예외 발생 즉시 콜스택 + 컨텍스트 포착예외가 아닌 "느린 흐름", 서비스 간 호출 관계 추적
Actuator + Prometheus + GrafanaJVM/인프라 메트릭 시계열 모니터링개별 요청의 구간별 소요 시간, 호출 경로 시각화
MDC traceId단일 서비스 내 요청 단위 로그 연결서비스 경계를 넘는 trace 전파

한 마디로, 에러가 나면 Sentry가 잡아주고, 서버가 아프면 Grafana가 알려주는데, "요청이 어디를 거쳐 어디에서 느려졌는지"를 보여주는 도구가 없었습니다. 이 빈자리를 채워줄 수 있는 게 분산 추적이었습니다.

왜 OpenTelemetry였는가

분산 추적 도구를 조사하면서 Zipkin, Jaeger, Datadog APM 등 여러 선택지를 비교했습니다. 그런데 계측(Instrumentation) 라이브러리 쪽을 먼저 정해야 한다는 걸 깨달았습니다. 계측 라이브러리가 데이터를 수집하는 역할이고, Jaeger나 Zipkin은 그 데이터를 저장하고 보여주는 백엔드(Backend)이기 때문입니다.

OpenTelemetry(줄여서 OTel)는 CNCF가 관리하는 관측 가능성(Observability) 표준입니다. 가장 마음에 들었던 점은 **벤더 중립(Vendor-neutral)**이라는 것이었습니다. 오늘은 Jaeger로 보내다가, 나중에 Datadog이나 Grafana Tempo로 바꾸고 싶으면 백엔드 설정만 변경하면 됩니다. 계측 코드를 다시 짤 필요가 없습니다.

처음에는 Micrometer Tracing(구 Spring Cloud Sleuth)도 고려했습니다. Spring 생태계와의 통합이 잘 되어 있어서 매력적이었지만, 결국 OTel을 선택한 이유는 두 가지였습니다.

  1. 팀 내에서 Spring Boot 외에 Python 기반 데이터 파이프라인도 운영하고 있었는데, OTel은 언어에 구애받지 않는 SDK를 제공했습니다.
  2. Traces뿐 아니라 Metrics, Logs까지 하나의 체계로 통합할 수 있는 로드맵이 있었습니다.

Spring Boot에 OpenTelemetry 연동하기

의존성 추가

build.gradle
dependencies {
    // OpenTelemetry Spring Boot Starter
    implementation 'io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.12.0'
 
    // OTLP Exporter (Jaeger로 전송)
    implementation 'io.opentelemetry:opentelemetry-exporter-otlp:1.46.0'
}

OTel의 Spring Boot Starter는 자동 계측(Auto-instrumentation)을 지원합니다. HTTP 요청, JDBC 쿼리, RestClient/WebClient 호출 등에 자동으로 Span(스팬)을 생성해줍니다. 처음에 이게 정말 되나 반신반의했는데, 의존성만 추가하고 설정을 넣자마자 트레이스가 쏟아져 나와서 놀랐습니다.

설정

application.yml
otel:
  service:
    name: payment-service
  traces:
    exporter: otlp
  exporter:
    otlp:
      endpoint: http://jaeger-collector:4317
      protocol: grpc
  resource:
    attributes:
      deployment.environment: production

service.name이 Jaeger UI에서 서비스를 구분하는 키가 되므로, 각 서비스마다 명확한 이름을 붙여야 합니다. 처음에 "my-app"으로 대충 넣었다가 서비스가 늘어나면서 구분이 안 되는 실수를 했습니다.

Jaeger 실행

Jaeger는 올인원(All-in-One) Docker 이미지로 빠르게 시작할 수 있었습니다.

docker-compose.yml
services:
  jaeger:
    image: jaegertracing/jaeger:2.4
    ports:
      - "16686:16686"   # Jaeger UI
      - "4317:4317"     # OTLP gRPC
      - "4318:4318"     # OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

http://localhost:16686을 브라우저에서 열면 Jaeger UI가 뜹니다. 설정 후 처음으로 트레이스가 화면에 나타났을 때, 요청 하나가 어떤 컴포넌트를 거쳐 얼마나 걸렸는지 폭포수(Waterfall) 형태로 보이는 게 인상적이었습니다.

수동 Span으로 비즈니스 로직 계측하기

자동 계측만으로도 HTTP 요청 → DB 쿼리 흐름은 잘 잡혔습니다. 하지만 "결제 검증에 얼마나 걸렸는지", "외부 PG사 API 호출이 느린 건지" 같은 비즈니스 로직 구간별 소요 시간은 수동으로 Span을 추가해야 했습니다.

PaymentService.java
@Service
@RequiredArgsConstructor
public class PaymentService {
 
    private final Tracer tracer;
    private final PgClient pgClient;
    private final PointClient pointClient;
 
    public PaymentResult processPayment(PaymentRequest request) {
        Span span = tracer.spanBuilder("payment.process")
            .setAttribute("payment.method", request.getMethod())
            .setAttribute("payment.amount", request.getAmount())
            .startSpan();
 
        try (Scope scope = span.makeCurrent()) {
            // 1단계: PG사 결제 승인
            PgResponse pgResponse = pgClient.approve(request);
            span.addEvent("pg_approval_completed");
 
            // 2단계: 포인트 적립 요청
            pointClient.accumulate(request.getUserId(), calculatePoints(pgResponse));
            span.addEvent("point_accumulation_requested");
 
            return PaymentResult.success(pgResponse);
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.recordException(e);
            throw e;
        } finally {
            span.end();
        }
    }
}

addEvent를 사용하면 Span 안에서 특정 시점을 기록할 수 있습니다. 이전 장애 상황에서 결제 승인과 포인트 적립 사이에서 문제가 생겼었는데, 이제는 트레이스만 보면 어느 구간에서 시간이 걸렸는지 한눈에 파악됩니다.

서비스 간 Context 전파

분산 추적의 핵심은 서비스 경계를 넘어 Trace Context(추적 컨텍스트)가 전파되는 것입니다. OTel의 자동 계측은 RestClient나 WebClient를 통한 HTTP 호출에 자동으로 traceparent 헤더를 심어줍니다.

자동으로 추가되는 HTTP 헤더
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

즉, 결제 서비스에서 시작된 요청이 포인트 서비스를 호출하면, 두 서비스의 Span이 같은 Trace ID로 연결됩니다. Jaeger UI에서 하나의 트레이스를 클릭하면 두 서비스를 가로지르는 전체 호출 흐름이 보입니다. 이전에 MDC에 traceId를 심던 방식은 단일 서비스 내에서만 유효했는데, 이제는 서비스 경계를 넘어 자동으로 이어집니다.

기존 도구와의 역할 분리

OTel + Jaeger를 도입한 뒤 가장 중요했던 작업은, 기존 Sentry와 Actuator와의 역할을 명확히 나누는 것이었습니다. 도구가 많아지면 "이건 어디서 봐야 하지?"라는 혼란이 생기기 때문입니다.

관심사담당 도구언제 보는가
에러/예외 추적Sentry에러 알림이 왔을 때, 콜스택과 유저 컨텍스트 확인
인프라 메트릭Actuator + Prometheus + Grafana서버 상태 이상 징후 감지, 용량 계획
요청 흐름/지연 분석Jaeger (OTel)느린 API 원인 추적, 서비스 간 의존성 파악
애플리케이션 로그구조화된 로그 (MDC)특정 요청의 상세 동작 확인

팀 내에서 이 역할 분리표를 위키에 공유하고, 장애 대응 매뉴얼에도 반영했습니다. "에러가 터졌으면 Sentry부터, 느리다는 제보가 오면 Jaeger부터, 서버가 불안정하면 Grafana부터"라는 단순한 규칙이 생기니 초기 대응 속도가 빨라졌습니다.

Sentry와 Trace ID 연동

기존에 Sentry에 MDC의 traceId를 태그로 심어두고 있었는데, OTel 도입 후에는 OTel의 Trace ID를 Sentry 태그에 추가하도록 변경했습니다. 이렇게 하면 Sentry에서 에러를 확인한 뒤, 해당 Trace ID를 복사해서 Jaeger에 붙여넣으면 에러가 발생한 요청의 전체 흐름을 바로 볼 수 있습니다.

SentryOtelEventProcessor.java
@Component
public class SentryOtelEventProcessor implements EventProcessor {
 
    @Override
    public SentryEvent process(SentryEvent event, Hint hint) {
        // OTel Trace ID를 Sentry 태그로 추가
        io.opentelemetry.api.trace.Span currentSpan =
            io.opentelemetry.api.trace.Span.current();
 
        if (currentSpan.getSpanContext().isValid()) {
            event.setTag("otel_trace_id",
                currentSpan.getSpanContext().getTraceId());
        }
 
        return event;
    }
}

샘플링 전략: 모든 요청을 추적할 필요는 없었습니다

초기에 traces-sample-rate1.0(100%)으로 설정하고 돌렸더니, 트레이스 데이터가 하루에 수 GB씩 쌓이기 시작했습니다. Jaeger의 저장소 용량 문제도 있었지만, 무엇보다 트레이스가 너무 많으면 정작 필요한 트레이스를 찾기 어려워졌습니다.

application.yml
otel:
  traces:
    sampler:
      type: parentbased_traceidratio
      arg: 0.1   # 10%만 샘플링

평상시에는 10%만 샘플링하되, 에러가 발생한 요청은 반드시 기록하도록 커스텀 샘플러를 구성했습니다.

CustomSampler.java
public class ErrorAlwaysSampler implements Sampler {
 
    private final Sampler baseSampler;
 
    public ErrorAlwaysSampler(double ratio) {
        this.baseSampler = Sampler.traceIdRatioBased(ratio);
    }
 
    @Override
    public SamplingResult shouldSample(
            Context parentContext, String traceId,
            String name, SpanKind spanKind,
            Attributes attributes, List<LinkData> parentLinks) {
 
        // 에러 속성이 있으면 무조건 기록
        if (attributes.get(AttributeKey.booleanKey("error")) != null) {
            return SamplingResult.recordAndSample();
        }
        return baseSampler.shouldSample(
            parentContext, traceId, name, spanKind, attributes, parentLinks);
    }
 
    @Override
    public String getDescription() {
        return "ErrorAlwaysSampler";
    }
}

Actuator 모니터링을 구축할 때 Prometheus의 scrape_interval을 5초로 잡았다가 저장소가 빠르게 찬 경험이 있었는데, 분산 추적에서도 비슷한 교훈을 다시 얻었습니다. 관측 데이터는 "전부 수집"이 아니라 "의미 있는 만큼 수집"이 정답이었습니다.

도입 전후 비교

항목도입 전도입 후
서비스 간 호출 흐름 파악각 서비스 로그를 시간대로 맞춰 수동 추적Jaeger UI에서 한 화면으로 확인
느린 API 구간 특정"어딘가 느린 것 같다" 수준의 추측Span별 소요 시간으로 정확한 병목 식별
에러 발생 시 흐름 추적Sentry 콜스택 + 로그 grep 조합Sentry에서 Trace ID 복사 → Jaeger에서 전체 흐름 확인
서비스 의존성 파악코드와 문서를 읽어야 파악 가능Jaeger의 서비스 의존성 그래프(DAG)로 시각화
장애 원인 파악 시간 (서비스 간 이슈)1시간 이상10~15분

가장 기억에 남는 순간은, 도입 직후 "주문 조회가 간헐적으로 3초 이상 걸린다"는 제보가 들어왔을 때였습니다. Jaeger에서 느린 트레이스만 필터링해보니, 주문 서비스 → 상품 서비스 호출 구간에서 커넥션 대기 시간이 2초 이상 걸리는 패턴이 보였습니다. 상품 서비스의 커넥션 풀 크기가 부족했던 것이 원인이었고, 설정 한 줄을 바꿔서 해결됐습니다. 이전이었다면 반나절은 걸렸을 문제였습니다.

마무리

처음에는 Sentry와 Actuator만 있으면 운영 모니터링은 충분하다고 생각했습니다. 실제로 단일 서비스 환경에서는 그게 맞았습니다.

하지만 서비스 간 호출이 늘어나면서 "에러는 없는데 안 되는" 상황, "느린데 어디가 느린지 모르는" 상황이 반복됐고, 결국 요청의 여정(journey) 자체를 추적하는 도구가 필요했습니다. OpenTelemetry + Jaeger 조합은 그 빈자리를 정확히 채워줬습니다.

도입하면서 가장 중요하게 느낀 건 도구의 역할을 명확히 나누는 것이었습니다. Sentry는 "무엇이 터졌는가", Actuator + Grafana는 "서버가 지금 건강한가", Jaeger는 "요청이 어디를 거쳐 어디서 막혔는가"를 각각 담당합니다. 하나의 도구가 모든 걸 해결해주지는 않았고, 각자의 강점을 살려 조합하는 것이 결국 관측 가능성(Observability)의 본질이라는 걸 이번 경험으로 배웠습니다.