OpenTelemetry Collector Gateway 패턴으로 멀티 서비스 텔레메트리 파이프라인 단일화하기
마이크로서비스를 10개, 20개 넘어 운영하다 보면 어느 순간 텔레메트리가 진짜 골치가 됩니다. 트레이스는 Jaeger로, 메트릭은 Prometheus로, 로그는 Elasticsearch로 각각 쏘다 보니 서비스마다 SDK 설정이 제각각이고, 새 백엔드를 추가할 때마다 수십 개 서비스를 다 건드려야 합니다. 저도 처음엔 "그냥 각 서비스에서 직접 내보내면 되는 거 아냐?"라고 생각했는데, 서비스가 늘어날수록 이 방식이 얼마나 무서운 기술 부채가 되는지 몸소 경험했습니다.
OpenTelemetry Collector Gateway 패턴은 이 문제를 근본적으로 해결합니다. 각 서비스가 단 하나의 OTLP 엔드포인트만 알면 되고, 백엔드 추가·교체·필터링 로직은 전부 Gateway 설정 파일 수준에서 끝납니다. 이 글에서는 Processor 체이닝으로 데이터를 안전하게 가공하는 방법과, Exporter 팬아웃으로 여러 백엔드에 동시에 데이터를 분배하는 설계를 실제 코드와 함께 풀어봅니다. Tail sampling, Routing Connector, loadbalancingexporter까지 실무에서 자주 마주치는 시나리오를 중심으로 정리했습니다.
Gateway 패턴이 왜 지금 중요한가
현장에서 벌어지는 일
각 서비스가 직접 여러 백엔드로 텔레메트리를 쏘는 구조는 처음엔 단순해 보입니다. 그런데 OpenTelemetry Collector 후속 서베이 분석(2026)에 따르면, 응답자 다수가 다수의 Collector 인스턴스를 운영 중이고, Kubernetes와 함께 VM 기반 배포도 함께 늘고 있다고 정리됩니다(수치는 서베이 원문 참조). 하이브리드·멀티클라우드 환경이 보편화되면서 텔레메트리 흐름이 훨씬 복잡해졌다는 뜻입니다.
이런 환경에서 "각 서비스가 직접 전송"하는 구조는 세 가지 문제를 만들어냅니다.
- 변경 비용: 백엔드 하나를 추가하거나 교체하려면 모든 서비스를 재배포해야 합니다.
- Tail sampling 불가: 동일 트레이스의 스팬이 여러 서비스에 흩어져 있으면, 전체 트레이스를 보고 샘플링 여부를 결정하는 Tail sampling이 원천적으로 불가능합니다.
- 리소스 낭비: 각 서비스가 무거운 변환·필터링 로직을 SDK 레벨에서 실행하면 애플리케이션 리소스를 잡아먹습니다.
Agent-to-Gateway 이중 구조
Gateway 패턴에서 가장 흔하게 쓰이는 배포 형태는 Agent-to-Gateway 이중 구조입니다. 각 노드(또는 Pod)에는 경량 Agent Collector를 띄워 로컬 데이터 수집만 담당하게 하고, 중앙의 Gateway Collector에서 실제 처리를 전담하는 방식입니다.
Agent는 설정을 최소화해서 경량 상태를 유지합니다. Tail sampling이나 복잡한 변환처럼 CPU와 메모리를 많이 쓰는 처리는 전부 Gateway에 집중합니다. Kubernetes 환경이라면 Agent는 DaemonSet, Gateway는 Deployment로 배포하고 Gateway에만 HPA를 붙이면 됩니다.
Processor 체이닝 — 순서가 곧 안정성이다
왜 순서가 중요한가
솔직히 처음엔 Processor 순서를 대충 생각했습니다. "어차피 다 처리되는 거 아냐?"라고요. 그런데 memory_limiter를 파이프라인 뒤쪽에 배치했다가 OOM으로 Collector가 죽는 걸 경험하고 나서 생각이 바뀌었습니다.
권장 Processor 실행 순서는 다음과 같습니다.
memory_limiter → resource → attributes → filter → tail_sampling → batch이 순서에는 이유가 있습니다. memory_limiter가 맨 앞에서 메모리 압박 상황에서 데이터를 먼저 거부해야, 나중에 어차피 버려질 데이터에 대해 무거운 변환·샘플링 로직이 실행되는 낭비를 막을 수 있습니다. batch는 반드시 마지막에 와야 합니다. tail_sampling 앞에 batch를 두면 아직 완성되지 않은 트레이스가 배치로 묶여 샘플링 결정 자체가 예측 불가능해집니다.
실제 Gateway Collector 설정 예시
아래는 SaaS 환경에서 트레이스를 받아 Tail sampling 후 두 백엔드로 팬아웃하는 Gateway 설정입니다. 프로덕션 예시이므로 retry_on_failure와 sending_queue를 명시했고, OTTL 표현식 평가 에러가 파이프라인을 흔들지 않도록 filter processor에는 error_mode: ignore를 붙였습니다.
# gateway-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 1500
spike_limit_mib: 300
resource:
attributes:
- key: deployment.environment
value: production
action: upsert
attributes:
actions:
- key: http.user_agent
action: delete # PII 제거
filter/drop_health:
error_mode: ignore # 속성 부재 시 표현식 평가 에러 무시
traces:
span:
- 'attributes["http.target"] == "/health"'
tail_sampling:
decision_wait: 30s
num_traces: 50000
policies:
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
- name: slow-traces-policy
type: latency
latency:
threshold_ms: 1000
- name: probabilistic-policy
type: probabilistic
probabilistic:
sampling_percentage: 10
batch:
send_batch_size: 1000
timeout: 5s
exporters:
otlphttp/jaeger:
endpoint: https://jaeger-collector:4318
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 5m
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
datadog:
api:
key: ${env:DD_API_KEY}
retry_on_failure:
enabled: true
sending_queue:
enabled: true
queue_size: 5000
prometheusremotewrite:
endpoint: <YOUR_GRAFANA_CLOUD_PROM_ENDPOINT> # 예: https://prometheus-prod-XX-prod-us-east-0.grafana.net/api/prom/push
retry_on_failure:
enabled: true
service:
# 주의: 여기서는 단일 Gateway 인스턴스를 전제로 tail_sampling을 구성합니다.
# Gateway를 2개 이상으로 스케일아웃한다면 아래 "Tail sampling과 Gateway 스케일아웃" 절의
# loadbalancingexporter 구성을 반드시 함께 적용해야 트레이스가 분산되지 않습니다.
pipelines:
traces:
receivers: [otlp]
processors:
- memory_limiter
- resource
- attributes
- filter/drop_health
- tail_sampling
- batch
exporters: [otlphttp/jaeger, datadog]
metrics:
receivers: [otlp]
processors:
- memory_limiter
- resource
- batch
exporters: [prometheusremotewrite]filter/drop_health처럼 슬래시로 이름을 붙이면 같은 타입의 Processor를 여러 개 정의할 수 있습니다. 헬스체크 트래픽을 걸러내는 건 생각보다 중요한데, 이걸 안 하면 Tail sampling 버킷이 의미 없는 스팬으로 금방 차버립니다.
Exporter 팬아웃 — 백엔드를 늘려도 서비스는 모른다
팬아웃이 동작하는 방식
service.pipelines 아래 exporters 목록에 여러 Exporter를 나열하면, Collector 내부적으로 팬아웃 컨슈머가 마지막 Processor의 출력을 복제해서 모든 Exporter로 동시에 전달합니다. 서비스 코드는 아무것도 바꿀 필요가 없습니다.
한 SaaS 팀이 메트릭은 Grafana Cloud로, 트레이스는 Datadog으로, 로그는 Elasticsearch로 보내야 하는 상황에서 각 서비스 코드 변경 없이 Gateway 설정만으로 구현한 사례가 있습니다. 백엔드를 교체할 때도 설정 파일 변경 후 Collector를 재기동하는 것으로 끝났습니다.
백엔드 마이그레이션에 팬아웃 활용하기
팬아웃의 또 다른 활용은 마이그레이션 중 병렬 전송입니다. 기존 Jaeger에서 새 추적 백엔드로 전환하는 과정에서 두 백엔드에 동시에 데이터를 보내며 검증 기간을 운영하다가, 검증이 끝나면 구버전 Exporter 항목만 제거하면 됩니다. 서비스 재배포 없이 마이그레이션이 완료됩니다.
# 마이그레이션 중간 단계 설정 (개념적 예시, 재시도·큐 설정은 생략)
exporters:
otlphttp/old_jaeger:
endpoint: https://old-jaeger:4318
otlphttp/new_backend:
endpoint: https://new-tracing-backend:4318
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, tail_sampling, batch]
exporters: [otlphttp/old_jaeger, otlphttp/new_backend] # 동시 전송Routing Connector — 서비스별 파이프라인 분리
routingprocessor에서 routing connector로
routingprocessor는 오래된 컴포넌트로, contrib 저장소의 routingprocessor README와 routingconnector README에 기재된 상태·권장 사항을 확인한 뒤, 새 설계에서는 routing connector를 채택하는 것이 실무에서 굳어지고 있습니다(정확한 상태는 사용 중인 Collector 버전과 각 README의 최신 안내를 기준으로 판단하세요). Connector는 한 파이프라인에서는 Exporter로, 다음 파이프라인에서는 Receiver로 동작하는 특별한 컴포넌트라서, 파이프라인 단위 라우팅이 가능해 멀티테넌시 설계에 훨씬 유연합니다.
OTTL(OpenTelemetry Transformation Language) 조건식을 사용해서 특정 서비스나 속성의 데이터를 별도 파이프라인으로 분리할 수 있습니다.
connectors:
routing:
default_pipelines: [traces/default]
error_mode: ignore
table:
- statement: route() where resource.attributes["service.name"] == "payment-service"
pipelines: [traces/payment]
- statement: route() where resource.attributes["service.name"] == "auth-service"
pipelines: [traces/auth]
service:
pipelines:
traces/ingress:
receivers: [otlp]
processors: [memory_limiter]
exporters: [routing] # Connector가 Exporter로 동작
traces/payment:
receivers: [routing] # Connector가 Receiver로 동작
processors: [tail_sampling, batch]
exporters: [otlphttp/payment_backend]
traces/auth:
receivers: [routing]
processors: [tail_sampling, batch]
exporters: [otlphttp/auth_backend]
traces/default:
receivers: [routing]
processors: [tail_sampling, batch]
exporters: [otlphttp/jaeger]Tail sampling과 Gateway 스케일아웃
Tail sampling은 동일 트레이스의 모든 스팬이 반드시 하나의 결정 지점에 모여야 정확하게 동작합니다. 앞서 Gateway 설정 예시에는 tail_sampling만 넣고 이 문제에 대한 대책을 두지 않았는데, Gateway 인스턴스를 2개 이상으로 늘리는 순간 스팬이 다른 인스턴스로 분산되어 불완전한 트레이스로 잘못된 샘플링 결정이 내려집니다. 트레이드오프 표에서 언급한 "최소 2개 인스턴스 필수"라는 조건과 정면으로 부딪히는 지점입니다.
이 문제의 해답이 loadbalancingexporter입니다. Agent 레벨에서 traceID를 기반으로 일관된 해싱을 적용해 동일 traceID의 스팬이 항상 같은 Gateway 인스턴스에 도달하도록 합니다.
# Agent Collector 설정 (각 노드에 DaemonSet으로 배포)
exporters:
loadbalancing:
protocol:
otlp:
tls:
insecure: true
resolver:
dns:
hostname: gateway-collector-headless.observability.svc.cluster.local
port: 4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loadbalancing]dns resolver는 Kubernetes headless service를 활용해 Gateway Pod 목록을 동적으로 가져옵니다. Gateway Pod가 추가·제거되면 자동으로 반영됩니다.
여기서 오해하기 쉬운 점은 loadbalancingexporter가 별도의 중앙 로드밸런서 컴포넌트가 아니라는 것입니다. 각 Agent Pod가 자체 loadbalancingexporter 인스턴스를 내부에 포함하고, 그 인스턴스들이 각자 독립적으로 동일한 DNS resolver를 조회해 traceID 해싱 결과에 따라 특정 Gateway로 라우팅합니다.
트레이드오프와 실무에서 자주 하는 실수
구조적 장단점 비교
| 항목 | Gateway 패턴 장점 | 고려해야 할 점 |
|---|---|---|
| 설정 관리 | 백엔드 추가·변경을 설정 파일 한 곳에서 처리 | Connector·다중 파이프라인 조합이 복잡해지면 디버깅 어려움 |
| 처리 집중화 | Tail sampling·필터·변환을 Gateway에 집중, Agent는 경량 유지 | Gateway 단일 장애점(SPOF) 위험 — 최소 2개 인스턴스 필수 |
| 팬아웃 | 동일 데이터를 여러 백엔드에 동시 전송, 벤더 락인 방지 | Exporter 수가 늘수록 네트워크 비용·레이턴시 증가 |
| 스케일링 | Gateway와 Agent를 독립적으로 수평 확장 가능 | Tail sampling 같은 Stateful Processor는 loadbalancingexporter 없이 스케일아웃 불가 |
| 비용 최적화 | Gateway 레벨 샘플링·필터로 백엔드 전달 데이터 볼륨 제어 | 멀티리전 환경에서 단일 중앙 Gateway는 크로스리전 레이턴시·전송 비용 증가 |
| 마이그레이션 | 팬아웃으로 구버전·신버전 백엔드 병렬 운영 후 무중단 전환 | decision_wait 튜닝 실패 시 불완전한 트레이스로 잘못된 샘플링 결정 |
실무에서 실제로 자주 보는 실수들
앞서 본문에서 다룬 Processor 순서(memory_limiter 맨 앞, batch는 반드시 tail_sampling 뒤) 같은 항목은 여기서 반복하지 않고, 본문에서 별도로 다루지 않은 함정만 짚습니다.
1. decision_wait를 너무 짧게 설정
decision_wait가 실제 트레이스 완료 레이턴시보다 짧으면, 스팬이 다 도착하기 전에 샘플링 결정이 내려집니다. 운영 환경의 P99 트레이스 레이턴시를 측정하고 여기에 버퍼를 더한 값으로 설정하는 것이 좋습니다.
2. Exporter의 재시도·큐 설정 누락
retry_on_failure와 sending_queue를 빼먹으면 백엔드 일시 장애 시 데이터가 그대로 유실됩니다. 프로덕션 Exporter에서는 기본값을 그대로 두지 말고 명시적으로 활성화하고 큐 크기를 정의해두세요.
3. Collector 자체 관찰성 누락
Gateway가 정상 동작하는지 확인하는 방법을 처음부터 설계에 포함해야 합니다. Collector 자체 내부 메트릭(큐 크기·드롭 카운트·수신·전송 지연 등)은 service.telemetry.metrics.address로 별도로 노출됩니다. 이는 파이프라인 데이터를 노출하는 prometheus exporter와 다른 개념이므로 혼동하지 마세요.
# Collector 자체 관찰성 설정
extensions:
zpages:
endpoint: 0.0.0.0:55679
service:
extensions: [zpages]
telemetry:
metrics:
level: detailed
address: 0.0.0.0:8888 # Collector 자체 내부 메트릭 노출 경로4. Gateway를 여러 개 띄웠는데 tail sampling이 이상하게 동작
Gateway 인스턴스가 여러 개일 때 Agent 쪽에서 loadbalancingexporter를 쓰지 않으면 traceID가 다른 인스턴스로 흩어져 불완전한 결정을 내립니다. 앞 절의 Agent 설정과 세트로 이해해야 합니다.
마무리
Gateway 패턴의 핵심은 결국 관심사 분리입니다. 각 마이크로서비스는 "어디로 보낼지"를 알 필요가 없고, Gateway가 그 결정을 전담합니다. Processor 체이닝은 데이터가 어떤 순서로 가공되는지를 명시적으로 선언하고, Exporter 팬아웃은 그 결과를 여러 백엔드에 동시에 분배합니다.
Routing Connector로 서비스별 파이프라인을 분리하면 멀티테넌시 환경에서 각 팀이 독립적인 백엔드를 가져갈 수 있고, loadbalancingexporter와 Tail sampling을 조합하면 분산 환경에서도 완전한 트레이스 기반 샘플링이 가능해집니다.
실무에서 Gateway 도입의 진짜 가치는 설정 파일 몇 줄이 서비스 재배포 수십 건을 대체한다는 데 있습니다. 백엔드 하나를 교체할 때 애플리케이션 팀에 티켓을 넣지 않아도 되고, 새 백엔드를 검증할 때 코드 변경 없이 팬아웃만 걸어두고 며칠 지켜보면 됩니다. Processor 순서 규칙 몇 가지와 Connector 개념만 잡히면, 이후 확장은 대부분 파이프라인 정의를 손보는 일로 수렴합니다.
참고 자료
- Gateway deployment pattern — OpenTelemetry 공식 문서
- Agent-to-gateway deployment pattern — OpenTelemetry 공식 문서
- Architecture — OpenTelemetry 공식 문서
- Configuration — OpenTelemetry 공식 문서
- Routing Connector README — opentelemetry-collector-contrib
- Routing Processor README — opentelemetry-collector-contrib
- Tail Sampling Processor README — opentelemetry-collector-contrib
- Load Balancing Exporter README — opentelemetry-collector-contrib
- OpenTelemetry Collector Follow-up Survey Analysis — OpenTelemetry Blog