Skip to content
Published on

OpenTelemetry로 마이크로서비스의 블랙박스를 해제하는 5가지 결정적 전략

Authors
  • Name
    Twitter

1. 서론: 왜 우리의 분산 시스템은 여전히 미궁 속일까?

1.1 마이크로서비스의 역설: 분리했지만 더 복잡해졌다

마이크로서비스 아키텍처(MSA)는 독립 배포, 기술 이질성, 팀 자율성이라는 매력적인 약속을 내걸고 모놀리스를 대체했다. 그러나 하나의 사용자 요청이 API Gateway에서 시작해 인증 서비스, 상품 카탈로그, 재고 관리, 결제, 알림까지 10~30개 이상의 서비스를 연쇄적으로 관통하는 현실에서, 문제가 발생했을 때 근본 원인을 추적하는 것은 마치 미궁을 헤매는 것과 같다.

사용자 요청의 여정 (일반적인 e-커머스)
============================================

[Client] ──▶ [API Gateway] ──▶ [Auth Service]
                  ├──▶ [Product Service] ──▶ [Search Engine]
                  │         │
                  │         └──▶ [Recommendation Service] ──▶ [ML Model]
                  ├──▶ [Cart Service] ──▶ [Redis Cache]
                  ├──▶ [Order Service] ──▶ [Inventory Service] ──▶ [Warehouse DB]
                  │         │
                  │         └──▶ [Payment Service] ──▶ [External PG API]
                  └──▶ [Notification Service] ──▶ [Email/SMS/Push]

하나의 "주문 완료"에 최소 12개 서비스가 관여한다.
p99 지연 원인을 찾으려면? 어디서부터 볼 것인가?

전통적인 APM(Application Performance Monitoring) 도구들은 이 문제를 부분적으로 해결해왔다. 그러나 대부분의 상용 APM은 자체 에이전트와 독점 프로토콜에 의존한다. Datadog Agent는 Datadog으로만 데이터를 보내고, New Relic Agent는 New Relic으로만 데이터를 보낸다. 이것이 바로 Vendor Lock-in이다.

1.2 Vendor Lock-in의 실체적 비용

Vendor Lock-in은 단순한 기술적 불편함을 넘어 실질적인 비즈니스 비용으로 이어진다.

영향 영역문제점실제 비용
라이선스 비용데이터 볼륨 기반 과금, 해마다 증가하는 단가연간 수십억 원 규모 (대규모 서비스 기준)
마이그레이션 비용전용 에이전트 재설치, 대시보드 재구축, 알림 규칙 재설정6~12개월 엔지니어링 투입
기술 부채벤더 독점 SDK에 결합된 코드, 벤더 독점 쿼리 언어코드베이스 전반에 걸친 침습적 변경 필요
전략적 유연성 상실더 나은 도구가 나와도 전환 불가, 가격 협상력 약화장기적 경쟁력 저하

1.3 OpenTelemetry: "Instrument Once, Export Anywhere"

**OpenTelemetry(OTel)**는 CNCF(Cloud Native Computing Foundation)의 두 번째로 활발한 프로젝트(Kubernetes 다음)로, 2019년 OpenTracing과 OpenCensus의 합병으로 탄생했다. OTel의 핵심 철학은 단순하면서도 혁명적이다.

"계측(Instrumentation)은 한 번만 하고, 원하는 곳 어디로든 내보낸다."

OTel이 제공하는 것은 크게 세 가지다.

  1. 표준 와이어 프로토콜 (OTLP): 텔레메트리 데이터의 전송 규격. gRPC와 HTTP를 모두 지원한다.
  2. SDK와 API: 모든 주요 언어(Java, Python, Go, .NET, Node.js, Rust, C++, PHP, Ruby 등)에서 사용할 수 있는 계측 라이브러리.
  3. OpenTelemetry Collector: 텔레메트리 데이터를 수신, 가공, 라우팅하는 벤더 중립적 파이프라인.
OpenTelemetry의 아키텍처 개요
============================================

  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
Service A  │  │  Service B  │  │  Service C    (Java SDK) (Python SDK)  (Go SDK)  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘
OTLPOTLPOTLP
         ▼                ▼                ▼
  ┌─────────────────────────────────────────────────┐
OpenTelemetry Collector  │  ┌──────────┐  ┌───────────┐  ┌──────────────┐ │
  │  │Receivers │→ │Processors │→ │  Exporters   │ │
  │  └──────────┘  └───────────┘  └──────────────┘ │
  └────────┬──────────────┬──────────────┬──────────┘
           │              │              │
           ▼              ▼              ▼
    ┌───────────┐  ┌───────────┐  ┌───────────┐
Jaeger   │  │   Tempo   │  │  Datadog     (Traces) (Traces) (All-in-1)    └───────────┘  └───────────┘  └───────────┘

OTel을 도입하면 벤더 교체가 Collector의 exporter 설정 변경만으로 완료된다. 코드 한 줄 수정할 필요 없이 Jaeger에서 Tempo로, 혹은 Datadog에서 Grafana Cloud로 전환할 수 있다.

이 글에서는 OpenTelemetry를 실전에서 효과적으로 활용하기 위한 5가지 결정적 전략을 아키텍트 관점에서 심층 분석한다. 단순한 "Getting Started" 수준이 아닌, 프로덕션 레벨의 의사결정에 필요한 깊이를 제공한다.


2. [Takeaway 1] W3C Baggage: 비즈니스 컨텍스트를 전파하는 비밀 병기

2.1 Trace Context와 Baggage의 구분

분산 추적에서 **컨텍스트 전파(Context Propagation)**는 근본적으로 두 가지로 나뉜다.

구분W3C Trace ContextW3C Baggage
W3C 표준W3C Trace ContextW3C Baggage
HTTP 헤더traceparent, tracestatebaggage
목적Trace ID, Span ID, 샘플링 플래그 전파임의의 비즈니스 key-value 전파
필수 여부추적을 위해 필수선택적 (비즈니스 요구에 따라)
데이터 예시00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01tenantId=acme,userTier=premium,featureFlag=newCheckout

핵심 포인트: Baggage는 Trace Context와 독립적으로 동작한다. 추적이 비활성화되어 있더라도 Baggage는 전파될 수 있으며, 반대로 추적이 활성화되어 있더라도 Baggage를 사용하지 않을 수 있다. 이 독립성이 Baggage를 더욱 유연한 도구로 만든다.

2.2 비즈니스 유스케이스: 왜 Baggage가 필요한가?

Baggage의 진짜 가치는 인프라 레벨을 넘어 비즈니스 컨텍스트를 서비스 경계를 가로질러 전파할 수 있다는 데 있다. 구체적인 활용 사례를 보자.

멀티테넌트 격리 (Tenant ID Propagation)

SaaS 환경에서 하나의 요청이 어떤 테넌트에 속하는지를 다운스트림 서비스까지 전파하면, 각 서비스가 독립적으로 테넌트별 rate limiting, 데이터 격리, 리소스 할당을 수행할 수 있다.

Feature Flag 전파

프론트엔드에서 결정된 feature flag 상태를 백엔드 서비스 체인 전체에 걸쳐 일관되게 적용할 수 있다. A/B 테스트에서 사용자가 실험군인지 대조군인지를 모든 서비스가 인지할 수 있다.

사용자 등급 기반 QoS (Quality of Service)

프리미엄 사용자의 요청에 더 높은 우선순위를 부여하거나, 더 넉넉한 타임아웃을 설정하거나, 더 정밀한 샘플링 비율을 적용할 수 있다.

비용 귀속 (Cost Attribution)

요청에 코스트 센터 태그를 부착하여 어떤 부서, 어떤 프로젝트가 인프라 비용을 얼마나 소비하는지 추적할 수 있다.

2.3 다국어 구현 예제

Java (Spring Boot + OTel SDK)

import io.opentelemetry.api.baggage.Baggage;
import io.opentelemetry.api.baggage.BaggageEntryMetadata;
import io.opentelemetry.context.Scope;
import io.opentelemetry.api.trace.Span;

// ---- API Gateway에서 Baggage 설정 (요청 진입점) ----
@RestController
public class GatewayController {

    @PostMapping("/api/orders")
    public ResponseEntity<?> createOrder(
            @RequestHeader("X-Tenant-Id") String tenantId,
            @RequestHeader("X-User-Tier") String userTier,
            HttpServletRequest request) {

        // W3C Baggage 설정 - 다운스트림 서비스 전체에 전파됨
        Baggage baggage = Baggage.builder()
            .put("tenantId", tenantId,
                 BaggageEntryMetadata.create("tenant context"))
            .put("userTier", userTier,
                 BaggageEntryMetadata.create("qos context"))
            .put("entryPoint", "order-api",
                 BaggageEntryMetadata.create("routing context"))
            .put("requestRegion", determineRegion(request),
                 BaggageEntryMetadata.create("geo context"))
            .build();

        // Baggage를 현재 Context에 attach
        try (Scope scope = baggage.makeCurrent()) {
            // 이 scope 내에서 호출되는 모든 다운스트림 서비스는
            // 자동으로 baggage를 HTTP 헤더로 전파받음
            return orderService.processOrder(request.getBody());
        }
    }
}

