Go에서 Distributed Tracing 구축: 서비스 간 요청 추적에 대한 완전 가이드

발행: (2026년 2월 18일 오전 09:09 GMT+9)
10 분 소요
원문: Dev.to

Source: Dev.to

위의 링크에 있는 전체 글 내용을 제공해 주시면, 해당 내용을 그대로 유지하면서 한국어로 번역해 드리겠습니다. (코드 블록, URL 및 기술 용어는 번역하지 않고 원본 그대로 유지합니다.)

📚 작가 홍보

베스트셀러 작가인 저는 여러분을 **Amazon**에서 제 책들을 살펴보시길 초대합니다.
잊지 말고 **Medium**에서 저를 팔로우하고 응원해 주세요. 감사합니다! 여러분의 응원은 큰 힘이 됩니다!

1. Tracer – 시스템의 핵심 요소

구현은 Tracer 구조체를 중심으로 이루어집니다. 이 구조체는 전체 추적 과정을 관리합니다. 트레이서를 생성할 때 다음을 제공해야 합니다:

  • 서비스 이름 – 트레이스에서 서비스를 식별합니다.
  • 샘플링 비율 – 실제로 기록하고자 하는 요청의 비율입니다 (고 트래픽 시스템에서 모든 요청을 기록하는 것은 비용이 과도하게 많이 듭니다).
tracer := NewTracer("order-service", 0.1) // Sample 10 % of traces

Source:

2. Span – 작업 단위

Span 은 하나의 작업 단위(예: DB 쿼리, HTTP 핸들러)를 나타냅니다. StartSpan 메서드가 바로 그 마법이 시작되는 곳입니다. 이 메서드는:

  1. 전달된 context 에서 부모 span을 찾습니다. 존재한다면, 새로운 span은 그 자식이 되어 트레이스 계층 구조를 형성합니다.
  2. 샘플러에게 이 span을 기록할지 여부를 묻습니다.
func (t *Tracer) StartSpan(ctx context.Context, name string, opts ...SpanOption) (context.Context, *Span) {
    var parentSpanContext trace.SpanContext
    if parent := trace.SpanFromContext(ctx); parent != nil {
        parentSpanContext = parent.SpanContext()
    }
    samplingResult := t.sampler.ShouldSample(SamplingParameters{
        TraceID:        generateTraceID(),
        ParentContext:  parentSpanContext,
        Name:           name,
        Attributes:     make(map[string]interface{}),
    })
    // ... create span based on the sampling decision
}

No‑op Span

샘플러가 기록하지 않기로 결정하면, no‑op span을 반환합니다. 이 span은 아무 작업도 하지 않으며, 오버헤드를 거의 0에 가깝게 유지하면서 동일한 코드 경로가 실행될 수 있게 합니다.

Real Span & Object Pool

샘플러가 기록을 허용하면, sync.Pool 에서 span을 가져옵니다. span 객체를 재사용하면 Go 가비지 컬렉터에 대한 부담을 줄일 수 있습니다.

span := t.spanPool.Get().(*Span)
// ... configure the span
return ctx, span

Span Lifecycle

span은 다음과 같은 정보를 저장합니다:

  • 고유 ID
  • 부모 ID
  • 시작 및 종료 타임스탬프
  • 속성(키‑값 쌍, 예: http.method="GET" 또는 db.query="SELECT * FROM users")

작업이 끝나면 EndSpan을 호출합니다:

  • 지속 시간 계산
  • 최종 상태 설정(성공, 오류 등)
  • span을 버퍼링된 채널에 보내어 내보내기 수행
  • span을 초기화하고 풀에 반환

3. 컨텍스트 전파 – 서비스 간 트레이스 데이터 전달

전파는 트레이스 정보를 한 서비스에서 다른 서비스로 전달합니다. HTTP의 경우, 트레이스 ID와 스팬 ID가 헤더에 인코딩됩니다.

들어오는 컨텍스트 추출

ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

나가는 컨텍스트 주입

tracer.Inject(ctx, propagation.HeaderCarrier(r.Header))

같은 패턴이 gRPC, 메시지 큐, 혹은 모든 전송 방식에서도 작동합니다—적절한 캐리어 타입만 사용하면 됩니다.

4. 샘플링 전략

4.1 확률 샘플링

가장 간단한 방법: 새로운 트레이스마다 주사위를 굴린다. 0.1 (10 %) 비율일 경우, 무작위 숫자가 0.1 이하이면 해당 트레이스가 샘플링된다.

  • 장점: 이해하기 쉽고 예측 가능하다.
  • 단점: 트래픽 급증 시, 거대한 양의 10 %만으로도 백엔드에 과부하를 일으킬 수 있다.

4.2 레이트 제한 샘플링

