grep

Backend

OpenTelemetry와 Kafka를 활용한 안정적인 Observability 구축기

홍성현Charles(찰스) / 공통플랫폼개발팀여기어때

2025년 11월 21일

원문에서 보기 ↗

안녕하세요. 여기어때컴퍼니 공통플랫폼개발팀의 찰스입니다.

현대의 마이크로서비스 아키텍처(MSA) 환경에서 시스템의 복잡성은 계속해서 증가하고 있습니다. 수십, 수백 개의 서비스가 서로 유기적으로 통신하며 하나의 기능을 완성하기에, 장애가 발생하면 그 원인을 찾고 시스템의 병목 지점을 분석하는 일은 전통적인 모니터링 방식만으로는 한계가 존재합니다.

저희는 지난 ‘모니터링에서 옵저버빌리티로 더 나은 시스템 이해를 위한 여정’ 편에서, 바로 이러한 전통적인 모니터링 방식이 가진 한계와 시스템을 더 깊이 이해하기 위한 ‘관찰 가능성(Observability)’의 필요성에 대해 자세히 다룬 바 있습니다.

저희 여기어때에서는 이러한 복잡성을 해결하기 위해 Cloud Native Computing Foundation (CNCF) 프로젝트인 OpenTelemetry를 도입하여, 시스템 내부를 깊이 들여다보며 문제를 신속하게 진단할 수 있는 안정적인 Observability 환경을 구축할 수 있었습니다. 이번 글에서는 OpenTelemetry의 기본 개념부터 저희가 어떤 과정을 통해 현재의 아키텍처를 완성하게 되었는지, 그 경험을 공유 드리고자 합니다.

OpenTelemetry: Observability의 표준

시스템에 문제가 생겼을 때 단순히 ‘CPU 사용량이 80%를 넘었다’와 같이 이미 알고 있는 시나리오를 확인하는 것을 모니터링 이라고 합니다. 반면, 관측 가능성(Observability) 은 ‘왜 특정 안드로이드 버전 사용자 그룹에서만 주문이 실패하고 있을까?’처럼 전혀 예측하지 못했던 복잡한 질문에 답할 수 있는 시스템의 능력을 의미합니다. OpenTelemetry는 바로 이 관측 가능성을 실현하기 위해 등장한 표준 기술로, 복잡한 시스템의 상태를 이해하는 데 필요한 데이터(Traces, Metrics, Logs)를 어떻게 수집하고 전송할지에 대한 업계 표준 약속이라고 생각할 수 있습니다.

https://opentelemetry.io/img/otel-diagram.svg

조금 더 기술적으로 정의하면, OpenTelemetry는 “Telemetry 데이터(Traces, Metrics, Logs)의 생성 및 수집을 표준화하기 위한 도구, API, SDK의 모음”입니다. CNCF의 주요 프로젝트 중 하나로, 기존의 분산 추적 표준이던 OpenTracing과 OpenCensus가 통합되어 탄생했습니다. 과거에는 Datadog, New Relic 같은 각 Observability 솔루션이 제공하는 독자적인 Agent와 SDK를 사용해야 했습니다. 이는 특정 벤더에 기술적, 비용적으로 종속되는 ‘Vendor Lock-in’ 문제를 일으킵니다. 예를 들어, 비용이나 정책상의 이유로 다른 솔루션으로 전환하려면, 데이터를 수집하는 모든 애플리케이션의 코드를 다시 수정해야 하는 막대한 엔지니어링 비용이 발생하게 됩니다.

OpenTelemetry는 이러한 문제를 해결하여 OTel 표준 API에 맞춰 애플리케이션을 한 번만 계측(Instrumentation)하면, 이후에는 Collector 설정 변경만으로 데이터를 원하는 어떤 백엔드로든 자유롭게 전송할 수 있습니다. 이는 마치 전 세계 어디서나 사용할 수 있는 ‘여행용 멀티 어댑터’처럼, 우리의 시스템을 특정 기술에 얽매이지 않고 비즈니스와 기술 환경 변화에 유연하게 대응할 수 있는 강력한 기반이 되어줍니다.

이러한 표준화를 실현하기 위해 OpenTelemetry 생태계의 모든 구성 요소는 OTLP(OpenTelemetry Protocol)라는 공통 언어를 사용합니다. OTLP는 Traces, Metrics, Logs 데이터를 SDK에서 Collector로, 또는 Collector에서 백엔드로 전송할 때 사용되는 표준화된 데이터 형식과 전송 방식을 정의합니다. OTLP는 고성능의 gRPC 또는 HTTP 통신을 기반으로 대용량의 Telemetry 데이터를 효율적으로 처리하며, 이를 통해 SDK 언어와 백엔드 시스템에 상관없이 일관된 데이터 전송을 보장합니다.

