OpenTelemetry: trace, log, 메트릭의 공통 표준

1. 개요

시스템이 여러 서비스로 나뉘면, 하나의 요청이 5개, 10개의 다른 프로세스를 거칠 수 있습니다. 오류가 발생하면 요청이 어디를 거쳤는지, 어느 단계에서 느렸는지, 그리고 어떤 로그가 그 요청에 속하는지 알아야 합니다. OpenTelemetry(OTel)는 바로 이 문제를 해결하는 오픈 표준입니다. 이것은 trace, metric, 그리고 로그를 수집하는 방식을 통일하고 이들을 어떤 백엔드로든 보낼 수 있게 합니다.

이 글을 다 읽으면, OTel의 핵심 개념과 왜 관심을 받는지 이해하게 될 것입니다. 또한 Spring Boot 3 애플리케이션에 OTel을 연결하고, Collector를 실행하며, instrumentation에 대한 테스트를 작성하고, 언제 이를 사용하지 말아야 하는지 알게 될 것입니다.

2. OpenTelemetry란?

OpenTelemetry는 CNCF에 속한 오픈소스 프로젝트입니다. 이것은 telemetry 데이터를 생성, 설명, 전달하는 방식을 정의합니다. OTel은 데이터를 저장하거나 보는 인터페이스가 아닙니다. 그 부분은 Jaeger, Prometheus, Grafana Tempo/Loki 또는 상용 서비스 같은 백엔드가 담당합니다.

2.1. 세 가지 신호(signals)

  • Trace: 하나의 요청의 여정입니다. 각 trace는 여러 span으로 구성되며, 각 span은 하나의 작업 단위입니다. 예를 들어 “PaymentService 호출” 또는 “orders 테이블 쿼리”가 있습니다. Span에는 시작 시간, 종료 시간, 그리고 여러 attribute가 있습니다.
  • Metric: 시간에 따라 집계되는 수치입니다. 예를 들어 초당 요청 수, p99 지연 시간, 또는 실패한 주문 수가 있습니다.
  • Log: 개별 이벤트 레코드입니다. OTel은 로그에 traceId와 spanId를 추가하므로, 로그 라인에서 해당 trace로 이동할 수 있습니다.

가장 큰 가치는 상호 연결에 있습니다. 세 신호 모두 동일한 컨텍스트와 리소스 설명을 공유합니다. 예를 들어 service.name이 있습니다.

2.2. 주요 구성 요소

구성 요소 역할
API 코드가 span과 metric을 생성하기 위한 인터페이스로, 벤더에 독립적입니다.
SDK API의 구현: sampling, batching, export를 포함합니다.
OTLP 데이터 전송 프로토콜(gRPC 또는 HTTP/protobuf)입니다.
Collector Telemetry를 수신, 처리 및 전달하는 독립적인 프로세스입니다.
Semantic conventions http.request.method, db.system 같은 attribute 이름 지정 규칙입니다.
Instrumentation 일반적인 프레임워크에 대해 자동으로 telemetry를 생성하는 라이브러리 또는 에이전트입니다.

2.3. Context 전파

여러 서비스의 span이 하나의 trace로 연결되려면, 컨텍스트가 요청을 따라가야 합니다. HTTP에서 OTel은 기본적으로 traceparent 헤더를 통해 W3C Trace Context 표준을 사용합니다:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

이 헤더는 네 부분으로 이루어집니다: version, traceId, 부모 span의 spanId, 그리고 sampling 플래그입니다. 요청을 수신하는 서비스는 이 헤더를 읽고 동일한 trace에 속하는 자식 span을 생성합니다.

3. 왜 OpenTelemetry가 주목받는가?

  • 벤더 중립성: 코드를 한 번만 instrument합니다. 백엔드를 바꾸고 싶으면 exporter나 Collector 설정만 수정하면 되고, 코드는 수정할 필요가 없습니다.
  • 기존 표준의 통합: OTel은 OpenTracing과 OpenCensus를 합치면서 탄생했습니다. 커뮤니티는 더 이상 두 가지 사이에서 선택할 필요가 없습니다.
  • 세 가지 신호를 위한 하나의 표준: 이전에는 각 데이터 유형마다 별도의 에이전트와 형식이 있었습니다.
  • 광범위한 생태계: 많은 프레임워크, 클라우드 제공자, 그리고 observability 도구가 OTLP를 지원합니다. Spring Boot 3도 Micrometer를 통해 통합되었습니다.
  • 유연한 Collector: 민감한 데이터를 필터링하고, 샘플링하며, 여러 백엔드로 라우팅할 수 있습니다. 모두 인프라 레이어에서 애플리케이션을 건드리지 않고 처리합니다.

