
OpenTelemetry: 분산 추적으로 병목 찾기
마이크로서비스 환경에서 요청이 어디서 느려지는지 어떻게 찾을까? OpenTelemetry로 로그·메트릭·트레이스를 하나로 묶어 병목을 정확히 짚어내는 법을 실전 코드와 함께 알아보자.

마이크로서비스 환경에서 요청이 어디서 느려지는지 어떻게 찾을까? OpenTelemetry로 로그·메트릭·트레이스를 하나로 묶어 병목을 정확히 짚어내는 법을 실전 코드와 함께 알아보자.
서버가 100대로 늘어나면 로그 파일도 100개로 쪼개집니다. 에러가 났을 때 이 파일들을 하나하나 열어볼 수는 없죠. 흩어진 로그를 수집(L), 저장/검색(E), 시각화(K)하는 ELK Stack의 구조와, 최신 트렌드인 ELKB(Beats) 및 EFK(Fluentd) 스택으로의 진화 과정을 다뤄봤습니다.

마이크로서비스 아키텍처(MSA)에서 API Gateway가 필수적인 이유를 '호텔 프론트 데스크' 비유로 설명합니다. Kong, Nginx, AWS API Gateway 비교 및 Rate Limiting, GraphQL 통합, Observability까지 심층 분석.

배포 후 '잘 되는데요?' 했는데 사용자만 에러를 겪고 있었다. Sentry 도입 후 에러를 실시간으로 잡게 된 이야기.
유저가 카카오톡으로 '안 돼요'라고 보내기 전에 에러를 먼저 감지하고 싶었다. Next.js 프로젝트에 Sentry를 연동하면서 배운 실전 설정과 알림 구성.
프로덕션에서 특정 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, 하나의 표준으로 통합한다.
CNCF(Cloud Native Computing Foundation) 산하 프로젝트로, 2019년 OpenCensus와 OpenTracing이 합쳐져서 탄생했다. 핵심은 벤더 중립적인 계측 표준이다.
[ 내 앱 ] → [ OTel SDK ] → [ OTel Collector ] → [ Jaeger / Tempo / Datadog / ... ]
한 번 OTel로 계측하면 백엔드(Jaeger, Grafana Tempo, Honeycomb, Datadog 등)를 바꿔도 앱 코드는 건드릴 필요 없다. 그게 OTel의 진짜 가치다.
트레이싱의 기본 단위는 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이 가지는 데이터
{
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" }
}
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
// 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();
}
});
}
마이크로서비스 간에 트레이스를 이어붙이려면 컨텍스트 전파가 필요하다. 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다. 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 확인
프로덕션에서는 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);
}
}
// 나쁜 예
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 작성 + 자동 계측 패키지 설치"느리다"는 제보를 받았을 때 로그를 뒤지는 것과 트레이스를 보는 것의 차이는 어마어마하다. 한 번 설정해두면 그 투자가 수십 배로 돌아온다.