// ---- 다운스트림 Order Service에서 Baggage 읽기 ----
@Service
public class OrderService {

    public void processOrder(OrderRequest order) {
        // 전파된 Baggage에서 비즈니스 컨텍스트 추출
        String tenantId = Baggage.current().getEntryValue("tenantId");
        String userTier = Baggage.current().getEntryValue("userTier");

        // 현재 Span에 비즈니스 속성으로 추가 (검색/필터링 용도)
        Span currentSpan = Span.current();
        currentSpan.setAttribute("business.tenant_id", tenantId);
        currentSpan.setAttribute("business.user_tier", userTier);

        // 사용자 등급에 따른 QoS 분기
        if ("premium".equals(userTier)) {
            processWithPriority(order);
        } else {
            processNormally(order);
        }
    }
}

Python (FastAPI + OTel SDK)

from opentelemetry import baggage, trace, context
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.context.context import Context
from fastapi import FastAPI, Request, Header
from typing import Optional

app = FastAPI()
tracer = trace.get_tracer("order-service")

# ---- API Gateway에서 Baggage 설정 ----
@app.post("/api/orders")
async def create_order(
    request: Request,
    x_tenant_id: Optional[str] = Header(None),
    x_user_tier: Optional[str] = Header(None, alias="X-User-Tier"),
):
    # Baggage 설정
    ctx = baggage.set_baggage("tenantId", x_tenant_id or "unknown")
    ctx = baggage.set_baggage("userTier", x_user_tier or "standard", context=ctx)
    ctx = baggage.set_baggage("featureFlag", "new-checkout-v2", context=ctx)

    # Context를 활성화하여 다운스트림 호출에 자동 전파
    token = context.attach(ctx)
    try:
        with tracer.start_as_current_span("process-order") as span:
            # Baggage 값을 Span 속성으로도 기록
            tenant = baggage.get_baggage("tenantId")
            tier = baggage.get_baggage("userTier")
            span.set_attribute("business.tenant_id", tenant)
            span.set_attribute("business.user_tier", tier)

            result = await order_processor.process(request)
            return {"status": "created", "order_id": result.id}
    finally:
        context.detach(token)


# ---- 다운스트림 Inventory Service에서 Baggage 읽기 ----
@app.get("/api/inventory/{product_id}")
async def check_inventory(product_id: str, request: Request):
    # OTel SDK가 자동으로 HTTP 헤더에서 Baggage를 추출
    tenant_id = baggage.get_baggage("tenantId")
    user_tier = baggage.get_baggage("userTier")

    with tracer.start_as_current_span("check-inventory") as span:
        span.set_attribute("business.tenant_id", tenant_id)

        # 테넌트별 격리된 데이터소스 접근
        inventory = await get_tenant_inventory(tenant_id, product_id)
        return {"available": inventory.quantity > 0}

Go (Gin + OTel SDK)

package main

import (
    "context"
    "net/http"

    "github.com/gin-gonic/gin"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/baggage"
    "go.opentelemetry.io/otel/attribute"
)

var tracer = otel.Tracer("order-service")

// API Gateway에서 Baggage 설정
func CreateOrderHandler(c *gin.Context) {
    tenantID := c.GetHeader("X-Tenant-Id")
    userTier := c.GetHeader("X-User-Tier")

    // W3C Baggage 멤버 생성
    tenantMember, _ := baggage.NewMember("tenantId", tenantID)
    tierMember, _ := baggage.NewMember("userTier", userTier)
    flagMember, _ := baggage.NewMember("featureFlag", "new-checkout-v2")

    bag, _ := baggage.New(tenantMember, tierMember, flagMember)

    // Context에 Baggage 부착
    ctx := baggage.ContextWithBaggage(c.Request.Context(), bag)

    // 다운스트림 호출 시 자동 전파
    ctx, span := tracer.Start(ctx, "process-order")
    defer span.End()

    span.SetAttributes(
        attribute.String("business.tenant_id", tenantID),
        attribute.String("business.user_tier", userTier),
    )

    result, err := processOrder(ctx, c.Request.Body)
    if err != nil {
        span.RecordError(err)
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }
    c.JSON(http.StatusCreated, result)
}

// 다운스트림에서 Baggage 읽기
func processOrder(ctx context.Context, body io.ReadCloser) (*OrderResult, error) {
    bag := baggage.FromContext(ctx)
    tenantID := bag.Member("tenantId").Value()
    userTier := bag.Member("userTier").Value()

    ctx, span := tracer.Start(ctx, "validate-order")
    defer span.End()

    span.SetAttributes(
        attribute.String("business.tenant_id", tenantID),
    )

    // 비즈니스 로직 처리...
    return &OrderResult{ID: "ord-12345"}, nil
}

2.4 Baggage 보안과 성능 고려사항

Baggage는 강력하지만, 보안과 성능 측면에서 신중한 접근이 필요하다.

보안 위험

Baggage의 전파 경로와 보안 위험
============================================

[내부 서비스]  ──HTTP 헤더──▶  [내부 서비스]  ──HTTP 헤더──▶  [외부 API]
     │                              │                             │
     │  baggage: tenantId=acme,     │  baggage: tenantId=acme,    │  ⚠️ 외부로
     │  userId=12345,               │  userId=12345,Baggage가
     │  email=user@acme.com         │  email=user@acme.com        │  유출됨!
     │                              │                             │
     └──────────────────────────────┘                             │
           Trust Boundary 내부             Trust Boundary 외부 ───┘

⚠️ Baggage는 평문 HTTP 헤더로 전송된다!
⚠️ PII(개인식별정보)를 절대 넣지 마라!

필수 보안 조치:

  1. PII 금지: 이메일, 전화번호, 주민번호 등 개인식별정보를 절대 Baggage에 넣지 않는다.
  2. Trust Boundary Sanitization: 외부 서비스 호출 시 Baggage를 제거하거나 필터링한다.
  3. 허용 목록(Allowlist) 기반 전파: 허용된 키만 전파하도록 Collector나 SDK에서 필터를 설정한다.
  4. 값 크기 제한: W3C Baggage 스펙에서는 총 8,192바이트를 권장하지만, 실무에서는 가능한 작게 유지한다.
# OTel Collector에서 Baggage 필터링 설정 예시
processors:
  # 특정 baggage 키만 허용하는 attributes 프로세서
  attributes/baggage-filter:
    actions:
      # 허용된 비즈니스 키만 유지
      - key: baggage.tenantId
        action: upsert
      - key: baggage.userTier
        action: upsert
      # 민감한 키는 삭제
      - key: baggage.email
        action: delete
      - key: baggage.userId
        action: delete

성능 오버헤드

Baggage는 모든 서비스 간 HTTP 요청의 헤더에 포함되므로 네트워크 오버헤드가 발생한다.

Baggage 크기초당 10,000 요청 기준 추가 대역폭영향 수준
100 bytes~1 MB/s무시할 수 있음
500 bytes~5 MB/s경미
2 KB~20 MB/s주의 필요
8 KB (스펙 최대치)~80 MB/s심각한 오버헤드

Best Practice: Baggage에는 짧은 식별자(ID)만 넣고, 실제 데이터는 서비스가 해당 ID로 조회하도록 설계한다. 예를 들어, 전체 사용자 프로필 대신 tenantId=acme만 전파하고, 각 서비스가 필요할 때 테넌트 설정을 캐시에서 조회한다.


3. [Takeaway 2] 자동 계측의 한계와 하이브리드 전략의 필요성

3.1 자동 계측(Zero-Code Instrumentation)이 커버하는 영역

OpenTelemetry의 자동 계측은 코드를 한 줄도 수정하지 않고 애플리케이션에 옵저버빌리티를 추가하는 기능이다. 언어별 메커니즘은 다르지만, 핵심 원리는 동일하다: 프레임워크와 라이브러리의 진입/퇴출 지점을 가로채서(intercept) 자동으로 Span을 생성한다.

자동 계측이 캡처하는 것들:

  • HTTP 요청/응답: 인바운드(서버) 및 아웃바운드(클라이언트) HTTP 호출
  • 데이터베이스 쿼리: JDBC, SQLAlchemy, database/sql 등의 DB 드라이버 호출
  • 메시지 큐: Kafka, RabbitMQ, SQS 등의 produce/consume 작업
  • gRPC 호출: unary 및 streaming RPC
  • Redis, Memcached 등 캐시 접근
  • 프레임워크 내부 라우팅: Spring MVC, Express, Django 등

