들어가며 — "관측성"이라는 단어의 인플레이션
"Observability" — 한때 SRE들만 쓰던 단어가 지금은 모든 DevOps 면접의 필수 질문이다. 하지만 질문을 해보면 대답은 대부분 이렇다.
> "로그 모으고, 메트릭 보고, Jaeger에서 trace 봅니다."
이건 **관측성이 아니라 모니터링이다**. 둘의 차이는:
- **모니터링** — "미리 정해둔 지표로 시스템이 정상인지 확인"
- **관측성** — "예상하지 못했던 문제도 외부 신호만으로 내부 상태를 추론할 수 있는 성질"
후자가 되려면 신호가 **풍부하고, 연결돼 있고, 무작위 질문에 답할 수 있어야** 한다. OpenTelemetry는 이걸 위한 **표준**이다.
> 이전 글 [Service Mesh 완전 해부](/blog/culture/2026-04-15-service-mesh-envoy-istio-linkerd-cilium-ebpf-ambient-xds-mtls-deep-dive-guide-2025)에서 Mesh가 자동으로 메트릭/trace를 뿌려준다고 했다. 이 글은 **그 신호들이 어떻게 수집·전송·저장·질의되는지**의 이야기다.
1. OpenTelemetry의 탄생 배경 — 왜 또 다른 표준인가
분열된 2010년대
관측성 도구는 2010년대 내내 파편화돼 있었다.
- **Jaeger** (Uber, 2017) — 분산 trace
- **Zipkin** (Twitter, 2012) — 분산 trace
- **Prometheus** (SoundCloud, 2012) — 메트릭
- **StatsD** (Etsy, 2011) — 메트릭
- **Fluentd** / **Logstash** — 로그 수집
각자 **SDK, 프로토콜, 데이터 모델이 전부 달랐다**. 서비스 A는 OpenTracing, 서비스 B는 OpenCensus를 썼다면 trace가 이어지지 않는다.
두 표준의 통합: OpenTelemetry
- **OpenTracing** (CNCF, 2016) — 벤더 중립 trace API
- **OpenCensus** (Google, 2017) — Google Cloud의 SDK
두 프로젝트는 목적이 겹쳤다. 2019년 CNCF가 두 팀을 **OpenTelemetry (OTel)**로 병합. 2021년 trace가 GA, 2023년 metric GA, 2024년 log GA, **2025년 profile이 네 번째 기둥**으로 추가됐다.
이름에 담긴 야심
"Telemetry"는 "원격 측정". 항공/우주/의료에서 쓰이던 이 단어를 선택한 건 **모든 신호를 하나의 프레임워크로**라는 의지다.
2. 세 기둥에서 네 기둥으로 — 신호의 구조
전통적인 세 기둥:
1. **Traces** — 요청 하나가 시스템을 거치며 남긴 인과 그래프
2. **Metrics** — 숫자로 집계된 시계열 (카운터, 게이지, 히스토그램)
3. **Logs** — 시점별 이벤트 메시지
그리고 새로 추가된:
4. **Profiles** — CPU/메모리 프로파일의 연속 시계열 (Continuous Profiling)
네 신호가 **하나의 trace context로 묶이는 것**이 OTel의 핵심이다.
Trace ID: abc123
┌────────────────┼────────────────┐
│ │ │
Span Metric Log
(기간) (집계값) (이벤트)
│ │ │
└────────── Profile ──────────────┘
(CPU flame graph)
사용자가 "이 요청 왜 느려?"라고 물으면, Trace ID 하나로 관련된 로그·메트릭·프로파일을 모두 찾을 수 있어야 한다. 이게 **correlation**이다.
3. Trace의 해부 — Span, Context, Baggage
Span의 구조
Span은 trace의 기본 단위다. 한 작업의 시작과 끝.
{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7",
"parentSpanId": "a2fb4a1d1a96d312",
"name": "GET /api/users/:id",
"kind": "SERVER",
"startTime": "2026-04-15T10:00:00.123Z",
"endTime": "2026-04-15T10:00:00.245Z",
"attributes": {
"http.method": "GET",
"http.status_code": 200,
"db.system": "postgresql",
"user.id": "u-42"
},
"events": [
{"name": "cache.miss", "time": "10:00:00.130Z"}
],
"status": { "code": "OK" }
}
핵심 필드:
- **traceId** — 128비트. 하나의 요청 전체에 공통
- **spanId** — 64비트. 이 span만의 고유 ID
- **parentSpanId** — 부모 span (루트면 없음)
- **kind** — SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL
Trace Context Propagation — 표준화된 헤더
서비스 A가 B를 호출할 때, HTTP 헤더로 context를 전달해야 trace가 이어진다. **W3C Trace Context** (2019 표준화):
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
└┬┘ └──────────── trace-id ─────────────┘ └───parent-id──┘└┬┘
version flags
- **version** — 00 (현재)
- **trace-id** — 128비트 hex
- **parent-id** — 부모 span ID
- **flags** — 00 (기록 안 함) / 01 (샘플링됨)
그리고 W3C가 함께 정의한 **Baggage** 헤더:
baggage: user.id=42,feature.flag=beta
Baggage는 **비즈니스 컨텍스트**를 전파한다. "이 요청은 VIP 사용자야"라는 정보를 모든 서비스가 받을 수 있다.
언어별 SDK의 자동 계측
OTel SDK는 대부분의 언어에서 **자동 계측(auto-instrumentation)**을 지원한다.
- **Java/Python/Node.js** — Java agent / 원숭이 패치로 기존 라이브러리에 훅 삽입
- **Go/Rust** — 컴파일러 제약으로 기본은 수동. eBPF로 자동화 시도 중
예: Python에서 Flask + requests + psycopg2를 쓰면, auto-instrumentation 한 줄로 다음이 전부 자동 trace된다.
opentelemetry-instrument --traces_exporter otlp python app.py
모든 HTTP 요청이 span이 되고, 나가는 requests 호출도 span이 되고, DB 쿼리도 span이 된다. 코드 수정 없음.
4. Metric의 새 데이터 모델
Prometheus 모델의 한계
Prometheus는 **pull** 기반. 서버가 앱의 `/metrics` endpoint를 스크레이프.
- 문제 1: 단기 수명 워크로드(Lambda, Job)는 스크레이프 전에 사라짐
- 문제 2: 고차원 라벨(user_id 같은 것)이면 cardinality 폭발
- 문제 3: 이종 시스템 간 의미 통일이 없음
OTel Metric의 구조
OTel은 네 가지 instrument를 정의한다:
- **Counter** — 단조 증가 (요청 수)
- **UpDownCounter** — 증감 가능 (동시 연결 수)
- **Histogram** — 분포 (지연)
- **Gauge** — 비동기 측정 (메모리 사용량)
그리고 핵심은 **Aggregation Temporality**:
- **Cumulative** — 시작부터 누적값 (Prometheus 스타일)
- **Delta** — 직전 export 이후 증분
Delta는 단기 워크로드에 적합. Cumulative는 재시작 추적에 강함.
Exemplar — 메트릭과 Trace의 다리
히스토그램의 특정 버킷에 "이 데이터 포인트에 해당하는 trace ID"를 붙일 수 있다.
http_request_duration_bucket{le="1.0"} 1523
exemplar: traceID=abc123
Grafana에서 히스토그램을 보다가 "P99 느린 요청은 뭐야?" 클릭 → 해당 trace로 점프. 이게 exemplar의 힘이다.
5. Log의 재정의
"왜 log가 새 표준이 필요해?"
ELK/Splunk/Loki가 이미 있는데 OTel이 Log 표준을 낸 이유:
- **Trace와의 연결** — 기존 로그는 trace ID가 없거나 따로 넣어야 함
- **Severity의 표준화** — 앱마다 "INFO", "warning", "warn"이 섞여 혼란
- **Attribute 모델** — key-value를 구조화
LogRecord 구조
{
"timestamp": "2026-04-15T10:00:00Z",
"severityText": "ERROR",
"severityNumber": 17,
"body": "Failed to charge card: insufficient_funds",
"traceId": "4bf92f35...",
"spanId": "00f067aa...",
"attributes": {
"user.id": 42,
"payment.amount": 29.99
},
"resource": {
"service.name": "payment-api",
"k8s.pod.name": "payment-api-7d4f-x9k2"
}
}
traceId/spanId가 기본 필드라는 점이 핵심. 어떤 로그도 trace에 자동 연결된다.
Bridges
기존 로그 라이브러리(Log4j, logback, zap, zerolog)는 OTel Log SDK로 직접 쓰지 않고 **Bridge**를 거친다. 기존 코드를 바꾸지 않고 OTel로 보낼 수 있다.
6. Profile — 네 번째 기둥
Continuous Profiling의 부상
2020년대 초 Grafana Pyroscope와 Polar Signals Parca가 "**항상 켜져 있는** CPU/메모리 프로파일"을 제공. 전통적 프로파일러(pprof, async-profiler)는 문제 발생 시 수동으로 돌렸다면, 이젠 항시 수집 후 과거로 돌아가 확인.
OTel Profiles Signal (2024 RC, 2025 GA)
프로토콜: **pprof** 포맷을 OTLP에 실어 보냄.
- Linux perf / eBPF로 **Unwind 없이** (frame pointer 또는 DWARF) 스택 샘플
- 1Hz 기본 샘플로 CPU 1% 미만 오버헤드
- Flame graph로 시각화
다른 신호와의 통합
Pyroscope + OTel 조합으로:
Trace 보기 → 느린 span 클릭 → "이 시점 CPU 프로파일" 버튼 →
해당 Pod의 해당 5초간 flame graph
함수 레벨까지 **어디서 시간이 샜나**가 보인다.
7. OTLP — 하나의 프로토콜
단일 와이어 포맷
네 신호 모두 **OTLP (OpenTelemetry Protocol)**로 전송된다.
- **OTLP/gRPC** (기본) — Protocol Buffers + gRPC
- **OTLP/HTTP** — Protocol Buffers 또는 JSON + HTTP
스키마 일부 (ExportTraceServiceRequest):
message Span {
bytes trace_id = 1;
bytes span_id = 2;
bytes parent_span_id = 4;
string name = 5;
SpanKind kind = 6;
fixed64 start_time_unix_nano = 7;
fixed64 end_time_unix_nano = 8;
repeated KeyValue attributes = 9;
repeated Event events = 11;
Status status = 15;
}
**중요**: OTLP는 엔드포인트 간 프로토콜일 뿐. 저장소(Jaeger, Tempo, Prometheus, Loki)와는 별개다.
8. Collector — 관측성 파이프라인의 심장
왜 Collector가 필요한가
앱이 직접 백엔드에 보내면 문제들:
- 앱마다 인증·재시도 로직
- 백엔드 바꾸면 앱 전부 재배포
- 라벨 rewrite 같은 전처리가 앱에 섞임
Collector는 **앱과 백엔드 사이의 유연한 중간자**.
3단계 파이프라인
Receiver → Processor → Exporter
[OTLP] [Batch] [Jaeger]
[Prometheus] [Filter] → [Loki]
[Zipkin] [Sampling] [Prometheus]
[HostMetrics][Transform] [Kafka]
[...30+] [Redact] [S3]
- **Receiver** — 신호를 받는 입구 (40+종 지원)
- **Processor** — 변환 (배칭, 샘플링, 재명명, PII 마스킹)
- **Exporter** — 백엔드로 내보냄 (40+종)
배포 토폴로지
**Agent 모드** (DaemonSet):
- 각 Node에 하나. 로컬 신호 수집 + 전달.
**Gateway 모드** (Deployment):
- 클러스터당 몇 개. Agent → Gateway → 백엔드.
- 장점: 백엔드 자격증명이 한 곳에, 큰 배칭 가능, Tail 샘플링 가능
현실에서는 **Agent + Gateway 2단 구성**이 가장 흔하다.
9. 샘플링 — 데이터 폭발을 다스리는 법
왜 샘플링인가
초당 100만 요청 × 평균 span 20개 = 초당 2000만 span. 1KB씩 계산하면 초당 20GB. 한 달이면 52TB. **저장과 쿼리 비용이 터진다**.
Head Sampling — 시작에서 결정
요청의 첫 span에서 결정. 이후 모든 자식 span은 이 결정을 물려받음.
장점: 간단, 적은 메모리
단점: "이 요청이 에러였는지"를 시작 시점에 모른다 → **에러 요청을 놓칠 수 있음**
구현:
TraceID의 앞 8바이트 → unsigned int → threshold 비교
→ 항상 동일한 요청은 동일한 결정 (일관성)
Tail Sampling — 끝에서 결정
요청의 모든 span이 완료된 뒤 결정. Collector에서 처리.
장점:
- "에러 난 trace는 무조건 저장"
- "P99 지연 trace는 무조건 저장"
- "모든 결제 요청 저장"
단점:
- Collector가 **trace를 완료될 때까지 메모리에 보관** (보통 30초~1분)
- Gateway 모드 필수
구성 예 (tail_sampling processor):
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: default
type: probabilistic
probabilistic: { sampling_percentage: 1 }
"에러, 1초 이상, 그리고 나머지 1%" → 대부분의 팀이 쓰는 기본 레시피.
Adaptive Sampling
트래픽이 폭증하면 비율을 자동 낮춤. 예산 기반 제어. 아직 OTel 표준엔 없고 벤더별 구현.
10. eBPF Auto-Instrumentation — 코드 수정 없이
기존 auto-instrumentation의 한계
- Java/Python: 에이전트 설치, JVM flag, 메모리 오버헤드
- Go: 에이전트 개념 약함 (바이너리 정적 링크)
- Rust: 매크로로 수동 계측 많음
eBPF의 접근
**Uprobe**로 사용자 공간 함수 진입점을 후킹. 함수가 실행되면 eBPF 프로그램이 **스택에서 인자 꺼내 event 생성**. 별도 사이드카나 코드 수정 없음.
대표 도구:
- **Grafana Beyla** (2023) — HTTP/gRPC를 eBPF로 자동 trace
- **Odigos** (2023) — Go, Java, Python 등 멀티 언어
- **Parca Agent** — 프로파일을 eBPF로
장점과 한계
장점:
- 코드 변경 0
- 언어 무관
- 매우 낮은 오버헤드
한계:
- 커널 권한 필요 (Pod에 `CAP_BPF`)
- 암호화된 트래픽은 TLS termination 지점까지만
- 비표준 라이브러리는 훅이 없을 수 있음
11. Semantic Conventions — 같은 것을 같은 이름으로
혼돈
앱 A는 `http.status`, 앱 B는 `http.response_status_code`, 앱 C는 `status`. 어떻게 같이 조회?
해결: 표준 속성 이름
OTel Semantic Conventions가 모든 속성 이름을 표준화한다.
http.request.method = "GET"
http.response.status_code = 200
url.path = "/api/users"
server.address = "api.example.com"
server.port = 443
db.system = "postgresql"
db.operation.name = "SELECT"
messaging.system = "kafka"
k8s.pod.name = "payment-api-x9k2"
k8s.container.name = "app"
service.name = "payment-api"
**Resource attributes** (Pod/Node/서비스 정보)와 **signal-level attributes** (요청 상세)가 나뉘어 있다. 쿼리할 때 `service.name="payment-api" AND http.response.status_code=500`처럼 통일된 이름으로 가능.
2024년 1.0 안정화
2024년에 HTTP, DB, messaging 등 주요 conventions가 1.0으로 안정화됐다. 그 이전 속성 이름(예: `http.status_code`)은 **이전 버전**으로 여전히 지원되지만, 새 코드는 1.0 스펙을 써야 한다.
12. 저장소 계층 — OTel 이후의 Jaeger, Tempo, Prometheus
OTel은 프로토콜이지 저장소가 아니다. 현장에서 쓰는 저장소들:
Trace
- **Jaeger** — Cassandra/Elasticsearch 백엔드
- **Grafana Tempo** — **S3 native**, 라벨 없이 trace ID만 인덱스 (저렴)
- **ClickHouse-based** — SigNoz, Uptrace
- **Honeycomb** — 칼럼형 저장소로 임의 쿼리
Metric
- **Prometheus** — remote_write로 OTel 받음
- **Mimir / Thanos / Cortex** — 장기 저장, 수평 확장
- **VictoriaMetrics** — Prometheus 호환 고성능
- **InfluxDB 3.0** — OTel 네이티브 지원
Log
- **Loki** — 라벨 인덱스만, 나머지는 전체 텍스트 스캔 (저렴)
- **Elasticsearch/OpenSearch** — 전통적, 풀텍스트 검색 강함
- **ClickHouse** — 수평 확장 + SQL
Profile
- **Pyroscope** (Grafana) — 컨티뉴어스 프로파일 특화
- **Parca** — 오픈소스, 단독 사용 가능
13. 실전 운영 — 6가지 교훈
교훈 1: Cardinality가 모든 걸 죽인다
메트릭 라벨에 `user_id`, `request_id`를 넣으면 시리즈 수가 폭발. 시리즈당 8KB 메모리라면 1000만 user_id = 80GB. **Prometheus/Cortex가 OOM**.
규칙: **라벨은 카디널리티가 bounded (수백 이하)인 것만**. 사용자 단위 지표는 trace attribute나 exemplar로.
교훈 2: Sampling을 일찍부터 설계
프로토타입에서 100% 샘플 → 프로덕션에서 비용 폭탄. 처음부터 **5~10% head + tail error 100%** 구성 권장.
교훈 3: Collector를 SPOF로 만들지 마라
Gateway Collector가 죽으면 텔레메트리 손실. 복제 + 큐 (Kafka/Kinesis)로 decoupling.
교훈 4: Semantic Conventions에 진심
팀마다 다른 속성 이름을 쓰면 쿼리가 지옥이 된다. **CI에 컨벤션 린터** 넣기. OTel Collector의 transform processor로 강제 rewrite도 가능.
교훈 5: Trace와 Log가 연결돼야 가치가 생긴다
로그에 trace_id/span_id가 꼭 있어야 한다. 로그 라이브러리의 MDC(Mapped Diagnostic Context) 또는 structured logging으로.
교훈 6: 보존 정책을 계층화
- Trace: 7일 hot, 30일 cold (S3)
- Metric: 15일 1m resolution, 1년 5m resolution
- Log: 30일, 정책은 feature-flag/PII 기준
통일된 30일 보관은 비용 낭비.
14. 함정과 안티패턴
안티패턴 1: span attribute에 PII를 넣기
이름, 이메일, 카드 번호가 trace에 들어가면 규제 위반. Redact processor나 앱 단에서 막아야.
안티패턴 2: 매 함수에 수동 span 추가
Span이 수백 개인 trace는 Jaeger UI에서 **보기 불가능**. 자동 계측 + 중요 경로만 수동.
안티패턴 3: log → metric → trace를 각각 다른 팀이 운영
사일로가 생김. **Platform team**이 하나로 운영해야 correlation이 생긴다.
안티패턴 4: "일단 전부 보내고 나중에 본다"
저장 비용은 선형이지만 쿼리 비용은 수퍼선형. 보내기 전에 필터하라.
안티패턴 5: Collector 없이 직접 백엔드로
초기엔 편하지만 백엔드 교체, 비밀 관리, 전처리가 불가능해진다. 처음부터 Collector 넣자.
안티패턴 6: histogram bucket을 기본값으로
`[0.005, 0.01, 0.025, ...]` 기본 bucket은 대부분의 도메인에 맞지 않는다. 실제 P99를 보고 **재조정**. bucket이 많으면 cardinality 폭발.
15. 실전 체크리스트 12가지
1. **서비스마다 `service.name`, `service.version` 필수 설정**
2. **W3C trace context 헤더 전파 확인** — 중간에 끊기는지 통합 테스트
3. **auto-instrumentation 먼저 → 수동은 최소화**
4. **샘플링 전략을 문서화** — Head/Tail/Adaptive를 조합해 결정
5. **Collector를 모든 경로에 두기** — 앱이 직접 백엔드 노출 X
6. **OTLP/gRPC + mTLS**로 Collector 보호
7. **Cardinality monitoring** — 상위 N개 라벨 값 카운트 대시보드
8. **Runbook에 trace URL 템플릿** — 알람에서 1클릭으로 trace로
9. **Semantic Conventions 1.x로 마이그레이션**
10. **Kubernetes Resource attributes 자동 주입** — k8sattributes processor
11. **retention 계층화 + 비용 알람**
12. **eBPF auto-instrumentation 파일럿** — Go/다언어 서비스에
다음 글 예고 — DNS 내부의 미스터리
Service Mesh와 OpenTelemetry를 배웠으니 이제 그 아래 계층의 기본, **DNS**를 제대로 볼 차례다. 다음 글에서는:
- **DNS 조회 한 번의 전체 경로** — 재귀 리졸버부터 권한 서버까지
- **EDNS0, DNSSEC, DoT, DoH, DoQ** — 보안과 사생활의 진화
- **CoreDNS** — Kubernetes의 숨은 심장
- DNS 기반 로드 밸런싱과 latency (ndots 5 함정!)
- **Happy Eyeballs** — IPv4/IPv6 중 빠른 쪽 선택
- DNS 기반 실패 사례 (AWS 2019, Akamai 2021)
- 퍼블릭 리졸버 대결 (1.1.1.1 vs 8.8.8.8 vs 9.9.9.9)
- Anycast와 DNS의 결합
"왜 Kubernetes에서 DNS가 느려요?", "CoreDNS를 왜 튜닝해요?" 같은 질문의 답이 거기 있다.
> **"모든 분산 장애의 절반은 DNS다."** 반 농담으로 쓰이는 이 말의 실체를 다음 글에서 확인해보자.
현재 단락 (1/269)
"Observability" — 한때 SRE들만 쓰던 단어가 지금은 모든 DevOps 면접의 필수 질문이다. 하지만 질문을 해보면 대답은 대부분 이렇다.