OpenTelemetry Semantic Conventions로 서비스 간 트레이스를 구조화하고 Tempo에서 쿼리 가능하게 만드는 법
분산 시스템을 모니터링하다 보면 어느 순간 이런 상황을 맞닥뜨립니다. 트레이스는 쌓이고 있는데, 막상 Grafana Explore를 열어 '결제 서비스에서 500 에러가 난 스팬만 보고 싶다'고 했을 때 쿼리를 어떻게 써야 할지 막막한 것이죠. 저도 처음엔 팀마다 http_method, httpMethod, HTTP_METHOD를 각자 쓰고 있었고, 어떤 서비스는 아예 빠져 있어서 전체 흐름을 추적하는 게 사실상 불가능했습니다.
OpenTelemetry Semantic Conventions는 이 문제를 속성 이름의 표준화로 해결합니다. 단순히 이름을 통일하자는 게 아니라, 트레이스·메트릭·로그 세 계층에 걸쳐 구조화된 네임스페이스를 부여하고, Tempo 같은 백엔드가 그 구조를 이해해서 효율적으로 인덱싱할 수 있게 합니다. 2026년 기준으로 HTTP·DB·Code 등 여러 네임스페이스가 이미 Stable로 승격됐고, GenAI 관련 네임스페이스는 여전히 Experimental 상태로 활발히 진화 중입니다(정확한 상태는 공식 스펙 페이지에서 최신값을 확인하세요).
이 글에서는 서비스 간 트레이스를 어떻게 구조화할지, 어떤 속성을 어디에 배치해야 TraceQL 쿼리가 제대로 동작하는지, 그리고 컨벤션 마이그레이션 때 흔히 겪는 함정을 실제 시나리오와 함께 살펴봅니다.
속성 계층을 먼저 이해하면 설계가 쉬워집니다
Resource vs Span, 두 계층의 역할 분리
Semantic Conventions에서 속성은 크게 두 곳에 붙습니다.
Resource 속성은 이 텔레메트리를 누가 만들었는가를 설명합니다. service.name, service.version, deployment.environment.name 같은 값이 여기 들어가고, 해당 프로세스에서 나오는 모든 스팬·메트릭·로그에 자동으로 첨부됩니다. 한 번 설정해두면 끝이에요.
Span 속성은 이 개별 작업에서 무슨 일이 있었는가입니다. http.request.method, db.operation.name, messaging.destination.name 같이 요청마다 달라지는 값이 들어갑니다.
솔직히 처음엔 이 구분이 왜 중요한지 몰랐는데, Tempo에서 쿼리를 짜다 보면 바로 체감이 됩니다.
{ resource.service.name = "payment-service" }
{ span.http.response.status_code >= 500 }
{ resource.service.name = "payment-service" && span.http.response.status_code >= 500 }TraceQL에서 resource. 접두사는 Resource 속성을, span. 접두사는 Span 속성을 지목합니다. 이 접두사 없이는 쿼리가 의도대로 동작하지 않으니 주의가 필요합니다.
Span Kind, 서비스 간 관계를 명시하는 방법
Span Kind는 다섯 가지입니다: SERVER, CLIENT, PRODUCER, CONSUMER, INTERNAL. 이게 있어야 Tempo의 서비스 맵에서 에지 방향이 제대로 그려집니다.
아래 다이어그램은 같은 trace_id 안에서 홉마다 어떤 종류의 스팬이 만들어지는지를 보여줍니다. 각 서비스는 요청을 받을 때 SERVER 스팬을, 외부를 호출할 때 CLIENT 스팬을 각각 만듭니다.
cart-service가 payment-service를 호출할 때, cart-service 프로세스 안에서는 CLIENT 스팬이 만들어지고 payment-service가 받는 쪽에서는 SERVER 스팬이 만들어집니다. 같은 trace_id 안에서 양쪽 모두 기록되고, 이 관계가 서비스 맵의 토폴로지를 형성합니다. Kafka 같은 비동기 경계에서는 CLIENT/SERVER 대신 PRODUCER/CONSUMER가 같은 역할을 합니다.
안정성 등급, 어떤 속성을 믿어도 되는가
| 등급 | 의미 | 2026년 기준 대표 예시 |
|---|---|---|
| Stable | 하위 호환 보장, 이름 변경 없음 | HTTP 컨벤션, DB 컨벤션(Stable 승격 이후 이름 기준), Code 속성 |
| Experimental | 변경 가능, 마이그레이션 가이드 필요 | gen_ai.* 네임스페이스 |
| Deprecated | 교체 속성으로 이전 권고 | 구버전 http.method, 구버전 db.system |
Stable 속성만 대시보드·알림 규칙에 쓰고, Experimental은 로컬 개발이나 탐색용으로 제한하는 게 현실적인 접근입니다.
실제 서비스 간 트레이스를 구조화해봅시다
전자상거래의 결제 플로우를 예시로 사용합니다. cart-service → payment-service → bank-gateway로 이어지는 세 홉짜리 HTTP 흐름입니다. 이 글의 모든 코드에서 사용하는 semconv 버전은 v1.26.0으로 통일합니다(다른 버전을 쓰는 경우 헬퍼 함수 이름이 다를 수 있으므로 해당 버전의 소스를 확인하세요).
1단계: Resource 설정, 서비스 정체성 박기
SDK를 초기화할 때 Resource를 한 번만 설정합니다. Go 개념 예시입니다.
// 개념적 예시 — opentelemetry-go SDK, semconv v1.26.0 기준
import (
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
res, _ := resource.New(ctx,
resource.WithSchemaURL(semconv.SchemaURL),
resource.WithAttributes(
semconv.ServiceName("payment-service"),
semconv.ServiceVersion("2.4.1"),
// DeploymentEnvironmentName 헬퍼의 존재 여부는 semconv 버전에 따라 다릅니다.
// 헬퍼가 없다면 아래처럼 Key 상수를 직접 씁니다.
semconv.DeploymentEnvironmentNameKey.String("production"),
),
)semconv 패키지를 쓰면 속성 이름을 직접 타이핑하지 않아도 됩니다. 오타나 구버전 이름을 실수로 쓰는 걸 컴파일 타임에 막을 수 있어서 팀 전체 일관성 유지에 효과적입니다. 참고로 deployment.environment.name은 비교적 최근에 도입된 속성이라, 사용하는 semconv 릴리스에 헬퍼 함수가 생성돼 있는지 소스로 확인하는 편이 안전합니다.
2단계: HTTP 스팬 속성, Stable 컨벤션 기준
HTTP 컨벤션은 2023년 말경 Stable로 승격됐습니다(정확한 릴리스는 HTTP 마이그레이션 가이드에서 확인 가능합니다). 아래는 CLIENT 스팬과 SERVER 스팬에서 각각 써야 하는 속성이 다르다는 점에 유의한 예시입니다.
// payment-service에서 bank-gateway를 호출하는 CLIENT 스팬
ctx, span := tracer.Start(ctx, "POST",
trace.WithSpanKind(trace.SpanKindClient),
)
defer span.End()
// CLIENT 스팬에는 url.* / server.* 계열을 사용합니다.
span.SetAttributes(
semconv.HTTPRequestMethodKey.String("POST"),
semconv.URLFull("https://bank-gateway.internal/charge"),
semconv.ServerAddress("bank-gateway.internal"),
semconv.ServerPort(443),
)
// 응답 후
span.SetAttributes(
semconv.HTTPResponseStatusCode(statusCode),
)여기서 자주 실수하는 지점이 http.route입니다. http.route는 서버가 매칭한 라우트 템플릿(/orders/{id})을 나타내는 SERVER 전용 속성입니다. CLIENT 스팬에는 url.full 또는 url.path와 server.address/server.port를 씁니다. payment-service의 수신 측(SERVER) 스팬에서라면 아래처럼 http.route를 붙이는 게 맞습니다.
// payment-service가 cart-service의 요청을 수신하는 SERVER 스팬
span.SetAttributes(
semconv.HTTPRequestMethodKey.String("POST"),
semconv.HTTPRoute("/checkout"),
semconv.URLPath("/checkout"),
)구버전(http.method, http.url)을 아직 쓰고 있다면 아래 마이그레이션 절에서 다룹니다.
3단계: DB 스팬 속성
DB 컨벤션은 여러 릴리스에 걸쳐 다듬어졌고, 최근에 Stable로 승격됐습니다(핵심 변경 목록은 DB 마이그레이션 가이드 참고). 대표적인 이름 변경은 db.system → db.system.name, db.statement → db.query.text, db.name → db.namespace 등입니다.
여기서 짚어둘 게 하나 있는데, db.system.name은 postgresql, mysql, redis, mssql 같은 단일 식별자 열거값을 사용합니다. <vendor>.<product> 형태의 이중 구조가 아닙니다. 아래 Python 개념 예시가 그 기준입니다.
# 개념적 예시 — Python OTel SDK, 수동 계측
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span(
"SELECT orders",
kind=trace.SpanKind.CLIENT,
) as span:
# 상수 임포트가 사용하는 semconv 릴리스에 포함돼 있다면 상수를,
# 아니면 정확한 스펙 표기의 문자열 리터럴을 사용합니다.
span.set_attribute("db.system.name", "postgresql")
span.set_attribute("db.operation.name", "SELECT")
span.set_attribute("db.collection.name", "orders")
span.set_attribute("db.namespace", "shop")
span.set_attribute("db.query.text", "SELECT * FROM orders WHERE id = ?")
result = db.execute(query, [order_id])Go 예시에서 semconv 헬퍼 사용을 권장했으니, Python에서도 사용하는 릴리스에 SpanAttributes.DB_SYSTEM_NAME 같은 상수가 포함돼 있다면 그쪽을 쓰는 게 일관됩니다. 아직 상수가 반영되지 않았다면 스펙 표기 문자열을 그대로 쓰되, 팀 안에서 오타를 잡을 수 있게 얇은 래퍼를 하나 두는 것도 방법입니다.
4단계: Kafka 비동기 경계 연결
동기 HTTP 흐름과 달리 메시지 큐는 트레이스 컨텍스트를 직접 전파해야 합니다. trace_id를 메시지 헤더에 실어 보내는 방식입니다.
# 개념적 예시 — Kafka 프로듀서, 헤더에 컨텍스트 주입
from opentelemetry.propagate import inject
headers = {}
inject(headers) # traceparent, tracestate 헤더 추가
producer.send(
"order-events",
value=payload,
headers=list(headers.items()),
)
span.set_attribute("messaging.system", "kafka")
span.set_attribute("messaging.destination.name", "order-events")
span.set_attribute("messaging.operation.type", "publish")컨슈머 쪽에서 extract(headers)로 컨텍스트를 복원하면, 비동기 경계를 넘어 하나의 트레이스로 연결됩니다. Grafana Tempo에서 trace_id를 검색하면 cart-service부터 notification-service까지 전체 흐름이 한 화면에 보이게 됩니다.
TraceQL로 실제 쿼리해보기
구조화가 잘 됐으면 이런 쿼리들이 가능해집니다. 앞선 코드 예시들과 경로를 맞춰서 /charge(CLIENT)와 /checkout(SERVER) 관점 모두 다룹니다.
{ span.http.route = "/checkout" && span.http.request.method = "POST" && span:status = error }
{ resource.service.name = "payment-service" && span.http.response.status_code >= 500 }
{ span.db.system.name = "postgresql" && span:duration > 500ms }
{ span.messaging.system = "kafka" && span.messaging.operation.type = "receive" }Tempo의 TraceQL Metrics를 쓰면 Prometheus 없이도 트레이스에서 바로 RED 계열 지표를 즉석 계산할 수 있습니다.
{ resource.service.name = "payment-service" } | rate() by (span.http.response.status_code)
{ span:status = error } | rate() by (resource.service.name)컨벤션 마이그레이션, 실수를 미리 피하는 방법
구버전에서 신버전으로: OTEL_SEMCONV_STABILITY_OPT_IN
HTTP 컨벤션처럼 대규모 rename이 있었을 때 갑자기 전환하면 기존 대시보드가 깨집니다. OTEL_SEMCONV_STABILITY_OPT_IN 환경변수로 점진적 전환이 가능합니다. 주의할 점은 이 환경변수의 값은 컨벤션별로 정의된다는 것입니다. HTTP에는 http/dup, http가 정의돼 있고, 다른 컨벤션은 별도 opt-in 토큰이 스펙에 정의된 시점에만 사용할 수 있습니다.
아래 다이어그램은 HTTP 마이그레이션을 예시로 한 흐름입니다.
http/dup 모드는 구버전과 신버전 속성을 동시에 발행합니다. 저장소 비용이 일시적으로 늘어나지만, 팀 전체가 쿼리를 바꾸는 동안 서비스가 끊기지 않는다는 게 장점입니다. DB나 다른 컨벤션 마이그레이션의 경우, 해당 컨벤션의 마이그레이션 가이드에서 지원되는 opt-in 토큰을 먼저 확인해야 합니다. HTTP 전용 토큰이 자동으로 DB에 적용되지는 않습니다.
OTel Collector에서 속성 변환
애플리케이션 코드를 건드리지 않고 Collector에서 rename을 처리하는 패턴도 있습니다.
# otel-collector-config.yaml
processors:
attributes/http-migration:
actions:
- key: http.request.method
from_attribute: http.method
action: insert
- key: http.response.status_code
from_attribute: http.status_code
action: insert
service:
pipelines:
traces:
processors: [attributes/http-migration, batch]코드 배포 없이 컨벤션 변환을 적용할 수 있어서, 레거시 서비스가 많은 환경에서 특히 유용합니다.
설계할 때 자주 부딪히는 트레이드오프
| 상황 | 잘못된 접근 | 권장 접근 |
|---|---|---|
| 사용자 ID 추적 | Resource 속성에 user.id 추가 |
Span 속성으로 두되, 아래 카디널리티 주의 참조 |
| 서비스 이름 포맷 | 팀마다 PaymentService, payment-service, payment_svc 혼용 |
조직 차원 가이드라인 선행. 서비스 맵이 단절됨 |
| 속성 과다 추가 | 나중에 필요할 수도 있으니 모든 것을 기록 | 쿼리 목적이 없는 속성은 추가하지 않기 |
| Experimental 속성 | 대시보드에 gen_ai.* 속성으로 알림 규칙 설정 |
Experimental은 탐색용으로만. 알림은 Stable 속성만 |
| 컨벤션 마이그레이션 | 구버전 이름을 한 번에 신버전으로 전환 | 해당 컨벤션의 opt-in dup 모드로 점진적 이중 발행 후 전환 |
카디널리티는 Resource와 Span 모두에서 문제가 됩니다
Resource 속성에 user.id나 order.id 같은 고카디널리티 값을 넣으면 안 된다는 건 잘 알려져 있습니다. Resource는 프로세스 수준의 정체성을 표현하는 곳이라, service.name이나 deployment.environment.name처럼 값의 종류가 적은 것만 들어가야 합니다.
다만 그렇다고 Span 속성에 넣기만 하면 안전한 건 아닙니다. Tempo에서 검색을 빠르게 하려고 특정 태그를 인덱싱하도록 설정하면, 그 태그가 Span 속성이라도 고카디널리티 값이 들어오는 순간 인덱스 크기와 쿼리 오버헤드가 함께 커집니다. 실무적으로는 이렇게 정리하는 게 안전합니다.
- Resource: 저카디널리티 값만 (
service.name,deployment.environment.name등). - Span: 필요한 만큼 넣되, Tempo의 dedicated attribute 인덱스에 등록하는 태그는 카디널리티가 관리 가능한 것만.
user.id,order.id처럼 진짜로 개별 요청 식별이 필요한 값은 스팬에 남기되, 검색 인덱스가 아닌 트레이스 본문으로만 조회하는 걸 기본으로 삼기.
schema_url로 버전 추적하기
컨벤션 버전을 명시하는 schema_url을 Resource와 Scope에 첨부하면, 백엔드가 버전 간 변환을 처리할 수 있는 근거가 생깁니다.
// Resource에 schema_url 첨부 — semconv v1.26.0
res, _ := resource.New(ctx,
resource.WithSchemaURL(semconv.SchemaURL),
resource.WithAttributes(
semconv.ServiceName("payment-service"),
),
)아직 모든 백엔드가 자동 변환을 완전히 지원하지는 않지만, 어떤 버전의 컨벤션을 썼는지 명시해두면 마이그레이션 계획을 세울 때 훨씬 수월합니다.
마무리, 이 설계가 깨지는 순간을 미리 알아두기
Semantic Conventions로 정리된 트레이스는 잘 굴러가다가도 몇 가지 지점에서 조용히 무너집니다. 지금 우리 팀 시스템에서 아래 조건 중 하나라도 걸린다면, 대시보드가 아직 깨지지 않았어도 곧 깨질 여지가 있습니다.
- 같은 서비스인데 프로세스마다
service.name표기가 다르게 배포돼 있는 경우. 서비스 맵이 조각나고,resource.service.name필터가 일부 트래픽을 놓칩니다. - CLIENT/SERVER 구분 없이
http.route가 CLIENT 스팬에 붙어 있는 경우. TraceQL로 라우트 단위 집계를 하면 호출 측과 수신 측이 뒤섞여 숫자가 부풀려집니다. - 컨벤션 마이그레이션 중인데
dup모드 없이 한쪽만 이름을 바꿔서 기존 알림 규칙과 새 알림 규칙이 서로 다른 이름을 참조하는 경우. - Tempo에서 특정 Span 속성을 검색 인덱스에 등록해놓고, 그 속성에
user.id같은 고카디널리티 값을 흘려보내는 경우.
트레이스를 잘 구조화한다는 건 결국 나중에 쿼리할 때 어떤 이름으로 무엇을 찾을 것인가를 미리 합의해두는 일입니다. 위 네 가지 경계 조건은 그 합의가 깨지는 가장 흔한 지점이니, 다음 스프린트에서 하나씩 짚어보는 것부터 시작해보시길 권합니다.
참고 자료