OpenTelemetry: 분산 추적으로 병목 찾기
프로덕션에서 특정 API가 가끔 2~3초씩 걸린다는 제보가 들어왔다. 로그 뒤져보면 딱히 에러도 없고, 그냥 "느림". 마이크로서비스가 5개쯤 엮여 있으면 어디서 느린지 파악조차 힘들다. 이게 관측 가능성(Observability)이 없을 때 겪는 고통이다.
OpenTelemetry(OTel)는 이 문제를 정면으로 해결하는 오픈 표준이다. 한 번 제대로 설정해두면 "어느 서비스, 어느 함수, 몇 밀리초"까지 바로 보인다.
관측 가능성의 세 기둥
시스템을 이해하는 데 필요한 데이터는 크게 세 종류다.
| 종류 | 설명 | 예시 |
|---|---|---|
| 로그 (Logs) | 특정 시점의 이벤트 기록 | "User 42 logged in at 14:32" |
| 메트릭 (Metrics) | 숫자로 집계된 시스템 상태 | http_requests_total = 1024 |
| 트레이스 (Traces) | 요청의 전체 흐름과 소요 시간 | A → B → C, 각 구간 ms 단위 |
로그만으로는 "무슨 일이 일어났나"는 알지만 "왜 느린가"는 모른다. 메트릭으로는 "지금 느리다"는 알지만 "어디서 느린가"는 모른다. 트레이스가 있어야 비로소 "A→B 구간에서 DB 쿼리가 800ms를 잡아먹었다"는 걸 알 수 있다.
OpenTelemetry는 이 세 가지를 하나의 SDK, 하나의 표준으로 통합한다.
OpenTelemetry가 뭐야?
CNCF(Cloud Native Computing Foundation) 산하 프로젝트로, 2019년 OpenCensus와 OpenTracing이 합쳐져서 탄생했다. 핵심은 벤더 중립적인 계측 표준이다.
[ 내 앱 ] → [ OTel SDK ] → [ OTel Collector ] → [ Jaeger / Tempo / Datadog / ... ]
한 번 OTel로 계측하면 백엔드(Jaeger, Grafana Tempo, Honeycomb, Datadog 등)를 바꿔도 앱 코드는 건드릴 필요 없다. 그게 OTel의 진짜 가치다.
핵심 컴포넌트
- API: 계측을 위한 인터페이스 정의
- SDK: API 구현체 (데이터 수집/처리)
- Collector: 에이전트/게이트웨이 역할. 데이터 수신 → 처리 → 전송
- OTLP: OpenTelemetry Line Protocol. 표준 데이터 전송 포맷
Span과 Trace 이해하기
트레이싱의 기본 단위는 Span이다.
Span = "어떤 작업을 했고, 얼마나 걸렸나"를 담은 레코드
여러 Span이 부모-자식 관계로 연결되면 Trace가 된다.
Trace ID: abc-123
│
├─ Span: POST /api/orders (총 342ms)
│ ├─ Span: validateRequest (3ms)
│ ├─ Span: getUserFromDB (45ms)
│ ├─ Span: checkInventory → InventoryService (210ms) ← 여기서 느리다!
│ │ ├─ Span: Redis cache lookup (2ms) - MISS
│ │ └─ Span: PostgreSQL query (205ms)
│ └─ Span: createOrderRecord (84ms)
checkInventory가 210ms인데 그 안에서 Redis 캐시 미스 후 PostgreSQL 직접 조회가 205ms임을 한눈에 볼 수 있다. 로그만으로는 절대 이 그림이 안 그려진다.
Span 속성
Span에는 다양한 정보가 붙는다.
// Span이 가지는 데이터
{
traceId: "abc123...", // 전체 트레이스 식별자
spanId: "def456...", // 이 Span의 식별자
parentSpanId: "bcd789...", // 부모 Span (없으면 root)
name: "GET /api/users/:id", // 작업 이름
startTime: 1711001234567, // 시작 시각 (나노초)
endTime: 1711001234812, // 종료 시각
attributes: { // 키-값 메타데이터
"http.method": "GET",
"http.url": "/api/users/42",
"http.status_code": 200,
"db.system": "postgresql",
"db.statement": "SELECT ...",
},
events: [ // Span 내 이벤트
{ name: "cache.miss", timestamp: ... }
],
status: { code: "OK" }
}
Node.js 자동 계측 설정
OTel의 강점 중 하나는 **자동 계측(Auto-instrumentation)**이다. Express, HTTP, PostgreSQL, Redis 등 주요 라이브러리를 코드 한 줄 추가 없이 자동으로 계측한다.
패키지 설치
npm install @opentelemetry/sdk-node \
@opentelemetry/auto-instrumentations-node \
@opentelemetry/exporter-otlp-http \
@opentelemetry/resources \
@opentelemetry/semantic-conventions
tracing.ts 작성
// src/tracing.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
const exporter = new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces",
});
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: "order-service",
[SEMRESATTRS_SERVICE_VERSION]: "1.0.0",
"deployment.environment": process.env.NODE_ENV ?? "development",
}),
traceExporter: exporter,
instrumentations: [
getNodeAutoInstrumentations({
// HTTP 요청 자동 계측
"@opentelemetry/instrumentation-http": {
ignoreIncomingRequestHook: (req) => {
// 헬스체크는 트레이싱 제외
return req.url === "/health";
},
},
// Express 라우트 자동 계측
"@opentelemetry/instrumentation-express": { enabled: true },
// PostgreSQL 자동 계측
"@opentelemetry/instrumentation-pg": { enabled: true },
// Redis 자동 계측
"@opentelemetry/instrumentation-redis": { enabled: true },
}),
],
});
sdk.start();
console.log("OpenTelemetry SDK started");
// 프로세스 종료 시 SDK 정상 종료
process.on("SIGTERM", () => {
sdk.shutdown().then(() => process.exit(0));
});
진입점에서 가장 먼저 로드
// src/index.ts - 반드시 다른 import보다 먼저!
import "./tracing";
import express from "express";
import { orderRouter } from "./routes/orders";
const app = express();
app.use(express.json());
app.use("/api/orders", orderRouter);
app.listen(3000, () => {
console.log("Order service running on :3000");
});
자동 계측만으로도 Express 라우트, DB 쿼리, 외부 HTTP 호출 등이 전부 Span으로 잡힌다.
수동 계측: 비즈니스 로직까지 추적하기
자동 계측은 인프라 레벨만 잡아준다. 비즈니스 로직의 병목을 찾으려면 수동으로 Span을 추가해야 한다.
// src/services/order.service.ts
import { trace, context, SpanStatusCode } from "@opentelemetry/api";
const tracer = trace.getTracer("order-service", "1.0.0");
export async function processOrder(orderId: string, userId: string) {
// 새 Span 시작
return tracer.startActiveSpan("processOrder", async (span) => {
// Span에 속성 추가
span.setAttributes({
"order.id": orderId,
"user.id": userId,
});
try {
const user = await validateAndFetchUser(userId);
const items = await fetchOrderItems(orderId);
const result = await chargePayment(user, items);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error) {
// 에러도 Span에 기록
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: (error as Error).message,
});
throw error;
} finally {
span.end(); // 반드시 end() 호출
}
});
}
async function validateAndFetchUser(userId: string) {
return tracer.startActiveSpan("validateAndFetchUser", async (span) => {
span.setAttribute("user.id", userId);
try {
// 캐시 먼저 확인
const cached = await redis.get(`user:${userId}`);
if (cached) {
span.addEvent("cache.hit");
return JSON.parse(cached);
}
span.addEvent("cache.miss");
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
if (!user) {
span.setStatus({ code: SpanStatusCode.ERROR, message: "User not found" });
throw new Error(`User ${userId} not found`);
}
await redis.setex(`user:${userId}`, 300, JSON.stringify(user));
return user;
} finally {
span.end();
}
});
}
Span 컨텍스트 전파
마이크로서비스 간에 트레이스를 이어붙이려면 컨텍스트 전파가 필요하다. HTTP 요청 헤더에 traceparent를 심는 방식이다.
import { propagation, context } from "@opentelemetry/api";
import axios from "axios";
async function callInventoryService(itemId: string) {
return tracer.startActiveSpan("callInventoryService", async (span) => {
span.setAttribute("item.id", itemId);
// 현재 컨텍스트를 HTTP 헤더에 주입
const headers: Record<string, string> = {};
propagation.inject(context.active(), headers);
// headers에 자동으로 'traceparent', 'tracestate' 추가됨
try {
const response = await axios.get(
`http://inventory-service/items/${itemId}`,
{ headers }
);
span.setAttribute("http.status_code", response.status);
return response.data;
} finally {
span.end();
}
});
}
수신 측 서비스에서는 자동 계측이 되어 있으면 헤더에서 컨텍스트를 자동으로 추출한다. 이렇게 해서 여러 서비스에 걸친 하나의 Trace가 만들어진다.
Jaeger로 내보내기
가장 빠르게 시각화하는 방법은 Jaeger다. Docker로 30초만에 띄울 수 있다.
# docker-compose.yml
version: "3.8"
services:
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
environment:
- COLLECTOR_OTLP_ENABLED=true
order-service:
build: .
ports:
- "3000:3000"
environment:
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
depends_on:
- jaeger
docker-compose up -d
# http://localhost:16686 에서 Jaeger UI 확인
Grafana Tempo + OpenTelemetry Collector
프로덕션에서는 OTel Collector를 게이트웨이로 쓰고 Grafana Tempo에 저장하는 구성이 많다.
# otel-collector-config.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
processors:
batch:
timeout: 1s
send_batch_size: 1024
# 민감 데이터 제거
attributes:
actions:
- key: "db.statement"
action: delete
exporters:
otlp/tempo:
endpoint: "tempo:4317"
tls:
insecure: true
# Prometheus로 메트릭도 내보내기
prometheus:
endpoint: "0.0.0.0:8889"
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, attributes]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
Collector를 쓰면 앱 코드 변경 없이 백엔드를 교체하거나, 여러 백엔드에 동시에 보내거나, 민감 데이터를 필터링하는 게 가능하다.
실전 디버깅 예시
실제로 병목을 찾는 시나리오를 보자.
증상: POST /api/checkout 가 평균 1.8초. SLA는 500ms.
트레이스 분석:
POST /api/checkout (1823ms)
├─ parseAndValidateRequest (8ms)
├─ fetchUserProfile (12ms)
├─ fetchCartItems (15ms)
├─ validateInventory (1650ms) ← !!!
│ ├─ item:1001 check (11ms)
│ ├─ item:1002 check (14ms)
│ ├─ item:1003 check (1580ms) ← 이 아이템이 문제
│ │ ├─ cache lookup (2ms) - MISS
│ │ └─ db query (1578ms) ← 풀스캔!
│ └─ item:1004 check (12ms)
└─ calculateTotal (3ms)
item:1003의 DB 쿼리가 1578ms. 즉시 해당 쿼리를 확인해보면:
-- 문제의 쿼리 (item_id에 인덱스 없음)
SELECT * FROM inventory WHERE item_id = '1003' AND warehouse_id = 'WH-42';
-- 해결: 복합 인덱스 추가
CREATE INDEX idx_inventory_item_warehouse
ON inventory(item_id, warehouse_id);
인덱스 추가 후 같은 쿼리가 3ms. 전체 체크아웃 시간이 248ms로 감소.
로그만 봤으면 "DB 쿼리가 느림" 정도만 알았겠지만, 트레이스 덕분에 어떤 아이템의 어떤 쿼리가 문제인지 정확히 짚을 수 있었다.
메트릭 연동
OTel은 트레이스뿐 아니라 메트릭도 수집한다.
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { OTLPMetricExporter } from "@opentelemetry/exporter-otlp-http";
const meterProvider = new MeterProvider({
exporter: new OTLPMetricExporter(),
interval: 5000, // 5초마다 내보내기
});
const meter = meterProvider.getMeter("order-service");
// 카운터
const orderCounter = meter.createCounter("orders.total", {
description: "Total number of orders processed",
});
// 히스토그램 (레이턴시 측정)
const checkoutDuration = meter.createHistogram("checkout.duration", {
description: "Time to complete checkout in ms",
unit: "ms",
});
// 사용
export async function processCheckout(cartId: string) {
const startTime = Date.now();
try {
const result = await doCheckout(cartId);
orderCounter.add(1, { status: "success" });
return result;
} catch (err) {
orderCounter.add(1, { status: "error" });
throw err;
} finally {
checkoutDuration.record(Date.now() - startTime);
}
}
베스트 프랙티스
Span 이름은 구체적으로
// 나쁜 예
tracer.startActiveSpan("query", ...)
// 좋은 예
tracer.startActiveSpan("db.users.findById", ...)
에러 반드시 기록
catch (error) {
span.recordException(error);
span.setStatus({ code: SpanStatusCode.ERROR });
throw error; // re-throw는 여전히 필요
}
민감 데이터 주의
// DB 쿼리에 실제 값 대신 파라미터화된 형태로
span.setAttribute("db.statement", "SELECT * FROM users WHERE id = $1");
// 실제 userId는 attribute에 넣지 않거나 별도 처리
샘플링 설정
import { TraceIdRatioBased } from "@opentelemetry/sdk-trace-base";
const sdk = new NodeSDK({
sampler: new TraceIdRatioBased(0.1), // 10%만 샘플링 (트래픽 많을 때)
// ...
});
마무리
OTel 도입 순서를 정리하면:
tracing.ts작성 + 자동 계측 패키지 설치- Jaeger 로컬에 띄워서 트레이스 확인
- 비즈니스 로직에 수동 Span 추가
- OTel Collector 도입 (프로덕션)
- Grafana Tempo + Grafana 대시보드 구성
"느리다"는 제보를 받았을 때 로그를 뒤지는 것과 트레이스를 보는 것의 차이는 어마어마하다. 한 번 설정해두면 그 투자가 수십 배로 돌아온다.