이것만으로도 인프라 레이어의 80~90%를 커버할 수 있다. 대부분의 지연 시간 문제, 에러율 급증, 서비스 간 의존성 파악이 가능해진다.

3.2 언어별 자동 계측 지원 수준

언어지원 수준메커니즘비침습성성능 오버헤드주요 특징
Java매우 높음Bytecode Manipulation (Java Agent)-javaagent JVM 옵션만 추가3~7% CPU200+ 라이브러리 자동 지원
Python높음Monkey Patchingopentelemetry-instrument CLI 래퍼5~10% CPUDjango, Flask, FastAPI 등 지원
.NET높음CLR Runtime Hooks환경변수 설정만으로 활성화3~5% CPUASP.NET Core, EF Core 지원
Node.js높음Module Loading Hooks (require/import)--require 플래그 추가5~8% CPUExpress, Fastify, NestJS 지원
Go중간eBPF 기반 또는 컴파일 타임 래핑eBPF는 비침습적, 래핑은 코드 수정 필요1~3% (eBPF)eBPF 방식은 커널 4.x+ 필요
Rust낮음수동 계측 필수 (tracing crate 연동)코드 수정 필수최소tracing-opentelemetry 크레이트 사용
C++낮음수동 계측 필수코드 수정 필수최소OTel C++ SDK 직접 사용

3.3 자동 계측이 놓치는 것: 비즈니스 로직의 블랙박스

자동 계측의 근본적 한계는 비즈니스 로직 내부를 들여다볼 수 없다는 점이다. 다음과 같은 시나리오를 생각해보자.

자동 계측만으로 보이는 것 vs 실제로 알아야 하는 것
============================================

자동 계측이 캡처하는 Span:
  [POST /api/orders] ──▶ [SELECT * FROM products] ──▶ [POST /payments]
       200 OK, 1.2s          50ms                       800ms

보이는 정보: "주문 API가 1.2초 걸렸고, DB는 50ms, 결제는 800ms"
모르는 정보: 나머지 350ms는 어디서 소비되었는가?

실제로 그 350ms 안에서 일어나는 일:
  ├── 재고 가용성 검증 로직 (50ms)
  ├── 할인 쿠폰 적용 규칙 엔진 (120ms)     ← 이것이 병목!
  ├── 배송비 계산 로직 (30ms)
  ├── 사기 탐지(Fraud Detection) 점수 계산 (80ms)
  └── 주문 이벤트 직렬화 (70ms)

자동 계측은 이 350ms를 "비즈니스 로직" 이라는
하나의 불투명한 덩어리로만 보여준다.

3.4 하이브리드 전략: 자동 + 수동 계측의 결합

최적의 전략은 자동 계측으로 인프라 레이어를 커버하고, 수동 계측으로 비즈니스 크리티컬 로직을 정밀하게 계측하는 것이다.

Java: 자동 계측 설정 + 수동 Span 추가

# 1단계: 자동 계측 (JVM 에이전트 방식)
# OTel Java Agent 다운로드
curl -L -o opentelemetry-javaagent.jar \
  https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

# JVM 시작 시 에이전트 첨부
java -javaagent:opentelemetry-javaagent.jar \
  -Dotel.service.name=order-service \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
  -Dotel.exporter.otlp.protocol=grpc \
  -Dotel.resource.attributes=service.namespace=ecommerce,deployment.environment=production \
  -jar order-service.jar
// 2단계: 비즈니스 크리티컬 로직에 수동 Span 추가
import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.common.Attributes;

@Service
public class OrderProcessingService {

    // 자동 계측 에이전트가 초기화한 글로벌 Tracer 사용
    private static final Tracer tracer =
        GlobalOpenTelemetry.getTracer("order-processing", "1.0.0");

    public OrderResult processOrder(OrderRequest request) {
        // 자동 계측: HTTP 진입 Span은 이미 생성되어 있음
        // 수동 계측: 비즈니스 로직의 세부 단계를 명시적으로 계측

        // 할인 적용 로직 계측
        Span discountSpan = tracer.spanBuilder("apply-discount-rules")
            .setAttribute("business.coupon_code", request.getCouponCode())
            .setAttribute("business.original_amount", request.getTotalAmount())
            .startSpan();

        try (var scope = discountSpan.makeCurrent()) {
            DiscountResult discount = discountEngine.calculate(request);
            discountSpan.setAttribute("business.discount_amount", discount.getAmount());
            discountSpan.setAttribute("business.discount_type", discount.getType());
            discountSpan.setAttribute("business.rules_evaluated", discount.getRulesCount());
        } catch (Exception e) {
            discountSpan.setStatus(StatusCode.ERROR, e.getMessage());
            discountSpan.recordException(e);
            throw e;
        } finally {
            discountSpan.end();
        }

        // 사기 탐지 계측
        Span fraudSpan = tracer.spanBuilder("fraud-detection")
            .setAttribute("business.user_id", request.getUserId())
            .setAttribute("business.order_amount", request.getTotalAmount())
            .startSpan();

        try (var scope = fraudSpan.makeCurrent()) {
            FraudScore score = fraudDetector.evaluate(request);
            fraudSpan.setAttribute("business.fraud_score", score.getValue());
            fraudSpan.setAttribute("business.fraud_decision", score.getDecision());

            if (score.getValue() > 0.8) {
                fraudSpan.addEvent("high-fraud-risk-detected",
                    Attributes.of(
                        AttributeKey.doubleKey("score"), score.getValue(),
                        AttributeKey.stringKey("reason"), score.getReason()
                    ));
            }
        } finally {
            fraudSpan.end();
        }

        // ...나머지 비즈니스 로직
    }
}

Python: 자동 계측 설정 + 수동 Span 추가

# 1단계: 자동 계측 패키지 설치
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install  # 감지된 라이브러리용 계측 패키지 자동 설치

# 2단계: 자동 계측으로 애플리케이션 실행
OTEL_SERVICE_NAME=order-service \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \
OTEL_EXPORTER_OTLP_PROTOCOL=grpc \
OTEL_RESOURCE_ATTRIBUTES=service.namespace=ecommerce,deployment.environment=production \
opentelemetry-instrument python -m uvicorn main:app --host 0.0.0.0 --port 8000
# 2단계: 비즈니스 로직에 수동 Span 추가
from opentelemetry import trace
from opentelemetry.trace import StatusCode

tracer = trace.get_tracer("order-processing", "1.0.0")

class OrderProcessor:
    async def process(self, order: OrderRequest) -> OrderResult:
        # 할인 로직 수동 계측
        with tracer.start_as_current_span(
            "apply-discount-rules",
            attributes={
                "business.coupon_code": order.coupon_code,
                "business.original_amount": order.total_amount,
            }
        ) as discount_span:
            try:
                discount = await self.discount_engine.calculate(order)
                discount_span.set_attribute("business.discount_amount", discount.amount)
                discount_span.set_attribute("business.rules_evaluated", discount.rules_count)
            except DiscountError as e:
                discount_span.set_status(StatusCode.ERROR, str(e))
                discount_span.record_exception(e)
                raise

        # 사기 탐지 수동 계측
        with tracer.start_as_current_span("fraud-detection") as fraud_span:
            score = await self.fraud_detector.evaluate(order)
            fraud_span.set_attribute("business.fraud_score", score.value)
            fraud_span.set_attribute("business.fraud_decision", score.decision)

            if score.value > 0.8:
                fraud_span.add_event("high-fraud-risk", attributes={
                    "score": score.value,
                    "reason": score.reason,
                })

        return await self._finalize_order(order, discount)

3.5 하이브리드 전략 적용 가이드라인

수동 계측을 모든 곳에 추가하면 오히려 노이즈가 된다. 어디에 수동 Span을 추가할지 결정하는 기준이 중요하다.

수동 계측 추가 기준예시우선순위
비용이 큰 비즈니스 로직가격 계산, 할인 엔진, 세금 계산높음
외부 의존성과의 상호작용자체 HTTP 클라이언트 래퍼, 레거시 API 호출높음
조건부 분기가 많은 로직결제 수단별 분기, 배송 방법 결정중간
배치/벌크 처리대량 데이터 가공, ETL 파이프라인 단계중간
단순 CRUD 연산기본 DB 읽기/쓰기낮음 (자동 계측으로 충분)
유틸리티 함수문자열 변환, 날짜 포맷팅불필요

4. [Takeaway 3] 테일 기반 샘플링: 데이터 홍수 속에서 진짜 문제만 골라내는 지능형 필터

4.1 왜 샘플링이 필요한가?

프로덕션 마이크로서비스 환경에서 모든 요청의 모든 Span을 100% 수집하면 어떻게 될까?

텔레메트리 볼륨 계산 예시
============================================

서비스 수: 30서비스당 평균 Span: 5/요청
초당 요청 (RPS): 10,000
Span당 평균 크기: 1 KB

