Spring Observation API 심화

Spring Boot 3 Observability 통합

Spring Boot 3부터 Micrometer Observation API가 프레임워크 전반에 내장되었다. 이전에는 메트릭(Micrometer), 트레이싱(Sleuth), 로깅(Logback MDC)을 각각 설정했지만, 이제 하나의 Observation으로 메트릭·트레이스·로그를 동시에 생성한다. Spring Cloud Sleuth는 더 이상 필요 없다.

핵심 구조는 이렇다: ObservationRegistryObservation을 등록하면, 연결된 ObservationHandler들이 메트릭(MeterRegistry), 트레이스(Tracer), 로그(MDC)를 자동으로 처리한다.

의존성 구성

// build.gradle.kts
dependencies {
    // Actuator (메트릭 엔드포인트)
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    // Micrometer Tracing + Bridge 선택 (둘 중 하나)
    // Option A: OpenTelemetry Bridge (권장)
    implementation("io.micrometer:micrometer-tracing-bridge-otel")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")

    // Option B: Brave(Zipkin) Bridge
    // implementation("io.micrometer:micrometer-tracing-bridge-brave")
    // implementation("io.zipkin.reporter2:zipkin-reporter-brave")

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

    // Logback에서 traceId/spanId 자동 주입
    runtimeOnly("io.micrometer:context-propagation")
}

기본 설정: application.yml

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  metrics:
    tags:
      application: order-service
      environment: production
    distribution:
      percentiles-histogram:
        http.server.requests: true  # 히스토그램 활성화
      minimum-expected-value:
        http.server.requests: 1ms
      maximum-expected-value:
        http.server.requests: 10s
  tracing:
    sampling:
      probability: 1.0  # 개발: 100%, 운영: 0.1 (10%)
  observations:
    http:
      server:
        requests:
          name: http.server.requests  # 커스텀 메트릭 이름

# OTLP Exporter 설정
management.otlp:
  tracing:
    endpoint: http://otel-collector:4318/v1/traces
  metrics:
    export:
      endpoint: http://otel-collector:4318/v1/metrics
      step: 30s

# 로그에 traceId, spanId 포함
logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

자동 계측: 설정만으로 동작하는 것들

Spring Boot 3의 자동 계측 범위는 광범위하다. 코드 변경 없이 다음이 모두 관측된다:

컴포넌트 자동 메트릭 자동 트레이스
Spring MVC/WebFlux http.server.requests ✅ Span 생성
RestClient/WebClient http.client.requests ✅ 헤더 전파
Spring Data (JDBC/JPA) spring.data.repository.* ✅ 쿼리 Span
Spring Security spring.security.*
Spring Kafka kafka.consumer/producer ✅ 헤더 전파
@Scheduled spring.task.scheduled.*

Observation API: 커스텀 관측

비즈니스 로직에 대한 관측은 Observation API로 직접 추가한다. 하나의 코드로 메트릭과 트레이스가 동시에 생성된다.

@Service
public class PaymentService {

    private final ObservationRegistry observationRegistry;
    private final PaymentGateway gateway;

    public PaymentResult processPayment(PaymentRequest request) {
        return Observation
            .createNotStarted("payment.process", observationRegistry)
            .contextualName("process-payment")  // 트레이스 Span 이름
            .lowCardinalityKeyValue("payment.method", request.getMethod().name())
            .lowCardinalityKeyValue("payment.currency", request.getCurrency())
            .highCardinalityKeyValue("payment.id", request.getId())  // 메트릭에 포함 안 됨
            .observe(() -> {
                // 이 블록이 Span으로 감싸지고,
                // 실행 시간이 메트릭으로 기록됨
                return gateway.charge(request);
            });
    }
}

lowCardinalityKeyValue는 메트릭 태그 + 트레이스 태그 모두에 포함된다. highCardinalityKeyValue는 트레이스에만 포함되고 메트릭에는 제외된다 (카디널리티 폭발 방지).

ObservationConvention으로 명명 규칙 표준화

// 관측 컨텍스트 정의
public class PaymentObservationContext extends Observation.Context {
    private final String paymentMethod;
    private final String currency;
    private final String paymentId;
    private boolean success;

    // 생성자, getter, setter...
}

// 명명 규칙 정의
public class PaymentObservationConvention
        implements ObservationConvention<PaymentObservationContext> {

    @Override
    public String getName() {
        return "payment.process";
    }

    @Override
    public String getContextualName(PaymentObservationContext context) {
        return "payment " + context.getPaymentMethod();
    }

    @Override
    public KeyValues getLowCardinalityKeyValues(PaymentObservationContext context) {
        return KeyValues.of(
            KeyValue.of("payment.method", context.getPaymentMethod()),
            KeyValue.of("payment.currency", context.getCurrency()),
            KeyValue.of("payment.outcome", context.isSuccess() ? "success" : "failure")
        );
    }

    @Override
    public KeyValues getHighCardinalityKeyValues(PaymentObservationContext context) {
        return KeyValues.of(
            KeyValue.of("payment.id", context.getPaymentId())
        );
    }

    @Override
    public boolean supportsContext(Observation.Context context) {
        return context instanceof PaymentObservationContext;
    }
}

// 사용
var context = new PaymentObservationContext("CARD", "KRW", "pay_123");
Observation.createNotStarted(new PaymentObservationConvention(), () -> context, observationRegistry)
    .observe(() -> {
        var result = gateway.charge(request);
        context.setSuccess(result.isSuccess());
        return result;
    });

@Observed 어노테이션: AOP 기반 관측

// 설정: ObservedAspect 빈 등록
@Configuration
public class ObservationConfig {

    @Bean
    public ObservedAspect observedAspect(ObservationRegistry registry) {
        return new ObservedAspect(registry);
    }
}

