로그 파일을 grep하면서 장애 원인을 찾던 시절이 있었다. 마이크로서비스가 12개인데, 요청 하나가 어디서 느려지는지 찾으려면 12개 서버의 로그를 다 뒤져야 했다. 그때 선배가 말했다. '야, 분산 추적이라는 게 있어.' OpenTelemetry를 도입한 뒤, 장애 원인 파악 시간이 2시간에서 5분으로 줄었다.

관측성(Observability)이란?

관측성은 시스템의 외부 출력을 통해 내부 상태를 이해할 수 있는 능력을 말합니다. 기존 모니터링이 "무엇이 문제인가"에 집중했다면, 관측성은 "왜 문제가 발생했는가"에 답할 수 있어야 합니다.

기존 모니터링 vs 관측성

관측성의 3대 신호(Three Pillars): Traces(분산 추적), Metrics(수치 지표), Logs(로그 데이터). OpenTelemetry는 이 세 가지를 하나의 프레임워크로 통합합니다.

OpenTelemetry 핵심 개념

OpenTelemetry(OTel)는 CNCF에서 관리하는 관측성 프레임워크로, 벤더에 종속되지 않는 텔레메트리 데이터 수집 표준을 제공합니다.

아키텍처 구성 요소

┌─────────────────────────────────────────────────────┐
│                    Application                       │
│  ┌─────────┐  ┌──────────┐  ┌─────────────────┐    │
│  │  OTel   │  │  OTel    │  │   OTel Log      │    │
│  │  Tracer │  │  Meter   │  │   Provider      │    │
│  └────┬────┘  └────┬─────┘  └───────┬─────────┘    │
│       └─────────────┼───────────────┘               │
│                     ▼                                │
│           OTel SDK (Exporter)                        │
└─────────────────────┬───────────────────────────────┘
                      ▼
            OTel Collector (선택)
            ┌─────────────────┐
            │  Receivers      │
            │  Processors     │
            │  Exporters      │
            └───────┬─────────┘
        ┌───────────┼───────────┐
        ▼           ▼           ▼
    Jaeger/Tempo  Prometheus   Loki
    (Traces)      (Metrics)    (Logs)

주요 용어 정리

Spring Boot에 OpenTelemetry 통합하기

의존성 추가

Spring Boot 3.x에서는 Micrometer Tracing을 통해 OpenTelemetry를 쉽게 통합할 수 있습니다.

// build.gradle.kts
dependencies {
    // Spring Boot Actuator
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // OpenTelemetry - 분산 추적
    implementation("io.micrometer:micrometer-tracing-bridge-otel")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")

    // OpenTelemetry - 자동 계측
    implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter:2.12.0")

    // Prometheus 메트릭 내보내기
    implementation("io.micrometer:micrometer-registry-prometheus")
}

application.yml 설정

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0  # 개발 환경: 100% 샘플링
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
    metrics:
      endpoint: http://localhost:4318/v1/metrics
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics

otel:
  resource:
    attributes:
      service.name: order-service
      service.version: 1.0.0
      deployment.environment: development
프로덕션 환경 주의: 샘플링 비율을 1.0(100%)으로 설정하면 모든 요청이 추적되어 성능에 영향을 줄 수 있습니다. 프로덕션에서는 0.1~0.3(10~30%) 정도가 적절합니다.

자동 계측 vs 수동 계측

자동 계측 (Zero-Code Instrumentation)

OpenTelemetry Java Agent를 사용하면 코드 변경 없이 대부분의 라이브러리에 대해 자동으로 계측이 적용됩니다.

# Java Agent로 실행
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://collector:4317 \
  -Dotel.traces.sampler=parentbased_traceidratio \
  -Dotel.traces.sampler.arg=0.3 \
  -jar order-service.jar

자동 계측이 지원하는 주요 라이브러리:

수동 계측 (Manual Instrumentation)

비즈니스 로직에 대한 세밀한 추적이 필요한 경우 수동 계측을 사용합니다.

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.context.Scope;

@Service
public class OrderService {

    private final Tracer tracer = GlobalOpenTelemetry.getTracer("order-service");

    public Order createOrder(OrderRequest request) {
        Span span = tracer.spanBuilder("createOrder")
            .setAttribute("order.customer_id", request.getCustomerId())
            .setAttribute("order.item_count", request.getItems().size())
            .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // 재고 확인
            Span stockSpan = tracer.spanBuilder("checkStock")
                .startSpan();
            try (Scope stockScope = stockSpan.makeCurrent()) {
                checkStockAvailability(request.getItems());
                stockSpan.setAttribute("stock.all_available", true);
            } catch (Exception e) {
                stockSpan.setStatus(StatusCode.ERROR, e.getMessage());
                stockSpan.recordException(e);
                throw e;
            } finally {
                stockSpan.end();
            }

            // 주문 저장
            Order order = orderRepository.save(toEntity(request));
            span.setAttribute("order.id", order.getId());
            span.setAttribute("order.total_amount", order.getTotalAmount());

            // 이벤트 기록
            span.addEvent("order_created", Attributes.of(
                AttributeKey.longKey("order_id"), order.getId()
            ));

            return order;
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, "주문 생성 실패");
            span.recordException(e);
            throw e;
        } finally {
            span.end();
        }
    }
}