초당 Span:  30 x 5 x 10,000 = 1,500,000 spans/sec
초당 데이터량: 1,500,000 x 1 KB = 1.5 GB/sec
일일 데이터량: 1.5 GB x 86,400 = ~130 TB/day

연간 저장 비용 (S3 기준): ~$35,000/month = ~$420,000/year
연간 Datadog 비용 (ingestion 기준): 수십배 이상

100% 수집은 비현실적이다. 샘플링은 필수다. 문제는 어떻게 샘플링할 것인가이다.

4.2 Head-based vs Tail-based 샘플링 비교

특성Head-based SamplingTail-based Sampling
결정 시점Trace 시작 시 (첫 번째 Span 생성 시)Trace 완료 후 (모든 Span 수집 후)
결정 기준확률적 (예: 10% 무작위)내용 기반 (에러, 지연 시간, 속성값 등)
메모리 요구량거의 없음높음 (Trace 완료 대기를 위한 버퍼 필요)
구현 위치SDK (애플리케이션 내부)Collector (외부 파이프라인)
에러 Trace 보장불가 (에러 발생 전에 이미 드롭될 수 있음)가능 (에러 Trace를 100% 유지 가능)
네트워크 비용낮음 (드롭된 Span은 전송하지 않음)높음 (모든 Span을 Collector까지 전송)
Collector 의존성없음높음 (전용 Collector 인프라 필요)
복잡도낮음높음

핵심 Trade-off: Head-based는 가볍지만 맹목적이고, Tail-based는 지능적이지만 무겁다.

4.3 Tail-based 샘플링의 핵심 전제조건: Trace-ID 기반 로드 밸런싱

Tail-based 샘플링이 올바르게 작동하려면, 하나의 Trace를 구성하는 모든 Span이 동일한 Collector 인스턴스에 도달해야 한다. 그래야 Collector가 Trace 전체를 보고 샘플링 결정을 내릴 수 있다.

이를 위해 2-tier Collector 아키텍처가 필요하다.

Tail-based Sampling을 위한 2-Tier Collector 아키텍처
============================================

  [Service A]   [Service B]   [Service C]   [Service D]
       │              │              │              │
OTLPOTLPOTLPOTLP
       ▼              ▼              ▼              ▼
  ┌──────────────────────────────────────────────────────┐
Tier 1: Agent Collectors              (DaemonSet, 각 노드에 1)  │                                                      │
  │  ┌──────────┐  ┌──────────┐  ┌──────────┐           │
  │  │ Agent 1  │  │ Agent 2  │  │ Agent 3  │           │
 (Node 1) (Node 2) (Node 3) │           │
  │  └────┬─────┘  └────┬─────┘  └────┬─────┘           │
  │       │              │              │                 │
  │       │   Load Balancing Exporter   │                 │
   (Trace ID 해시 기반)       │                 │
  └───────┼──────────────┼──────────────┼─────────────────┘
          │              │              │
          ▼              ▼              ▼
  ┌──────────────────────────────────────────────────────┐
Tier 2: Gateway Collectors           (Deployment/StatefulSet, 수평 확장)  │                                                      │
  │  ┌──────────────┐  ┌──────────────┐                  │
  │  │  Gateway 1   │  │  Gateway 2   │                  │
  │  │              │  │              │                  │
  │  │ Trace ID     │  │ Trace ID     │                  │
  │  │ a1xx → 여기  │  │ b2xx → 여기  │                  │
  │  │              │  │              │                  │
  │  │ tail_sampling │  │ tail_sampling │                  │
  │  │ processor    │  │ processor    │                  │
  │  └──────┬───────┘  └──────┬───────┘                  │
  └─────────┼──────────────────┼──────────────────────────┘
            │                  │
            ▼                  ▼
      ┌───────────┐     ┌───────────┐
Backend  │     │  Backend       (Tempo) (Jaeger)      └───────────┘     └───────────┘

4.4 OTel Collector 설정: Tier 1 (Agent)

# otel-collector-agent.yaml
# Tier 1: 각 노드에 DaemonSet으로 배포되는 Agent Collector

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # 메모리 보호를 위한 리미터 (필수!)
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
    spike_limit_mib: 128

  # 기본 속성 추가 (노드 정보 등)
  resource:
    attributes:
      - key: k8s.node.name
        value: '${K8S_NODE_NAME}'
        action: upsert
      - key: deployment.environment
        value: 'production'
        action: upsert

  # 배치 처리 (네트워크 효율성)
  batch:
    send_batch_size: 1024
    send_batch_max_size: 2048
    timeout: 5s

exporters:
  # Trace ID 기반 로드 밸런싱 → Tier 2 Gateway로 전송
  loadbalancing:
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      dns:
        hostname: otel-gateway-headless.observability.svc.cluster.local
        port: 4317

  # Metrics와 Logs는 직접 백엔드로 전송 (샘플링 불필요)
  otlp/metrics:
    endpoint: mimir.observability.svc.cluster.local:4317
    tls:
      insecure: true

  otlp/logs:
    endpoint: loki.observability.svc.cluster.local:4317
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [loadbalancing] # Trace는 Gateway로 라우팅

    metrics:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp/metrics] # Metrics는 직접 전송

    logs:
      receivers: [otlp]
      processors: [memory_limiter, resource, batch]
      exporters: [otlp/logs] # Logs는 직접 전송

4.5 OTel Collector 설정: Tier 2 (Gateway with Tail Sampling)

# otel-collector-gateway.yaml
# Tier 2: Tail-based Sampling을 수행하는 Gateway Collector

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 4096 # Gateway는 더 많은 메모리 필요
    spike_limit_mib: 1024

  # ⚠️ 중요: tail_sampling 전에 batch를 사용하지 마라!
  # batch가 같은 Trace의 Span을 분리할 수 있다.

  # Tail-based 샘플링 정책
  tail_sampling:
    # Trace 완료를 기다리는 시간
    # 시스템의 최대 예상 Trace 지속시간 + 네트워크 마진
    decision_wait: 30s
    # 결정 후 추가 Span을 기다리는 유예 시간
    num_traces: 100000 # 동시 추적할 최대 Trace 수
    expected_new_traces_per_sec: 1000

    policies:
      # 정책 1: 에러가 발생한 Trace는 100% 유지 (최우선)
      - name: errors-always-keep
        type: status_code
        status_code:
          status_codes:
            - ERROR

      # 정책 2: 높은 지연 시간의 Trace 유지 (p99 이상)
      - name: high-latency
        type: latency
        latency:
          threshold_ms: 5000 # 5초 이상 걸린 Trace

      # 정책 3: 특정 서비스의 Trace 항상 유지 (크리티컬 서비스)
      - name: critical-services
        type: string_attribute
        string_attribute:
          key: service.name
          values:
            - payment-service
            - order-service
          enabled_regex_matching: false

      # 정책 4: 프리미엄 사용자의 Trace 더 높은 비율로 유지
      - name: premium-users
        type: and
        and:
          and_sub_policy:
            - name: is-premium
              type: string_attribute
              string_attribute:
                key: business.user_tier
                values: ['premium', 'enterprise']
            - name: premium-rate
              type: probabilistic
              probabilistic:
                sampling_percentage: 50 # 50% 유지

      # 정책 5: 나머지 정상 Trace는 5%만 유지 (비용 최적화)
      - name: baseline-probabilistic
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

  # tail_sampling 이후에 batch 적용
  batch:
    send_batch_size: 2048
    timeout: 10s

exporters:
  otlp/tempo:
    endpoint: tempo.observability.svc.cluster.local:4317
    tls:
      insecure: true

  otlp/jaeger:
    endpoint: jaeger-collector.observability.svc.cluster.local:4317
    tls:
      insecure: true

service:
  telemetry:
    metrics:
      address: 0.0.0.0:8888 # Collector 자체 메트릭 모니터링
    logs:
      level: info

  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]

4.6 Tail Sampling 메모리 사이징 공식

Tail sampling은 결정을 내리기 전까지 모든 Span을 메모리에 보관해야 하므로, 적절한 메모리 산정이 중요하다.

메모리 요구량 계산 공식
============================================

Required Memory (GB) =
  traces_per_second
  x decision_wait_seconds
  x avg_spans_per_trace
  x bytes_per_span
  / 1,000,000,000
  x safety_factor

예시 계산:
  traces_per_second     = 1,000
  decision_wait_seconds = 30
  avg_spans_per_trace   = 15
  bytes_per_span        = 1,000 (1 KB)
  safety_factor         = 2.0

  = 1,000 x 30 x 15 x 1,000 / 1,000,000,000 x 2.0
  = 450,000,000 / 1,000,000,000 x 2.0
  = 0.45 x 2.0
  = 0.9 GB

→ 최소 1 GB, 권장 2 GB 메모리 할당