4. Spring Boot 3에 OpenTelemetry 적용

Spring Boot 3는 Micrometer Observation을 추상화 계층으로 사용합니다. 하나의 observation을 작성하면 Micrometer가 metric(timer)과 span 모두를 생성합니다. OTel로의 bridge가 export 부분을 담당합니다.

4.1. 의존성

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <!-- Micrometer Tracing -> OpenTelemetry 브리지 -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <!-- OTLP를 통한 trace export -->
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <!-- OTLP를 통한 metric export -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-otlp</artifactId>
    </dependency>
    <!-- 테스트용 -->
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-observation-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

버전을 명시할 필요가 없습니다. Spring Boot가 BOM을 통해 Micrometer와 OpenTelemetry 라이브러리의 버전을 관리합니다.

4.2. 설정

spring:
  application:
    name: order-service

management:
  tracing:
    sampling:
      probability: 1.0   # dev: 100% 샘플링; production에서는 줄여야 함
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces
    metrics:
      export:
        url: http://localhost:4318/v1/metrics
        step: 30s

logging:
  pattern:
    level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"

각 설정 그룹의 의미:

  • spring.application.name은 service.name으로 사용됩니다. 이것은 백엔드가 서비스를 구분하는 데 가장 중요한 attribute입니다.
  • management.tracing.sampling.probability는 기록할 trace의 비율을 결정합니다.
  • 두 otlp 엔드포인트는 OTLP/HTTP를 통해 Collector를 가리키며, 기본 포트는 4318입니다.
  • logging.pattern.level은 MDC에서 traceId와 spanId를 각 로그 라인에 삽입합니다. 이것은 로그를 trace와 연결하는 가장 간단한 방법입니다.

4.3. Observation API를 사용한 비즈니스 instrumentation

Spring이 생성한 controller로의 HTTP 요청과 RestClient/WebClient를 통한 호출은 이미 자동으로 instrument됩니다. 직접 추가해야 할 부분은 중요한 비즈니스 로직입니다:

public record Order(String id, String paymentMethod, long amount) {}
@FunctionalInterface
public interface PaymentService {
    String charge(Order order);
}
@Service
public class CheckoutService {

    private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);

    private final ObservationRegistry registry;
    private final PaymentService paymentService;

    public CheckoutService(ObservationRegistry registry, PaymentService paymentService) {
        this.registry = registry;
        this.paymentService = paymentService;
    }

    public String checkout(Order order) {
        return Observation.createNotStarted("order.checkout", registry)
                .lowCardinalityKeyValue("payment.method", order.paymentMethod())
                .highCardinalityKeyValue("order.id", order.id())
                .observe(() -> {
                    log.info("Processing order {}", order.id());
                    return paymentService.charge(order);
                });
    }
}

위의 코드에서 주의할 점:

  • Observation.createNotStarted("order.checkout", registry)는 order.checkout이라는 이름의 observation을 생성합니다.
  • lowCardinalityKeyValue는 적은 값을 가진 attribute(CARD, CASH, WALLET)을 추가합니다. 이 attribute는 metric과 span 모두에 나타납니다.
  • highCardinalityKeyValue는 주문 ID와 같이 매우 많은 값을 가진 attribute를 추가합니다. 이것은 span에만 붙으며, metric을 팽창시키지 않습니다.
  • observe(...)는 span을 열고, 시간을 측정하며, 예외가 있으면 기록하고, supplier가 반환될 때 span을 닫습니다.

실행 시, 로그는 다음과 같이 보입니다:

 INFO [order-service,4bf92f3577b34da6a3ce929d0e0e4736,a1b2c3d4e5f60718] ... Processing order A-1001

