Spring Boot - OpenTelemetry!

OpenTelemetry

https://opentelemetry.io/
https://www.cncf.io/projects/opentelemetry/
otel doc: https://opentelemetry.io/docs/languages/java/instrumentation/

1

클라우드 네이티브 환경에서 애플리케이션의 관측가능성을 지원하기 위한 유명한 프로젝트.
현재 CNCF 프로젝트중 다운로드 횟수 2위를 차지할 만큼 모니터링 프로젝트의 표준으로 자리잡았다.

각종 언어별 라이브러리, 컨테이너 이미지, 헬름 차트, k8s CRD 등을 제공한다.

OpenTelemetry 프로젝트를 사용하면 아래 3개 관측 데이터에 대해 Observability 기능을 지원한다.

  • 로그
  • 추적
  • 메트릭

Spring 에서 OpenTelemetry 를 사용하는 여러가지 방법이 있지만, 여기선 Java 를 지원하는 2가지 library 사용방법을 알아본다.

  • io.openTelemetry
  • io.micrometer

먼저 테스트를 위해 OTEL 컬렉터를 실행

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# docker-compose.yaml

version: '3.9'

services:
otel-collector:
image: otel/opentelemetry-collector-contrib:0.83.0
container_name: monitoring-otel-collector
command: [ "--config=/etc/otel-collector-config.yaml" ]
networks:
default:
aliases:
- otel
volumes:
- ./etc/monitoring/otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP

OpenTelemetry javaagent 를 사용하면 코드 변경 없이 [로그, 추적, 메트릭] 에 대해 자동 계측이 가능하다.
하지만 javaagent 의 잠재적인 보안 문제, 애플리케이션 내 메서드 인터셉터로 인해 성능 문제가 발생하므로 직접 구성하는 것을 추천한다.
https://medium.com/cloud-native-daily/how-to-send-traces-from-spring-boot-to-jaeger-229c19f544db

모든 관측 데이터를 수집하기 위해 OpenTelemetry 를 사용하지 않아도 된다.
로그는 fluentbit 같은 file log tail 방식, 메트릭은 prometheus pull 방식을 사용하면 된다.
추적데이터는 OpenTelemetry 연동구조가 가장 대중적이며, zipkin 이나 jeager 시스템을 사용중이라면 전용 라이브러리를 사용할 수 있다.
tempo 의 경우 입력으로 otlp 프로토콜을 받기에 OpenTelemetry 라이브러리를 써야만한다.

데모코드

https://github.com/Kouzie/spring-boot-demo/tree/main/observability-demo

io.opentelemetry

https://mvnrepository.com/artifact/io.opentelemetry

io.opentelemetryOpenTelemetry 에서 제공하는 라이브러리로 [로그, 추적, 메트릭] 관측 데이터를 OTEL 컬렉터 로 전달할 수 있다.

io.opentelemetry 패키지에서 주로 사용하는 라이브러리는 아래 3가지

  • opentelemetry-api: 전송할 측정데이터 처리를 위한 클래스, 함수 정의.
  • opentelemetry-sdk: 측정데이터의 처리를 위한 클래스, 함수 구현체.
  • opentelemetry-exporter-otlp: 측정데이터 exporter 의 구현체, OTEL HTTP, OTEL GRPC 프로토콜을 사용 가능.

opentelemetry-sdk 안에 이미 opentelemetry-api 가 포함되어 있지만 비즈니스 로직에서는 opentelemetry-api 의존성을 주입받아 사용하는 것을 권장한다.
opentelemetry-sdk 는 별도의 모듈로 구성해서 비즈니스 로직이 담겨있는 모듈에 의존성을 주입하는 것을 권장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
dependencyManagement {
imports {
mavenBom "io.opentelemetry:opentelemetry-bom:1.34.1"
}
}