4.7 샘플링 전략 선택 가이드

시나리오권장 전략근거
초기 도입, 트래픽 낮음 (< 1K RPS)Head-based 100% (샘플링 없음)볼륨이 작아 비용 부담 없음
성장기, 중간 트래픽 (1K~10K RPS)Head-based 10~50%단순하고 효과적
대규모 트래픽, 에러 추적 중요Tail-based (에러 100% + 정상 5%)비용 절감과 에러 가시성 동시 달성
멀티 팀, 서비스별 다른 정책 필요Tail-based 복합 정책서비스별, 사용자 등급별 차등 샘플링
규제 요구 (모든 거래 기록 필수)100% 수집 + 별도 아카이브 파이프라인규제 준수를 위한 전수 기록

5. [Takeaway 4] Semantic Conventions: 데이터의 표준화와 협업의 가치

5.1 표준 없는 세계의 혼란

20개 팀이 각자의 마이크로서비스를 운영하고, 각 팀이 자유롭게 텔레메트리 속성명을 정한다면 어떻게 될까?

표준 없이 각 팀이 자유롭게 명명한 속성들
============================================

A (주문 서비스):     user_id="12345", status_code=200, method="POST"
B (결제 서비스):     userId="12345",  httpStatus=200,  httpMethod="POST"
C (재고 서비스):     uid="12345",     response_code=200, req_method="POST"
D (알림 서비스):     customer_id="12345", http_code=200, verb="POST"
E (검색 서비스):     user="12345",    code=200,        http.method="POST"

같은 사용자의 같은 요청인데 5가지 다른 속성명을 사용한다.
 서비스 간 상관 분석(Correlation) 불가!
→ 통합 대시보드 구축 불가!
→ 자동 알림 규칙 작성 불가!

이 문제를 해결하는 것이 OpenTelemetry Semantic Conventions이다.

5.2 Semantic Conventions의 구조

OpenTelemetry Semantic Conventions(현재 v1.40.0)는 텔레메트리 데이터의 표준 속성명과 의미를 정의한다. 주요 영역별 표준 속성을 살펴보자.

리소스 속성 (Resource Attributes)

서비스 자체를 식별하는 메타데이터이다.

속성명타입설명예시
service.namestring서비스의 논리적 이름 (필수)order-service
service.versionstring서비스 버전2.1.0
service.namespacestring서비스 그룹/네임스페이스ecommerce
deployment.environment.namestring배포 환경production
host.idstring호스트 고유 식별자i-0a1b2c3d4e5f6
host.namestring호스트명ip-10-0-1-42
k8s.pod.namestringKubernetes Pod 이름order-service-7d4f5b-x9z2k
k8s.namespace.namestringKubernetes 네임스페이스production
k8s.deployment.namestringKubernetes Deployment 이름order-service

HTTP 속성 (Span Attributes)

HTTP 요청/응답에 대한 표준 속성이다.

속성명타입설명예시
http.request.methodstringHTTP 메서드POST
url.fullstring전체 URLhttps://api.example.com/orders
url.pathstringURL 경로/api/orders
http.response.status_codeintHTTP 응답 코드201
server.addressstring서버 주소api.example.com
server.portint서버 포트443
network.protocol.versionstring프로토콜 버전2.0
user_agent.originalstringUser-Agent 헤더 원본Mozilla/5.0...

데이터베이스 속성

속성명타입설명예시
db.systemstring데이터베이스 시스템postgresql
db.namespacestring데이터베이스 이름orders_db
db.operation.namestringDB 작업명SELECT
db.query.textstring쿼리 텍스트 (sanitized)SELECT * FROM orders WHERE id = ?
db.collection.namestring테이블/컬렉션명orders

5.3 표준화의 실전 효과: Before vs After

Before (표준 없음):
  팀별로 다른 속성명 → 통합 쿼리 불가
  ─────────────────────────────────────
  Grafana에서 "모든 서비스의 5xx 에러율" 대시보드를 만들려면?

  Panel 1 (주문): rate({status_code=~"5.."})
  Panel 2 (결제): rate({httpStatus=~"5.."})
  Panel 3 (재고): rate({response_code=~"5.."})
  → 서비스마다 별도 쿼리 필요.  서비스 추가 시 대시보드 수정 필수.


After (Semantic Conventions 적용):
  모든 팀이 동일한 속성명 사용 → 단일 쿼리로 전체 조회
  ─────────────────────────────────────
  Grafana에서 "모든 서비스의 5xx 에러율" 대시보드:

  단일 쿼리: rate({http.response.status_code=~"5.."}) by (service.name)
  → 모든 서비스가 자동으로 포함.  서비스 추가 시 변경 없음.

5.4 Cross-Signal Correlation: Logs, Metrics, Traces 통합

Semantic Conventions의 또 다른 강력한 이점은 **시그널 간 상관 분석(Cross-Signal Correlation)**이다. 같은 속성명을 사용하면 Trace에서 발견한 이상을 Metric에서 확인하고, 관련 Log를 즉시 조회할 수 있다.

Cross-Signal Correlation 워크플로우
============================================

1. Alert 발생:
   metric: http_server_request_duration_seconds{service.name="order-service"} > 5s

2. Trace에서 원인 추적:
   trace: service.name="order-service"
          AND http.response.status_code >= 500
Span에서 db.operation.name="SELECT",
            db.collection.name="orders" 확인

3. 관련 Log 조회:
   log: service.name="order-service"
        AND trace_id="abc123"
"Connection pool exhausted" 에러 로그 발견

모든 시그널에서 service.name, trace_id 등 동일한 속성명을 사용하기에
이 워크플로우가 자연스럽게 연결된다.

5.5 Semantic Conventions 적용을 위한 Collector 설정

팀별로 이미 서로 다른 속성명을 사용하고 있다면, Collector의 attributes 프로세서로 중앙에서 정규화할 수 있다.

# Collector에서 속성명을 Semantic Conventions에 맞게 정규화
processors:
  # 레거시 속성명을 표준 속성명으로 변환
  attributes/normalize:
    actions:
      # HTTP 속성 정규화
      - key: http.method
        action: upsert
        from_attribute: httpMethod # 팀 B의 속성명
      - key: http.method
        action: upsert
        from_attribute: req_method # 팀 C의 속성명
      - key: http.method
        action: upsert
        from_attribute: verb # 팀 D의 속성명
      # 레거시 키 삭제
      - key: httpMethod
        action: delete
      - key: req_method
        action: delete
      - key: verb
        action: delete

      # 사용자 ID 정규화
      - key: enduser.id
        action: upsert
        from_attribute: user_id
      - key: enduser.id
        action: upsert
        from_attribute: userId
      - key: enduser.id
        action: upsert
        from_attribute: uid
      - key: enduser.id
        action: upsert
        from_attribute: customer_id
      # 레거시 키 삭제
      - key: user_id
        action: delete
      - key: userId
        action: delete
      - key: uid
        action: delete
      - key: customer_id
        action: delete

      # HTTP 상태 코드 정규화
      - key: http.response.status_code
        action: upsert
        from_attribute: status_code
      - key: http.response.status_code
        action: upsert
        from_attribute: httpStatus
      - key: http.response.status_code
        action: upsert
        from_attribute: response_code
      - key: http.response.status_code
        action: upsert
        from_attribute: http_code

  # 필수 리소스 속성이 누락된 경우 기본값 추가
  resource:
    attributes:
      - key: service.namespace
        value: 'default'
        action: insert # 이미 존재하면 덮어쓰지 않음
      - key: deployment.environment.name
        value: 'production'
        action: insert

5.6 커스텀 비즈니스 속성 네이밍 가이드

Semantic Conventions에 정의되지 않은 비즈니스 속성을 추가할 때는 일관된 네이밍 규칙을 따라야 한다.

규칙좋은 예나쁜 예
네임스페이스를 접두사로 사용business.order_idorderId
snake_case 사용business.payment_methodbusiness.paymentMethod
단위를 속성명에 포함business.order_total_usdbusiness.order_total
불리언은 is_ 접두사business.is_first_orderbusiness.first_order
열거형은 소문자business.user_tier="premium"business.user_tier="PREMIUM"

6. [Takeaway 5] OTLP 전송 방식의 선택: gRPC vs HTTP

6.1 OTLP(OpenTelemetry Protocol)란?

OTLP는 OpenTelemetry가 정의한 텔레메트리 데이터 전송을 위한 표준 프로토콜이다. 현재 OTLP 스펙 1.9.0 기준으로, Traces, Metrics, Logs(그리고 최근 추가된 Profiles)를 단일 프로토콜로 전송할 수 있다. OTLP는 세 가지 전송 방식을 지원한다.

6.2 성능 비교

