모든 관측데이터를 수집하기 위해 OpenTelemetry 를 사용하지 않아도 된다. 로그는 fluentbit 같은 file log tail 방식, 메트릭은 prometheus pull 방식을 사용하면 된다. 추적데이터는 OpenTelemetry 연동구조가 가장 대중적이며, zipkin 이나 jeager 시스템을 사용중이라면 전용 라이브러리를 사용할 수 있다. tempo 의 경우 입력으로 otlp 프로토콜을 받기에 OpenTelemetry 라이브러리를 써야만한다.
io.opentelemetry 는 OpenTelemetry 에서 제공하는 라이브러리로 [로그, 추적, 메트릭] 관측데이터를 OTEL 컬렉터 로 전달 할 수 있다.
io.opentelemetry 패키지에서 주로 사용하는 라이브러리는 아래 3가지
opentelemetry-api: 전송할 측정데이터 처리를 위한 클래스, 함수 정의.
opentelemetry-sdk: 측정데이터의 처리를 위한 클래스, 함수 구현체.
opentelemetry-exporter-otlp: 측정데이터 exporter 의 구현체, OTEL HTTP, OTEL GRPC 프로토콜을 사용 가능.
opentelemetry-sdk 안에 이미 opentelemetry-api 가 포함되어 있지만 비즈니스 로직에서는 opentelemetry-api 의존성 주입 받아 사용하는것을 권장. opentelemetry-sdk 는 별도의 모듈로 구성해서 비즈니스 로직이 담겨있는 모듈의 의준성 주입하는것을 권장한다.
@Bean// ★ Micrometer가 참조할 OpenTelemetrySdk 를 반드시 노출 public OpenTelemetry openTelemetry(SdkLoggerProvider sdkLoggerProvider, SdkTracerProvider sdkTracerProvider) { ContextPropagatorspropagators= ContextPropagators.create( TextMapPropagator.composite( W3CTraceContextPropagator.getInstance(), W3CBaggagePropagator.getInstance() ) );
OpenTelemetrySdkotel= OpenTelemetrySdk.builder() // .setMeterProvider(sdkMeterProvider) prometheus 대체 .setLoggerProvider(sdkLoggerProvider) .setTracerProvider(sdkTracerProvider) .setPropagators(propagators) .build();
GlobalOpenTelemetry.resetForTest(); // 혹시 이전 글로벌 인스턴스가 있으면 리셋 GlobalOpenTelemetry.set(otel); OpenTelemetryAppender.install(otel); // install log agent in log appender
만약 [fluentbit, promtail] 를 사용해 file log tail 방식으로 전송할 예정이라면 file log 에도 MDC(Mapped Diagnostic Context) 정보 출력해야 함으로 추적 데이터가 포함된 로그가 standard output 에 출력되도록 설정한다.
logback 에서도 OpenTelemetry exporter 를 통해 컬렉터로 로그데이터를 전송하고 OpenTelemetry appender 를 통해 관측데이터 식별을 위한 mdc 가 포함된 형태로 로그를 출력하도록 설정한다.
SpringBoot2 에서는 zipkin, jaeger 등의 추적 백엔드 서비스를 사용하기 위해 Slueth 자동계측 라이브러리를 사용했다. SpringBoot3 부터는 Micrometer 를 사용해서 추적 백엔드 서비스 사용이 가능하다.
Spring Cloud Sleuth 는 SpringBoot 3.x 에서 중단되었다. Micrometer 를 사용한 추적데이터 수집은 SpringBoot 3.x 부터 지원되며 Spring Cloud Sleuth 형태를 이어받았다.
micrometer-tracing-bridge-otel 은 의존성 분리가 되어있지 않기 때문에 io.opentelemetry 라이브러리를 같이 사용해야한다.
1 2 3
implementation "org.springframework.boot:spring-boot-starter-actuator" implementation "io.micrometer:micrometer-tracing-bridge-otel"// trace 인터페이스 implementation "io.opentelemetry:opentelemetry-exporter-otlp"// trace 데이터 전송을 위한 라이브러리
아래와 같이 실제 Micromter 에서 제공하는 OtelTracer 구현체 내부에서 io.opentelemetry 패키지의 구현체를 필요로 하기에 의존성 주입을 해줘야한다.
이제 모든 HTTP 요청에 자동으로 추적 데이터가 설정되며 로그에도 관련 mdc 정보가 출력되고 OTEL 컬렉터 로도 추적 데이터가 Push 된다.
아래와 같이 micrometer-tracing 라이브러리에서 제공하는 어노테이션을 사용해서 새로운 Span 을 메서드마다 생성할 수 있다.
Controller 는 자동계측되기 때문에 Span 태그가 의미 없지만 Service 나 다른 메서드에는 의미있는 Span 생성이 가능
1 2 3 4 5 6 7 8 9 10 11
@GetMapping("/{num1}/{num2}") @ContinueSpan("calculate") public String calculate(@SpanTag("num1")@PathVariable Long num1, @SpanTag("num2")@PathVariable Long num2) { log.info("calculate invoked, num1:{}, num2:{}", num1, num2); LongaddResult= calculatingClient.addNumbers(num1, num2); // 결과 값을 저장할 AtomicInteger 생성 result.set(addResult); summary.record(num1); summary.record(num2); return result.toString(); }
@Bean funredisConnectionFactory(clientResources: ClientResources): RedisConnectionFactory { val configuration = RedisStandaloneConfiguration(host, port.toInt()) configuration.setPassword(password) val clientConfig = LettuceClientConfiguration.builder() .clientResources(clientResources).build() val factory = LettuceConnectionFactory(configuration, clientConfig) factory.afterPropertiesSet() // Redis 연결 테스트 수행 val connection = factory.connection return factory; }
자동계측을 지원하지 않는 라이브러리의 경우 Spring AOP 를 사용하거나 아래와 같이 kotlin 함수 재정의 방식을 사용해서 trace 계측 코드를 운영코드 사이에 삽입해야 한다.
@Component classTraceDeligator( privateval tracer: Tracer, privateval propagator: Propagator, // trace context 전파를 위한 Propagator, inject, extract 기능 제공 ) {
/** 일반 업무 로직 스팬 */ fun<T>run( spanName: String, tags: Map<String, String> = emptyMap(), function: () -> T ): T { val parent = tracer.currentSpan() val span = tracer.nextSpan(parent).name(spanName).start() try { tags.forEach { (k, v) -> span.tag(k, v) } return tracer.withSpan(span).use { function() } } catch (e: Throwable) { span.error(e) throw e } finally { span.end() } }
/** 메시지 퍼블리시: 새 스팬 시작 → carrier에 inject → function(carrier) 실행 */ fun<C : TraceCarrier, T>publish( spanName: String, tags: Map<String, String> = emptyMap(), makeCarrier: () -> C, function: (C) -> T ): T { val span = tracer.spanBuilder() .setParent(tracer.currentTraceContext().context()) .name(spanName) .kind(Span.Kind.PRODUCER) .start() tags.forEach { (k, v) -> span.tag(k, v) } try { val ctx = span.context() val carrier = makeCarrier() // carrier(message header) 에 trace context 주입하기 propagator.inject(ctx, carrier, CarrierAdapters.setter) return function(carrier) } catch (e: Throwable) { span.error(e); throw e } finally { span.end() } }
/** 메시지 컨슘: carrier에서 extract → 새 스팬 시작 → function() 실행 */ fun<T>consume( spanName: String, tags: Map<String, String> = emptyMap(), carrier: TraceCarrier, function: () -> T ): T { val span = propagator.extract(carrier, CarrierAdapters.getter) .name(spanName) .kind(Span.Kind.CONSUMER) .start() tags.forEach { (k, v) -> span.tag(k, v) } try { return tracer.withSpan(span).use { function() } } catch (e: Throwable) { span.error(e) throw e } finally { span.end() } } } }