dependencies {
implementation "io.opentelemetry:opentelemetry-api"
implementation "io.opentelemetry:opentelemetry-sdk"
implementation "io.opentelemetry:opentelemetry-exporter-otlp"

// mdc 포맷 추가, json 형태로 로그 출력을 위한 추가 의존성
implementation "net.logstash.logback:logstash-logback-encoder:8.1"

// OTEL Log Exporter with LOGBACK appender
def OTEL_LOGBACK_VERSION = "2.0.0-alpha"
implementation "io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:$OTEL_LOGBACK_VERSION" // otel collector 로 로그 전송
implementation "io.opentelemetry.instrumentation:opentelemetry-logback-mdc-1.0:$OTEL_LOGBACK_VERSION" // appender 와 logback mdc 통합
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
/**
* https://opentelemetry.io/docs/languages/java/exporters/#usage
***/
@Configuration
public class OpenTelemetryConfig {

@Value("${spring.application.name}")
private String appName;

@Value("${server.version:1.0.0}")
private String serviceVersion;

@Bean
public Resource resource() {
String instanceId = getInstanceId();
Attributes attributes = Attributes.of(
AttributeKey.stringKey("service.namespace"), "demo-service",
AttributeKey.stringKey("service.name"), appName,
AttributeKey.stringKey("service.instance.id"), instanceId,
AttributeKey.stringKey("service.version"), serviceVersion
);
return Resource.getDefault().merge(Resource.create(attributes));
}

private String getInstanceId() {
try {
return InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
return UUID.randomUUID().toString();
}
}

@Bean
public SdkTracerProvider sdkTracerProvider(Resource resource) {
OtlpGrpcSpanExporter exporter = OtlpGrpcSpanExporter.builder()
.setEndpoint("http://otel:4317")
.setTimeout(Duration.ofSeconds(5))
.build();
return SdkTracerProvider.builder()
.setResource(resource)
.addSpanProcessor(BatchSpanProcessor.builder(exporter).build())
.setSampler(Sampler.traceIdRatioBased(1.0)) // 필요 시 낮추기
.build();
}

@Bean
public SdkLoggerProvider sdkLoggerProvider(Resource resource) {
OtlpGrpcLogRecordExporter exporter = OtlpGrpcLogRecordExporter.builder()
.setEndpoint("http://otel:4317")
.setTimeout(Duration.ofSeconds(5))
.build();
return SdkLoggerProvider.builder()
.addLogRecordProcessor(BatchLogRecordProcessor.builder(exporter).build())
.setResource(resource)
.build();
}

@Bean // ★ Micrometer가 참조할 OpenTelemetrySdk 를 반드시 노출
public OpenTelemetry openTelemetry(SdkLoggerProvider sdkLoggerProvider,
SdkTracerProvider sdkTracerProvider) {
ContextPropagators propagators = ContextPropagators.create(
TextMapPropagator.composite(
W3CTraceContextPropagator.getInstance(),
W3CBaggagePropagator.getInstance()
)
);

OpenTelemetrySdk otel = 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

return otel;
}
}

Log

otel logback: https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/logback/logback-appender-1.0/library

만약 [fluentbit, promtail] 를 사용해 file log tail 방식으로 전송할 예정이라면 file log 에도 MDC(Mapped Diagnostic Context) 정보를 출력해야 하므로 추적 데이터가 포함된 로그가 표준 출력(Standard Output)에 출력되도록 설정한다.

logback 에서도 OpenTelemetry exporter 를 통해 컬렉터로 로그데이터를 전송하고 OpenTelemetry appender 를 통해 관측데이터 식별을 위한 mdc 가 포함된 형태로 로그를 출력하도록 설정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Console에 JSON 로그 출력 -->
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<providers>
<timestamp/> <!-- @timestamp 자동 생성 -->
<version/> <!-- @version 자동 생성 -->
<logLevel/> <!-- level -->
<threadName/> <!-- thread -->
<loggerName/> <!-- logger -->
<message/> <!-- message -->
<mdc/> <!-- Micrometer/OTel이 MDC에 주입하는 traceId, spanId 자동 포함 -->
<stackTrace/> <!-- 예외를 JSON 구조로 출력 -->
</providers>
</encoder>
</appender>

<!-- OpenTelemetry Exporter: 로그를 OTel Collector로 전송 -->
<appender name="OpenTelemetry" class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender"/>

<root level="info">
<appender-ref ref="OpenTelemetry"/>
<appender-ref ref="Console"/>
</root>
</configuration>
1
2
3
4
5
6
7
8
9
10
11
// logback 출력 로그
{
timestamp=2024-05-14T04:02:18.106Z,
level=INFO,
thread=http-nio-8080-exec-1,
traceId=db5882a7b3d310198106b96f529f0ade,
spanId=3e72805295708786,
logger=com.kube.demo.greeting.contorller.GreetingController,
message=greet invoked,
context=default
}

Meter, Trace

Meter 에 대한 설정을 하지 않으면 생존신고를 위한 메트릭만 OTEL 컬렉터로 전송한다.
Tracer 에 대한 설정을 하지 않으면 어떠한 추적데이터도 OTEL 컬렉터로 전송되지 않는다.

추가적인 메트릭, 추적데이터를 OTEL 컬렉터 에 전송하기 위한 Meter, Tracer 설정방법은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 메트릭데이터 생성기 io.opentelemetry.api.metrics.Meter
@Bean
public Meter customMeter(OpenTelemetry openTelemetry) {
return openTelemetry.meterBuilder("exampleMeter")
.setInstrumentationVersion("1.0.0")
.build();
}
// 추적데이터 생성기 io.opentelemetry.api.trace
@Bean
public Tracer customTracer(OpenTelemetry openTelemetry) {
return openTelemetry.tracerBuilder("exampleTracer")
.setInstrumentationVersion("1.0.0")
.build();
}

...
// io.opentelemetry.api.metrics.LongCounter
private LongCounter counter;
private Attributes attributes;

@PostConstruct
private void init() {
// Build counter e.g. LongCounter
this.counter = meter
.counterBuilder("processed_jobs")
.setDescription("Processed jobs")
.setUnit("1")
.build();
this.attributes = Attributes.of(AttributeKey.stringKey("Key"), "SomeWork");
}

@GetMapping
public String greet() throws JsonProcessingException {
// Span 생성
Span span = tracer.spanBuilder("exampleSpan")
.setSpanKind(SpanKind.INTERNAL)
.startSpan();
// Span 내에서 작업 수행
try (Scope scope = span.makeCurrent()) {
// 수행할 작업
log.info("greet invoked");
counter.increment(1);
span.addEvent("count increment inside the span");
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, "Exception occurred");
span.recordException(e);
} finally {
span.end();
}
HelloJava helloJava = new HelloJava(greetingMessage + ", version:" + version, LocalDateTime.now());
return objectMapper.writeValueAsString(helloJava);
}

