본문 바로가기
Infra2026년 9월 5일23분 읽기

OpenTelemetry 실전 — 트레이스·메트릭·로그를 하나로 묶는 벤더 중립 관측성

YS
김영삼
조회 7
OpenTelemetry 실전 — 트레이스·메트릭·로그를 하나로 묶는 벤더 중립 관측성

OpenTelemetry(OTel)는 트레이스·메트릭·로그를 하나의 표준 SDK와 OTLP 프로토콜로 수집해, 어떤 백엔드(Jaeger·Prometheus·Grafana·상용 SaaS)에도 그대로 흘려보내는 벤더 중립 계측 표준이다. 핵심은 "코드는 한 번 계측하고, 백엔드는 나중에 바꾼다"는 것.

이 글은 실제로 서비스에 OTel을 붙여본 관점에서 정리한다. 자동/수동 계측, W3C 컨텍스트 전파, 스팬을 어떻게 쪼개고 무엇을 담을지, head/tail 샘플링, 왜 Collector를 반드시 앞단에 두는지, 그리고 나를 가장 여러 번 데게 만든 메트릭 카디널리티 함정까지 다룬다.

몇 년 전만 해도 관측성 도구를 고르는 건 곧 계측 코드를 그 벤더에 묶는 일이었다. APM 에이전트를 심으면 나중에 갈아탈 때 계측을 통째로 다시 해야 했다. OTel이 바꾼 건 딱 그 지점, 계측과 백엔드의 분리다. 개인적으로는 이 이유만으로도 신규 서비스엔 무조건 OTel부터 깔고 시작한다.

항목 핵심 사실
시그널(signals)Traces·Metrics·Logs(+ Profiles가 뒤늦게 합류)
전송 프로토콜OTLP(gRPC 4317 / HTTP 4318)
컨텍스트 전파W3C Trace Context — traceparent / tracestate 헤더
안정성 상태Traces·Metrics·Logs 스펙 GA(stable), Profiles는 development
거버넌스CNCF 졸업 프로젝트(2021년 인큐베이팅), Kubernetes 다음가는 활동량
권장 배포 형태앱 → OTel Collector → 백엔드(1개 이상)

OpenTelemetry가 실제로 해결하는 문제는 뭔가

한마디로 계측의 표준화와 벤더 종속 탈피다. 애플리케이션 코드에는 OTel API로만 계측을 넣고, 데이터를 어디로 보낼지는 설정(환경변수·Collector 설정)으로 결정한다. 그래서 Jaeger로 보다가 Grafana Tempo로, 다시 Datadog으로 옮겨도 애플리케이션 코드는 한 줄도 안 바뀐다.

OTel은 세 층으로 나뉜다. (1) API — 코드가 의존하는 얇은 인터페이스, (2) SDK — 실제 구현(샘플러·프로세서·익스포터), (3) Collector — 수집·가공·라우팅을 담당하는 별도 프로세스. 라이브러리를 만든다면 API에만 의존해야 한다. 그래야 그 라이브러리를 쓰는 애플리케이션이 SDK 유무·버전을 자유롭게 고른다. 이 분리를 이해 못 하고 라이브러리에 SDK를 박아넣는 실수를 종종 본다.

그리고 OTel의 진짜 무기는 상관관계(correlation)다. 같은 trace_id가 스팬에도, 그 순간의 로그에도, exemplar를 통해 메트릭에도 붙는다. 에러율 그래프의 한 지점을 찍으면 그 요청의 트레이스로, 다시 그 스팬이 남긴 로그로 넘어간다. 예전엔 이 연결을 사람이 timestamp 맞춰가며 눈으로 했다. 그게 사라진다.

트레이스·메트릭·로그, 세 시그널의 역할 분담

셋은 대체재가 아니라 각자 답하는 질문이 다르다. 메트릭은 "무엇이 잘못됐나"(에러율·지연·처리량의 추세), 트레이스는 "어디서 잘못됐나"(요청이 서비스들을 지나며 어디서 느려졌나), 로그는 "왜 잘못됐나"(그 순간의 상세 컨텍스트)에 답한다. 조사 흐름도 보통 이 순서다.

시그널 답하는 질문 비용 특성 주의점
Metrics무엇이·언제 잘못됐나저렴하나 카디널리티에 폭발라벨에 고유값 금지
Traces어디서 느리거나 실패했나양 많음 → 샘플링 필수전파 끊기면 반쪽
Logs왜 그렇게 됐나(상세)가장 비쌈(저장·인덱싱)trace_id 주입해야 값어치