항목OTLP/gRPCOTLP/HTTP (Protobuf)OTLP/HTTP (JSON)
기본 포트431743184318
직렬화 형식Protobuf (바이너리)Protobuf (바이너리)JSON (텍스트)
전송 프로토콜HTTP/2 (양방향 스트리밍)HTTP/1.1 또는 HTTP/2HTTP/1.1 또는 HTTP/2
측정 처리량~10,000-50,000 spans/sec~5,000-30,000 spans/sec~3,000-15,000 spans/sec
상대 CPU 사용1.0x (기준)1.2x2.5x
페이로드 크기가장 작음 (1.0x)작음 (1.0x, 동일 Protobuf)큼 (3~5x)
연결 관리Connection MultiplexingConnection per requestConnection per request
헤더 압축HPACK (자동)없음없음
압축 지원gzip, zstdgzip, zstdgzip, zstd

6.3 gRPC의 장점과 적합한 시나리오

gRPC가 유리한 경우:

  • 고처리량 환경: 초당 10,000 spans 이상을 생성하는 서비스
  • 서비스 간 내부 통신: Kubernetes 클러스터 내부 같은 신뢰할 수 있는 네트워크
  • HTTP/2 지원 인프라: 서비스 메시, 내부 로드 밸런서
  • 양방향 스트리밍: 연속적인 텔레메트리 스트리밍이 필요한 경우
  • 대역폭 절감이 중요한 경우: 바이너리 인코딩 + HPACK 압축
# gRPC Exporter 설정 예시 (SDK)
# 환경변수 방식
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000
# Collector Receiver 설정 (gRPC)
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16 # 최대 수신 메시지 크기
        max_concurrent_streams: 100 # 동시 스트림 수
        keepalive:
          server_parameters:
            max_connection_idle: 60s
            max_connection_age: 300s
            time: 30s
            timeout: 10s
        tls:
          cert_file: /certs/server.crt
          key_file: /certs/server.key
          client_ca_file: /certs/ca.crt # mTLS

6.4 HTTP의 장점과 적합한 시나리오

HTTP가 유리한 경우:

  • 방화벽/프록시 호환성: 기업 네트워크에서 HTTP/1.1만 허용하는 경우
  • 브라우저 환경: 웹 프론트엔드에서 직접 텔레메트리 전송 (CORS 지원)
  • 서버리스/엣지: AWS Lambda, Cloudflare Workers 등 gRPC 지원이 어려운 환경
  • 디버깅: JSON 형식으로 전송하여 페이로드를 사람이 읽을 수 있음
  • 범용 인프라: HTTP 로드 밸런서, CDN, 리버스 프록시 호환
# HTTP Exporter 설정 예시 (SDK)
# 환경변수 방식 (Protobuf)
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
OTEL_EXPORTER_OTLP_COMPRESSION=gzip
OTEL_EXPORTER_OTLP_TIMEOUT=10000

# HTTP JSON (디버깅 용도)
OTEL_EXPORTER_OTLP_PROTOCOL=http/json
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# Collector Receiver 설정 (HTTP)
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins:
            - 'https://*.example.com' # 브라우저 CORS 허용
          allowed_headers:
            - 'Content-Type'
            - 'X-Custom-Header'
          max_age: 7200
        tls:
          cert_file: /certs/server.crt
          key_file: /certs/server.key

6.5 전송 방식 선택 의사결정 트리

OTLP 전송 방식 선택 의사결정 트리
============================================

시작: 텔레메트리 전송 방식을 선택해야 한다

Q1. 브라우저 또는 서버리스 환경인가?
  ├── YESHTTP/Protobuf 사용
           (gRPC는 브라우저/Lambda에서 지원 불가)
  └── NO ──▶ Q2. 방화벽이 HTTP/2 또는 gRPC를 차단하는가?
                ├── YESHTTP/Protobuf 사용
                         (HTTP/1.1로 폴백 가능)
                └── NO ──▶ Q3. 초당 10,000 spans 이상인가?
                              ├── YES → gRPC 사용
                                       (HTTP/2 멀티플렉싱, 최고 성능)
                              └── NO ──▶ Q4. 디버깅이 주 목적인가?
                                            ├── YESHTTP/JSON 사용
                                                     (사람이 읽을 수 있음)
                                            └── NOHTTP/Protobuf 사용
                                                      (가장 범용적, 무난한 선택)

6.6 하이브리드 구성: gRPC + HTTP 동시 수신

실무에서는 Collector가 gRPC와 HTTP를 동시에 수신하도록 구성하여, 다양한 클라이언트를 모두 수용하는 전략이 일반적이다.

# 하이브리드 Receiver 설정 (gRPC + HTTP 동시 수신)
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16
        keepalive:
          server_parameters:
            max_connection_idle: 60s
            time: 30s
            timeout: 10s
      http:
        endpoint: 0.0.0.0:4318
        cors:
          allowed_origins: ['*']
          allowed_headers: ['*']
# 서비스 유형별 권장 구성
# ┌─────────────────────────┬──────────────────────────┐
# │ 서비스 유형              │ 권장 전송 방식             │
# ├─────────────────────────┼──────────────────────────┤
# │ 백엔드 (Java, Go)       │ gRPC (포트 4317)          │
# │ 프론트엔드 (Browser JS) │ HTTP/Protobuf (포트 4318) │
# │ 서버리스 (Lambda)       │ HTTP/Protobuf (포트 4318) │
# │ 디버깅/테스트           │ HTTP/JSON (포트 4318)     │
# │ IoT/Edge                │ HTTP/Protobuf (포트 4318) │
# └─────────────────────────┴──────────────────────────┘

7. OTel Collector 배포 토폴로지: Agent vs Gateway

7.1 배포 패턴 개요

OpenTelemetry Collector는 세 가지 핵심 배포 패턴을 지원하며, 각각의 장단점과 적합한 시나리오가 다르다.

OTel Collector 배포 패턴 비교
============================================

Pattern 1: Agent (DaemonSet)
  ┌─────────────────────────────────┐
Kubernetes Node  │  ┌─────────┐  ┌─────────┐      │
  │  │Service A│  │Service B│      │
  │  └────┬────┘  └────┬────┘      │
  │       │ localhost   │          │
  │       ▼             ▼          │
  │  ┌──────────────────────┐      │
  │  │   OTel Collector     │      │
   (DaemonSet Pod)    │      │
  │  └──────────┬───────────┘      │
  └─────────────┼──────────────────┘
          ┌──────────┐
Backend          └──────────┘

Pattern 2: Sidecar
  ┌──────────────────────────────┐
Application Pod  │  ┌──────────┐ ┌───────────┐  │
  │  │App       │ │OTel       │  │
  │  │Container │→│Collector  │  │
  │  │          │ (Sidecar)  │  │
  │  └──────────┘ └─────┬─────┘  │
  └─────────────────────┼────────┘
                  ┌──────────┐
Backend                  └──────────┘

Pattern 3: Gateway (Deployment)
  [Service A]  [Service B]  [Service C]
       │            │            │
       └────────────┼────────────┘
          ┌──────────────────┐
OTel Collector            (Deployment,          │   replicas: 3+)+ HPA          └────────┬─────────┘
             ┌──────────┐
Backend             └──────────┘

7.2 배포 패턴별 상세 비교

특성Agent (DaemonSet)SidecarGateway (Deployment)
배포 단위노드당 1개Pod당 1개클러스터당 N개 (수평 확장)
리소스 격리노드의 모든 Pod 공유Pod 전용 리소스중앙 집중
장애 영향 범위해당 노드의 모든 서비스해당 Pod만모든 서비스 (단일 장애점)
설정 관리노드 공통 설정Pod별 맞춤 설정 가능중앙 집중 설정
네트워크 지연최소 (localhost)최소 (localhost)네트워크 홉 존재
리소스 효율높음낮음 (Pod마다 중복)매우 높음
적합한 시나리오범용, 가장 일반적멀티테넌트, 보안 격리 필요중앙 처리, 샘플링, 라우팅

7.3 Kubernetes DaemonSet 배포 예시

# otel-collector-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: otel-collector-agent
  namespace: observability
  labels:
    app: otel-collector
    component: agent
spec:
  selector:
    matchLabels:
      app: otel-collector
      component: agent
  template:
    metadata:
      labels:
        app: otel-collector
        component: agent
    spec:
      serviceAccountName: otel-collector
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:0.120.0
          args:
            - '--config=/conf/otel-collector-config.yaml'
          ports:
            - containerPort: 4317 # gRPC
              hostPort: 4317
              protocol: TCP
            - containerPort: 4318 # HTTP
              hostPort: 4318
              protocol: TCP
            - containerPort: 8888 # Prometheus metrics
              protocol: TCP
          env:
            - name: K8S_NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName
            - name: K8S_POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
            - name: GOMEMLIMIT
              value: '460MiB' # Go runtime 메모리 제한
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: 1000m
              memory: 512Mi
          volumeMounts:
            - name: config
              mountPath: /conf
          livenessProbe:
            httpGet:
              path: /
              port: 13133 # health_check extension
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 13133
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: config
          configMap:
            name: otel-agent-config

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: otel-agent-config
  namespace: observability