io.micrometer

위에서 느꼈겠지만 io.opentelemetry 는 자동계측은 지원하지 않는다,

사용자가 코드 사이사이에 [Meter, Tracer] 를 사용해 수기로 관측데이터를 생성 및 지정해줘야 한다.

대부분의 사용자가 운영 코드 사이사이에 계측 관련 코드를 넣고 싶지 않을 것이기에 io.micrometer 와 같은 자동 계측을 지원하는 라이브러리와 같이 사용한다.

메트릭, 추적 데이터를 수집하는 어플리케이션은 OpenTelemetry 말고도 굉장히 많은데,
io.micrometer 는 자동계측 뿐만 수동계측을 진행할 때에도 동일한 코드로 다양한 관측 백엔드 서비스를 사용할 수 있도록 도와준다.

  • OpenTelemetry
  • Prometheus
  • Zipkin
  • Jaeger

대부분 관측백엔드에서 제공하는 라이브러리의 사용방식이 비슷하다.
메트릭에선 [Counter, Gauage, Summary] 를 정의하고 추적에선 Span 을 정의한다.

io.micrometer 를 사용하면 여러가지 백엔드 서비스 라이브러리를 주입받아 동일한 코드로 관측데이터 계측을 지원한다.

Metric

io.micrometer 에서 제공하는 MeterRegistry 를 사용하면 JVM 자동 계측 메트릭과 사용자 지정 메트릭을 같이 관리할 수 있다.

1
implementation 'io.micrometer:micrometer-registry-otlp'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Micrometer 메트릭 생성기
// io.micrometer.core.instrument.MeterRegistry
@Bean
public MeterRegistry meterRegistry() {
OtlpConfig otlpConfig = new OtlpConfig() {
@Override
public String get(String key) {
return null;
}

// 아쉽게도 micrometer 에서 otlp(grpc) 프로토콜은 지원하지 않음
@Override
public String url() {
return "http://localhost:4318/v1/metrics";
}

@Override
public Duration step() {
return Duration.ofSeconds(5);
}
};
return new OtlpMeterRegistry(otlpConfig, Clock.SYSTEM);
}

친숙한 Micrometer 의 메트릭 클래스, 함수들을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Slf4j
@RestController
@RequestMapping("/greeting")
@RequiredArgsConstructor
public class GreetingController {
private final ObjectMapper objectMapper;
private final MeterRegistry registry;
@Value("${greeting.message}")
private String greetingMessage;
// io.micrometer.core.instrument.Counter
private Counter counter; // 카운터 메트릭

@PostConstruct
private void init() {
// Counter 설정
this.counter = Counter.builder("api.call.count")
.description("api call count")
.tags("team", "monitoring", "deploy_version", "dev")
.register(registry);
}

@Value("${image.version}")
private String version;

@GetMapping
public String greet() throws JsonProcessingException {
log.info("greet invoked");
counter.increment(1);
HelloJava helloJava = new HelloJava(greetingMessage + ", version:" + version, LocalDateTime.now());
return objectMapper.writeValueAsString(helloJava);
}
}