실무 팁 하나. 로그에 trace_id를 안 넣으면 OTel을 쓰는 의미의 절반이 날아간다. 로그와 트레이스를 잇는 유일한 끈이 trace_id이기 때문이다. OTel의 로그 연동을 쓰면 활성 스팬의 trace_id·span_id가 로그 레코드에 자동으로 붙는데, 이걸 놓치는 팀이 의외로 많다.

계측: 자동이 먼저, 수동은 도메인에

계측에는 두 가지가 있다. 자동 계측(auto-instrumentation)은 HTTP 서버·클라이언트, DB 드라이버, 메시지 큐 같은 표준 라이브러리 호출을 프레임워크가 알아서 스팬으로 감싼다. 수동 계측은 내 도메인 로직("결제 검증", "재고 차감")에 직접 스팬과 속성을 넣는 것이다. 순서는 항상 자동부터다. 자동만으로도 서비스 경계와 DB 쿼리는 다 보이니까.

Node.js는 코드를 안 건드리고도 붙는다. 이게 자동 계측의 매력이다.

# Node.js 자동 계측 — 앱 코드 수정 없이 실행 명령만 감싼다
npm install @opentelemetry/api \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/sdk-node

# OTLP 목적지와 서비스 이름은 환경변수로만 지정
export OTEL_SERVICE_NAME=checkout-api
export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
export OTEL_TRACES_SAMPLER=parentbased_traceidratio
export OTEL_TRACES_SAMPLER_ARG=0.1   # 루트 요청의 10%만 채집

# --require 로 SDK를 프로세스 시작 전에 로드
node --require @opentelemetry/auto-instrumentations-node/register app.js

그다음 진짜 값어치는 수동 스팬에서 나온다. 자동 계측은 "HTTP POST /orders가 800ms 걸렸다"까지만 알려주지, 그 안에서 재고 확인이 느렸는지 결제 게이트웨이가 느렸는지는 내가 나눠줘야 보인다. Python 예시.

# Python — 도메인 로직에 수동 스팬 + 의미 있는 속성
from opentelemetry import trace

tracer = trace.get_tracer("orders.service")

def place_order(order):
    # start_as_current_span: 컨텍스트에 자동 등록되어 자식 스팬이 알아서 연결됨
    with tracer.start_as_current_span("place_order") as span:
        # 낮은 카디널리티 속성만 (order_id 같은 고유값은 트레이스엔 OK, 메트릭 라벨엔 금지)
        span.set_attribute("order.item_count", len(order.items))
        span.set_attribute("payment.method", order.payment_method)  # card/paypal 등 유한 집합
        try:
            reserve_inventory(order)      # 여기서 열리는 스팬은 자식으로 붙는다
            charge_payment(order)
        except PaymentError as e:
            # 예외 기록 + 스팬 상태를 ERROR로 (이게 있어야 에러 트레이스 필터가 먹는다)
            span.record_exception(e)
            span.set_status(trace.StatusCode.ERROR, "payment failed")
            raise
참고 속성 이름은 마음대로 짓지 말고 Semantic Conventions를 따르는 게 좋다. http.request.method, db.system, server.address 같은 표준 키를 쓰면 백엔드의 대시보드와 자동 계측이 같은 언어로 말한다. 예전엔 http.method였다가 http.request.method로 바뀐 것처럼 이름이 변한 것들이 있으니, 이미 stable로 굳은 최신 규약을 확인하고 쓰는 게 안전하다.

컨텍스트 전파 — 트레이스가 서비스를 건너는 법

분산 트레이스의 생사는 컨텍스트 전파(context propagation)에 달렸다. 서비스 A가 B를 호출할 때 현재 trace_id와 span_id를 HTTP 헤더에 실어 보내야, B가 만든 스팬이 같은 트레이스에 이어붙는다. 표준은 W3C Trace Context이고, 실제 헤더는 traceparent 하나로 끝난다.

# traceparent 헤더 구조 (버전-트레이스ID-부모스팬ID-플래그)
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  └ 32 hex (16B) trace_id      └ 16 hex (8B) span_id  └ 01 = sampled
             └ version

# tracestate 는 벤더별 부가 정보(선택). 예:
tracestate: rojo=00f067aa0ba902b7,congo=t61rcWkgMzE

HTTP 자동 계측을 켜두면 이 주입/추출은 대개 알아서 된다. 문제는 자동 계측이 닿지 않는 경계다. 나는 이걸로 몇 번 데였다. Kafka·SQS 같은 메시지 큐, 커스텀 RPC, 배치 잡의 경계에서 traceparent를 손으로 실어 보내지 않으면 트레이스가 거기서 뚝 끊긴다. 프로듀서에서 메시지 헤더에 컨텍스트를 inject하고, 컨슈머에서 extract해 새 스팬의 부모로 이어줘야 한다.