백엔드는 두 가지를 수신합니다:

  • 요청의 HTTP span 아래에 order.checkout span이 있습니다.
  • order.checkout timer metric으로, payment.method로 나뉩니다. 이 metric에서 호출 수, 총 시간, 최대 시간을 계산할 수 있습니다.

주의: Spring 코드에서 직접 io.opentelemetry.api.trace.Tracer를 호출하는 대신 Observation API를 우선하세요. 코드가 특정 tracing 라이브러리에 의존하지 않을 것입니다. 하나의 측정이 metric과 trace를 모두 생성합니다.

4.4. 대안: Java agent

코드를 수정하거나 의존성을 추가하고 싶지 않다면 OpenTelemetry Java agent를 사용할 수 있습니다. 에이전트는 JDBC, HTTP 클라이언트, Kafka와 같은 많은 일반 라이브러리를 자동으로 instrument합니다:

export OTEL_SERVICE_NAME=order-service
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1

java -javaagent:./opentelemetry-javaagent.jar -jar order-service.jar

에이전트는 레거시 애플리케이션이나 운영 팀이 instrumentation을 중앙에서 제어하고 싶을 때 적합합니다. 트레이드오프는 더 세밀한 제어가 어렵고, 시작 시간이 증가하며, 에이전트가 bytecode에 개입하는 “블랙박스”처럼 작동한다는 것입니다.

주의: Java agent와 Micrometer bridge를 같은 종류의 신호에 대해 동시에 실행하지 마세요. 중복된 span이나 context가 왜곡될 것입니다.

5. OpenTelemetry Collector 실행

