- Published on
gRPC Interceptor로 분산 트레이싱 전파 오류 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 간 호출이 많아질수록 분산 트레이싱은 “있으면 편한 기능”이 아니라 장애 분석의 기본 인프라가 됩니다. 그런데 gRPC 환경에서는 HTTP 헤더 기반 전파가 아니라 metadata 기반 전파를 하다 보니, 한 군데만 설정이 어긋나도 trace가 중간에서 끊기거나, 더 나쁘게는 서로 다른 요청이 같은 trace로 묶이는 사고가 납니다.
이 글은 gRPC Interceptor를 이용해 분산 트레이싱 전파 오류를 탐지하고, 원인별로 교정 또는 차단하는 방법을 다룹니다. OpenTelemetry를 기준으로 설명하지만, 핵심은 “메타데이터 경계에서 검증하고 표준화한다”입니다.
관련해서 gRPC 타임아웃이 섞이면 증상이 더 복잡해지므로, deadline 이슈도 함께 점검하는 것을 권합니다: Go gRPC deadline exceeded(코드 4) 원인·해결
gRPC에서 트레이싱 전파가 깨지는 대표 패턴
1) 클라이언트에서 메타데이터를 안 넣음
HTTP라면 미들웨어에서 자동으로 헤더를 심는 경우가 많지만, gRPC는 호출 시점에 context에 메타데이터가 들어가야 합니다. 한 서비스만 누락되어도 그 이후 hop은 전부 새 trace가 생성됩니다.
2) 키 대소문자/인코딩 문제
gRPC 메타데이터 키는 관례적으로 소문자를 쓰며, 일부 프록시나 언어 SDK는 대소문자나 바이너리 키(-bin) 처리에서 제약이 있습니다. traceparent, baggage 같은 키가 변형되면 파서가 실패합니다.
3) 잘못된 “trace 재사용”
서버가 들어온 컨텍스트를 무시하고 매 요청마다 새 span을 만들면, 분산 트레이싱이 아니라 “서비스 단위 로컬 트레이싱”이 됩니다. 반대로, 전역 변수에 저장한 span 컨텍스트를 재사용하면 요청이 섞입니다.
4) 스트리밍에서 컨텍스트가 유실
Unary는 한 번의 컨텍스트로 끝나지만, streaming은 메시지 수명이 길고, 핸들러 내부에서 goroutine을 분리하면 컨텍스트 전달이 끊기기 쉽습니다.
5) 프록시/게이트웨이가 메타데이터를 드랍
Envoy, gRPC-Gateway, L7 프록시에서 allowlist 설정이 있거나, 특정 헤더만 통과시키는 정책이 있으면 traceparent가 사라집니다.
Interceptor를 써야 하는 이유: “경계에서 표준화”
애플리케이션 코드 곳곳에 트레이싱 전파 로직을 흩뿌리면 다음 문제가 생깁니다.
- 팀마다 구현이 달라져 trace가 불연속
- 일부 RPC만 누락되어 디버깅이 어려움
- 장애 시 임시 로깅을 넣다가 또 누락
Interceptor는 gRPC의 입구/출구 경계에서 컨텍스트를 검사하고, 메타데이터를 강제할 수 있습니다.
- Client interceptor: outbound 호출에
traceparent/baggage주입 - Server interceptor: inbound 메타데이터 파싱, 유효성 검증, 로깅/차단
구현: Go 기준 OpenTelemetry 전파 검증 Interceptor
아래 코드는 “전파가 실제로 들어왔는지”를 서버에서 검증하고, 문제가 있으면 구조적으로 로그를 남기는 예시입니다.
핵심 포인트는 다음입니다.
metadata.FromIncomingContext로traceparent/baggage확인- OpenTelemetry propagator로 추출 후
SpanContext유효성 검사 - 유효하지 않으면 원인 분류(누락, 파싱 실패, 샘플링 플래그 이상 등)
서버 Unary Interceptor: 전파 검증 + 진단 로그
package tracing
import (
"context"
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
type diagLogger interface {
Info(msg string, kv ...any)
Warn(msg string, kv ...any)
}
type mdCarrier struct{ md metadata.MD }
func (c mdCarrier) Get(key string) string {
vals := c.md.Get(strings.ToLower(key))
if len(vals) == 0 {
return ""
}
return vals[0]
}
func (c mdCarrier) Set(key, value string) {
c.md.Set(strings.ToLower(key), value)
}
func (c mdCarrier) Keys() []string {
out := make([]string, 0, len(c.md))
for k := range c.md {
out = append(out, k)
}
return out
}
func UnaryServerTracePropagationGuard(log diagLogger) grpc.UnaryServerInterceptor {
prop := otel.GetTextMapPropagator()
if prop == nil {
prop = propagation.TraceContext{}
}
return func(
ctx context.Context,
req any,
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
carrier := mdCarrier{md: md}
// 1) 메타데이터 레벨에서 빠른 진단
tp := carrier.Get("traceparent")
bg := carrier.Get("baggage")
if tp == "" {
log.Warn("missing traceparent", "method", info.FullMethod)
} else if !strings.HasPrefix(tp, "00-") && !strings.HasPrefix(tp, "ff-") {
log.Warn("suspicious traceparent version", "method", info.FullMethod, "traceparent", tp)
}
if bg != "" && len(bg) > 4096 {
log.Warn("oversized baggage", "method", info.FullMethod, "len", len(bg))
}
// 2) OTel propagator로 추출 후 SpanContext 검증
extractedCtx := prop.Extract(ctx, carrier)
sc := trace.SpanContextFromContext(extractedCtx)
if !sc.IsValid() {
// traceparent가 있는데 invalid면 파싱 실패/변조 가능성이 큼
if tp != "" {
log.Warn("invalid span context extracted", "method", info.FullMethod, "traceparent", tp)
} else {
log.Info("no remote span context", "method", info.FullMethod)
}
}
// 핸들러에는 추출된 컨텍스트를 넘겨야 downstream span이 이어짐
return handler(extractedCtx, req)
}
}
이 Interceptor의 목적은 “무조건 고쳐준다”가 아니라, 어느 구간에서 전파가 깨졌는지를 빠르게 특정하는 것입니다.
missing traceparent: upstream에서 주입이 안 됨suspicious traceparent version: 게이트웨이/프록시가 변형했을 가능성invalid span context extracted: 값이 있으나 포맷이 깨짐
클라이언트 Unary Interceptor: outbound 주입 강제
클라이언트 쪽은 “현재 컨텍스트의 span”을 메타데이터로 주입해야 합니다. 호출 코드에서 매번 직접 넣기보다 interceptor로 강제하면 누락을 줄일 수 있습니다.
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
type outgoingCarrier struct{ md metadata.MD }
func (c outgoingCarrier) Get(key string) string {
vals := c.md.Get(key)
if len(vals) == 0 {
return ""
}
return vals[0]
}
func (c outgoingCarrier) Set(key, value string) { c.md.Set(key, value) }
func (c outgoingCarrier) Keys() []string {
out := make([]string, 0, len(c.md))
for k := range c.md {
out = append(out, k)
}
return out
}
func UnaryClientTraceInjector() grpc.UnaryClientInterceptor {
prop := otel.GetTextMapPropagator()
if prop == nil {
prop = propagation.TraceContext{}
}
return func(
ctx context.Context,
method string,
req, reply any,
cc *grpc.ClientConn,
invoker grpc.UnaryInvoker,
opts ...grpc.CallOption,
) error {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.New(nil)
} else {
md = md.Copy()
}
carrier := outgoingCarrier{md: md}
prop.Inject(ctx, carrier)
ctx = metadata.NewOutgoingContext(ctx, md)
return invoker(ctx, method, req, reply, cc, opts...)
}
}
여기서 중요한 점은 md.Copy()입니다. 공유된 metadata.MD를 그대로 수정하면 다른 goroutine 호출과 섞일 수 있어, 전파가 “가끔” 깨지는 형태로 나타납니다.
흔한 전파 오류를 “재현 가능한” 체크리스트로 바꾸기
운영에서 어려운 점은 전파 오류가 대개 확률적으로 보인다는 것입니다. 아래처럼 인터셉터 로그를 원인 분류용 이벤트로 만들면, 재현이 쉬워집니다.
A) 누락 vs 파싱 실패를 분리
- 누락:
traceparent자체가 없음 - 파싱 실패:
traceparent가 있는데SpanContext가 invalid
둘은 대응이 완전히 다릅니다.
- 누락이면: upstream 클라이언트 interceptor 누락, 게이트웨이 allowlist, 배치/비동기 작업 컨텍스트 미전달을 먼저 봅니다.
- 파싱 실패면: 프록시가 값에 공백/따옴표를 추가했는지, 키 대소문자 변형, 다중 값 삽입, URL 인코딩 등을 봅니다.
B) “trace가 끊긴 RPC”를 빠르게 찾는 방법
서버 interceptor에서 아래 필드를 항상 남기면 탐색 비용이 크게 줄어듭니다.
method:info.FullMethodpeer: 클라이언트 IP(가능하면)traceparent원문 일부(전체는 보안/개인정보 정책에 맞게)has_baggage: 존재 여부
이렇게 모은 뒤, 특정 method에서만 누락이 증가하면 “그 RPC만 호출하는 특정 클라이언트” 또는 “그 경로의 게이트웨이” 문제일 확률이 큽니다.
스트리밍 RPC에서 전파가 깨질 때의 함정
스트리밍은 흔히 다음 패턴으로 깨집니다.
- 핸들러가 메시지 처리 루프에서 goroutine을 띄우고
context.Background()를 써버림 - stream wrapper를 만들며
Context()를 교체
대응은 간단합니다.
- goroutine에 반드시
ctx를 넘기고, 새 컨텍스트를 만들더라도context.WithCancel(ctx)처럼 부모를 유지 - stream interceptor에서
stream.Context()를 기준으로 추출한 컨텍스트를 “stream 래퍼”로 주입
스트리밍의 복원력 설계 자체는 별도 주제이므로, 끊김/재시도 설계는 다음 글도 같이 보면 좋습니다: gRPC 스트리밍 끊김 대응 - Retry·Circuit Breaker 설계
전파 오류가 성능/타임아웃 이슈로 위장하는 경우
전파가 깨지면 단순히 trace가 끊기는 데서 끝나지 않습니다.
- 상위 스팬이 없으니, 하위 서비스에서만 지연이 보이고 “어디서 시작됐는지”가 안 보임
- 결과적으로 원인 분석이 느려져 타임아웃/재시도 폭탄으로 번짐
- deadline 전파가 함께 깨지면, 특정 hop에서만
deadline exceeded가 급증
따라서 tracing interceptor를 넣을 때는 deadline 전파도 같이 점검하는 편이 좋습니다. 특히 Go에서는 context 전달 실수 하나가 tracing과 deadline을 동시에 깨뜨립니다. 관련 원인/해결은 다음 글을 참고하세요: Go gRPC deadline exceeded(코드 4) 원인·해결
운영 팁: “차단 모드”는 단계적으로
전파 오류를 잡겠다고 처음부터 잘못된 traceparent를 전부 에러 처리하면, 예상치 못한 클라이언트(구버전 앱, 외부 파트너, 배치 잡)가 장애를 맞을 수 있습니다.
권장 단계는 다음입니다.
- 관측 모드: 누락/파싱 실패를 로그와 메트릭으로만 수집
- 소프트 교정:
baggage과대/비정상 값은 drop,traceparent는 그대로 두되 새 trace 생성 - 하드 차단: 보안상 위험(의도적 조작) 또는 시스템 오염이 큰 경우에만
InvalidArgument등으로 거부
특히 baggage는 사용자 입력이 섞이기 쉬워 크기 제한과 allowlist를 두는 것이 안전합니다.
마무리: Interceptor는 “전파 품질 게이트”다
gRPC에서 분산 트레이싱 전파는 애플리케이션 로직이 아니라 플랫폼 품질에 가깝습니다. Interceptor를 통해
- outbound 주입을 강제하고
- inbound에서 유효성 검증과 원인 분류를 하고
- 스트리밍/비동기 경로에서 컨텍스트 유실을 막으면
trace가 끊기는 문제를 “감”이 아니라 “데이터”로 해결할 수 있습니다.
다음 단계로는, 위 Interceptor 로그를 기반으로
missing traceparent비율을 서비스별로 대시보드화- 특정
method/특정 클라이언트 버전에서만 발생하는지 분해 - 게이트웨이 allowlist 정책에
traceparent/baggage가 포함되는지 검증
까지 연결하면, 분산 트레이싱 전파 오류를 재발 방지 수준으로 관리할 수 있습니다.