# Python — 큐 메시지에 컨텍스트 수동 주입/추출
from opentelemetry.propagate import inject, extract
from opentelemetry import trace, context

# [프로듀서] 현재 컨텍스트를 메시지 헤더(dict)에 주입
def publish(payload):
    headers = {}
    inject(headers)                      # traceparent 등이 headers에 채워짐
    queue.send(payload, headers=headers)

# [컨슈머] 헤더에서 부모 컨텍스트를 복원해 스팬을 이어붙임
def consume(msg):
    ctx = extract(msg.headers)           # 상류의 trace 컨텍스트 복원
    tracer = trace.get_tracer("worker")
    with tracer.start_as_current_span("process_msg", context=ctx):
        handle(msg)
주의 여러 언어·구버전 에이전트가 섞인 환경이라면 전파 포맷을 OTEL_PROPAGATORS로 맞춰야 한다. B3(Zipkin)를 쓰던 서비스와 W3C를 쓰는 서비스가 만나면 헤더 이름이 달라 트레이스가 갈라진다. 과도기엔 tracecontext,b3multi처럼 여러 개를 동시에 켜두면 둘 다 추출된다.

스팬 설계 — 얼마나 잘게, 무엇을 담나

좋은 스팬은 "의미 있는 작업 단위" 하나에 대응한다. 너무 잘게 쪼개면(루프 반복마다 스팬) 트레이스가 수천 개 스팬으로 부풀어 읽기도, 저장하기도 곤란하다. 반대로 하나의 거대 스팬만 있으면 어디가 느린지 안 보인다. 경험칙은 외부 호출(DB·HTTP·큐)과 비용이 큰 도메인 단계마다 하나. 반복은 스팬 대신 속성이나 이벤트로 요약한다.

  • 스팬 이름은 저카디널리티로: GET /users/{id}가 맞다. GET /users/12345처럼 ID를 이름에 박으면 스팬 이름이 무한히 늘어나 그룹핑이 깨진다. ID는 속성으로.
  • 속성(attribute) vs 이벤트(event): 스팬 전체를 설명하는 값은 속성, "언제 무슨 일이 일어났다"는 순간 기록은 이벤트(add_event). 예외는 record_exception으로 이벤트화된다.
  • 에러는 반드시 status=ERROR: 이걸 안 걸면 "실패한 트레이스만 보기" 필터가 무용지물이 된다. 예외를 기록만 하고 상태를 안 바꾸는 실수가 흔하다.
  • SpanKind를 맞춰라: SERVER/CLIENT/PRODUCER/CONSUMER/INTERNAL. 이게 정확해야 서비스 맵과 지연 집계가 제대로 그려진다.

개인적으로는 도메인 스팬에 "이 요청이 왜 이렇게 흘렀는지"를 나중의 내가 이해할 만큼만 속성을 담는다. cache.hit=true, retry.count=2, feature.flag=new_checkout 같은 것들. 이런 속성 하나가 장애 원인 분석에서 30분을 아껴준 적이 여러 번이다.

샘플링 — head냐 tail이냐

트래픽이 조금만 커져도 모든 트레이스를 저장하는 건 비현실적이다. 그래서 샘플링으로 일부만 남긴다. 크게 둘로 나뉜다.

Head 샘플링은 트레이스 시작 시점(루트 스팬)에서 "남길지"를 확률로 결정한다. 결정이 traceparent 플래그로 전파되어 트레이스 전체가 일관되게 남거나 버려진다. 싸고 단순한 대신, 문제가 있는 트레이스인지 시작할 때는 알 수 없다. 느린 요청이나 에러가 샘플링에서 탈락할 수 있다.

Tail 샘플링은 트레이스가 끝난 뒤 전체를 보고 결정한다. "에러가 있으면 100% 보관, 2초 이상 걸렸으면 보관, 나머지는 5%"처럼. 대신 한 트레이스의 모든 스팬이 한 곳에 모여야 판단할 수 있어, Collector가 스팬을 buffering해야 하고 메모리를 먹는다. tail 샘플링은 Collector에서 처리한다.

# OTel Collector — tail_sampling 프로세서 (에러/지연은 살리고 나머지는 확률로)
processors:
  tail_sampling:
    decision_wait: 10s          # 트레이스 완성 대기 (이 시간 안에 온 스팬만 판단)
    num_traces: 100000
    policies:
      - name: keep-errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: keep-slow
        type: latency
        latency: { threshold_ms: 2000 }
      - name: sample-rest
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

