분산 트레이싱 샘플링 전략 실전: 100% 수집 없이 장애를 놓치지 않는 비용 최적화 설계

100% 수집이라는 환상이 운영 비용을 잡아먹는다
분산 아키텍처로 전환하고 나면 팀은 자연스럽게 모든 요청을 추적하고 싶어합니다. 우리 팀 역시 초기에 OpenTelemetry SDK를 붙이고 모든 trace를 Jaeger로 내보내는 설정을 처음 기본값으로 택했습니다.
문제는 트래픽이 늘면서 빠르게 드러났습니다. DAU가 20만을 넘어서는 시점에 Jaeger Elasticsearch 클러스터 비용이 APM 예산의 절반 이상을 차지하기 시작했고, Collector 파드의 메모리 사용량이 예측 불가능하게 치솟았습니다.
샘플링 전략은 단순히 "몇 퍼센트를 수집할까"가 아닙니다. 어떤 요청이 저장할 가치가 있는지, 에러와 고지연 요청을 어떻게 우선 보존할지, 샘플링 결정을 분산 서비스 간에 어떻게 일관되게 전파할지까지 포함합니다.
1. 왜 100% 샘플링은 불가능한가
trace 하나는 여러 서비스의 span으로 구성됩니다. 초당 1,000건의 요청을 처리하는 서비스라면 초당 1만~3만 개의 span이 발생합니다. Span 하나를 평균 2KB의 직렬화된 데이터로 잡으면 하루에 수 TB가 쌓입니다.
Collector 파드는 span을 수신하고, 배치로 묶고, 압축하고, 백엔드로 전송합니다. 100% 수집 모드에서는 이 파이프라인이 애플리케이션 트래픽과 비례해 선형으로 확장되어야 합니다.
OpenTelemetry 공식 문서의 Sampling 개념 설명은 이 문제를 정면으로 인정합니다. "Not all data is equally valuable."
2. Head-based vs Tail-based 샘플링
샘플링 전략의 가장 근본적인 구분은 결정 시점입니다.
Head-based sampling은 요청이 들어오는 최초 지점, 즉 trace가 시작될 때 샘플링 여부를 결정합니다. 장점은 단순함입니다. 단점은 요청을 받는 시점에는 해당 요청이 나중에 에러를 낼지, 고지연 요청이 될지 알 수 없습니다.
Tail-based sampling은 요청의 전체 처리가 완료된 후 trace 전체를 보고 샘플링 여부를 결정합니다. 장애 트레이스를 훨씬 높은 비율로 보존할 수 있습니다. 대신 Collector가 span을 버퍼링해야 하므로 메모리 사용량이 늘어납니다.
실무에서는 두 전략을 계층적으로 결합하는 방식이 가장 유효합니다. 진입 지점에서 확실히 불필요한 요청을 head sampling으로 빠르게 탈락시키고, 나머지는 Collector tail sampling 레이어로 넘깁니다.
3. OpenTelemetry Collector 샘플링 프로세서
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
processors:
filter/drop_health:
traces:
span:
- 'attributes["http.route"] == "/health"'
- 'attributes["http.route"] == "/readyz"'
- 'attributes["http.route"] == "/metrics"'
tail_sampling:
decision_wait: 30s
num_traces: 200000
expected_new_traces_per_sec: 2000
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow-traces
type: latency
latency:
threshold_ms: 2000
- name: keep-checkout-always
type: string_attribute
string_attribute:
key: "http.route"
values: ["/api/v1/checkout", "/api/v1/payment"]
- name: baseline-sampling
type: probabilistic
probabilistic:
sampling_percentage: 10
batch:
timeout: 5s
send_batch_size: 1024
exporters:
otlp/tempo:
endpoint: "tempo:4317"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [filter/drop_health, tail_sampling, batch]
exporters: [otlp/tempo]
decision_wait는 trace의 마지막 span이 도착할 때까지 기다리는 최대 시간입니다. 서비스 전체의 p99 지연 시간보다 충분히 길게 설정해야 합니다.
4. Priority Sampling 구현
import { trace, SpanStatusCode } from '@opentelemetry/api';
const tracer = trace.getTracer('order-service', '1.0.0');
export async function processCheckout(
orderId: string,
userId: string,
isVipUser: boolean,
): Promise<CheckoutResult> {
return tracer.startActiveSpan('checkout.process', async (span) => {
try {
if (isVipUser) {
span.setAttribute('sampling.priority', 1);
span.setAttribute('sampling.reason', 'vip_user');
}
const orderValue = await getOrderValue(orderId);
if (orderValue > 500_000) {
span.setAttribute('sampling.priority', 1);
span.setAttribute('sampling.reason', 'high_value_order');
}
const result = await executeCheckout(orderId, userId);
if (!result.success) {
span.setStatus({ code: SpanStatusCode.ERROR, message: result.errorCode });
}
return result;
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw err;
} finally {
span.end();
}
});
}
Collector 측에서는 string_attribute 정책으로 sampling.priority 값이 "1"인 trace를 100% 보존하도록 설정합니다.
5. Error-biased 샘플링 규칙
정상 트래픽 100건 중 에러가 1건 발생한다면, 10% 무작위 샘플링 시 에러 trace 10건 중 평균 1건밖에 남지 않습니다.
Jaeger 공식 문서의 Sampling 가이드는 adaptive sampling을 소개하며 에러 트래픽과 정상 트래픽을 구분하는 방식을 설명합니다.
Error-biased 샘플링을 구현할 때 주의해야 하는 패턴: HTTP 상태 코드 5xx만 기준으로 삼으면 4xx를 반환하는 비즈니스 에러를 놓칩니다. 더 정확한 방식은 span status를 ERROR로 명시적으로 설정하는 것입니다.
6. Jaeger와 Tempo 저장 비용 비교
| 항목 | Jaeger (Elasticsearch/Cassandra) | Grafana Tempo (object storage) |
|---|---|---|
| 저장소 기반 | ES 또는 Cassandra 클러스터 | S3 / GCS / Azure Blob |
| 인덱스 비용 | 고카디널리티 태그 인덱스로 비용 급증 | 최소 인덱스 |
| 운영 복잡도 | ES 클러스터 관리 부담 | 관리형 object storage 활용 |
| 비용 구조 | 컴퓨트 + 스토리지 혼합 | 주로 스토리지 + 요청 비용 |
| 검색 방식 | 다차원 검색 | trace ID 또는 TraceQL |
Grafana Tempo 공식 문서에서는 object storage 기반 설계가 핵심임을 강조합니다.
7. 샘플링 결정 전파 (W3C TraceContext)
W3C Trace Context 스펙은 traceparent와 tracestate HTTP 헤더를 통해 trace 정보를 전파하는 표준을 정의합니다.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
마지막 바이트인 flags가 샘플링 결정을 담습니다. 01이면 sampled=true, 00이면 sampled=false입니다.
Tail sampling 시나리오에서는 SDK는 sampled=true로 간주하고 모든 span을 Collector로 보내야 합니다. 이 때문에 tail sampling 파이프라인에서는 SDK를 AlwaysOn sampler로 설정하고, 실제 드롭 결정은 오직 Collector에서만 수행합니다.
8. 고트래픽 서비스 적용 사례
우리 팀이 결제 도메인 서비스에 이 전략을 적용하며 겪은 경험입니다.
1단계로 health check 요청을 filter processor로 즉시 제거했습니다. 당시 전체 span의 약 15%가 k8s liveness/readiness probe에서 오는 것이었습니다.
2단계로 tail_sampling 프로세서를 도입해 에러 trace와 결제 경로 trace를 100% 보존하고, 나머지 정상 트래픽을 10% 확률로 줄였습니다. OTLP Loadbalancer Exporter를 앞단에 두고 trace_id 기반 해싱으로 라우팅하는 구성으로 동일 trace의 span을 하나의 인스턴스로 모으는 문제를 해결했습니다.
3단계로 저장 백엔드를 Elasticsearch에서 Grafana Tempo + S3로 마이그레이션했습니다.
이 과정에서 배운 것은 span attribute에 고카디널리티 값을 인덱스 대상 태그로 넣지 않는 것이 저장 비용 제어에서 샘플링률만큼 중요하다는 점입니다.
9. 샘플링률 동적 조정
정적 샘플링률은 설정하고 잊을 수 있지만, 트래픽 패턴이 변하면 비효율이 생깁니다.
Jaeger의 Remote Sampling 기능은 Collector 또는 중앙 샘플링 서버에서 operation별 샘플링률을 동적으로 내려줍니다.
OpenTelemetry 생태계에서는 OpAMP를 통한 Collector 원격 설정 변경이 장기 방향입니다.
실용적인 중간 방법은 샘플링 설정을 ConfigMap으로 분리하고, 자동화 스크립트로 갱신하는 것입니다.
10. SLO와 샘플링 연동
샘플링 전략이 SLO와 분리되어 있으면, 정작 SLO 위반을 일으킨 요청이 저장되지 않는 상황이 발생합니다.
SLO 연동 샘플링의 핵심 아이디어는 SLO 기준에 해당하는 요청을 샘플링 우선 대상으로 삼는 것입니다. SLO가 p95 1초라면 Collector에서 1초 초과 trace를 100% 보존합니다.
OpenTelemetry와 SLO 설계를 함께 다루는 글에서 더 자세히 설명하고 있습니다. 로그 구조화와 Correlation ID 활용 가이드도 참고하세요. 샘플링으로 인해 trace가 보존되지 않은 요청도 로그에 trace_id가 있으면 사후 분석에서 맥락을 어느 정도 복원할 수 있습니다.
결론
- 에러 trace 전량 보존 규칙이 있는가:
status_code: [ERROR]정책이 tail sampling 최우선 규칙으로 설정되어 있어야 합니다. - SLO 임계값과 latency 보존 기준이 일치하는가.
- 동일 trace의 span이 하나의 Collector 인스턴스로 라우팅되는가.
- W3C traceparent 헤더를 모든 내부 서비스가 전달하는가.
- 고카디널리티 attribute가 인덱스 대상에서 제외되어 있는가.
샘플링 전략의 목표는 저장 비용을 최소화하는 것이 아닙니다. 장애가 났을 때 원인을 추론하는 데 필요한 trace가 반드시 남아 있도록 보장하면서, 그 외 요청의 저장 비율을 운영 예산 안으로 줄이는 것입니다.