Jaeger와 Tempo로 분산 추적 시각화

Docker Compose로 인프라 구성

version: '3.8'
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP
      - "8889:8889"   # Prometheus exporter
    volumes:
      - ./otel-collector-config.yaml:/etc/otelcol/config.yaml
    depends_on:
      - tempo
      - loki

  tempo:
    image: grafana/tempo:2.4.0
    ports:
      - "3200:3200"   # Tempo API
    volumes:
      - ./tempo-config.yaml:/etc/tempo/config.yaml
    command: ["-config.file=/etc/tempo/config.yaml"]

  prometheus:
    image: prom/prometheus:v2.51.0
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  loki:
    image: grafana/loki:2.9.0
    ports:
      - "3100:3100"

  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
    volumes:
      - ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml

OTel Collector 설정

# otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    timeout: 5s
    send_batch_size: 1024
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128
  attributes:
    actions:
      - key: environment
        value: production
        action: upsert

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
  loki:
    endpoint: http://loki:3100/loki/api/v1/push

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch, attributes]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [loki]

커스텀 메트릭 수집

비즈니스에 특화된 메트릭을 직접 정의하여 수집할 수 있습니다.

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.metrics.LongCounter;
import io.opentelemetry.api.metrics.DoubleHistogram;
import io.opentelemetry.api.metrics.Meter;
import io.opentelemetry.api.common.Attributes;

@Component
public class OrderMetrics {

    private final LongCounter orderCounter;
    private final DoubleHistogram orderAmountHistogram;
    private final LongCounter orderFailureCounter;

    public OrderMetrics() {
        Meter meter = GlobalOpenTelemetry.getMeter("order-service");

        this.orderCounter = meter.counterBuilder("orders.created.total")
            .setDescription("주문 생성 총 건수")
            .setUnit("1")
            .build();

        this.orderAmountHistogram = meter.histogramBuilder("orders.amount")
            .setDescription("주문 금액 분포")
            .setUnit("KRW")
            .setExplicitBucketBoundariesAdvice(
                List.of(10000.0, 50000.0, 100000.0, 500000.0, 1000000.0)
            )
            .build();

        this.orderFailureCounter = meter.counterBuilder("orders.failed.total")
            .setDescription("주문 실패 총 건수")
            .build();
    }

    public void recordOrderCreated(String paymentMethod, double amount) {
        Attributes attrs = Attributes.of(
            AttributeKey.stringKey("payment.method"), paymentMethod
        );
        orderCounter.add(1, attrs);
        orderAmountHistogram.record(amount, attrs);
    }

    public void recordOrderFailed(String reason) {
        Attributes attrs = Attributes.of(
            AttributeKey.stringKey("failure.reason"), reason
        );
        orderFailureCounter.add(1, attrs);
    }
}

로그 상관관계: Trace ID 연동

로그에 Trace ID를 포함시키면, 특정 요청의 로그만 필터링하여 문제를 빠르게 추적할 수 있습니다.

Logback 설정으로 Trace ID 자동 삽입

<!-- logback-spring.xml -->
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>
        %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36}
        [traceId=%X{trace_id} spanId=%X{span_id}] - %msg%n
      </pattern>
    </encoder>
  </appender>

  <appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <provider class="net.logstash.logback.composite.loggingevent.MdcJsonProvider"/>
    </encoder>
  </appender>

  <root level="INFO">
    <appender-ref ref="JSON" />
  </root>
</configuration>

이 설정으로 출력되는 로그 예시:

{
  "timestamp": "2026-03-15T10:23:45.123Z",
  "level": "INFO",
  "logger": "com.example.OrderService",
  "message": "주문 생성 완료: orderId=12345",
  "trace_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "span_id": "1a2b3c4d5e6f7a8b",
  "service.name": "order-service"
}
Grafana에서 로그 → 트레이스 연동: Grafana의 Loki 데이터 소스 설정에서 "Derived fields"를 구성하면, 로그의 trace_id 필드를 클릭하여 바로 Tempo의 트레이스 뷰로 이동할 수 있습니다.

Grafana 대시보드 구성

데이터 소스 자동 프로비저닝