현실적인 조합은 이렇다. 애플리케이션에서는 head 샘플링을 아예 끄거나 100%로 두고(parentbased_always_on), 실제 취사선택은 Collector의 tail 샘플링에 맡긴다. 그래야 "에러는 다 보고 싶다"는 요구를 만족시킨다. head를 10%로 걸어두면 정작 필요한 에러 트레이스의 90%가 이미 앱에서 사라진 뒤라 tail이 손쓸 게 없다. 이 순서를 거꾸로 잡아 낭패 본 팀을 봤다.

Collector를 왜 항상 앞단에 두나

앱에서 백엔드로 직접 쏠 수도 있지만, 프로덕션이라면 거의 항상 Collector를 중간에 둔다. 이유는 명확하다. 백엔드 주소·인증·재시도·배치·필터링 같은 운영 설정을 앱 바깥으로 빼기 위해서다. 백엔드를 바꾸거나 하나 더 추가할 때 수십 개 서비스를 재배포하는 대신 Collector 설정만 고치면 된다.

Collector 파이프라인은 receivers → processors → exporters 세 단계다. 받고, 가공하고, 내보낸다. 여러 백엔드로 동시에 fan-out도 가능하다(트레이스는 Tempo, 메트릭은 Prometheus로).

# Collector 최소 파이프라인 — OTLP로 받아 배치 후 여러 백엔드로
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  batch: { timeout: 5s, send_batch_size: 1024 }   # 네트워크 왕복 줄이기
  memory_limiter: { check_interval: 1s, limit_mib: 512 }  # OOM 방지 (맨 앞에 두기)

