분산 트레이싱 샘플링 전략: 100% 수집 없이 장애를 놓치지 않는 비용 최적화 설계
핵심 요약
이 글에서 확인할 내용
100% 수집이라는 환상이 운영 비용을 잡아먹는다 분산 아키텍처로 전환하고 나면 팀은 자연스럽게 모든 요청을 추적하고 싶어합니다. 우리 팀 역시 초기에 OpenTelemetry SDK를 붙이고 모든 trace를 Jaeger로 내보내는 설정을 처음 기본값으로 택했습니다. 당시에는 이 결정이 합리적으로 보였습니다.
- 1. 왜 100% 샘플링은 불가능한가
- 2. Head-based vs Tail-based 샘플링
- 3. OpenTelemetry Collector 샘플링 프로세서
100% 수집이라는 환상이 운영 비용을 잡아먹는다
분산 아키텍처로 전환하고 나면 팀은 자연스럽게 모든 요청을 추적하고 싶어합니다. 우리 팀 역시 초기에 OpenTelemetry SDK를 붙이고 모든 trace를 Jaeger로 내보내는 설정을 처음 기본값으로 택했습니다.
당시에는 이 결정이 합리적으로 보였습니다. observability 도입 초기에는 "혹시 나중에 필요할지도 모르는" trace를 놓치는 것이 가장 두려운 실패 시나리오였고, 100% 수집이 가장 안전한 선택지처럼 느껴졌기 때문입니다. 하지만 안전하다고 느꼈던 그 선택은 곧 예산과 운영 부담이라는 형태로 청구서가 되어 돌아왔습니다.
문제는 트래픽이 늘면서 빠르게 드러났습니다. DAU가 20만을 넘어서는 시점에 Jaeger Elasticsearch 클러스터 비용이 APM 예산의 절반 이상을 차지하기 시작했고, Collector 파드의 메모리 사용량이 예측 불가능하게 치솟았습니다.
더 심각한 문제는 비용만이 아니었습니다. Kibana에서 특정 trace를 검색할 때 응답이 5초 이상 걸리는 일이 잦아졌고, 정작 장애가 발생한 순간에는 검색 자체가 타임아웃되는 역설적인 상황도 겪었습니다. 관측 가능성을 위해 도입한 시스템이 정작 장애 대응 순간에 병목이 되어버린 셈입니다.
샘플링 전략은 단순히 "몇 퍼센트를 수집할까"가 아닙니다. 어떤 요청이 저장할 가치가 있는지, 에러와 고지연 요청을 어떻게 우선 보존할지, 샘플링 결정을 분산 서비스 간에 어떻게 일관되게 전파할지까지 포함합니다.
이 글에서는 head-based와 tail-based 샘플링의 근본적인 차이부터, OpenTelemetry Collector의 tail_sampling 프로세서를 실제 운영 환경에 맞게 튜닝하는 방법, 에러와 고가치 트랜잭션을 우선 보존하는 priority sampling 구현, Jaeger에서 Grafana Tempo로 저장 백엔드를 옮기며 겪은 마이그레이션 경험, 그리고 실무에서 자주 받는 질문까지 순서대로 다룹니다. 이 글을 끝까지 읽고 나면 여러분의 서비스 규모에 맞는 샘플링 정책을 실제 설정 파일로 바로 옮길 수 있을 것입니다.
1. 왜 100% 샘플링은 불가능한가
trace 하나는 여러 서비스의 span으로 구성됩니다. 초당 1,000건의 요청을 처리하는 서비스라면 초당 1만~3만 개의 span이 발생합니다. Span 하나를 평균 2KB의 직렬화된 데이터로 잡으면 하루에 수 TB가 쌓입니다.
이 수치를 실제 비용으로 환산하면 감이 옵니다. 하루 3TB의 span 데이터를 Elasticsearch에 저장하고 30일 보관 정책을 적용한다고 가정하면 클러스터에는 상시 90TB 안팎의 데이터가 쌓여 있어야 합니다. 여기에 색인용 replica shard와 스냅샷 백업까지 고려하면 실제 필요한 디스크 용량은 손쉽게 2~3배로 불어납니다. 압축과 롤업을 아무리 촘촘히 적용해도, 애초에 저장해야 할 원본 데이터의 양 자체를 줄이지 않으면 근본적인 해결이 되지 않습니다.
Collector 파드는 span을 수신하고, 배치로 묶고, 압축하고, 백엔드로 전송합니다. 100% 수집 모드에서는 이 파이프라인이 애플리케이션 트래픽과 비례해 선형으로 확장되어야 합니다.
우리가 처음 겪은 문제도 정확히 이 지점이었습니다. 트래픽이 평소의 3배로 튀는 프로모션 이벤트마다 Collector의 CPU와 메모리가 한계치까지 치솟았고, 오토스케일링이 새 파드를 띄우는 속도가 span 유입 속도를 따라가지 못해 일부 span이 유실되는 현상까지 발생했습니다. 100% 수집 파이프라인은 결국 애플리케이션 자체의 트래픽 스파이크에 observability 인프라가 그대로 종속된다는 뜻이고, 이는 관측 시스템이 정작 가장 필요한 순간에 무너질 위험을 안고 있다는 의미이기도 합니다.
OpenTelemetry 공식 문서의 Sampling 개념 설명은 이 문제를 정면으로 인정합니다. "Not all data is equally valuable."
결국 질문은 "데이터를 얼마나 모을 것인가"가 아니라 "어떤 데이터가 장애 원인 분석에 실제로 기여하는가"로 바뀌어야 합니다. 이 관점 전환이 이후에 소개할 모든 샘플링 정책 설계의 출발점입니다.
2. Head-based vs Tail-based 샘플링
샘플링 전략의 가장 근본적인 구분은 결정 시점입니다.
Head-based sampling은 요청이 들어오는 최초 지점, 즉 trace가 시작될 때 샘플링 여부를 결정합니다. 장점은 단순함입니다. 단점은 요청을 받는 시점에는 해당 요청이 나중에 에러를 낼지, 고지연 요청이 될지 알 수 없습니다. Head-based sampling은 다시 확률적(probabilistic) 방식과 비율 제한(rate-limiting) 방식으로 나뉘는데, 확률적 방식은 구현이 간단한 대신 트래픽이 몰리는 시간대에 저장 비용이 함께 튀는 단점이 있고, 비율 제한 방식은 초당 저장할 trace 수의 상한을 고정하지만 트래픽이 적은 시간대에는 오히려 필요 이상으로 많은 trace를 버리게 됩니다.
Tail-based sampling은 요청의 전체 처리가 완료된 후 trace 전체를 보고 샘플링 여부를 결정합니다. 장애 트레이스를 훨씬 높은 비율로 보존할 수 있습니다. 대신 Collector가 span을 버퍼링해야 하므로 메모리 사용량이 늘어납니다.
| 기준 | Head-based | Tail-based |
|---|---|---|
| 결정 시점 | trace 시작 시점 | trace 종료(또는 timeout) 시점 |
| 메모리 사용 | 거의 없음(즉시 버림) | 전체 trace 버퍼링 필요 |
| 에러·고지연 우선 보존 | 불가능 | 가능 |
| 구현 난이도 | SDK 설정만으로 충분 | Collector 레이어 별도 구성 필요 |
| 적합한 규모 | 소규모, 저비용 우선 | 중~대규모, 정확도 우선 |
실무에서는 두 전략을 계층적으로 결합하는 방식이 가장 유효합니다. 진입 지점에서 확실히 불필요한 요청을 head sampling으로 빠르게 탈락시키고, 나머지는 Collector tail sampling 레이어로 넘깁니다. 이 결합 구조에서 head sampling 레이어가 담당하는 역할은 명확합니다. 헬스체크, 정적 리소스 요청, 내부 모니터링 트래픽처럼 애초에 장애 분석 가치가 없는 요청을 애플리케이션 SDK 단계에서 걸러내는 것입니다. 반면 실제 비즈니스 로직을 태우는 요청은 SDK에서 AlwaysOn sampler로 무조건 통과시키고, 그 요청이 에러였는지 느렸는지에 대한 판단은 전적으로 Collector의 tail sampling 레이어로 위임합니다. 이렇게 역할을 나누면 애플리케이션 코드는 단순한 On/Off 결정만 담당하고, 복잡한 정책 판단은 중앙화된 Collector 설정 파일 하나로 관리할 수 있습니다.
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 지연 시간보다 충분히 길게 설정해야 합니다.
이 설정에는 실무에서 자주 놓치는 함정이 있습니다. tailsamplingprocessor는 내부적으로 circular buffer 구조로 trace를 관리하기 때문에, num_traces에 설정한 개수를 넘어서는 새 trace가 들어오면 가장 오래된 trace부터 결정이 나기도 전에 버려집니다. 공식 README는 이 문제를 줄이려면 num_traces를 늘리거나 decision_wait를 줄이라고 안내하는데, 두 방법 모두 메모리 사용량이 늘어나는 트레이드오프가 있습니다(opentelemetry-collector-contrib tailsamplingprocessor README). 기본값은 decision_wait 30초, num_traces 5만 건인데, AWS Distro for OpenTelemetry 문서는 더 구체적인 산정 공식을 제시합니다. 서비스의 최대 예상 지연이 10초이고 초당 최대 트래픽이 1,000건이라면 decision_wait는 10초, num_traces는 최소 1만 건(10초 × 1,000건/초)으로 잡으라는 것입니다(AWS Distro for OpenTelemetry Processors 문서).
위 예시 설정에서 decision_wait 30초에 expected_new_traces_per_sec 2,000건이라면 이 공식으로는 최소 6만 건이 필요한데, num_traces를 200,000으로 여유 있게 잡은 이유가 여기에 있습니다. 저희 팀도 처음에는 공식 그대로 6만 건 근처로 맞췄다가, 프로모션 이벤트로 트래픽이 순간적으로 3배 튀는 시점에 trace가 결정 전에 버려지는 사고를 겪은 뒤 지금의 여유값으로 올렸습니다. 이 값이 부족한지는 tail sampling의 dropped 계열 카운터 메트릭으로 확인할 수 있고, 이 값이 계속 증가한다면 num_traces를 늘리거나 decision_wait를 줄여야 합니다. 다만 두 값 모두 무작정 늘리면 Collector 메모리 사용량이 함께 커지므로, 파이프라인 맨 앞에 memory_limiter 프로세서를 두어 OOM 이전에 스스로 오래된 데이터를 정리하도록 안전장치를 마련해 두는 것이 실무에서는 필수적입니다.
4. Priority Sampling 구현
tail_sampling 프로세서의 정책은 위에서 아래로 평가되며, 하나의 trace가 여러 정책에 동시에 해당하면 가장 먼저 매칭된 정책이 적용됩니다. 이 특성을 활용하면 애플리케이션 코드에서 비즈니스 맥락을 span attribute로 남기고, Collector 설정에서 그 attribute를 최우선 정책으로 배치하는 식으로 "비즈니스가 중요하다고 판단하는 trace"를 우선 보존할 수 있습니다.
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% 보존하도록 설정합니다.
여기서 조심해야 할 부분은 정책 순서입니다. keep-checkout-always처럼 구체적인 조건의 정책을 baseline-sampling보다 반드시 앞에 둬야 합니다. 순서가 뒤바뀌면 결제 trace조차 10% 확률 정책에 먼저 걸려 90%가 버려지는 사고로 이어질 수 있습니다. 저희 팀에서도 초기 배포 때 이 순서를 실수로 뒤집어, 결제 실패 trace 일부가 그대로 유실된 채로 하루를 넘긴 적이 있습니다. 이후로는 정책 순서를 변경할 때마다 반드시 스테이징 환경에서 알려진 trace_id로 강제 재현 테스트를 거치는 절차를 팀 규칙으로 만들었습니다.
5. Error-biased 샘플링 규칙
정상 트래픽 100건 중 에러가 1건 발생한다면, 10% 무작위 샘플링 시 에러 trace 10건 중 평균 1건밖에 남지 않습니다.
Jaeger 공식 문서의 Sampling 가이드는 adaptive sampling을 소개하며 에러 트래픽과 정상 트래픽을 구분하는 방식을 설명합니다.
Error-biased 샘플링을 구현할 때 주의해야 하는 패턴: HTTP 상태 코드 5xx만 기준으로 삼으면 4xx를 반환하는 비즈니스 에러를 놓칩니다. 더 정확한 방식은 span status를 ERROR로 명시적으로 설정하는 것입니다.
이 격차는 SRE가 사후 분석(post-mortem)을 작성할 때 특히 뼈아프게 다가옵니다. 장애 티켓에는 "오전 10시 23분경 결제 실패율이 급증했다"는 기록이 남아 있는데, 정작 그 시간대에 실패한 요청의 trace가 샘플링에서 탈락해 저장소에 남아있지 않다면 원인 규명 자체가 불가능해집니다. Error-biased 정책은 바로 이 공백을 메우기 위한 최소한의 안전장치입니다.
또 하나 실무에서 자주 놓치는 지점은 재시도(retry)와 서킷 브레이커의 상호작용입니다. 클라이언트가 실패한 요청을 자동으로 재시도하면 동일한 논리적 요청이 여러 개의 trace로 기록됩니다. 이때 최초 실패 trace만 에러로 표시되고 재시도 성공 trace는 정상으로 표시되면, 대시보드 상의 에러율은 낮아 보여도 실제로는 재시도 비용이 계속 쌓이고 있는 상황을 놓치게 됩니다. 저희는 이 문제를 해결하기 위해 재시도 체인 전체에 동일한 retry.group_id span attribute를 부여하고, 이 그룹 안에 에러가 하나라도 있으면 그룹 전체를 tail sampling에서 보존하도록 정책을 확장했습니다.

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 기반 설계가 핵심임을 강조합니다.
실제로 저장 비용을 비교해 보면 체감 차이가 뚜렷합니다. Elasticsearch 기반 구성에서는 데이터가 늘어날수록 마스터 노드와 데이터 노드의 힙 메모리, 샤드 수를 함께 늘려야 하기 때문에 컴퓨트 비용이 스토리지 비용과 함께 선형으로 증가합니다. 반면 Tempo는 trace 데이터를 압축된 블록 형태로 object storage에 그대로 던져 넣고, 조회 시점에만 필요한 블록을 읽어오는 구조이기 때문에 컴퓨트 계층을 훨씬 얇게 유지할 수 있습니다. 저희 환경 기준으로 동일한 보존 기간(14일)에 대해 비교했을 때, 스토리지 자체의 GB당 비용은 큰 차이가 없었지만 클러스터를 유지하기 위한 컴퓨트·운영 인력 비용까지 합산하면 총소유비용(TCO) 기준으로 절반 이하로 줄었습니다.
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에서만 수행합니다.
traceparent 헤더 하나만으로는 부족한 상황도 있습니다. 여러 벤더의 관측 도구를 동시에 사용하거나, 커스텀 샘플링 근거를 서비스 간에 전달해야 할 때는 tracestate 헤더를 함께 사용합니다. tracestate는 키-값 쌍의 리스트 형태로 벤더별 정보를 담을 수 있게 설계되어 있어, 예를 들어 tracestate: waylog=priority:1처럼 특정 요청이 priority sampling 대상임을 하위 서비스에도 알려줄 수 있습니다.
실무에서 이 전파 체계가 깨지는 가장 흔한 원인은 서비스 메시나 API 게이트웨이 설정입니다. 일부 게이트웨이는 기본적으로 커스텀 헤더를 백엔드로 전달하지 않도록 설정되어 있어, traceparent 헤더가 게이트웨이 경계에서 조용히 사라지는 경우가 있습니다. 이 경우 게이트웨이 이후 구간의 span들이 서로 다른 trace_id를 갖게 되어, 하나의 사용자 요청이 여러 개의 끊어진 trace로 쪼개지는 증상이 나타납니다. 저희도 API Gateway를 교체하는 과정에서 이 문제를 겪었고, 헤더 화이트리스트에 traceparent와 tracestate를 명시적으로 추가한 뒤에야 trace가 다시 이어졌습니다.
8. 고트래픽 서비스 적용 사례
우리 팀이 결제 도메인 서비스에 이 전략을 적용하며 겪은 경험입니다.
1단계로 health check 요청을 filter processor로 즉시 제거했습니다. 당시 전체 span의 약 15%가 k8s liveness/readiness probe에서 오는 것이었습니다. 이 수치를 처음 확인했을 때 팀 내부에서도 놀라움이 있었습니다. k8s 클러스터의 노드 수가 늘어날수록 probe 호출 빈도도 함께 늘어나기 때문에, 서비스 인스턴스를 스케일아웃할 때마다 관측 가치가 없는 span의 비율이 함께 커지는 구조였습니다. filter processor 하나를 추가하는 단순한 변경만으로 Collector의 처리량이 즉시 15% 이상 여유를 확보했다는 점은, 복잡한 샘플링 정책보다 먼저 손봐야 할 것이 무엇인지 알려주는 좋은 사례였습니다.
2단계로 tail_sampling 프로세서를 도입해 에러 trace와 결제 경로 trace를 100% 보존하고, 나머지 정상 트래픽을 10% 확률로 줄였습니다. 이 단계에서 가장 까다로웠던 문제는 하나의 trace에 속한 span들이 서로 다른 Collector 인스턴스에 흩어지는 현상이었습니다. tail sampling은 trace 전체를 봐야 정확한 결정을 내릴 수 있는데, 로드밸런서가 단순 라운드로빈으로 span을 분산시키면 같은 trace의 span이 여러 인스턴스에 나뉘어 도착해 각자 불완전한 정보로 판단을 내리게 됩니다. OTLP Loadbalancer Exporter를 앞단에 두고 trace_id 기반 해싱으로 라우팅하는 구성으로 동일 trace의 span을 하나의 인스턴스로 모으는 문제를 해결했습니다. 이 과정에서 앞서 설정해 둔 memory_limiter 안전장치가 예기치 않은 재시작 상황에서 Collector가 통째로 죽는 것을 막아준 순간이기도 했습니다.
3단계로 저장 백엔드를 Elasticsearch에서 Grafana Tempo + S3로 마이그레이션했습니다. 트래픽을 한 번에 전환하지는 않았습니다. 먼저 두 백엔드로 동시에 데이터를 이중 전송(dual write)하는 기간을 2주 정도 두고, Tempo의 TraceQL 검색 결과와 기존 Jaeger UI의 검색 결과가 동일한 trace를 반환하는지 교차 검증했습니다. 이 검증 기간 동안 Jaeger의 서비스 의존성 그래프 기능을 그대로 대체할 수 없어 별도의 Grafana 대시보드를 새로 구성하는 작업이 예상보다 오래 걸렸습니다. 결과적으로 전체 마이그레이션에는 계획했던 3주가 아니라 5주가 걸렸는데, 이 지연의 대부분은 샘플링 로직 자체가 아니라 팀이 익숙했던 조회 UX를 새 도구에서 재현하는 데 들어간 시간이었습니다.
이 과정에서 배운 것은 span attribute에 고카디널리티 값을 인덱스 대상 태그로 넣지 않는 것이 저장 비용 제어에서 샘플링률만큼 중요하다는 점입니다.
9. 샘플링률 동적 조정
정적 샘플링률은 설정하고 잊을 수 있지만, 트래픽 패턴이 변하면 비효율이 생깁니다. 트래픽이 늘어나는 시기에는 고정된 퍼센트가 그대로 저장 비용 증가로 이어지고, 반대로 트래픽이 줄어드는 새벽 시간대나 비수기에는 필요 이상으로 낮은 샘플링률 때문에 드물게 발생하는 에러를 놓치는 비대칭이 생깁니다.
Jaeger의 Remote Sampling 기능은 Collector 또는 중앙 샘플링 서버에서 operation별 샘플링률을 동적으로 내려줍니다. 목표는 보통 초당 저장할 trace 수의 상한(traces per second)으로 정의되기 때문에, 트래픽이 늘어나도 저장되는 절대량은 어느 정도 일정하게 유지됩니다.
OpenTelemetry 생태계에서는 OpAMP를 통한 Collector 원격 설정 변경이 장기 방향입니다. 아직 모든 배포 환경에서 프로덕션 수준으로 성숙했다고 보기는 어렵기 때문에, 당장 도입 가능한 실용적인 중간 방법은 샘플링 설정을 ConfigMap으로 분리하고, 트래픽 지표를 관찰하는 자동화 스크립트가 주기적으로 그 값을 갱신한 뒤 Collector를 롤링 재시작하는 방식입니다. 완전히 실시간은 아니지만, 하루 단위 혹은 시간 단위로 샘플링률을 트래픽에 맞춰 조정하는 것만으로도 정적 설정 대비 저장 비용과 데이터 손실 위험을 동시에 줄일 수 있습니다.
10. SLO와 샘플링 연동
샘플링 전략이 SLO와 분리되어 있으면, 정작 SLO 위반을 일으킨 요청이 저장되지 않는 상황이 발생합니다.
SLO 연동 샘플링의 핵심 아이디어는 SLO 기준에 해당하는 요청을 샘플링 우선 대상으로 삼는 것입니다. SLO가 p95 1초라면 Collector에서 1초 초과 trace를 100% 보존합니다.
SLO 연동 샘플링을 설계할 때 실무에서 자주 부딪히는 질문은 "SLO 임계값이 여러 개일 때 어떻게 우선순위를 매길 것인가"입니다. 예를 들어 가용성 SLO(에러율)와 지연 SLO(p95 응답 시간)를 동시에 운영한다면, tail_sampling 정책에도 두 조건을 모두 최우선 순위로 배치해야 두 SLO 중 어느 하나라도 위반된 요청의 trace를 놓치지 않습니다.
여기서 한 걸음 더 나아가면, SLO 대시보드 자체에 "이 시간대 SLO 위반 이벤트에 연결된 trace 목록"을 바로 조회할 수 있는 링크를 심는 것도 유용합니다. 알림이 울린 순간과 trace 조회 사이의 이동 경로를 줄이는 것만으로도 장애 대응 시간(MTTR)이 체감상 크게 줄어듭니다. SLO와 샘플링을 별개의 팀(SRE와 observability 플랫폼 팀)이 각각 관리하는 조직 구조라면, 이 두 설정이 서로 어긋나지 않도록 SLO 임계값 변경 이력을 샘플링 정책 검토 프로세스에 정기적으로 포함시키는 것을 권장합니다.
OpenTelemetry와 SLO 설계를 함께 다루는 글에서 더 자세히 설명하고 있습니다. 로그 구조화와 Correlation ID 활용 가이드도 참고하세요. 샘플링으로 인해 trace가 보존되지 않은 요청도 로그에 trace_id가 있으면 사후 분석에서 맥락을 어느 정도 복원할 수 있습니다.
11. 실무자가 자주 묻는 질문
Q. 샘플링률을 몇 %로 시작해야 하나요?
정답은 없지만, 저희는 baseline 10%에서 시작해 저장 비용과 "사후 분석 때 trace를 못 찾은 사고" 빈도를 2주 단위로 관찰하며 조정했습니다. 신규 서비스라면 초기 한 달은 20~30%로 넉넉하게 잡고, 트래픽 패턴이 안정화된 뒤 낮추는 순서를 권합니다.
Q. 100% 수집을 유지해야 하는 경우도 있나요?
결제, 정산처럼 금액이 걸린 도메인이나 법적 감사 요건이 있는 트랜잭션은 baseline 샘플링과 별개로 항상 100% 보존 정책을 분리해 두는 것이 안전합니다. 위 Collector 설정 예시의 keep-checkout-always 정책이 그 역할을 합니다.
Q. head sampling만으로는 정말 부족한가요?
트래픽이 아주 작고(초당 수십 건 이하) 에러율도 낮은 초기 서비스라면 head sampling만으로 충분할 수 있습니다. 다만 서비스가 커지고 에러 패턴이 다양해지는 시점부터는 tail sampling 없이는 "에러가 발생했는데 trace가 없다"는 상황을 구조적으로 피할 수 없습니다.
Q. 샘플링 정책을 바꾼 뒤 검증은 어떻게 하나요?
저희는 정책을 변경할 때마다 알려진 trace_id 몇 개를 스테이징에서 강제로 재현하고, Collector 로그에서 어떤 정책이 매칭되어 최종 결정이 내려졌는지 확인하는 절차를 거칩니다. tail_sampling 프로세서는 디버그 로그 레벨에서 각 trace가 어떤 정책에 의해 sampled·not-sampled 되었는지 남기므로, 배포 전 검증에 그대로 활용할 수 있습니다.
마무리
- 에러 trace 전량 보존 규칙이 있는가:
status_code: [ERROR]정책이 tail sampling 최우선 규칙으로 설정되어 있어야 합니다. - SLO 임계값과 latency 보존 기준이 일치하는가.
- 동일 trace의 span이 하나의 Collector 인스턴스로 라우팅되는가.
- W3C traceparent 헤더를 모든 내부 서비스가 전달하는가.
- 고카디널리티 attribute가 인덱스 대상에서 제외되어 있는가.
- num_traces와 decision_wait가 실제 트래픽량 대비 여유 있게 설정되어 있는가: dropped 계열 카운터 메트릭이 0에 가까운지 주기적으로 확인해야 합니다.
- 정책 변경 후 스테이징 환경에서 알려진 trace_id로 재현 테스트를 거쳤는가.
샘플링 전략의 목표는 저장 비용을 최소화하는 것이 아닙니다. 장애가 났을 때 원인을 추론하는 데 필요한 trace가 반드시 남아 있도록 보장하면서, 그 외 요청의 저장 비율을 운영 예산 안으로 줄이는 것입니다. 이 균형은 한 번 설정하고 끝나는 것이 아니라, 트래픽 패턴과 조직의 SLO가 바뀔 때마다 다시 들여다봐야 하는 살아있는 정책이라는 점을 기억해야 합니다.