gRPC 메타데이터를 넘어도 traceId가 이어지는 Node.js 분산 트레이싱
Jaeger 화면을 열었는데 트레이스가 서비스마다 뚝뚝 끊겨 있는 상황, 한 번쯤 겪어보셨을 겁니다. HTTP 서비스끼리는 traceparent 헤더 덕분에 잘 붙어 있는데, gRPC 호출을 건너는 순간 orphan 스팬이 잔뜩 생기는 문제입니다. 저도 처음 gRPC 서비스를 계측할 때 "W3C TraceContext는 HTTP 전용이 아닌가?" 하고 헷갈렸는데, 결론부터 말하면 gRPC에서도 동일한 표준이 작동합니다 — 단, HTTP 헤더 대신 gRPC 메타데이터(Metadata) 라는 채널을 통해서.
이 글에서는 컨텍스트가 손실되는 세 가지 원인을 먼저 짚고, @opentelemetry/instrumentation-grpc를 통한 자동 계측부터 커스텀 인터셉터, Baggage, OpenTelemetry Collector 파이프라인까지 실제 코드와 함께 살펴봅니다. OpenTelemetry는 CNCF Graduated 프로젝트이고, 2026년 기준 Node.js SDK의 트레이스 시그널은 오래전에 Stable로 진입한 상태이니 팀 규모나 서비스 수를 따져 아래 판단 기준에 부합한다면 도입을 진지하게 검토해볼 만합니다.
- gRPC로 연결된 서비스가 2개 이상이고, 언어가 섞여 있는 경우
- 이미 로그 기반으로 traceId를 심어 상관관계를 잡고 있지만 스팬 트리 시각화가 부족한 경우
- 프로덕션에서 p99 latency 원인 추적을 코드 계측 없이 하고 있는 경우
gRPC 경계에서 트레이스가 끊기는 이유
traceparent는 메타데이터로 흐른다
gRPC는 HTTP/2 기반 프로토콜입니다. 컨텍스트 전파도 동일한 traceparent / tracestate 키-값 쌍을 사용하되, 이를 HPACK 헤더로 인코딩된 메타데이터에 담아 전달합니다. 채널이 다를 뿐 W3C TraceContext 명세 자체는 동일하게 적용됩니다.
OpenTelemetry는 propagation.inject()로 현재 컨텍스트를 메타데이터에 쓰고, 수신 측에서 propagation.extract()로 복원한 뒤 context.with()로 핸들러를 감싸 부모-자식 스팬 관계를 형성합니다. 이 과정 중 어딘가 구멍이 생기면 트레이스가 단절됩니다.
세 가지 손실 원인
첫째, 계측 누락. 멀티 언어 환경에서 Python 서비스 하나만 OpenTelemetry 초기화를 빠뜨려도 그 지점에서 체인이 끊깁니다. 중간 서비스에 계측이 없으면 traceparent를 받더라도 다음 서비스로 전파하지 않습니다.
둘째, 인터셉터 부재. 커스텀 gRPC 클라이언트처럼 자동 계측 라이브러리가 개입하지 못하는 경우, 메타데이터에 traceparent를 주입하는 인터셉터가 없으면 컨텍스트가 실리지 않은 채 요청이 나갑니다.
셋째, 비동기 경계 미처리. Node.js SDK는 AsyncLocalStorage 기반의 AsyncLocalStorageContextManager로 Promise와 setTimeout 같은 비동기 경계를 자동으로 처리합니다(@opentelemetry/context-async-hooks 패키지가 이를 구현합니다). 다만 EventEmitter는 조금 다릅니다. 리스너 함수가 등록된 시점의 컨텍스트가 아닌 이벤트가 emit되는 시점의 컨텍스트에서 실행되기 때문에, 리스너 안에서 스팬을 만들면 예상치 못한 부모 스팬에 붙거나 컨텍스트가 아예 없는 상황이 종종 발생합니다. 필요하다면 리스너를 등록할 때 context.bind(context.active(), listener)로 명시적으로 컨텍스트를 묶어주는 편이 안전합니다.
worker_threads도 별도 케이스입니다. 프로세스 수준의 격리가 있어 AsyncLocalStorage가 워커 스레드를 넘지 못하므로, 메시지 페이로드에 직렬화된 컨텍스트를 직접 포함시켜야 합니다.
SDK 초기화부터 Collector 파이프라인까지
1. Node.js SDK에 gRPC 계측 붙이기
가장 먼저 확인할 것은 초기화 순서입니다. SDK 파일은 반드시 다른 모든 import보다 먼저 불러와야 자동 계측이 정상 동작합니다.
// tracing.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { GrpcInstrumentation } from '@opentelemetry/instrumentation-grpc';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { Resource } from '@opentelemetry/resources';
const sdk = new NodeSDK({
resource: new Resource({
'service.name': 'order-service',
}),
traceExporter: new OTLPTraceExporter({
url: 'http://localhost:4317',
}),
instrumentations: [
new GrpcInstrumentation(),
],
});
sdk.start();
process.on('SIGTERM', () => {
sdk.shutdown().finally(() => process.exit(0));
});// index.ts — tracing.ts를 가장 먼저
import './tracing';
import express from 'express';
// ...@opentelemetry/exporter-trace-otlp-grpc의 url 옵션은 스킴을 생략한 localhost:4317 형태나 http://localhost:4317 / https:// 형태를 지원합니다. 환경변수(OTEL_EXPORTER_OTLP_ENDPOINT)로 지정할 때도 동일합니다. 자세한 옵션은 공식 exporters 가이드를 참고하세요.
GrpcInstrumentation이 등록되면 grpc-js 기반 클라이언트와 서버 양쪽에 자동으로 인터셉터가 붙습니다. 클라이언트 호출 시 traceparent가 메타데이터에 삽입되고, 서버 수신 시 추출되어 부모-자식 스팬 관계가 형성됩니다. 많은 경우 이것만으로 충분합니다.
2. 자동 계측이 안 될 때 — 커스텀 인터셉터
레거시 gRPC 클라이언트나 계측 라이브러리가 개입하지 못하는 상황이라면 인터셉터를 직접 작성해야 합니다.
// otel-client-interceptor.ts
import { context, propagation } from '@opentelemetry/api';
import * as grpc from '@grpc/grpc-js';
export function otelClientInterceptor(
options: grpc.CallOptions,
nextCall: (options: grpc.CallOptions) => grpc.InterceptingCall,
): grpc.InterceptingCall {
return new grpc.InterceptingCall(nextCall(options), {
start(
metadata: grpc.Metadata,
listener: grpc.InterceptingListener,
next: (metadata: grpc.Metadata, listener: grpc.InterceptingListener) => void,
) {
propagation.inject(context.active(), metadata, {
set(carrier: grpc.Metadata, key: string, value: string) {
carrier.set(key, value);
},
});
next(metadata, listener);
},
});
}
const client = new OrderServiceClient(
'order-service:50051',
grpc.credentials.createInsecure(),
{ interceptors: [otelClientInterceptor] },
);서버 측에서는 핸들러 진입 시 메타데이터에서 컨텍스트를 복원한 뒤 context.with()로 이후 로직을 감싸면 됩니다.
// order-service-handler.ts
import {
context,
propagation,
ROOT_CONTEXT,
SpanStatusCode,
trace,
} from '@opentelemetry/api';
import * as grpc from '@grpc/grpc-js';
const metadataGetter = {
get(carrier: grpc.Metadata, key: string) {
const values = carrier.get(key);
return values.length > 0 ? String(values[0]) : undefined;
},
keys(carrier: grpc.Metadata) {
return Object.keys(carrier.getMap());
},
};
async function handleCreateOrder(
call: grpc.ServerUnaryCall<CreateOrderRequest, CreateOrderResponse>,
callback: grpc.sendUnaryData<CreateOrderResponse>,
) {
const parentCtx = propagation.extract(ROOT_CONTEXT, call.metadata, metadataGetter);
await context.with(parentCtx, async () => {
const tracer = trace.getTracer('order-service');
const span = tracer.startSpan('handle-create-order');
try {
const response = await processOrder(call.request);
span.end();
callback(null, response);
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
callback(err as grpc.ServiceError);
}
});
}3. Baggage로 비즈니스 컨텍스트 전파하기
traceparent와 tracestate가 트레이스 연결을 위한 채널이라면, Baggage는 userId나 tenantId 같은 비즈니스 식별자를 호출 체인 전체에 흘려보내는 별도 채널입니다. gRPC 메타데이터에 직접 담으면 직접 수신자만 볼 수 있지만, Baggage는 다운스트림 서비스 전체에 자동으로 전파됩니다.
import { context, propagation } from '@opentelemetry/api';
async function handleHttpRequest(req: Request) {
const baggage = propagation.createBaggage({
'user.id': { value: req.user.id },
'tenant.id': { value: req.tenant.id },
});
const ctxWithBaggage = propagation.setBaggage(context.active(), baggage);
await context.with(ctxWithBaggage, async () => {
await orderServiceClient.createOrder(request);
});
}
function getCurrentTenantId(): string | undefined {
const baggage = propagation.getBaggage(context.active());
return baggage?.getEntry('tenant.id')?.value;
}두 가지 주의할 점이 있습니다. 첫째, Baggage는 모든 다운스트림 서비스에 전파되므로, 민감한 정보(토큰, 비밀번호)를 담으면 의도치 않은 노출이 생깁니다. 비즈니스 식별자 정도로만 제한해서 사용하는 것이 권장됩니다. 둘째, W3C Baggage 명세는 헤더 크기 제한(총 8192바이트, 항목 수 64개)을 두고 있어 임의로 큰 페이로드를 실을 수는 없습니다. 이 제한을 넘기면 프록시나 게이트웨이에서 헤더가 잘려나갈 수 있으니, 요약된 ID만 담고 상세 정보는 별도 조회하는 편이 안전합니다.
4. Collector 파이프라인 구성
애플리케이션 코드 외부에서 배치 처리, 샘플링, 메타데이터 보강을 담당하는 게 Collector의 핵심 역할입니다. Kubernetes 환경에서는 사이드카(에이전트) Collector와 중앙 Gateway Collector를 조합하는 2계층 구조가 널리 쓰입니다.
사이드카 Collector는 K8s Pod 속성을 자동으로 붙이고 Gateway로 전달합니다.
# collector-sidecar.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
k8sattributes:
extract:
metadata:
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
exporters:
otlp:
endpoint: gateway-collector:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, k8sattributes]
exporters: [otlp]여기서 한 가지 필수 전제 조건이 있습니다. k8sattributes 프로세서는 K8s API를 조회해 Pod 메타데이터를 가져오기 때문에, Collector 서비스 계정에 다음과 같은 ClusterRole이 필요합니다.
# collector-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: otel-collector-k8sattributes
rules:
- apiGroups: [""]
resources: [pods, namespaces]
verbs: [get, watch, list]
- apiGroups: [apps]
resources: [replicasets]
verbs: [get, watch, list]
- apiGroups: [extensions]
resources: [replicasets]
verbs: [get, watch, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: otel-collector-k8sattributes
subjects:
- kind: ServiceAccount
name: otel-collector
namespace: observability
roleRef:
kind: ClusterRole
name: otel-collector-k8sattributes
apiGroup: rbac.authorization.k8s.io이 RBAC가 없으면 Collector가 기동은 되지만 Pod 속성이 붙지 않거나 API 접근 실패 로그가 계속 쌓입니다.
Gateway Collector에는 테일 샘플링을 추가합니다. 헤드 기반 샘플링은 요청 초반에 결정을 내리기 때문에 후반부의 오류를 놓칠 수 있어서, 오류 트레이스는 전량 수집하고 정상 트레이스만 비율 샘플링하는 혼합 전략이 자주 쓰입니다(Uptrace의 sampling 가이드 참고).
# gateway-collector.yaml (핵심 부분)
processors:
batch:
timeout: 5s
send_batch_size: 1024
k8sattributes:
extract:
metadata:
- k8s.pod.name
- k8s.namespace.name
- k8s.node.name
tail_sampling:
decision_wait: 30s
num_traces: 50000
policies:
- name: errors-policy
type: status_code
status_code:
status_codes: [ERROR]
- name: slow-traces-policy
type: latency
latency:
threshold_ms: 500
- name: normal-traces-policy
type: probabilistic
probabilistic:
sampling_percentage: 10
service:
pipelines:
traces:
receivers: [otlp]
processors: [k8sattributes, tail_sampling, batch]
exporters: [otlp]여기까지 구성하면 Node.js → Python → Java가 섞인 환경에서도 단일 traceId로 체인 전체가 연결됩니다. 사이드카와 게이트웨이가 함께 들어간 흐름을 시퀀스로 정리하면 다음과 같습니다.
트레이드오프와 자주 만나는 함정들
| 항목 | 자동 계측 | 커스텀 인터셉터 |
|---|---|---|
| 설정 복잡도 | 낮음 — SDK 초기화만 | 높음 — 인터셉터 직접 구현 |
| 적용 범위 | grpc-js 자동 지원 | 레거시 클라이언트도 대응 가능 |
| 유지보수 | SDK 업데이트 따라감 | 직접 관리 필요 |
| gRPC 스트리밍 | 서버·클라이언트·양방향 스트리밍 모두 스팬 생성됨. 스트림 핸들러 내부까지 컨텍스트를 자동 전달하지는 않음 | 스트림 핸들러에서 컨텍스트를 명시적으로 캡처·전달 |
| 멀티 언어 환경 | W3C 표준으로 자동 호환 | 동일 |
gRPC 스트리밍의 함정. @opentelemetry/instrumentation-grpc는 단방향 RPC뿐 아니라 서버·클라이언트·양방향 스트리밍에서도 스팬을 만들어 줍니다. 다만 스트림이 열린 뒤 데이터가 오가는 동안 각 콜백 내부까지 컨텍스트가 자동으로 이어지지는 않습니다. 스트리밍 시작 시점의 컨텍스트를 지역 변수로 캡처한 뒤, 데이터 콜백 안에서 context.with()로 감싸는 처리가 필요합니다.
OTLP gRPC 로드밸런싱 주의. gRPC는 장기 연결(long-lived connection)을 유지하기 때문에 일반 L4 로드밸런서로는 Gateway Collector 레플리카 간에 트래픽이 고르게 분산되지 않습니다. gRPC 인식(L7) 로드밸런서를 쓰거나 에이전트 Collector 레이어를 중간에 두는 방식으로 해결할 수 있습니다.
테일 샘플링의 메모리 부담. Collector가 트레이스 완성을 위해 스팬을 decision_wait 시간 동안 메모리에 보관합니다. num_traces 값을 예상 동시 트레이스 수에 맞게 조정하지 않으면 고트래픽 환경에서 OOM이 발생할 수 있습니다.
이 아키텍처가 다루지 못하는 것
여기까지의 구성은 gRPC 경계에서 traceId를 잃지 않는 데 필요한 뼈대에 가깝습니다. 다만 다음 상황들은 별도의 설계 판단이 필요합니다.
- 서비스 메시 없는 mTLS 환경. Istio나 Linkerd 없이 애플리케이션 레벨에서 mTLS를 직접 하는 경우, 프록시가 자동으로 붙여주던
X-Forwarded-*계열의 힌트가 사라져 서비스 토폴로지 인식이 부정확해질 수 있습니다.service.name리소스 속성을 서비스마다 명확히 지정하는 게 특히 중요해집니다. - 비 OTLP 백엔드. Datadog Agent나 New Relic 처럼 자체 프로토콜만 지원하는 백엔드로 보낼 때는 Collector의 exporter 모듈로 변환을 걸어야 하고, 이 과정에서 일부 속성이 소실되거나 이름이 매핑되는 경우가 있습니다. 백엔드별 attribute 매핑 문서를 미리 확인해두면 삽질을 크게 줄일 수 있습니다.
- Baggage 크기 제한. 위에서 언급한 대로 헤더 크기 상한이 있어 큰 컨텍스트를 실을 수 없습니다. 사용자 프로필 전체 같은 데이터는 traceId를 키로 별도 저장소에 두고 필요 시 조회하는 편이 안전합니다.
- 비동기 큐를 경유하는 트레이스. Kafka, RabbitMQ, SQS 같은 메시지 브로커를 건너는 순간에는 프로토콜 자체가 W3C TraceContext를 자동 전달하지 않습니다. 헤더/속성에
traceparent를 직접 실어보내는 계측 라이브러리(예:@opentelemetry/instrumentation-kafkajs) 도입이 별도로 필요합니다.
접근 순서 자체는 이미 앞에서 다루었으니, 팀 상황에 맞게 자동 계측부터 시작하되 위 네 가지 중 해당되는 항목이 있다면 프로덕션 롤아웃 전에 미리 시나리오를 정리해두는 것이 안전합니다. Kubernetes 환경이라면 OpenTelemetry Operator의 Pod 자동 계측 주입 기능을 함께 검토해볼 만합니다 — Deployment 어노테이션만으로 gRPC 인터셉터와 컨텍스트 전파가 활성화되므로, 서비스 수가 많은 조직에서는 표준화된 계측 배포 방식으로 유용합니다.
참고 자료
- OpenTelemetry 공식 문서 - Baggage
- OpenTelemetry 공식 문서 - Collector Architecture
- OpenTelemetry 공식 문서 - JS Exporters
- W3C Baggage 명세
- W3C Trace Context 명세
- How to Propagate OpenTelemetry Trace Context Through gRPC Metadata - OneUptime
- How to Implement OpenTelemetry Tracing for gRPC Services - OneUptime
- How to Add Distributed Tracing to gRPC with OpenTelemetry - OneUptime
- OTel Trace Context Propagation for gRPC Streams - Tracetest Blog
- OpenTelemetry Context Propagation: W3C TraceContext & Troubleshooting Guide - Uptrace
- How to Propagate Trace Context Across Async Boundaries - OneUptime
- How to Implement Distributed Tracing in Node.js Microservices - OneUptime
- OpenTelemetry Collector: Beginner's Guide to Telemetry Pipelines - Dash0
- OTLP gRPC Exporter: A Practical Guide - Dash0
- OpenTelemetry Context Propagation Explained - Better Stack
- OpenTelemetry Sampling: head-based and tail-based - Uptrace
- OpenTelemetry Collector in Kubernetes - groundcover
- @opentelemetry/instrumentation-grpc - npm
- k8sattributes processor - OpenTelemetry Collector Contrib
- How to Trace gRPC Calls Across Kubernetes Services with OpenTelemetry - OneUptime
- Essential OpenTelemetry Best Practices - Better Stack