exporters:
  otlphttp/tempo:   { endpoint: http://tempo:4318 }
  prometheus:       { endpoint: 0.0.0.0:8889 }

service:
  pipelines:
    traces:  { receivers: [otlp], processors: [memory_limiter, batch], exporters: [otlphttp/tempo] }
    metrics: { receivers: [otlp], processors: [memory_limiter, batch], exporters: [prometheus] }
참고 배포 형태는 보통 두 가지를 섞는다. 노드마다 붙는 agent(=DaemonSet, sidecar)는 앱 근처에서 빠르게 받아 리소스 속성(k8s.pod.name 등)을 붙이고, 그 뒤의 gateway(중앙 집중 Collector 풀)에서 tail 샘플링·집계 같은 무거운 작업을 한다. tail 샘플링은 gateway에서, 그것도 트레이스 ID 기준으로 로드밸런싱해 같은 트레이스가 같은 인스턴스로 가게 해야 제대로 동작한다.

카디널리티 함정 — 비용이 폭발하는 지점

이게 OTel(정확히는 메트릭)에서 나를 가장 여러 번 곤란하게 만든 주제다. 메트릭의 비용은 라벨(속성) 조합의 개수, 즉 카디널리티에 비례해 곱셈으로 커진다. 각 유니크한 라벨 조합이 별도의 시계열(time series)이 되기 때문이다.

예를 들어 http_requests_total에 라벨을 method(5) × status(10) × endpoint(20)만 붙이면 1,000개 시계열이다. 여기에 무심코 user_id를 넣는 순간, 사용자가 10만 명이면 시계열이 10만 배로 튄다. Prometheus 메모리가 터지거나 SaaS 청구서가 자릿수를 바꾼다. 나는 실제로 customer_id를 메트릭 라벨에 넣었다가 스테이징에서 Collector가 OOM으로 죽는 걸 보고서야 이 규칙을 몸으로 익혔다.

  • 메트릭 라벨에 절대 넣지 말 것: user_id, request_id, trace_id, 이메일, URL 전체 경로(ID 포함), 타임스탬프, 랜덤/UUID. 즉 값의 종류가 무한히 늘 수 있는 것.
  • 이런 고유값은 트레이스로: 트레이스의 스팬 속성엔 user_id를 넣어도 된다. 트레이스는 시계열이 아니라 개별 레코드라 곱셈 폭발이 없다. "고유 식별자는 트레이스/로그, 유한 범주는 메트릭"이 핵심 분업이다.
  • 엔드포인트는 템플릿으로: /users/{id}정규화. 경로에 raw ID가 남으면 그게 곧 무한 카디널리티다.
  • 방어선을 Collector에: 실수는 반드시 난다. attributes 프로세서로 위험 라벨을 삭제/해싱하거나, filter로 특정 라벨을 드롭해 앱 실수가 백엔드까지 안 가게 막는다.
# Collector 방어선 — 고유값 라벨을 백엔드로 내보내기 전에 제거
processors:
  attributes/scrub:
    actions:
      - key: user.id        # 실수로 붙은 고유값 라벨을 통째로 삭제
        action: delete
      - key: http.target    # ID 포함 경로 → 삭제(대신 http.route 사용)
        action: delete

현장 교훈 카디널리티 사고는 조용히 쌓이다 한 번에 터진다. 배포 직후엔 멀쩡하다가, 트래픽이 실제 사용자 분포를 타면서 시계열이 며칠에 걸쳐 불어난다. Collector·백엔드의 "활성 시계열 수" 자체를 메트릭으로 감시하고 알람을 걸어두는 걸 강력히 권한다. 터진 뒤 청구서로 알게 되는 것보다 백 배 낫다.

백엔드 무관 — 갈아타기와 이중 송출

OTel의 약속은 "한 번 계측, 어디로든 전송"이다. 앱은 OTLP로만 뱉고, 목적지는 Collector의 exporter가 정한다. 그래서 무중단 마이그레이션이 쉽다. 같은 데이터를 신구 백엔드 두 곳에 exporter로 fan-out하고, 새 백엔드에서 대시보드가 재현되는지 확인한 뒤 옛 exporter를 뗀다. 벤더 에이전트 시절엔 상상하기 힘든 방식이다.

물론 공짜는 아니다. 백엔드마다 UI·쿼리 언어·상관관계 구현이 다르니 대시보드와 알람은 다시 만들어야 한다. 그래도 계측 코드가 안 바뀐다는 것 — 이게 데이터의 소유권을 벤더가 아니라 나에게 남겨준다.

자주 묻는 질문

OpenTelemetry를 쓰면 Prometheus나 Jaeger는 버려야 하나요?

아니다. OTel은 계측·전송 표준이지 저장·시각화 백엔드가 아니다. Prometheus는 메트릭 저장/쿼리 백엔드로, Jaeger·Tempo는 트레이스 백엔드로 그대로 쓴다. OTel Collector가 그 백엔드들로 데이터를 보내주는 역할을 한다. 둘은 경쟁이 아니라 조합이다.

자동 계측만으로 충분한가요?

시작점으로는 충분하다. HTTP·DB·큐 같은 경계가 다 보이니까. 하지만 도메인 로직의 병목("재고 확인이 느렸다")이나 비즈니스 속성("어떤 결제 수단이었나")은 수동 스팬·속성으로 직접 넣어야 보인다. 자동으로 넓게 깔고, 문제가 자주 나는 곳에 수동으로 깊이를 더하는 순서를 권한다.

head 샘플링과 tail 샘플링, 뭘 써야 하나요?

에러와 느린 요청을 놓치기 싫다면 tail 샘플링이 답이다. 트레이스를 끝까지 본 뒤 결정하므로 "에러는 전부 보관" 같은 규칙이 가능하다. 대신 Collector 메모리와 gateway 구성 부담이 있다. 트래픽이 작고 단순하다면 head 샘플링(확률)만으로도 충분하다. 실무에선 앱은 100%로 보내고 tail에서 거르는 조합이 흔하다.

메트릭 카디널리티가 터졌는지 어떻게 아나요?

백엔드의 활성 시계열 수(active series)가 갑자기 우상향하면 신호다. Prometheus라면 prometheus_tsdb_head_series 같은 내부 메트릭을, SaaS라면 사용량 대시보드를 감시한다. 원인은 대개 라벨에 들어간 고유값(user_id, raw URL)이다. Collector의 attributes/filter 프로세서로 방어선을 미리 쳐두는 게 최선이다.

앱에서 백엔드로 직접 보내면 안 되나요? 꼭 Collector가 필요한가요?

개발·PoC 단계라면 직접 보내도 된다. 하지만 프로덕션에선 Collector를 두는 걸 강력히 권한다. 백엔드 주소·인증·배치·재시도·샘플링·필터링을 앱 밖으로 빼서, 운영 변경 시 수십 개 서비스를 재배포하지 않아도 되기 때문이다. tail 샘플링과 다중 백엔드 fan-out도 Collector가 있어야 가능하다.

로그도 OTel로 통합해야 하나요?

가장 큰 이득은 trace_id 연결이다. 기존 로그 파이프라인(예: 파일 → Fluent Bit)이 잘 돌고 있다면 급히 갈아엎을 필요는 없지만, 로그 레코드에 활성 스팬의 trace_id·span_id를 주입하는 것만이라도 하면 트레이스↔로그 점프가 열린다. 이것만으로 디버깅 속도가 확 달라진다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록