OpenTelemetry의 3가지 핵심 요소: Traces, Metrics, Logs

OpenTelemetry는 시스템을 관찰하기 위한 세 가지 핵심 데이터 유형(Signal)을 정의합니다. 이 세 가지 Signal이 유기적으로 결합될 때 비로소 시스템에 대한 깊이 있는 통찰력, 즉 Observability를 확보할 수 있습니다.

OpenTelemetry Collector: 데이터 파이프라인의 핵심

Collector는 애플리케이션에서 생성된 Telemetry 데이터를 수신(Receive), 처리(Process), 내보내기(Export)하는 역할을 수행하는 OpenTelemetry의 핵심 컴포넌트입니다. 애플리케이션 SDK와 백엔드 시스템 사이에서 중계자 역할을 하며, 데이터 처리 부담을 애플리케이션에서 분리하고 유연하며 강력한 파이프라인을 구성할 수 있게 해줍니다.

https://opentelemetry.io/docs/collector/img/otel-collector.svg

Collector는 다음과 같은 구성 요소로 이루어진 파이프라인을 통해 동작합니다.

이러한 구조 덕분에, 애플리케이션은 데이터 생성에만 집중할 수 있고, 데이터의 처리와 라우팅에 대한 복잡한 로직은 모두 Collector에서 중앙 관리할 수 있습니다.

여기어때 OpenTelemetry 구성

지금까지 OpenTelemetry의 핵심 개념과 Collector에 대해 알아보았습니다. 저희는 이러한 개념들을 바탕으로 안정성과 확장성, 그리고 관리 효율성을 고려하여 데이터 파이프라인을 구축했습니다.

지금부터는 저희가 현재의 아키텍처를 어떻게 설계하고 구성했는지, 그 과정을 다이어그램과 함께 단계별로 소개해 드리겠습니다.

여기어때 OpenTelemetry 구성

① Application에서 데이터 수집

모든 데이터 수집은 애플리케이션에 설치된 OpenTelemetry SDK 로부터 시작됩니다. 저희는 코드 변경을 최소화하고 도입 장벽을 낮추기 위해, SDK가 제공하는 자동 계측(Auto-Instrumentation) 방식을 주로 사용하고 있습니다. 이는 개발자가 직접 코드를 수정하지 않아도, SDK가 런타임에 널리 사용되는 라이브러리나 프레임워크(예: Spring, Django, Express.js)의 코드를 감지하고 자동으로 계측하는 강력한 기능입니다. 이를 통해 외부 HTTP 요청, DB 쿼리 등 표준적인 작업에 대한 Span 데이터가 자동으로 생성됩니다.

저희와 같은 쿠버네티스 환경에서는 OpenTelemetry Operator를 활용하여 이 과정을 더욱 자동화하고 중앙에서 관리할 수 있습니다. Operator를 사용하면, 개발자가 배포 명세(manifest)에 특정 어노테이션(annotation)만 추가하는 것만으로도 Operator가 Pod 생성 시점에 자동으로 SDK를 주입해 줍니다. 또한, SDK가 바라볼 Collector 주소나 샘플링 설정 등 모든 계측(Instrumentation) 관련 구성을 Operator의 Custom Resource(CR)를 통해 한곳에서 관리할 수 있어, 코드나 빌드 스크립트를 변경할 필요 없이 계측을 적용하고 설정을 일괄 변경할 수 있습니다.

이렇게 OpenTelemetry SDK로부터 생성된 데이터는 네트워크 효율성을 높이기 위해 SDK 내부에서 설정된 배치(Batch) 단위로 묶여 중앙 Gateway Collector로 전송됩니다.

② Gateway Collector에서 데이터 처리

Gateway Collector는 모든 애플리케이션의 SDK로부터 데이터를 수신하는 중앙 관문 역할을 합니다. Gateway라는 중앙 관문을 둠으로써 얻게 되는 이점은 다음과 같습니다.

이러한 이점들을 바탕으로, 저희는 Gateway Collector가 수신한 데이터를 processors를 통해 정제하고 exporters를 통해 Kafka로 전송하도록 파이프라인을 구성했습니다. 실제 설정 예시는 다음과 같습니다.