MeterRegistry 는 HTTP 프로토콜을 사용하는 만큼 OTLP 프로토콜의 의존성이 완벽히 분리되어 아예 io.opentelemetry 라이브러리를 사용하지 않는다.

메트릭만 측정해도 되는 상황이라면 io.opentelemetry 라이브러리를 모두 걷어내고 micrometer-registry-otlp 만 설정해도 Micrometer 에서 메트릭 데이터를 OTEL 컬렉터 로 내보내(Export) 준다.

1

Prometheus

대부분 애플리케이션에서 메트릭을 otlp 로 전송하지 않는다.
Pull Prometheus Metric 방식이 표준으로 자리 잡았다.

OTEL 컬렉터 에서 Pull Prometheus Metric 방식도 지원한다.

대부분 스프링부트 코드에서 micrometer-registry-otlp 보다 micrometer-registry-prometheus 라이브러리를 주로 사용한다.

Prometheus Metric 노출을 위해 [actuator, micrometer] 라이브러리 사용.

1
2
implementation "org.springframework.boot:spring-boot-starter-actuator"
implementation "io.micrometer:micrometer-registry-prometheus"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
management:
server:
port: ${MANAGEMENT_SERVER_PORT:9000}
endpoints:
web:
exposure:
include: prometheus, health, info, threaddump # 외부에 노출할 actuator 엔드포인트 목록
metrics:
tags:
application: ${spring.application.name} # 모든 메트릭에 application 이름을 태그로 붙여 Prometheus 에서 구분 가능하게 함
distribution:
percentiles-histogram:
http.server.requests: true # HTTP 요청 처리 시간에 대한 히스토그램 생성 (Grafana 에서 P99 등 계산 시 사용)
prometheus:
metrics:
export:
enabled: true # Prometheus 형식으로 메트릭 수집 허용
endpoint:
prometheus:
enabled: true # /actuator/prometheus 엔드포인트 활성화
threaddump:
enabled: true # 스레드 덤프 확인 엔드포인트 활성화

http://localhost:9000/actuator/prometheus 를 통해 Metric 을 읽어올 수 있다.

Prometheus Metric Push Base

참고로 OTEL 컬렉터 에서 Pushbase Prometheus Metric 방식을 지원하지 않는다.
pushbase 를 사용하고 싶다면 아래와 같이 SpringBoot 서버에서 Prometheus 서버에 직접 Metric 을 전달해야한다.

1
management.prometheus.metrics.export.pushgateway.base-url=${METRIC_URL:http://localhost:9091}

https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html#actuator.metrics.export.prometheus
--management.prometheus.metrics.export.pushgateway.enabled=true 커맨드 실행명령으로 전달시 동작한다.

Trace

SpringBoot2 에서는 zipkin, jaeger 등의 추적 백엔드 서비스를 사용하기 위해 Sleuth 자동 계측 라이브러리를 사용했다.
SpringBoot3 부터는 Micrometer 를 사용해서 추적 백엔드 서비스 사용이 가능하다.

Spring Cloud SleuthSpringBoot 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 데이터 전송을 위한 라이브러리

아래와 같이 실제 Micrometer 에서 제공하는 OtelTracer 구현체 내부에서 io.opentelemetry 패키지의 구현체를 필요로 하기에 의존성을 주입해 줘야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
package io.micrometer.tracing.otel.bridge;

...
import io.micrometer.tracing.Tracer;
import io.opentelemetry.context.Context;
import io.opentelemetry.context.Scope;

public class OtelTracer implements Tracer {

private final io.opentelemetry.api.trace.Tracer tracer;
...
}

OTEL 컬렉터 로 추적 데이터를 Push 하기 위해 application.properties 에도 아래와 같이 OTEL 컬렉터 주소를 설정한다.

1
management.otlp.tracing.endpoint=http://localhost:4318/v1/tracing

이제 모든 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);
Long addResult = calculatingClient.addNumbers(num1, num2);
// 결과 값을 저장할 AtomicInteger 생성
result.set(addResult);
summary.record(num1);
summary.record(num2);
return result.toString();
}

서드파티 Client Trace