# grafana-datasources.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true

  - name: Tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    jsonData:
      tracesToLogsV2:
        datasourceUid: loki
        filterByTraceID: true
      tracesToMetrics:
        datasourceUid: prometheus

  - name: Loki
    type: loki
    access: proxy
    url: http://loki:3100
    jsonData:
      derivedFields:
        - datasourceUid: tempo
          matcherRegex: '"trace_id":"(\w+)"'
          name: TraceID
          url: '$${__value.raw}'

RED 메트릭 대시보드

서비스 모니터링의 핵심은 RED(Rate, Errors, Duration) 메트릭입니다. Grafana에서 다음 PromQL 쿼리로 대시보드를 구성합니다.

# Rate: 초당 요청 수
rate(http_server_request_duration_seconds_count{service_name="order-service"}[5m])

# Errors: 에러율 (%)
100 * sum(rate(http_server_request_duration_seconds_count{
  service_name="order-service", http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_seconds_count{
  service_name="order-service"}[5m]))

# Duration: p99 응답 시간
histogram_quantile(0.99,
  sum(rate(http_server_request_duration_seconds_bucket{
    service_name="order-service"}[5m])) by (le)
)

실무 트러블슈팅 사례

사례 1: 간헐적 타임아웃 추적

주문 API에서 간헐적으로 5초 이상 소요되는 요청이 발생했습니다. 기존 로그만으로는 원인 파악이 어려웠지만, 분산 추적으로 해결했습니다.

# Tempo에서 느린 트레이스 검색
# TraceQL 쿼리
{ span.http.route = "/api/orders" && duration > 5s }

# 결과: payment-service -> external-pg API 호출에서 병목 발견
# Span 상세:
#   service: payment-service
#   operation: POST /pg/approve
#   duration: 4.8s
#   attributes:
#     pg.provider: "nice-pay"
#     pg.retry_count: 2

분석 결과, 특정 PG사의 응답 지연이 원인이었으며, 서킷 브레이커 적용으로 해결했습니다.

사례 2: N+1 쿼리 발견

# 트레이스에서 반복적인 DB Span 패턴 발견
Trace: GET /api/orders?page=1
  ├─ SELECT * FROM orders LIMIT 20          (2ms)
  ├─ SELECT * FROM order_items WHERE order_id = 1  (1ms)
  ├─ SELECT * FROM order_items WHERE order_id = 2  (1ms)
  ├─ SELECT * FROM order_items WHERE order_id = 3  (1ms)
  │  ... (20개 반복)
  └─ 총 DB Span: 21개, 총 소요: 45ms

# 해결: Fetch Join 적용 후
Trace: GET /api/orders?page=1
  ├─ SELECT o.*, oi.* FROM orders o JOIN order_items oi ...  (5ms)
  └─ 총 DB Span: 1개, 총 소요: 5ms

성능 영향 최소화 팁

OpenTelemetry 도입 시 성능 영향을 최소화하기 위한 실전 팁입니다.

샘플링 전략

# 부모 기반 + 비율 기반 샘플링
otel.traces.sampler=parentbased_traceidratio
otel.traces.sampler.arg=0.1

# Tail-based 샘플링 (Collector에서 처리)
# otel-collector-config.yaml
processors:
  tail_sampling:
    decision_wait: 10s
    policies:
      # 에러가 발생한 트레이스는 항상 수집
      - name: error-policy
        type: status_code
        status_code:
          status_codes: [ERROR]
      # 느린 요청은 항상 수집
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 3000
      # 나머지는 10%만 수집
      - name: probabilistic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

성능 최적화 체크리스트

성능 측정 결과: 자동 계측 활성화 시 평균 3~5%의 CPU 오버헤드가 발생합니다. 샘플링 비율을 10%로 설정하면 오버헤드를 1~2% 이내로 줄일 수 있습니다. 반드시 부하 테스트를 통해 영향을 확인하세요.

실전 도입 로드맵

OpenTelemetry를 점진적으로 도입하는 것을 권장합니다.

마무리

OpenTelemetry는 분산 시스템의 관측성을 확보하는 데 가장 효과적인 도구입니다. 벤더 종속 없이 표준화된 방식으로 Traces, Metrics, Logs를 통합 수집하고, Grafana 생태계와 연동하여 강력한 관측성 플랫폼을 구축할 수 있습니다. 중요한 것은 도구를 도입하는 것 자체가 아니라, 팀이 관측성 데이터를 활용하여 문제를 빠르게 진단하고 해결하는 문화를 만드는 것입니다. 작은 범위에서 시작하여 점진적으로 확대해 나가시기 바랍니다.

추천 리소스: OpenTelemetry 공식 문서(opentelemetry.io), Grafana 관측성 스택 튜토리얼, CNCF 관측성 백서를 참고하면 더 깊이 있는 학습이 가능합니다.
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글