OpenTelemetry Collector에서 카디널리티 폭발을 잡고 민감 데이터를 파이프라인 단에서 지우는 법
프로덕션 환경에서 메트릭 비용이 갑자기 3배로 뛰거나, 로그에 사용자 이메일이 그대로 남아 있다는 걸 뒤늦게 발견한 경험이 있으신가요? user_id 레이블 하나 추가했다가 Prometheus 스토리지가 한 주 만에 포화 상태에 빠졌던 기억, GDPR 대응을 위해 애플리케이션 코드 수십 군데를 고쳐야 했던 기억이 있는 분이라면 이 글이 도움이 될 겁니다.
OTTL(OpenTelemetry Transformation Language) 과 Transform Processor를 활용하면 두 문제를 모두 Collector 파이프라인 단에서 해결할 수 있습니다. 애플리케이션 코드 한 줄 건드리지 않고, 메트릭 카디널리티를 줄이고, 이메일·카드번호·SSN 같은 민감 데이터를 백엔드에 도달하기 전에 지울 수 있습니다. 2025~2026년 사이 업스트림에 올라온 패턴들도 함께 살펴볼 예정입니다.
핵심 개념
OTTL이란 무엇인가
OTTL은 OpenTelemetry Collector 파이프라인 안에서 텔레메트리 데이터를 읽고, 수정하고, 삭제하기 위한 도메인 특화 표현 언어(DSL) 입니다. Go 기반의 opentelemetry-collector-contrib 저장소에 포함되어 있고, Transform Processor가 이를 실행 엔진으로 채택합니다.
각 statement는 **Editor 함수(데이터 변환)**와 where 조건(불리언 표현식) 두 부분으로 나뉩니다.
# 기본 문법 구조
transform:
trace_statements:
- context: span
statements:
- <Editor함수> where <조건식>"컨텍스트"는 "내가 건드리고 싶은 데이터의 단위"로 이해하면 편합니다. 스팬 하나, 데이터포인트 하나, 로그 레코드 하나 같은 식입니다.
컨텍스트와 신호 유형
Transform Processor는 신호 유형별로 컨텍스트를 분리해서 실행합니다.
| 신호 유형 | 사용 가능한 컨텍스트 |
|---|---|
trace_statements |
span, spanevent, resource, scope |
metric_statements |
metric, datapoint, resource, scope |
log_statements |
log, resource, scope |
리소스 어트리뷰트를 수정할 때는
span컨텍스트 대신resource컨텍스트를 쓰면 훨씬 효율적입니다. 컨텍스트는 중첩 구조이기 때문에span컨텍스트에서resource.attributes에 접근하면 스팬 수만큼 반복 실행됩니다.
주요 Editor 함수 한눈에 보기
| 함수 | 설명 |
|---|---|
delete_key(map, key) |
맵에서 특정 키 삭제 |
delete_matching_keys(map, pattern) |
정규식에 매칭되는 키 일괄 삭제 |
replace_pattern(target, regex, replacement) |
정규식 패턴 치환 (SHA256 옵션 지원) |
replace_all_patterns(map, mode, regex, replacement) |
맵 내 모든 값/키에 패턴 치환 |
set(target, value) |
값 설정 |
truncate_all(map, limit) |
맵 내 모든 문자열 값을 지정 길이로 절단 |
keep_matching_keys(map, pattern) |
패턴에 매칭되는 키만 보존, 나머지 삭제 |
merge_maps(target, source, strategy) |
맵 병합 |
keep_matching_keys와merge_maps는 본문 시나리오에서 직접 다루지 않습니다. 사용 맥락은 공식 ottlfuncs README를 참고하세요.
카디널리티 폭발이 왜 무서운가
method(5가지), status_code(5가지), pod_id(1,000개), user_id(1,000,000개)를 동시에 메트릭 레이블로 쓰면 어떻게 될까요?
5 × 5 × 1,000 × 1,000,000 = 250억 개의 고유 시계열이 생성됩니다. OpenTelemetry SDK의 기본 카디널리티 한계는 메트릭당 2,000개인데, 이 상태로 Prometheus나 SaaS 백엔드에 밀어넣으면 저장·쿼리 비용이 기하급수적으로 올라갑니다.
아래 다이어그램은 어트리뷰트 두 개(고유값 기반)를 제거했을 때 시계열 수가 어떻게 달라지는지를 보여줍니다.
flowchart LR
subgraph 제거 전
direction TB
A1["method × status × pod_id × user_id"] --> B1["시계열 수: 250억"]
end
subgraph 제거 후
direction TB
A2["method × status"] --> B2["시계열 수: 25"]
end
제거 전 -->|"delete_key pod_id, user_id"| 제거 후수치가 극단적으로 보이지만, 실제로 레이블 제거만으로도 스토리지 규모가 수십 배 차이 납니다.
어떤 프로세서를 언제 쓸까
여러 프로세서가 비슷해 보여서 처음에 어느 걸 써야 할지 고민되실 수 있습니다.
핵심 차이점을 요약하면: Redaction Processor는 신호 종류와 무관하게 어트리뷰트(key-value 쌍)만 다루며, 로그 바디(문자열 또는 맵)에는 접근하지 못합니다. 로그 바디 안의 카드번호나 비밀번호를 지우려면 Transform Processor가 필수입니다.
실전 적용
시나리오 1 — 메트릭 카디널리티 줄이기
가장 빈번한 케이스입니다. 인스턴스별 고유 ID를 메트릭 레이블에서 제거하기만 해도 시계열 수가 크게 줄어듭니다.
processors:
transform:
metric_statements:
- context: datapoint
statements:
# 카디널리티를 폭발시키는 고유 인스턴스 어트리뷰트 제거
- delete_key(attributes, "container.id")
- delete_key(attributes, "pod.uid")
- delete_key(attributes, "server.instance_id")
# URL 경로의 동적 세그먼트를 와일드카드로 치환
- replace_pattern(attributes["http.route"], "/[0-9]+", "/{id}")마지막 줄이 특히 효과적입니다. /users/12345/orders, /users/67890/orders처럼 수천 개의 고유 경로가 /users/{id}/orders 하나로 통합됩니다.
업계 보고 사례 중에는 이 패턴만으로 스토리지 비용을 최대 70%까지 절감한 경우도 있습니다. 필자 경험으로는 고유 ID 어트리뷰트 몇 개를 제거했을 때 30~40% 수준의 절감이 일관되게 나타났습니다.
시나리오 2 — 스팬 어트리뷰트의 PII 마스킹
이메일이나 전화번호가 스팬 어트리뷰트로 흘러들어오는 경우입니다.
processors:
transform:
trace_statements:
- context: span
statements:
# user.email이 존재하면 먼저 PII 태깅 (감사 로그용)
- set(attributes["pii.detected"], true) where attributes["user.email"] != nil
# 이메일 로컬 파트 마스킹, 도메인은 보존
# YAML에서 $$는 리터럴 $로 해석됩니다
- replace_pattern(attributes["user.email"], "^(.+)@(.+)$", "****@$$2")
# 전화번호 패턴 마스킹
- replace_pattern(attributes["phone"], "\\d{3}-\\d{4}-\\d{4}", "***-****-****")set을 먼저 배치한 이유가 있습니다. statements는 작성 순서대로 실행되기 때문에, 마스킹 후에 user.email로 조건을 걸면 이미 변환된 값에 의존하게 됩니다. 원본 존재 여부를 확인해야 한다면 마스킹 전에 처리해야 합니다.
****@company.com 형태로 남기면 어느 도메인 사용자인지는 파악하되 개인은 특정할 수 없어서 디버깅과 컴플라이언스를 동시에 만족시킬 수 있습니다.
시나리오 3 — 로그 바디의 신용카드·SSN 마스킹
이게 Transform Processor를 써야 하는 결정적인 이유입니다. 로그 바디 문자열에 직접 OTTL을 적용할 수 있거든요.
아래 예시는 로그 바디가 단순 문자열 타입인 경우를 전제합니다. JSON 파싱 결과처럼 구조화된 바디라면
body["field_name"]형태로 개별 필드에 접근해야 합니다.
processors:
transform:
log_statements:
- context: log
statements:
# 카드 번호 패턴 마스킹
- replace_pattern(body, "card=\\d{13,19}", "card=***REDACTED***")
# SSN 마스킹
- replace_pattern(body, "\\b\\d{3}-\\d{2}-\\d{4}\\b", "***-**-****")
# 비밀번호 필드 마스킹
- replace_pattern(body, "password=[^\\s&]+", "password=***")Redaction Processor로는 이 작업이 불가능합니다. 어트리뷰트가 아닌 바디 문자열 자체에 접근해야 하기 때문입니다.
시나리오 4 — SHA-256 가명화
GDPR에서 삭제만이 유일한 선택지는 아닙니다. 동일 사용자를 추적해야 하는 디버깅 상황에서는 해시 기반 가명화가 유용합니다.
processors:
transform:
trace_statements:
- context: span
statements:
# user.id를 SHA-256 해시로 치환 (uid- 접두어 포함)
# YAML에서 $$는 리터럴 $로 해석됩니다
- replace_pattern(attributes["user.id"], "(.+)", "$$1", SHA256, "uid-%s")원본 user.id를 알고 있으면 같은 해시값으로 추적이 가능하고, 백엔드에는 해시만 저장됩니다.
시나리오 5 — 3단계 통합 PII 방어 파이프라인
앞서 살펴본 개별 시나리오들을 하나의 파이프라인으로 합치면 다음과 같은 구조가 됩니다. 각 프로세서가 잘하는 역할을 분담하는 방식입니다.
processors:
# 1단계: 알려진 PII 어트리뷰트 키 삭제
attributes:
actions:
- key: user.password
action: delete
- key: credit_card_number
action: delete
# 2단계: 어트리뷰트 값 패턴 기반 차단 (Allowlist 방식)
# Redaction Processor는 신호 무관하게 어트리뷰트만 처리하며 로그 바디에는 접근 불가
redaction:
allow_all_keys: false
allowed_keys:
- http.method
- http.status_code
- service.name
blocked_values:
- "\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b"
- "\\b(?:\\d[ -]?){13,19}\\b"
# 3단계: 로그 바디 및 복잡한 조건부 마스킹
transform:
log_statements:
- context: log
statements:
- replace_pattern(body, "ssn=\\d{9}", "ssn=REDACTED")
service:
pipelines:
traces:
processors: [attributes, redaction, transform]
logs:
processors: [attributes, transform]장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 파이프라인 단일 제어점 | 애플리케이션 코드 수정 없이 Collector에서 전사 PII 정책을 일괄 적용할 수 있습니다 |
| 표현력 | 정규식, 조건부 where 절, 중첩 컨텍스트 지원으로 복잡한 규칙도 표현 가능합니다 |
| 로그 바디 접근 | Redaction/Attributes Processor가 지원하지 않는 로그 바디 문자열 변환이 가능합니다 |
| 멀티 시그널 | Traces·Metrics·Logs 세 신호를 단일 프로세서로 처리합니다 |
| 카디널리티 비용 절감 | 고유 어트리뷰트 제거만으로 스토리지 비용을 수십 배 수준으로 절감할 수 있습니다 |
단점 및 주의사항
| 항목 | 내용 |
|---|---|
| 성능 오버헤드 | 모든 스팬/로그에 정규식을 적용하면 CPU 부하가 증가합니다. 고트래픽 환경에서는 where 조건으로 적용 범위를 좁히는 게 좋습니다 |
| 새 레코드 생성 불가 | OTTL은 반복 중 레코드를 추가하면 스킵이나 비결정적 동작이 발생합니다. 레코드 확장이 필요하면 Unroll Processor(2025년 contrib 합류)를 별도로 활용할 수 있습니다 |
| Redaction Processor 범위 | Redaction Processor는 신호 종류와 무관하게 어트리뷰트(key-value)만 다루며, 로그 바디(문자열 또는 맵)에는 접근하지 못합니다. 로그 바디 마스킹에는 Transform Processor가 필수입니다 |
| 문 실행 순서 의존성 | statements는 작성 순서대로 실행되므로, 앞선 statement에서 어트리뷰트를 삭제하면 뒤 statement에서 그 키에 접근할 수 없습니다 |
| 검증 도구 | 공식적으로 관리되는 단독 테스트 도구는 없으며, 커뮤니티 기반 playground를 보조 수단으로 활용할 수 있습니다. 다만 스테이징 환경에서 실제 트래픽으로 검증하는 것이 가장 신뢰할 수 있는 방법입니다 |
| PII 누락 리스크 | 미리 정의된 패턴만 처리하므로, 코드에 새로운 PII 패턴이 추가되면 규칙도 별도로 업데이트해야 합니다 |
실무에서 자주 하는 실수
실수 1 — 리소스 어트리뷰트를 span 컨텍스트에서 수정하기
# 비효율: 스팬마다 반복 실행됨
- context: span
statements:
- delete_key(resource.attributes, "k8s.pod.uid")
# 효율적: 리소스 단위로 한 번만 실행됨
- context: resource
statements:
- delete_key(attributes, "k8s.pod.uid")실수 2 — 삭제한 키를 뒤에서 참조하기
statements:
- delete_key(attributes, "user.email")
# 이미 삭제됐으므로 조건이 항상 false가 됩니다
- set(attributes["pii.detected"], true) where attributes["user.email"] != nilset을 먼저, delete_key를 나중에 배치하면 됩니다.
실수 3 — 카디널리티를 줄이겠다며 Filter로 시그널 전체를 드롭하기
어트리뷰트 제거로 해결할 수 있는 문제를 시그널 드롭으로 해결하면 디버깅 컨텍스트 자체가 사라집니다. 레이블을 줄이되 시그널은 살려두는 접근을 권장합니다.
마치며
핵심을 정리하면 이렇습니다.
- OTTL + Transform Processor는 파이프라인 단일 제어점에서 카디널리티 폭발과 PII 문제를 동시에 해결하는 가장 표현력 있는 도구입니다.
- Redaction Processor는 어트리뷰트 Allowlist 차단에, Transform Processor는 로그 바디 마스킹과 복잡한 조건부 변환에 각각 최적화되어 있습니다. 둘의 경계는 신호 유형이 아니라 접근 대상(어트리뷰트 vs. 바디) 입니다.
- statements 실행 순서, 컨텍스트 선택, 정규식 성능이 운영 품질을 가르는 세 가지 포인트입니다.
적용 순서를 제안하면 다음과 같습니다.
-
현재 메트릭 레이블에서 고유값 어트리뷰트를 식별합니다 —
container.id,pod.uid,request_id,user_id같은 키를 찾아delete_key로 제거하는 것부터 시작하면 효과를 바로 확인할 수 있습니다. -
로그와 스팬에서 PII가 흘러나오는 경로를 파악합니다 — Collector에
debugexporter를 임시로 연결해 실제 어트리뷰트 값을 샘플링해보면 어떤 패턴이 필요한지 명확해집니다. -
3단계 파이프라인(Attributes → Redaction → Transform)을 스테이징에 먼저 배포합니다 — 커뮤니티 playground로 기본 문법을 검증한 뒤, 스테이징에서 실제 트래픽 샘플로 규칙을 확인하고 프로덕션에 적용하는 흐름이 안전합니다.
2026년 들어 Filter Processor에도 OTTL 컨텍스트 자동 추론이 지원되기 시작했고, Unroll Processor도 2025년에 contrib에 합류했습니다. 이 두 변화가 맞물리면 레코드 분기·확장 패턴과 필터링 조건을 더 간결하게 표현할 수 있게 됩니다. OTTL 문법을 지금 익혀두면 이 변화를 자연스럽게 흡수할 수 있습니다.
참고 자료
- OpenTelemetry 공식 — Transforming Telemetry
- OpenTelemetry 공식 — Handling Sensitive Data
- Transform Processor README (opentelemetry-collector-contrib)
- OTTL Functions README (ottlfuncs)
- Redaction Processor README (opentelemetry-collector-contrib)
- Dash0 — Mastering the OpenTelemetry Transformation Language (OTTL)
- Dash0 — Mastering the OpenTelemetry Transform Processor
- Dash0 — Scrubbing Sensitive Data from OpenTelemetry Logs, Traces & Metrics
- Better Stack — Redacting Sensitive Data with the OpenTelemetry Collector
- Better Stack — OTTL Recipes for Transforming OpenTelemetry Data
- oneuptime — Handle High-Cardinality Metrics in OpenTelemetry Without Blowing Your Budget
- oneuptime — How to Make Your OpenTelemetry Pipeline GDPR-Compliant
- oneuptime — How to Transform Log Bodies Using OTTL
- OpenTelemetry Blog — OTTL Context Inference Comes to the Filter Processor (2026)
- OpenTelemetry Blog — Contributing the Unroll Processor (2025)
- SigNoz Docs — OTTL OpenTelemetry Transformation Language
- ControlTheory — Control High Metric Cardinality with the OTel Collector Filter Processor
- Last9 — Redacting Sensitive Data in OpenTelemetry Collector
- Dynatrace Docs — Mask Sensitive Data with the OTel Collector
- Honeycomb Docs — Handle Sensitive Information with the OpenTelemetry Collector
- Grafana Alloy — otelcol.processor.transform Reference
- Splunk Observability — Transform Processor
- Sumo Logic Docs — String Hashing and Masking using Transform Processor and OTTL