https://docs.spring.io/spring-data/redis/reference/observability.html
https://docs.spring.io/spring-data/mongodb/reference/observability/observability.html

DB, Redis, RabbitMQ 등 외부 서드파티 연동시에도 자동계측할 수 있도록 trace 연동설정이 가능하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// import io.micrometer.observation.ObservationRegistry
@Bean
fun mongoClientSettings(observationRegistry: ObservationRegistry): MongoClientSettings {
val connection = "mongodb://$username:$password@$host:27017/$databaseName"
logger.info("connection string: $connection")
val connectionString = ConnectionString(connection)
return MongoClientSettings.builder()
.applyConnectionString(connectionString)
.contextProvider(ContextProviderFactory.create(observationRegistry))
.addCommandListener(MongoObservationCommandListener(observationRegistry, connectionString))
.build()
}

// import io.lettuce.core.resource.ClientResources
// import io.lettuce.core.tracing.MicrometerTracing
@Bean
fun clientResources(observationRegistry: ObservationRegistry): ClientResources {
return ClientResources.builder()
.tracing(MicrometerTracing(observationRegistry, "demo-redis"))
.build()
}

@Bean
fun redisConnectionFactory(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 계측 코드를 운영코드 사이에 삽입해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
@Component
class TraceSupport(_trace: TraceDelegator) {
init {
trace = _trace
}

companion object {
private lateinit var trace: TraceDeligator
fun <T> run(
spanName: String,
tags: Map<String, String> = emptyMap(),
function: () -> T
): T {
return trace.run(spanName, tags, function)
}

fun <C : TraceCarrier, T> publish(
spanName: String,
tags: Map<String, String> = emptyMap(),
makeCarrier: () -> C,
function: (C) -> T
): T {
return trace.publish(spanName, tags, makeCarrier, function)
}

fun <T> consume(
spanName: String,
tags: Map<String, String> = emptyMap(),
carrier: TraceCarrier,
function: () -> T
): T {
return trace.consume(spanName, tags, carrier, function)
}
}

@Component
class TraceDelegator(
private val tracer: Tracer,
private val 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()
}
}
}
}

Slow Query 모니터링

Spring Boot 애플리케이션에서 발생하는 데이터베이스 지연(Slow Query)을 실시간으로 감지하고 시각화하는 것은 운영 환경에서 매우 중요하다.
여기서는 **MySQL(JDBC)**와 MongoDB를 대상으로 슬로우 쿼리를 측정하고, 이를 Grafana 대시보드로 구성하는 과정을 다룬다.

관측 데이터 수집의 표준인 OpenTelemetryMicrometer를 활용하며, 모든 구성은 Docker 환경에서 동작하도록 설계되었다.

매번 실제 부하를 기다릴 수 없으므로, 의도적으로 지연을 발생시키는 엔드포인트를 구성한다.

MySQL (JDBC)

JdbcTemplate과 MySQL의 SLEEP() 함수를 사용한다. datasource-micrometer 라이브러리를 통해 쿼리 실행 시간이 자동으로 측정된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/sleep/{seconds}")
public String sleepQuery(@PathVariable int seconds) {
int clampedSeconds = Math.min(seconds, 30);
log.info("[SlowQuery] 자동 측정 시작 - SLEEP {}초", clampedSeconds);

// 바인딩 변수를 사용해야 Prometheus에서 동일한 태그(SELECT SLEEP(?))로 묶입니다.
jdbcTemplate.queryForObject("SELECT SLEEP(?)", Integer.class, clampedSeconds);

log.info("[SlowQuery] 자동 측정 종료");
return String.format("자동 측정 완료: %d초 대기됨\n" +
"→ /actuator/prometheus 에서 'jdbc_query_seconds'를 확인하세요.\n" +
"→ 'jdbc_query' 태그값이 'SELECT SLEEP(?)' 인지 확인하세요.", clampedSeconds);
}
1
2
3
4
5
6
7
8
9
10
management:
observations:
key-values:
jdbc:
query:
enabled: true # JDBC 쿼리 실행을 관찰(Observation) 대상으로 등록
metrics:
distribution:
percentiles-histogram:
jdbc.query: true # SQL 실행 시간에 대한 히스토그램 생성 (슬로우 쿼리 감지용)

MongoDB