data:
  otel-collector-config.yaml: |
    extensions:
      health_check:
        endpoint: 0.0.0.0:13133

    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

      # 노드 레벨 메트릭 수집 (호스트 메트릭)
      hostmetrics:
        collection_interval: 30s
        scrapers:
          cpu: {}
          memory: {}
          disk: {}
          network: {}

      # Kubelet 메트릭 수집
      kubeletstats:
        collection_interval: 30s
        auth_type: "serviceAccount"
        endpoint: "https://${K8S_NODE_NAME}:10250"
        insecure_skip_verify: true

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 400
        spike_limit_mib: 100

      batch:
        send_batch_size: 1024
        timeout: 5s

      resource:
        attributes:
          - key: k8s.node.name
            value: "${K8S_NODE_NAME}"
            action: upsert

      # Kubernetes 메타데이터 자동 추가
      k8sattributes:
        auth_type: "serviceAccount"
        extract:
          metadata:
            - k8s.pod.name
            - k8s.pod.uid
            - k8s.namespace.name
            - k8s.deployment.name
            - k8s.node.name
          labels:
            - tag_name: app.label.team
              key: team
              from: pod
          annotations:
            - tag_name: app.annotation.version
              key: app-version
              from: pod

    exporters:
      # Traces → Gateway (Tail Sampling을 위해)
      loadbalancing:
        protocol:
          otlp:
            tls:
              insecure: true
        resolver:
          dns:
            hostname: otel-gateway-headless.observability.svc.cluster.local
            port: 4317

      # Metrics → Prometheus/Mimir 직접 전송
      prometheusremotewrite:
        endpoint: http://mimir.observability.svc.cluster.local:9009/api/v1/push

      # Logs → Loki 직접 전송
      otlp/logs:
        endpoint: loki.observability.svc.cluster.local:4317
        tls:
          insecure: true

    service:
      extensions: [health_check]
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [loadbalancing]
        metrics:
          receivers: [otlp, hostmetrics, kubeletstats]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [prometheusremotewrite]
        logs:
          receivers: [otlp]
          processors: [memory_limiter, k8sattributes, resource, batch]
          exporters: [otlp/logs]

7.4 Gateway Deployment 예시

# otel-collector-gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: otel-collector-gateway
  namespace: observability
spec:
  replicas: 3
  selector:
    matchLabels:
      app: otel-collector
      component: gateway
  template:
    metadata:
      labels:
        app: otel-collector
        component: gateway
    spec:
      containers:
        - name: otel-collector
          image: otel/opentelemetry-collector-contrib:0.120.0
          args:
            - '--config=/conf/otel-collector-config.yaml'
          ports:
            - containerPort: 4317
              protocol: TCP
          env:
            - name: GOMEMLIMIT
              value: '3600MiB'
          resources:
            requests:
              cpu: 1000m
              memory: 2Gi
            limits:
              cpu: 4000m
              memory: 4Gi
          volumeMounts:
            - name: config
              mountPath: /conf
      volumes:
        - name: config
          configMap:
            name: otel-gateway-config

---
# Headless Service (Load Balancing Exporter의 DNS 리졸버용)
apiVersion: v1
kind: Service
metadata:
  name: otel-gateway-headless
  namespace: observability
spec:
  clusterIP: None
  selector:
    app: otel-collector
    component: gateway
  ports:
    - port: 4317
      targetPort: 4317
      protocol: TCP

---
# HPA (수평 자동 확장)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: otel-gateway-hpa
  namespace: observability
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: otel-collector-gateway
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 75

7.5 OpenTelemetry Operator를 활용한 자동화

Kubernetes 환경에서는 OpenTelemetry Operator를 사용하면 Collector의 배포와 자동 계측 주입을 선언적으로 관리할 수 있다.

# OpenTelemetryCollector CRD를 통한 선언적 배포
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata:
  name: otel-agent
  namespace: observability
spec:
  mode: daemonset # daemonset | deployment | sidecar | statefulset
  image: otel/opentelemetry-collector-contrib:0.120.0
  resources:
    requests:
      cpu: 200m
      memory: 256Mi
    limits:
      cpu: 1000m
      memory: 512Mi
  config:
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318
    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 400
      batch:
        send_batch_size: 1024
        timeout: 5s
    exporters:
      otlp:
        endpoint: otel-gateway.observability.svc.cluster.local:4317
        tls:
          insecure: true
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: [memory_limiter, batch]
          exporters: [otlp]

---
# 자동 계측 주입 (Java 애플리케이션에 OTel Agent 자동 설치)
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: java-instrumentation
  namespace: production
spec:
  exporter:
    endpoint: http://otel-agent.observability.svc.cluster.local:4317
  propagators:
    - tracecontext
    - baggage
  sampler:
    type: parentbased_traceidratio
    argument: '0.25' # Head-based 25% (Tail sampling이 추가 필터링)
  java:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest
    env:
      - name: OTEL_JAVAAGENT_DEBUG
        value: 'false'

---
# 이 annotation을 Pod/Deployment에 추가하면 자동 계측 활성화
# metadata:
#   annotations:
#     instrumentation.opentelemetry.io/inject-java: "java-instrumentation"

8. 옵저버빌리티 백엔드 비교

올바른 백엔드 선택은 옵저버빌리티 전략의 성패를 좌우한다. OpenTelemetry의 벤더 중립성 덕분에 백엔드를 자유롭게 선택하고 교체할 수 있다.

8.1 오픈소스 vs 상용 백엔드 비교

솔루션유형TracesMetricsLogsOTLP 네이티브비용 모델특징
JaegerOSSOXXO무료CNCF 졸업 프로젝트, 경량 추적 전용
Grafana TempoOSSOXXO무료오브젝트 스토리지 기반, 인덱스 불필요
Grafana MimirOSSXOXO무료Prometheus 호환 장기 저장소
Grafana LokiOSSXXOO무료레이블 기반 로그 집계
SigNozOSSOOOO무료올인원 OSS 옵저버빌리티 (ClickHouse 기반)
Grafana CloudSaaSOOOO사용량 기반 (무료 티어 있음)Tempo+Mimir+Loki 통합
DatadogSaaSOOOO호스트 + 사용량 기반가장 풍부한 기능, 높은 가격
New RelicSaaSOOOO사용량 기반 (무료 100GB/월)넉넉한 무료 티어
Elastic APMOSS/SaaSOOOO노드 + 사용량 기반Elasticsearch 기반, 강력한 검색
HoneycombSaaSOXXO이벤트 기반고카디널리티 분석 특화
AWS X-RaySaaSOXXO (ADOT 경유)사용량 기반AWS 네이티브 통합
DynatraceSaaSOOOO호스트 기반AI 기반 자동 근본 원인 분석

8.2 백엔드 선택 기준

요구사항권장 솔루션근거
비용 최소화, 자체 운영 가능SigNoz 또는 Grafana Stack (Tempo+Mimir+Loki)OSS 무료, 커뮤니티 지원
운영 부담 최소화Grafana Cloud 또는 New Relic관리형 SaaS, 무료 티어 존재
대기업, 풍부한 기능 필요Datadog 또는 Dynatrace가장 성숙한 기능셋, 엔터프라이즈 지원
AWS 올인AWS X-Ray + CloudWatch네이티브 AWS 통합, IAM 연동
고카디널리티 분석HoneycombBubbleUp 등 고유 분석 기능
Elasticsearch 이미 운영 중Elastic APM기존 인프라 활용

9. 프로덕션 도입 로드맵

9.1 단계별 도입 체크리스트

OpenTelemetry 도입은 한 번에 모든 것을 적용하는 것이 아니라, 단계적이고 점진적으로 진행해야 한다.

Phase 1: 기반 구축 (2~4주)

체크리스트
============================================
[ ] OTel Collector를 Kubernetes DaemonSet으로 배포
[ ] OTLP Receiver (gRPC + HTTP) 활성화
[ ] 백엔드 선택 및 Exporter 설정 (Tempo, Jaeger)
[ ] memory_limiter 프로세서 설정
[ ] health_check extension 설정
[ ] Collector 자체 메트릭 모니터링 (Prometheus scrape)
[ ] 파일럿 서비스 1~2개에 자동 계측 적용
[ ] 기본 대시보드 구축 (RED metrics: Rate, Errors, Duration)

Phase 2: 확산 및 표준화 (4~8주)

체크리스트
============================================
[ ] Semantic Conventions 가이드 작성 및 팀 교육
[ ] 레거시 속성명 정규화를 위한 attributes 프로세서 설정
[ ] 자동 계측을 전체 서비스로 확산
[ ] k8sattributes 프로세서로 Kubernetes 메타데이터 자동 부착
[ ] resource 프로세서로 필수 리소스 속성 보장
[ ] service.name, deployment.environment 등 필수 속성 검증
[ ] 알림 규칙 설정 (에러율, 지연 시간 임계치)