# Gateway Collector 설정 예시 (Helm Chart values.yaml)
mode: deployment
config:
  # ...
  processors:
    transform/drop_unneeded_resource_attributes:
      error_mode: ignore
      trace_statements:
        - context: resource
          statements:
            - delete_key(attributes, "process.command_args")
            - delete_key(attributes, "process.executable.path")
            # ... (불필요한 속성들 제거)
    transform/add_default_namespace:
      error_mode: ignore
      trace_statements:
        - context: resource
          statements:
            - set(attributes["service.namespace"], "unknown_namespace") where attributes["service.namespace"] == nil
    transform/truncate_log_body:
      error_mode: ignore
      log_statements:
        - context: log
          statements:
            - set(body.string, Substring(body.string, 0, 10000)) where Len(body.string) > 10000
  exporters:
    kafka:
      # ...
      sending_queue:
        queue_size: 1000000000
        sizer: bytes
        batch:
          flush_timeout: 200ms
          max_size: 1000000
          min_size: 1
  service:
    pipelines:
      logs:
        receivers:
          - otlp
        processors:
          - memory_limiter
          - transform/add_default_namespace
          - transform/drop_unneeded_resource_attributes
          - transform/truncate_log_body
          - batch
        exporters:
          - kafka
      traces:
        # ...
      metrics:
        # ...

주요 설정

③ Kafka: 안정성과 확장성의 핵심

이렇게 Gateway Collector를 거친 모든 데이터는 각각의 Signal(Logs, Traces, Metrics)에 맞는 Kafka 토픽으로 전달됩니다. 이처럼 Kafka를 파이프라인 중간에 둠으로써 얻게 된 이점은 다음과 같습니다.

④ Signal Collector에서 최종 처리

Kafka의 각 토픽으로 전달된 데이터는 Traces, Metrics, Logs를 각각 전담하는 독립적인 Collector 그룹에 의해 소비(consume)됩니다. 저희는 Grafana LGTM 스택 을 활용하여, Trace는 Tempo , Metric은 Mimir , Log는 Loki 를 통해 데이터를 적재하고, Grafana를 통해 이 모든 데이터를 통합적으로 시각화하고 있습니다.

OpenTelemetry + Grafana LGTM

Collector를 이처럼 Signal별로 분리하여 구성하면 다음과 같은 이점을 얻을 수 있습니다.

다음은 Log Collector의 설정 예시입니다.

# Log Collector 설정 예시 (Helm Chart values.yaml)
mode: deployment
config:
  receivers:
    kafka:
      brokers:
        - "..."
      autocommit:
        enable: false  # default = true
      message_marking:
        after: true  # (default = false) 메시지가 성공적으로 export 된 후 commit
  processors:
    batch:
      send_batch_size: 500  # default = 8192
      send_batch_max_size: 500  # default = 0
  exporters:
    otlphttp:
      endpoint: "..."
      sending_queue:
        enabled: false  # (default = true)
  service:
    pipelines:
      logs:
        receivers:
          - kafka
        processors:
          - memory_limiter
          - batch
        exporters:
          - otlphttp
# KEDA
extraManifests:
  - apiVersion: keda.sh/v1alpha1
    kind: ScaledObject
    metadata:
      name: "{{ .Values.fullnameOverride }}-keda-kafka-scaledobject"
      namespace: "{{ .Release.Namespace }}"
    spec:
      scaleTargetRef:
        name: "{{ .Values.fullnameOverride }}"
      minReplicaCount: 1
      maxReplicaCount: 6
      pollingInterval: 30
      triggers:
        - type: kafka
          metadata:
            bootstrapServers: '{{ join "," .Values.config.receivers.kafka.brokers }}'
            consumerGroup: "{{ .Values.config.receivers.kafka.group_id }}"
            topic: "{{ .Values.config.receivers.kafka.logs.topic }}"
            lagThreshold: "500"
            offsetResetPolicy: "latest"
            limitToPartitionsWithLag: "true"
          authenticationRef:
            name: "{{ .Values.fullnameOverride }}-keda-trigger-auth-kafka-credential"

주요 설정

맺음말

지금까지 OpenTelemetry의 기본 개념과, 이를 활용하여 여기어때가 어떻게 안정적인 Observability 아키텍처를 구축했는지 그 과정을 공유해 드렸습니다.

OpenTelemetry와 Kafka를 활용한 현재의 아키텍처를 통해 저희는 벤더 종속성에서 벗어나 유연하고 확장 가능한 데이터 파이프라인을 구축했으며, 어떤 상황에서도 Telemetry 데이터를 유실 없이 안정적으로 수집할 수 있는 견고한 기반을 마련했습니다.

이 글이 복잡한 MSA 환경에서 안정적인 Observability를 고민하는 분들께 조금이나마 도움이 되기를 바랍니다.

긴 글 읽어주셔서 감사합니다.