# 스팬에 무엇을 넣지 말아야 하는가 - 속성 상한, 카디널리티, 그리고 지울 수 없는 데이터
트레이싱을 붙이고 나면 속성을 넣는 일이 쉬워집니다. 나중에 도움이 될 것 같아 요청 본문을 통째로 넣고, 사용자 식별자를 넣고, 헤더를 넣습니다. 몇 주 뒤 저장 비용 청구서와 "이 사용자 데이터를 지워 달라"는 요청이 함께 옵니다.
문제는 넣는 순간에는 아무 경고도 없다는 점입니다. SDK가 막아 주는 것과 막아 주지 않는 것이 따로 있고, 그 경계가 직관과 다릅니다. 이 글에서는 스펙이 정한 상한이 실제로 무엇을 자르는지, 카디널리티가 스팬과 메트릭에서 왜 다른 의미인지, 그리고 개인정보를 트레이스에 넣으면 왜 되돌릴 수 없는지 정리합니다.
# 1. 기본 상한은 개수만 막고 길이는 안 막는다
스펙의 기본값이 비대칭입니다.
AttributeCountLimit: Default=128 - Maximum allowed attribute count per record
AttributeValueLengthLimit: Default=Infinity - Maximum allowed attribute value length (applies to string values and byte arrays)
개수는 128개로 제한되지만 값 길이는 기본이 무제한입니다. 요청 본문을 문자열로 넣으면 그대로 나갑니다. 1MB짜리 JSON을 속성에 넣으면 1MB가 그대로 익스포터를 타고 백엔드에 저장됩니다. "설마 그렇게까지 나가겠어"라는 기대가 틀리는 지점입니다.
초과했을 때의 동작도 다릅니다.
if adding an attribute to an attribute collection would result in exceeding the limit ... the SDK MUST discard that attribute
SDKs MUST truncate that value, so that its length is at most equal to the limit
개수는 버리고, 길이는 자릅니다. 그리고 개수 초과에서 버려지는 것은 나중에 추가된 속성입니다. 여기서 실질적인 위험이 나옵니다. 스팬 생성 시점에 자동 계측이 수십 개를 붙이고, 애플리케이션이 도메인 속성을 몇 개 더 붙이고, 마지막에 예외 정보를 붙이는 순서라면 정작 필요한 에러 정보가 상한에 걸려 사라집니다. 문제가 터졌을 때만 조용히 없어지는 형태입니다.
알림도 기대하기 어렵습니다.
There MAY be a log emitted to indicate to the user that an attribute was truncated or discarded. To prevent excessive logging, the log MUST NOT be emitted more than once per record.
MAY이고, 있어도 레코드당 한 번입니다. 로그로 발견하는 방식은 통하지 않습니다.
대응은 값 길이 상한을 명시적으로 거는 것입니다. 기본값이 무제한이므로 안 거는 것은 선택이 아니라 방치입니다.
# 스팬 속성 개수와 값 길이를 함께 제한한다
export OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT=64
export OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT=512
값 길이를 512자쯤으로 자르면 실수로 본문을 넣어도 피해가 제한됩니다. 완전한 방어는 아니지만(잘린 512자에도 개인정보가 남을 수 있습니다) 비용 측면의 사고는 막습니다.
# 2. 상한이 적용되지 않는 곳이 있다
같은 스펙 문서가 예외를 둡니다.
Resource attributes SHOULD be exempt from the limits
그리고 메트릭 속성도 현재 상한 대상이 아닙니다. 이 예외가 중요한 이유는 가장 위험한 곳이 예외 대상이기 때문입니다.
리소스 속성은 프로세스 단위로 한 번 정해지므로 개수가 많아도 문제가 적습니다. 문제는 메트릭입니다. 메트릭 속성에 고카디널리티 값을 넣으면 SDK가 아무것도 막아 주지 않고, 결과는 백엔드에서 시계열 폭발로 나타납니다.
# 3. 카디널리티는 스팬과 메트릭에서 다른 뜻이다
여기가 이 글의 핵심입니다. 같은 값을 넣어도 두 신호에서 비용 구조가 완전히 다릅니다.
| 스팬 속성 | 메트릭 속성 | |
|---|---|---|
| 값이 다양해지면 | 스팬 개수는 그대로, 각 스팬이 커짐 | 시계열 개수가 곱해짐 |
| 비용 증가 형태 | 선형 (저장·색인) | 조합의 곱 |
| 샘플링으로 완화 | 가능 | 불가 |
user_id를 넣으면 | 대체로 괜찮음 | 사용자 수만큼 시계열 |
스팬은 이미 요청마다 하나씩 생깁니다. 거기에 user_id를 붙여도 스팬 개수가 늘지 않습니다. 저장량이 조금 늘 뿐이고, 샘플링으로 비율을 낮추면 비용도 함께 줄어듭니다. 그래서 트레이스에는 고유 식별자를 넣는 것이 정상적인 사용법입니다. 특정 요청을 찾아가는 것이 트레이스의 존재 이유이기 때문입니다.
메트릭은 반대입니다. 속성 조합마다 시계열이 하나씩 생기고, 조합은 곱으로 늘어납니다. endpoint(50) × status_code(6) × region(3)이면 900개로 관리 가능하지만, 여기에 user_id(10만)를 넣으면 9천만입니다. 샘플링해도 줄지 않습니다. 집계값은 모든 요청을 반영해야 하기 때문입니다.
따라서 규칙은 신호별로 달라야 합니다.
- 스팬: 고유 식별자를 넣는다. 대신 값 크기를 통제한다.
- 메트릭: 값의 종류가 유한하고 미리 알 수 있는 것만 넣는다.
같은 코드에서 두 신호를 함께 내보낼 때 속성 딕셔너리를 재사용하는 습관이 사고를 만듭니다. 스팬용으로 만든 속성을 그대로 카운터에 넘기면 그 순간 시계열이 폭발합니다. 두 신호의 속성 생성 지점을 분리하는 것이 실질적인 예방책입니다.
# 위험: 하나를 만들어 둘 다에 쓴다
attrs = {"endpoint": endpoint, "user.id": user_id, "trace.request_id": req_id}
span.set_attributes(attrs)
request_counter.add(1, attrs) # user.id 만큼 시계열이 생긴다
# 안전: 신호별로 따로 만든다
span.set_attributes({"endpoint": endpoint, "user.id": user_id, "request.id": req_id})
request_counter.add(1, {"endpoint": endpoint, "status_code": status})
# 4. 속성을 넣는 시점이 샘플링을 좌우한다
스펙이 명시하는 규칙이 하나 더 있습니다.
adding attributes at span creation is preferred to calling
SetAttributelater, as samplers can only consider information already present during span creation
샘플러는 스팬 생성 시점의 속성만 봅니다. 나중에 set_attribute로 붙인 값은 샘플링 판정에 쓰이지 않습니다.
이 규칙이 실무에서 걸리는 경우가 있습니다. "에러가 난 요청은 전부 남기고 나머지는 1%만"이라는 정책을 세우려는데, 에러 여부는 함수가 끝날 때 알게 됩니다. 스팬 생성 시점에는 아직 모릅니다. 헤드 기반 샘플러로는 이 정책을 구현할 수 없습니다.
정리하면 이렇습니다.
- 생성 시점에 알 수 있는 값(엔드포인트, 테넌트, 우선순위)은 생성 인자로 넘긴다. 샘플링 규칙에 쓸 수 있다.
- 끝나야 아는 값(결과, 소요 시간, 예외)으로 샘플링하려면 테일 기반 샘플링이 필요하다. 즉 Collector 단계에서 판단한다.
# 샘플러가 볼 수 있게 생성 시점에 넘긴다
with tracer.start_as_current_span(
"checkout",
attributes={"tenant.id": tenant_id, "checkout.kind": kind},
) as span:
result = do_checkout()
span.set_attribute("checkout.item_count", len(result.items)) # 샘플링엔 안 쓰임
# 5. 트레이스에 들어간 개인정보는 사실상 지울 수 없다
비용은 늘리면 되고 상한은 조이면 되지만, 개인정보는 성격이 다릅니다.
트레이스 데이터의 특성 때문입니다. 스팬은 수집기를 거쳐 백엔드로 흐르고, 도중에 여러 곳에 버퍼링되고, 백엔드에서는 보존 기간까지 남습니다. 특정 사용자의 데이터만 골라 지우는 기능을 제공하는 백엔드는 드물고, 있어도 색인이 사용자 기준으로 되어 있지 않아 실효성이 낮습니다. 넣지 않는 것이 유일하게 확실한 통제입니다.
실무 규칙을 정리하면 이렇습니다.
| 넣지 말 것 | 대신 넣을 것 |
|---|---|
| 요청·응답 본문 원본 | 본문 크기, 스키마 버전, 항목 수 |
| 이메일·전화번호·이름 | 내부 식별자(user.id) |
| 인증 헤더, 쿠키, 토큰 | 인증 방식(auth.method), 만료 여부 |
| 전체 URL(쿼리스트링 포함) | 경로 템플릿(/orders/{id}) |
| 결제 수단 정보 | 결제 수단 종류 |
| 자유 입력 텍스트 | 길이, 언어 코드 |
쿼리스트링이 특히 놓치기 쉽습니다. 리셋 토큰이나 서명된 URL 파라미터가 여기 실려 오고, 전체 URL을 속성에 넣으면 그대로 저장됩니다. 경로는 템플릿만 넣는 것이 원칙이고, 이것은 카디널리티 관점에서도 맞는 선택입니다. 실제 ID가 박힌 경로를 메트릭 속성으로 쓰면 3절의 폭발이 그대로 일어납니다.
헤더도 마찬가지입니다. "일단 다 넣고 나중에 거른다"는 접근은 안 됩니다. 거를 시점에는 이미 저장돼 있습니다. 허용 목록 방식이어야 합니다.
ALLOWED_HEADERS = frozenset({"content-type", "user-agent", "x-request-id"})
def header_attributes(headers):
return {
f"http.request.header.{k.lower()}": v
for k, v in headers.items()
if k.lower() in ALLOWED_HEADERS
}
# 6. 두 번째 방어선을 수집기에 둔다
애플리케이션 코드만으로 통제하면 언젠가 새는 곳이 생깁니다. 서드파티 자동 계측이 무엇을 붙일지는 우리가 정하지 않기 때문입니다. Collector 단계에서 한 겹 더 거는 것이 현실적입니다.
processors:
attributes/redact:
actions:
# 통째로 삭제
- key: http.request.header.authorization
action: delete
- key: http.request.body
action: delete
# 해시로 대체 - 동일성 비교는 되고 원문은 남지 않는다
- key: user.email
action: hash
# 패턴 기반 삭제
- pattern: ^http\.request\.header\.x-internal-.*
action: delete
# 값 길이 상한을 수집기에서도 한 번 더
transform/truncate:
trace_statements:
- context: span
statements:
- truncate_all(attributes, 512)
hash 액션이 유용한 지점이 있습니다. 원문은 남기지 않으면서 "같은 사용자인가"는 비교할 수 있습니다. 다만 값 공간이 작으면(예: 상태 코드) 해시가 사실상 원문이므로, 식별자처럼 공간이 넓은 값에만 의미가 있습니다.
이 방어선은 애플리케이션 통제를 대체하지 않습니다. 수집기에 닿기 전에 네트워크를 한 번 타고, 수집기 자체 로그에 남을 수 있습니다. 순서는 언제나 "애초에 안 넣기 → 코드에서 거르기 → 수집기에서 거르기"입니다.
# 7. 이름 규칙도 나중에 비용이 된다
스펙의 명명 규칙은 단순하지만 지키지 않으면 되돌리기 어렵습니다.
Use namespacing. Delimit the namespaces using a dot character. For example
service.versiondenotes the service version whereserviceis the namespace andversionis an attribute in that namespace.
Two attributes, two metrics, or two events MUST NOT share the same name.
같은 개념을 팀마다 userId, user_id, user.id로 쓰면 나중에 대시보드와 알림을 두 벌 만들게 됩니다. 이미 저장된 데이터의 속성 이름은 바꿀 수 없으므로, 규칙을 늦게 정할수록 과거 데이터와 새 데이터가 갈라집니다.
그리고 속성 이름 자체에 가변값을 넣으면 안 됩니다. user.12345.role 같은 이름은 이름 공간을 무한히 늘려 백엔드의 속성 색인을 망가뜨립니다. 값은 값 자리에 넣어야 합니다.
# 8. 직접 확인하는 방법
지금 무엇이 나가고 있는지는 익스포터를 콘솔로 바꿔 보면 바로 보입니다.
# 로컬에서 실제 내보내는 스팬을 그대로 출력
export OTEL_TRACES_EXPORTER=console
python -m myapp
운영 환경이라면 수집기 앞에서 세는 편이 낫습니다.
# 스팬당 속성 개수와 값 길이 분포 - 상한에 걸리는지 확인
from collections import Counter
def audit(spans):
counts = Counter()
for s in spans:
counts[len(s.attributes)] += 1
oversize = [k for k, v in s.attributes.items()
if isinstance(v, str) and len(v) > 512]
if oversize:
print(f"{s.name}: 긴 값 {oversize}")
print(sorted(counts.items()))
메트릭 쪽 카디널리티는 백엔드에서 직접 셉니다.
# 메트릭별 시계열 개수 상위 - 급증한 것이 폭발 지점이다
topk(10, count by (__name__) ({__name__=~".+"}))
# 특정 메트릭의 라벨별 고유값 개수
count(count by (endpoint) (http_server_request_duration_seconds_count))
개인정보 유출 여부는 정규식으로 훑는 것이 현실적입니다. 완벽하지 않지만 명백한 사고는 잡습니다.
# 콘솔 익스포터 출력에서 이메일 형태·JWT 형태를 찾는다
python -m myapp 2>&1 \
| grep -oE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+|eyJ[A-Za-z0-9_-]{10,}' \
| sort -u
# 9. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 필요한 속성이 스팬에 없음 | 128개 상한에 걸려 버려짐 | 속성 수 감사, 자동 계측 속성 축소 |
| 스팬 하나가 수 MB | 값 길이 기본 무제한 | OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT 설정 |
| 트레이스 저장 비용 급증 | 본문·헤더를 통째로 넣음 | 크기·스키마만 남기고 허용 목록 적용 |
| 메트릭 백엔드가 느려지거나 거부 | 메트릭 속성에 고유값 | 스팬과 메트릭 속성 생성 분리 |
| 상한을 걸었는데 메트릭은 그대로 | 메트릭 속성은 상한 예외 | 코드에서 직접 통제 |
| 에러만 100% 샘플링이 안 됨 | 샘플러는 생성 시점 속성만 봄 | 테일 기반 샘플링으로 전환 |
| 지워 달라는 요청에 대응 불가 | 트레이스는 선택 삭제가 어려움 | 애초에 넣지 않기, 보존 기간 단축 |
| 같은 개념의 속성이 두 이름 | 명명 규칙 부재 | 네임스페이스 규칙 확정 후 통일 |
# 10. 마무리
- 기본 상한은 개수만 막고 길이는 안 막습니다. 값 길이 상한은 명시적으로 걸어야 합니다.
- 개수 초과 시 버려지는 것은 나중에 붙인 속성입니다. 문제가 생겼을 때 필요한 정보가 먼저 사라질 수 있습니다.
- 리소스 속성과 메트릭 속성은 상한 예외입니다. 가장 위험한 곳을 SDK가 막아 주지 않습니다.
- 카디널리티의 비용은 신호마다 다릅니다. 스팬에
user.id를 넣는 것은 정상이고, 같은 값을 메트릭에 넣으면 시계열이 곱으로 늘어납니다. 속성 딕셔너리를 두 신호에 재사용하지 않아야 합니다. - 샘플러는 스팬 생성 시점의 속성만 봅니다. 결과에 따라 샘플링하려면 테일 기반으로 가야 합니다.
- 트레이스에 들어간 개인정보는 선택적으로 지우기 어렵습니다. 애플리케이션에서 허용 목록으로 막고, 수집기에서 한 겹 더 거는 순서로 설계합니다.
계측 코드를 어디에 어떻게 붙일지는 OpenTelemetry 데코레이터를 직접 만들 때 무너지는 것들 (opens new window)에 정리했습니다.
# 참고
- OpenTelemetry - Common specification: Attribute Limits (opens new window)
- OpenTelemetry - Tracing API (Span) (opens new window)
- OpenTelemetry - Semantic Conventions: Naming (opens new window)
- OpenTelemetry Collector Contrib - Attributes Processor (opens new window)
- OpenTelemetry Collector Contrib - Transform Processor (opens new window)
- OpenTelemetry - SDK 환경 변수 스펙 (속성 상한) (opens new window)