Spring Boot 3 Observability 통합
Spring Boot 3부터 Micrometer Observation API가 프레임워크 전반에 내장되었다. 이전에는 메트릭(Micrometer), 트레이싱(Sleuth), 로깅(Logback MDC)을 각각 설정했지만, 이제 하나의 Observation으로 메트릭·트레이스·로그를 동시에 생성한다. Spring Cloud Sleuth는 더 이상 필요 없다.
핵심 구조는 이렇다: ObservationRegistry에 Observation을 등록하면, 연결된 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을 적극 활용하면, 비즈니스 로직에 대한 깊은 가시성을 최소한의 코드로 확보할 수 있다.