Phase 3: 고도화 (8~12주)

체크리스트
============================================
[ ] 비즈니스 크리티컬 로직에 수동 계측 추가 (하이브리드 전략)
[ ] W3C Baggage를 통한 비즈니스 컨텍스트 전파 도입
[ ] Tail-based sampling을 위한 2-tier Collector 아키텍처 구축
[ ] Load Balancing Exporter 설정 (Trace ID 기반)
[ ] tail_sampling 프로세서 정책 설계 및 튜닝
[ ] Cross-signal correlation 대시보드 구축
[ ] SLO(Service Level Objective) 기반 모니터링

Phase 4: 최적화 및 운영 성숙 (지속적)

체크리스트
============================================
[ ] 샘플링 정책 지속적 튜닝 (비용 vs 가시성 최적화)
[ ] Collector 리소스 사용량 모니터링 및 자동 확장(HPA) 적용
[ ] OTel Operator 도입 (자동 계측 주입 자동화)
[ ] 멀티 클러스터 / 멀티 리전 옵저버빌리티 통합
[ ] Baggage 보안 정책 (Trust Boundary 필터링)
[ ] 팀별 셀프 서비스 대시보드 가이드라인
[ ] 정기적인 옵저버빌리티 성숙도 평가

9.2 완전한 파이프라인 구성 예시

프로덕션 환경에서의 완전한 OTel Collector 파이프라인 구성 예시를 제시한다. 이 설정은 Receiver, Processor, Exporter의 전체 흐름을 보여준다.

# production-otel-collector.yaml
# 프로덕션 레벨의 완전한 Collector 파이프라인

extensions:
  health_check:
    endpoint: 0.0.0.0:13133
  pprof:
    endpoint: 0.0.0.0:1777 # Go pprof 프로파일링
  zpages:
    endpoint: 0.0.0.0:55679 # 디버깅용 zPages

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
        max_recv_msg_size_mib: 16
      http:
        endpoint: 0.0.0.0:4318

  # Prometheus 메트릭 스크래핑 (기존 Prometheus 타겟 호환)
  prometheus:
    config:
      scrape_configs:
        - job_name: 'kubernetes-pods'
          kubernetes_sd_configs:
            - role: pod
          relabel_configs:
            - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
              action: keep
              regex: true

  # 호스트 메트릭
  hostmetrics:
    collection_interval: 30s
    scrapers:
      cpu:
        metrics:
          system.cpu.utilization:
            enabled: true
      memory:
        metrics:
          system.memory.utilization:
            enabled: true
      disk: {}
      network: {}

processors:
  # 1. 메모리 보호 (항상 첫 번째)
  memory_limiter:
    check_interval: 1s
    limit_mib: 1800
    spike_limit_mib: 400

  # 2. Kubernetes 메타데이터 부착
  k8sattributes:
    auth_type: 'serviceAccount'
    passthrough: false
    extract:
      metadata:
        - k8s.pod.name
        - k8s.pod.uid
        - k8s.namespace.name
        - k8s.deployment.name
        - k8s.statefulset.name
        - k8s.daemonset.name
        - k8s.node.name
      labels:
        - tag_name: service.team
          key: team
          from: pod
        - tag_name: service.component
          key: component
          from: pod

  # 3. 리소스 속성 보장
  resource:
    attributes:
      - key: deployment.environment.name
        value: 'production'
        action: insert
      - key: service.namespace
        value: 'default'
        action: insert

  # 4. 속성 정규화 (Semantic Conventions)
  attributes/normalize:
    actions:
      - key: http.request.method
        action: upsert
        from_attribute: http.method
      - key: http.response.status_code
        action: upsert
        from_attribute: http.status_code

  # 5. 민감 정보 제거
  attributes/redact:
    actions:
      - key: db.query.text
        action: hash # 쿼리를 해시로 대체
      - key: http.request.header.authorization
        action: delete
      - key: http.request.header.cookie
        action: delete

  # 6. 불필요한 Span 필터링
  filter/drop-health:
    error_mode: ignore
    traces:
      span:
        - 'attributes["http.target"] == "/health"'
        - 'attributes["http.target"] == "/readyz"'
        - 'attributes["http.target"] == "/livez"'
        - 'attributes["http.route"] == "/metrics"'

  # 7. 배치 처리
  batch:
    send_batch_size: 2048
    send_batch_max_size: 4096
    timeout: 10s

exporters:
  # Traces → Grafana Tempo
  otlp/tempo:
    endpoint: tempo.observability.svc.cluster.local:4317
    tls:
      insecure: true
    retry_on_failure:
      enabled: true
      initial_interval: 5s
      max_interval: 30s
      max_elapsed_time: 300s
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 5000

  # Metrics → Prometheus Remote Write (Mimir)
  prometheusremotewrite:
    endpoint: http://mimir.observability.svc.cluster.local:9009/api/v1/push
    tls:
      insecure: true
    retry_on_failure:
      enabled: true

  # Logs → Grafana Loki
  otlp/loki:
    endpoint: loki.observability.svc.cluster.local:4317
    tls:
      insecure: true

  # 디버깅용 (개발 환경에서만 활성화)
  debug:
    verbosity: basic
    sampling_initial: 5
    sampling_thereafter: 200

service:
  extensions: [health_check, pprof, zpages]

  telemetry:
    metrics:
      address: 0.0.0.0:8888
      level: detailed
    logs:
      level: info
      encoding: json

  pipelines:
    traces:
      receivers: [otlp]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - attributes/normalize
        - attributes/redact
        - filter/drop-health
        - batch
      exporters: [otlp/tempo]

    metrics:
      receivers: [otlp, prometheus, hostmetrics]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - batch
      exporters: [prometheusremotewrite]

    logs:
      receivers: [otlp]
      processors:
        - memory_limiter
        - k8sattributes
        - resource
        - attributes/redact
        - batch
      exporters: [otlp/loki]

10. 결론: 옵저버빌리티 카르텔로부터의 탈출

10.1 OpenTelemetry가 바꾸는 게임의 규칙

전통적인 APM 시장은 **"옵저버빌리티 카르텔"**과 같은 구조였다. 한 번 특정 벤더의 에이전트를 설치하면, 데이터 형식, 쿼리 언어, 대시보드, 알림 규칙 모두가 해당 벤더에 종속된다. 벤더 교체 비용이 너무 높아 가격이 올라도 참을 수밖에 없는 구조다.

OpenTelemetry는 이 구조를 근본적으로 변화시킨다.

  • 계측(Instrumentation)과 백엔드(Backend)의 분리: OTel SDK로 한 번 계측하면, 백엔드는 Collector 설정만으로 교체 가능
  • 표준 와이어 프로토콜(OTLP): 어떤 벤더도 자체 프로토콜을 강요할 수 없음
  • 커뮤니티 주도: CNCF의 두 번째로 활발한 프로젝트, 500+ 기여자, 주요 벤더 모두 참여

10.2 전략적 가시성의 실질적 효과

이 글에서 다룬 5가지 전략은 단순한 기술적 호기심이 아니라, 실질적인 비즈니스 가치로 이어진다.

전략비즈니스 가치
W3C Baggage멀티테넌트 격리, 사용자 등급별 차별화된 SLA, 비용 귀속
하이브리드 계측비즈니스 로직 병목 30초 내 식별, MTTR(평균 복구 시간) 60% 이상 단축
Tail-based Sampling텔레메트리 비용 80~95% 절감하면서 에러 Trace 100% 보존
Semantic Conventions팀 간 협업 마찰 제거, 통합 대시보드 구축 시간 90% 단축
OTLP 전송 최적화환경별 최적 성능, 네트워크 비용 최소화

10.3 시작을 위한 다음 단계

  1. 지금 당장: 파일럿 서비스 하나에 OTel 자동 계측을 적용하고, OTel Collector를 통해 오픈소스 백엔드(Jaeger 또는 Tempo)로 데이터를 전송한다.
  2. 2주 이내: Semantic Conventions 가이드를 팀에 공유하고, 표준 속성명 사용을 합의한다.
  3. 4주 이내: 비즈니스 크리티컬 서비스에 수동 계측을 추가하여 하이브리드 전략을 검증한다.
  4. 8주 이내: Tail-based sampling을 도입하고 비용 최적화를 시작한다.
  5. 12주 이내: W3C Baggage로 비즈니스 컨텍스트 전파를 구현하여 완전한 옵저버빌리티를 달성한다.

벤더의 관측이 아닌, 우리의 관측을 되찾을 때다. OpenTelemetry는 그 여정의 가장 확실한 출발점이다.


참고 자료