애플리케이션은 백엔드로 직접 보내는 대신 Collector로 데이터를 보내야 합니다. Collector는 데이터를 batch로 처리하고, 필터링하며, 라우팅하는 곳입니다. 다음은 최소한의 설정입니다:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch: {}
  attributes/strip-pii:
    actions:
      - key: customer.email
        action: delete

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [attributes/strip-pii, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

이 설정은 네 부분으로 구성됩니다:

  • receivers.otlp는 gRPC(4317)와 HTTP(4318) 모두를 통해 데이터를 수신합니다.
  • attributes/strip-pii는 데이터가 인프라를 떠나기 전에 customer.email attribute를 삭제합니다.
  • batch는 요청 수를 줄이기 위해 데이터를 보내기 전에 묶습니다.
  • debug exporter는 검토를 위해 콘솔에 데이터를 출력합니다. 실제 환경에서는 사용 중인 백엔드의 exporter로 교체합니다. 이전 Collector 버전에서는 이 exporter를 logging이라고 불렀습니다.

Docker로 Collector를 시작합니다:

docker run --rm -p 4317:4317 -p 4318:4318 \
  -v "$(pwd)/otel-collector.yaml:/etc/otelcol-contrib/config.yaml" \
  otel/opentelemetry-collector-contrib

체크아웃 API를 호출해 보고 Collector의 콘솔을 확인하세요. 다음과 비슷한 span을 볼 것입니다:

Span #0
    Trace ID       : 4bf92f3577b34da6a3ce929d0e0e4736
    Parent ID      : 9a8b7c6d5e4f3a2b
    Name           : order.checkout
    Attributes:
         -> payment.method: Str(CARD)
         -> order.id: Str(A-1001)

6. Instrumentation 테스트

Instrumentation도 코드이므로 테스트가 필요합니다. TestObservationRegistry를 사용하면 Spring context나 Collector를 시작하지 않고도 observation을 확인할 수 있습니다:

import io.micrometer.observation.tck.TestObservationRegistry;
import io.micrometer.observation.tck.TestObservationRegistryAssert;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class CheckoutServiceTest {

    @Test
    void whenCheckout_thenObservationIsRecordedWithPaymentMethod() {
        TestObservationRegistry registry = TestObservationRegistry.create();
        PaymentService paymentService = order -> "PAID-" + order.id();
        CheckoutService service = new CheckoutService(registry, paymentService);

        String result = service.checkout(new Order("A-1001", "CARD", 250_000));

        assertThat(result).isEqualTo("PAID-A-1001");
        TestObservationRegistryAssert.assertThat(registry)
                .hasObservationWithNameEqualTo("order.checkout")
                .that()
                .hasLowCardinalityKeyValue("payment.method", "CARD")
                .hasHighCardinalityKeyValue("order.id", "A-1001")
                .hasBeenStarted()
                .hasBeenStopped();
    }
}

이 테스트는 세 가지를 확인합니다:

  • 비즈니스 로직의 결과가 여전히 맞습니다.
  • order.checkout observation이 생성되었으며, 올바른 attribute를 포함합니다.
  • Observation이 열렸다 닫혔습니다. span을 닫는 것을 잊는 것은 production에서 찾기 매우 어려운 버그입니다.

7. 흔한 실수

  • Cardinality 폭발: userId, orderId 또는 파라미터를 포함하는 URL을 metric 태그에 넣으면 metric 시리즈 수가 폭증하고 저장 비용이 증가합니다. 이 값들은 span에만 첨부해야 합니다.
  • Production에서 100% 샘플링: 데이터 량과 비용이 트래픽에 따라 선형으로 증가합니다. probability를 줄이거나 Collector에서 tail sampling을 사용하여 오류 trace나 느린 trace를 유지하세요.
  • 비동기 실행 시 context 손실: @Async, CompletableFuture, 또는 자신이 만든 스레드 풀을 사용할 때, context는 자동으로 새 스레드로 따라가지 않습니다. context-propagation 라이브러리의 ContextExecutorService 같은 context propagation 메커니즘으로 executor를 감싸거나, Spring이 관리하는 미리 구성된 executor를 사용해야 합니다.
  • 민감한 데이터 유출: 이메일, 카드 번호, 토큰이 attribute나 로그에 유출될 수 있습니다. 코드에서 즉시 필터링하고 Collector에 추가 필터링 레이어를 추가하세요.
  • 임의의 이름 지정: semantic conventions를 따르지 않으면 대시보드와 경고가 서비스 간에 재사용하기 어려워집니다.
  • Collector 건너뛰기: 애플리케이션에서 백엔드로 직접 보내면 백엔드를 변경할 때마다 모든 서비스를 다시 배포해야 합니다.

8. 언제 사용하지 말아야 할까 (또는 아직 필요 없을 때)

  • 작은 애플리케이션, 단일 인스턴스, 적은 의존성: 구조화된 로그와 Actuator의 몇 가지 메트릭이 보통 충분합니다. “분산된” 것이 없을 때 distributed tracing은 거의 가치를 제공하지 않습니다.
  • 팀에 백엔드와 운영자가 없는 경우: OTel은 단순한 파이프라인입니다. 아무도 데이터를 보지 않으면 추가 CPU, 네트워크, 저장 비용만 지불하게 됩니다.
  • 극도로 지연 시간이나 리소스에 민감한 시스템: 예를 들어 임베디드 애플리케이션이나 마이크로초 단위의 hot path. Instrumentation을 광범위하게 활성화하기 전에 오버헤드를 측정하세요.
  • OTel이 한 번에 기존 모든 도구를 대체하기를 기대: 각 신호와 언어별 성숙도가 다를 수 있습니다. 사용하려는 부분의 지원 상태를 확인하고 단계별로 전환하되, tracing부터 시작하세요.

9. 결론

OpenTelemetry는 공통 API, OTLP 프로토콜, 그리고 Collector를 통해 trace, metric, log를 생성하고 전달하는 방식을 통일합니다. 덕분에 코드의 instrumentation을 백엔드 선택과 분리할 수 있습니다.

Spring Boot 3에서는 가장 간단한 방법이 네 가지 단계로 이루어집니다. 첫째, Micrometer Observation을 사용하여 한 번의 측정으로 metric과 span을 모두 생성합니다. 둘째, OTLP를 통해 Collector로 export합니다. 셋째, traceId를 로그에 삽입합니다. 넷째, 중요한 observation에 대한 테스트를 작성합니다. 동시에 cardinality, sampling, 그리고 민감한 데이터를 처음부터 제어하세요.

시스템이 아직 작다면 서두를 필요가 없습니다. 서비스 수가 증가하고 디버깅에 수 시간이 소요되기 시작하면, OTel은 투자할 가치가 있는 기반입니다.

opentelemetryobservabilityspring-boottracingtech-trends