샘플링 비율을 10%에서 5%로 낮춰도 기존 트레이스가 그대로 남는 이유 — OpenTelemetry TraceIdRatioBased

seonwoo_jung·2일 전

1. 도입

마이크로서비스마다 독립된 샘플러를 붙이고 각자 "이 요청을 기록할지"를 동전 던지듯 무작위로 정한다면, 서비스 A는 어떤 요청의 span을 남기고 서비스 B는 같은 요청인데 남기지 않는 상황이 생기지 않을까? 그렇게 되면 트레이싱 백엔드에는 중간이 뜯긴 트레이스만 쌓인다. OpenTelemetry Specification은 이 문제에 답을 정해뒀다 — TraceIdRatioBased 샘플러는 호출마다 새 난수를 뽑지 않고 trace ID 자체를 난수원으로 재사용한다. 이 글은 그 설계가 왜 필요한지, 샘플링 비율을 낮춰도 기존에 뽑히던 트레이스 집합이 그대로 남는 monotonicity(단조성)가 코드 레벨에서 어떻게 나오는지를 따라간다.

2. 핵심 개념

스펙은 "기록 여부"와 "전파 여부"를 별개 축으로 둔다. IsRecording=false면 속성·이벤트가 전부 버려지고, Sampled flag는 TraceFlags에 실려 자식 span에 그대로 전파되며 exporter 전송 여부를 뜻한다. ShouldSample()은 이 두 축의 조합인 세 가지 Decision 중 하나를 반환한다.

DecisionIsRecordingSampled flag
DROPfalse(없음)
RECORD_ONLYtrue미설정
RECORD_AND_SAMPLEtrue설정

Sampled=true인데 IsRecording=false인 조합은 스펙이 명시적으로 금지한다. 자식은 부모의 Sampled flag만 보고 "이 트레이스는 기록 중"이라 믿고 자기도 기록하는데, 부모가 실제론 기록하지 않았다면 트레이스 중간에 구멍이 남기 때문이다.

3. 내부 동작

TraceIdRatioBasedSampler는 128비트 trace ID의 하위 64비트만 떼어 부호 있는 long으로 해석하고, ratio * Long.MAX_VALUE로 만든 임계값 t와 비교한다.

trace_id (32 hex chars)
[ 4bf92f3577b34da6 | a3ce929d0e0e4736 ]
   상위 64bit(미사용)      하위 64bit → long v
                           |v| <  t  → SAMPLE
                           |v| >= t  → DROP

abs(v)[0, Long.MAX_VALUE] 구간이고 t가 클수록 이 구간이 순수하게 확장만 되므로, ratio=0.1에서 뽑힌 트레이스(|v| < t(0.1))는 t(0.1) ⊂ t(0.5)이니 ratio=0.5에서도 반드시 뽑힌다. 이게 monotonicity의 전부다 — 매번 새 난수를 굴리는 방식이었다면 이 포함 관계는 보장되지 않는다. 결정성도 같은 구조에서 나온다: 입력이 traceIdratio뿐인 순수 함수라 어떤 언어의 SDK가 판정해도 같은 결과가 나온다.

하필 하위 64비트를 쓰는 이유는 64비트 trace ID만 지원하던 구세대 시스템과의 호환 때문으로 알려져 있다. Zipkin/B3 계열처럼 64비트 ID를 128비트로 승격할 때 상위 64비트를 0으로 채우는 구현이 섞여 있어서, 상위 비트를 난수원으로 썼다면 이런 트레이스는 전부 같은 값(v=0)으로 몰려 샘플링이 왜곡된다. W3C Trace Context Level 2도 하위 56비트 이상의 난수성만 요구한다.

ParentBased는 자체 판정 로직이 없는 데코레이터다. 부모 유무·종류에 따라 5개의 독립된 delegate 슬롯 중 하나로 위임한다.

상황파라미터기본값
부모 없음(root span)root필수
remote 부모, Sampled=trueremoteParentSampledAlwaysOn
remote 부모, Sampled=falseremoteParentNotSampledAlwaysOff
local 부모, Sampled=truelocalParentSampledAlwaysOn
local 부모, Sampled=falselocalParentNotSampledAlwaysOff

기본값만 보면 "부모의 결정을 그대로 상속"하는 것처럼 보이지만, 이는 기본값 조합이 우연히 그렇게 짜인 것뿐이다. remote 부모는 신뢰하고 local 부모는 재평가하는 식으로 슬롯마다 다른 전략을 끼울 수 있다. TraceIdRatioBased 자체는 부모의 Sampled flag를 무시하도록 스펙에 명시돼 있어, 부모를 참고하는 책임은 전부 ParentBased 쪽으로 분리돼 있다.

4. 예시 / 코드

순수 산술 함수라 JShell에서 바로 재현된다.

long idUpperBound(double ratio) {
  if (ratio == 0.0) return Long.MIN_VALUE;   // 특수값: 아무것도 안 뽑힘
  if (ratio == 1.0) return Long.MAX_VALUE;   // 특수값: 전부 뽑힘
  return (long) (ratio * (double) Long.MAX_VALUE);
}

boolean shouldSample(String traceIdHex, double ratio) {
  long v = new java.math.BigInteger(traceIdHex.substring(16), 16).longValue();
  return Math.abs(v) < idUpperBound(ratio);
}

String tid = "4bf92f3577b34da6a3ce929d0e0e4736";
boolean atLow = shouldSample(tid, 0.1);
boolean atHigh = shouldSample(tid, 0.5);
// atLow가 true인데 atHigh가 false인 조합은 이 구현에서 절대 나오지 않는다

ratio를 0.0과 1.0에서도 일반식으로 계산하면 부동소수점 반올림 때문에 "전부 뽑힘/아무것도 안 뽑힘"이 완벽히 보장되지 않을 수 있다 — 그래서 두 값만 별도 분기로 하드코딩돼 있다. 임의의 trace ID로 ratio를 0.01 단위씩 올려가며 스캔해보면, 한 번 true였던 트레이스가 비율을 더 올렸을 때 false로 뒤집히는 사례는 나오지 않는다.

5. 정리

TraceIdRatioBased가 결정론적인 이유는 새 난수를 만들지 않고 trace ID 생성 시점의 무작위성을 재사용하기 때문이고, monotonicity는 "난수 재사용 + 구간 비교"라는 구조 자체에서 자동으로 따라 나온다. 더 파고들 만한 지점은 tracestate의 otth 필드로 threshold를 실어 head-based·tail-based 샘플러 간 일관성을 맞추는 consistent probability sampling 확장이다.

참고 자료

0개의 댓글