MongoDB의 $where 연산자와 JavaScript sleep()을 활용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@GetMapping("/mongo/sleep/{seconds}")
public String mongoSleep(@PathVariable int seconds) {
long ms = seconds * 1000L;
log.info("[MongoDB] 자동 측정 시작 - SLEEP {}ms", ms);

// 1. 데이터가 없으면 지연이 발생하지 않으므로, 임시 문서를 하나 생성하거나 확인합니다.
String tempCollection = "slow_query_temp";
if (mongoTemplate.count(new Query(), tempCollection) == 0) {
mongoTemplate.insert(new Document("name", "dummy"), tempCollection);
}

// 2. 안전한 전용 컬렉션에서 $where 실행 (JavaScript sleep 활용)
// 주의: $where는 성능에 좋지 않으며, 실서비스에서는 모니터링 테스트용으로만 사용해야 합니다.
Query query = new Query(Criteria.where("$where").is("sleep(" + ms + ") || true"));

try {
mongoTemplate.find(query, Object.class, tempCollection);
} catch (Exception e) {
log.error("[MongoDB] 쿼리 실행 중 오류 발생", e);
return "오류 발생: " + e.getMessage();
}

log.info("[MongoDB] 자동 측정 종료");
return String.format("MongoDB 자동 측정 완료: %d초 대기됨\n" +
"→ /actuator/prometheus 에서 'mongodb_driver_commands'를 확인하세요.", seconds);
}
1
2
3
4
5
6
7
8
management:
metrics:
mongo:
command:
enabled: true # MongoDB 명령어 실행 메트릭 활성화
distribution:
percentiles-histogram:
mongodb.driver.commands: true # MongoDB 명령어 실행 시간에 대한 히스토그램 생성 (슬로우 쿼리 감지용)

대시보드 쿼리 (PromQL)

중복된 수집 경로 문제를 방지하기 위해 sum by를 사용하여 데이터를 그룹화하고, 전체 합계를 횟수로 나누어 평균 지연 시간을 산출한다.

랜덤한 부하를 발생시켜 대시보드에 데이터가 쌓이는지 확인한다.

1
2
3
4
5
6
7
# 랜덤 숫자 연산 및 1~5초 사이의 지연 발생 테스트 스크립트
for i in {1..10}; do
num1=$((RANDOM % 100)); num2=$((RANDOM % 100)); sleep_sec=$((1 + RANDOM % 5));
curl -s "http://localhost:8080/slow-query/sleep/$sleep_sec" > /dev/null;
curl -s "http://localhost:8081/calculating/mongo/sleep/$sleep_sec" > /dev/null;
echo "Request set $i complete";
done

위와 같이 쿼리를 호출하고 /actuator/prometheus 요청 시 아래와 같은 응답을 확인할 수 있다.

MySQL (JDBC) 원본 데이터

1
2
3
4
# HELP jdbc_query_seconds
# TYPE jdbc_query_seconds summary
jdbc_query_seconds_count{application="service-greeting",error="none",jdbc_query="SELECT SLEEP(?)",jdbc_query_enabled="true",} 1.0
jdbc_query_seconds_sum{application="service-greeting",error="none",jdbc_query="SELECT SLEEP(?)",jdbc_query_enabled="true",} 1.017248001

MongoDB 원본 데이터

1
2
3
4
# HELP mongodb_driver_commands_seconds
# TYPE mongodb_driver_commands_seconds summary
mongodb_driver_commands_seconds_count{application="service-calculating",cluster_id="...",collection="test_collection",command="find",server_address="mongodb:27017",status="SUCCESS",} 1.0
mongodb_driver_commands_seconds_sum{application="service-calculating",cluster_id="...",collection="test_collection",command="find",server_address="mongodb:27017",status="SUCCESS",} 0.015247209

Prometheus 쿼리는 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"editorMode": "code",
"expr": "sum by (application, jdbc_query) (rate(jdbc_query_seconds_sum{jdbc_query!=\"\"}[1m])) / sum by (application, jdbc_query) (rate(jdbc_query_seconds_count[1m]))",
"format": "time_series",
"legendFormat": "{{application}} - {{jdbc_query}}",
"range": true,
"refId": "A"
}
],
"title": "MySQL Average Query Duration",
"type": "timeseries"

"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "Prometheus"
},
"editorMode": "code",
"expr": "sum by (application, command, collection) (rate(mongodb_driver_commands_seconds_sum{command!=\"\"}[1m])) / sum by (application, command, collection) (rate(mongodb_driver_commands_seconds_count[1m]))",
"format": "time_series",
"legendFormat": "{{application}} - {{command}} ({{collection}})",
"range": true,
"refId": "A"
}
],
"title": "MongoDB Average Command Duration",
"type": "timeseries"

1