// 사용: 메서드에 @Observed 추가
@Service
public class InventoryService {

    @Observed(
        name = "inventory.check",
        contextualName = "check-stock",
        lowCardinalityKeyValues = {"inventory.type", "warehouse"}
    )
    public StockResult checkStock(String productId) {
        // 자동으로 메트릭 + 트레이스 생성
        return inventoryRepo.findStock(productId);
    }

    @Observed(name = "inventory.reserve")
    public void reserveStock(String productId, int quantity) {
        // ...
    }
}

커스텀 ObservationHandler

관측 이벤트에 커스텀 로직을 추가할 수 있다. 예를 들어 느린 요청을 자동 감지하는 핸들러:

@Component
public class SlowRequestHandler implements ObservationHandler<Observation.Context> {

    private static final Logger log = LoggerFactory.getLogger(SlowRequestHandler.class);
    private static final Duration SLOW_THRESHOLD = Duration.ofSeconds(3);

    @Override
    public void onStop(Observation.Context context) {
        Duration duration = Duration.between(
            context.get(Observation.Context.class).getLowCardinalityKeyValue("start").getValue(),
            Instant.now().toString()
        );

        long elapsed = context.getOrDefault(
            ObservationHandler.class, 0L
        );

        // Observation의 wall time 확인
        if (context instanceof ServerRequestObservationContext serverCtx) {
            // HTTP 요청의 경우
            String uri = serverCtx.getCarrier().getRequestURI();
            // 3초 이상이면 경고 로그
            log.warn("Slow request detected: {} took {}ms", uri, elapsed);
        }
    }

    @Override
    public boolean supportsContext(Observation.Context context) {
        return true;  // 모든 관측에 적용
    }
}

// ObservationFilter로 태그 가공
@Component
public class ServerRequestObservationFilter implements ObservationFilter {

    @Override
    public Observation.Context map(Observation.Context context) {
        if (context instanceof ServerRequestObservationContext serverCtx) {
            // URI 정규화: /api/users/123 → /api/users/{id}
            String uri = serverCtx.getCarrier().getRequestURI();
            context.addLowCardinalityKeyValue(
                KeyValue.of("uri.normalized", normalizeUri(uri))
            );
        }
        return context;
    }
}

분산 트레이싱: 서비스 간 컨텍스트 전파

// RestClient (Spring Boot 3.2+) — 자동 전파
@Configuration
public class RestClientConfig {

    @Bean
    public RestClient restClient(RestClient.Builder builder) {
        return builder
            .baseUrl("http://inventory-service:8080")
            .build();
        // Observation이 자동으로 W3C Trace Context 헤더 주입
        // traceparent: 00-{traceId}-{spanId}-01
    }
}

@Service
public class OrderService {

    private final RestClient restClient;

    public Order createOrder(OrderRequest req) {
        // 이 호출의 트레이스가 inventory-service까지 전파됨
        StockResult stock = restClient.get()
            .uri("/api/stock/{productId}", req.getProductId())
            .retrieve()
            .body(StockResult.class);

        // order-service Span → inventory-service Span이
        // 같은 traceId로 연결됨
        return orderRepo.save(new Order(req, stock));
    }
}

Kafka 메시지 트레이스 전파

// KafkaTemplate — 자동으로 헤더에 trace context 주입
@Service
public class OrderEventPublisher {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public void publishOrderCreated(Order order) {
        // traceparent 헤더가 Kafka 메시지에 자동 포함
        kafkaTemplate.send("order-events",
            order.getId(),
            new OrderCreatedEvent(order));
    }
}

// Consumer 측에서도 자동으로 trace context 복원
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderCreatedEvent event) {
    // 동일한 traceId 하에 새로운 Span이 생성됨
    inventoryService.reserveStock(event.getProductId(), event.getQuantity());
}

Grafana 스택 연동 구성

# docker-compose.yml — 옵저버빌리티 스택
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    ports:
      - "4317:4317"   # gRPC
      - "4318:4318"   # HTTP
    volumes:
      - ./otel-config.yml:/etc/otel/config.yaml

  tempo:
    image: grafana/tempo:2.4.0
    ports:
      - "3200:3200"

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

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

  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    environment:
      - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor

OTel Collector 설정

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

processors:
  batch:
    timeout: 5s
    send_batch_size: 1000

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheus]

운영 팁: 샘플링 전략

# 운영 환경 샘플링 설정
management:
  tracing:
    sampling:
      probability: 0.1  # 10%만 트레이싱

# 에러 발생 시 항상 트레이싱 (커스텀 Sampler)
---
@Bean
public Sampler customSampler() {
    Sampler defaultSampler = Sampler.traceIdRatioBased(0.1);
    return (parentContext, traceId, name, spanKind, attributes, parentLinks) -> {
        // 에러 관련 Span은 항상 샘플링
        if (name.contains("error") || name.contains("exception")) {
            return SamplingResult.create(SamplingDecision.RECORD_AND_SAMPLE);
        }
        // 헬스체크는 항상 제외
        if (name.contains("/actuator")) {
            return SamplingResult.create(SamplingDecision.DROP);
        }
        return defaultSampler.shouldSample(
            parentContext, traceId, name, spanKind, attributes, parentLinks);
    };
}

Spring Boot 3의 Observability는 “한 번 계측하면 메트릭·트레이스·로그 세 가지를 동시에 얻는다”는 철학이다. Observation API 하나로 Prometheus 메트릭, Tempo 트레이스, Loki 로그가 모두 연결된다. @Observed 어노테이션과 ObservationConvention을 적극 활용하면, 비즈니스 로직에 대한 깊은 가시성을 최소한의 코드로 확보할 수 있다.

관련 글: Spring OpenTelemetry 분산 추적 | Spring Actuator 운영 심화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux