- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — "관측성"이라는 단어의 인플레이션
"Observability" — 한때 SRE들만 쓰던 단어가 지금은 모든 DevOps 면접의 필수 질문이다. 하지만 질문을 해보면 대답은 대부분 이렇다.
"로그 모으고, 메트릭 보고, Jaeger에서 trace 봅니다."
이건 관측성이 아니라 모니터링이다. 둘의 차이는:
- 모니터링 — "미리 정해둔 지표로 시스템이 정상인지 확인"
- 관측성 — "예상하지 못했던 문제도 외부 신호만으로 내부 상태를 추론할 수 있는 성질"
후자가 되려면 신호가 풍부하고, 연결돼 있고, 무작위 질문에 답할 수 있어야 한다. OpenTelemetry는 이걸 위한 표준이다.
이전 글 Service Mesh 완전 해부에서 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. 세 기둥에서 네 기둥으로 — 신호의 구조
전통적인 세 기둥:
- Traces — 요청 하나가 시스템을 거치며 남긴 인과 그래프
- Metrics — 숫자로 집계된 시계열 (카운터, 게이지, 히스토그램)
- Logs — 시점별 이벤트 메시지
그리고 새로 추가된:
- 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가지
- 서비스마다
service.name,service.version필수 설정 - W3C trace context 헤더 전파 확인 — 중간에 끊기는지 통합 테스트
- auto-instrumentation 먼저 → 수동은 최소화
- 샘플링 전략을 문서화 — Head/Tail/Adaptive를 조합해 결정
- Collector를 모든 경로에 두기 — 앱이 직접 백엔드 노출 X
- OTLP/gRPC + mTLS로 Collector 보호
- Cardinality monitoring — 상위 N개 라벨 값 카운트 대시보드
- Runbook에 trace URL 템플릿 — 알람에서 1클릭으로 trace로
- Semantic Conventions 1.x로 마이그레이션
- Kubernetes Resource attributes 자동 주입 — k8sattributes processor
- retention 계층화 + 비용 알람
- 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다." 반 농담으로 쓰이는 이 말의 실체를 다음 글에서 확인해보자.