초당 스팬 수를 제한하는 보다 정교한 접근 방식이다.

  • 크레딧 시스템 – 매초 샘플러는 고정된 크레딧 수(예: 100)를 획득한다.
  • 스팬이 생성될 때마다 한 개의 크레딧을 사용한다. 크레딧이 남아 있지 않으면 해당 스팬은 삭제된다.

이는 급격한 트래픽 급증 상황에서도 트레이싱 백엔드에 가해지는 부하를 제한한다.

5. 모두 합치기

func handler(w http.ResponseWriter, r *http.Request) {
    // 1️⃣ Extract incoming trace context
    ctx := tracer.Extract(r.Context(), propagation.HeaderCarrier(r.Header))

    // 2️⃣ Start a new span for this handler
    ctx, span := tracer.StartSpan(ctx, "http.handler", trace.WithAttributes(
        attribute.String("http.method", r.Method),
        attribute.String("http.path", r.URL.Path),
    ))
    defer span.EndSpan()

    // 3️⃣ Do some work (e.g., DB query)
    doDBWork(ctx)

    // 4️⃣ Call downstream service, injecting trace context
    req, _ := http.NewRequestWithContext(ctx, "GET", "http://service-b/api", nil)
    tracer.Inject(ctx, propagation.HeaderCarrier(req.Header))
    http.DefaultClient.Do(req)

    // 5️⃣ Respond to the client
    fmt.Fprintln(w, "OK")
}

TL;DR

  • Tracer – 중앙 관리자, 서비스 이름 및 샘플링 비율을 보유합니다.
  • Span – 작업 단위; StartSpan으로 생성하고 EndSpan으로 종료합니다.
  • Propagation – 들어오는 헤더를 Extract, 나가는 헤더를 Inject.
  • Sampling – 데이터 양을 제어하기 위한 확률 vs. 레이트‑리밋.

이러한 구성 요소들을 사용하면 어떤 Go 서비스든 계측하고, 엔드‑투‑엔드 가시성을 확보하며, 오버헤드를 제어할 수 있습니다. 즐거운 트레이싱!

레이트‑리밋 샘플러

만약 크레딧이 남아 있지 않다면, 새로운 스팬은 더 많은 크레딧이 쌓일 때까지 샘플링되지 않습니다. 이는 데이터 양에 대한 엄격한 상한선을 제공합니다.

func (rls *RateLimitingSampler) ShouldSample(params SamplingParameters) SamplingResult {
    rls.mu.Lock()
    defer rls.mu.Unlock()
    // Update credits based on time passed
    now := time.Now()
    elapsed := now.Sub(rls.lastCreditUpdate).Seconds()
    rls.currentCredits += elapsed * rls.creditsPerSecond
    // Spend a credit if we have one
    if rls.currentCredits >= 1.0 {
        rls.currentCredits -= 1.0
        return SamplingResult{Decision: RecordAndSample}
    }
    return SamplingResult{Decision: Drop}
}

더 똑똑한 시스템은 적응형 샘플링을 사용할 수 있습니다. 이는 HTTP 오류 코드가 증가하는 것을 감지하면 자동으로 샘플링 비율을 높여, 장애 발생 시 가시성을 향상시킵니다. 샘플러 인터페이스는 이러한 다양한 전략을 손쉽게 플러그인할 수 있게 합니다.

스팬 내보내기

스팬을 수집하는 것은 한 가지 일이고, 이를 유용한 곳으로 보내는 것은 또 다른 일입니다. TraceExporter가 이를 처리합니다. 스팬은 버퍼링된 채널(exporterCh)에 전송됩니다. 별도의 고루틴이 이 채널에서 읽어 스팬을 배치로 묶습니다. 배치 처리는 효율성을 위해 매우 중요합니다—HTTP 요청당 하나의 스팬만 보내면 비효율적이기 때문입니다. 스팬을 그룹화함으로써 네트워크 오버헤드를 크게 줄일 수 있습니다.

배치 프로세서는 배치가 가득 찰 때까지(예: 100개의 스팬) 또는 타이머가 작동할 때까지(예: 매 5초) 기다립니다. 이렇게 하면 트래픽이 많을 때는 스팬이 빠르게 내보내지고, 트래픽이 적을 때는 부분 배치가 영원히 대기하지 않게 됩니다.

func (te *TraceExporter) processBatches() {
    batch := make([]*SpanData, 0, te.batchSize)
    for {
        select {
        case span := <-te.batchCh:
            batch = append(batch, span)
            if len(batch) >= te.batchSize {
                te.sendBatch(batch)
                batch = batch[:0]
            }
        case <-time.After(te.flushInterval):
            if len(batch) > 0 {
                te.sendBatch(batch)
                batch = batch[:0]
            }
        }
    }
}
0 조회
Back to Blog

관련 글

더 보기 »