🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

분산 추적 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 22. · 4 Views

분산 추적 완벽 가이드

마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.


목차

  1. 분산_추적이란
  2. Trace와_Span_개념
  3. Trace_ID_전파
  4. Span_계층_구조
  5. 추적_컨텍스트
  6. 샘플링_전략

1. 분산 추적이란

어느 날 김개발 씨는 회사의 마이크로서비스 시스템에서 발생한 장애 알림을 받았습니다. "주문 처리가 느립니다"라는 간단한 메시지였지만, 문제를 찾기가 막막했습니다.

주문 서비스, 결제 서비스, 재고 서비스, 배송 서비스까지 무려 네 개의 서비스를 거쳐야 하는데, 어디서 문제가 생긴 걸까요?

분산 추적은 마이크로서비스 환경에서 하나의 요청이 여러 서비스를 거쳐가는 전체 경로를 추적하는 기술입니다. 마치 택배 송장 번호로 물건의 이동 경로를 확인하는 것처럼, 요청에 고유한 식별자를 부여하여 전체 흐름을 파악합니다.

이를 통해 성능 병목 지점을 찾고, 장애가 발생한 정확한 위치를 특정할 수 있습니다.

다음 코드를 살펴봅시다.

// 기본 분산 추적 초기화
const tracer = require('opentelemetry').trace.getTracer('order-service');

async function processOrder(orderId) {
  // 새로운 추적 시작
  const span = tracer.startSpan('process-order');

  try {
    // 주문 처리 로직
    console.log(`Processing order: ${orderId}`);
    console.log(`Trace ID: ${span.spanContext().traceId}`);

    // 결제 서비스 호출 (추적 컨텍스트 자동 전파)
    await callPaymentService(orderId);

    span.setStatus({ code: 0 }); // 성공
  } catch (error) {
    span.setStatus({ code: 2, message: error.message }); // 실패
    throw error;
  } finally {
    span.end(); // 추적 종료
  }
}

김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 처음 회사에 입사했을 때는 하나의 거대한 모놀리식 애플리케이션을 다뤘습니다.

그때는 로그 파일 하나만 열어보면 문제를 금방 찾을 수 있었습니다. 하지만 지금은 상황이 완전히 달라졌습니다.

회사가 마이크로서비스 아키텍처로 전환하면서 시스템이 수십 개의 작은 서비스로 쪼개졌습니다. 하나의 주문 요청이 처리되려면 주문 서비스, 결제 서비스, 재고 서비스, 배송 서비스를 차례로 거쳐야 합니다.

오늘 아침, 김개발 씨의 모니터에 빨간 불이 켜졌습니다. "주문 처리 시간이 평소보다 3배 느립니다"라는 경고 메시지가 떴습니다.

하지만 어느 서비스가 문제인지 알 수 없었습니다. 각 서비스의 로그를 일일이 뒤져봐야 할까요?

바로 그때 선배 개발자 박시니어 씨가 다가왔습니다. "김개발 씨, 분산 추적 대시보드 보셨어요?

한눈에 어디가 문제인지 알 수 있는데요." 그렇다면 분산 추적이란 정확히 무엇일까요? 쉽게 비유하자면, 분산 추적은 마치 택배 송장 번호와 같습니다.

여러분이 온라인 쇼핑몰에서 물건을 주문하면 택배 송장 번호를 받습니다. 이 번호 하나로 물건이 물류창고에서 출발해서, 지역 허브를 거쳐, 마지막으로 여러분의 집 앞까지 도착하는 전체 여정을 추적할 수 있습니다.

분산 추적도 이와 똑같은 방식으로 작동합니다. 분산 추적이 없던 시절에는 어땠을까요?

개발자들은 각 서비스의 로그 파일을 일일이 열어봐야 했습니다. 주문 서비스 로그를 열어서 특정 주문 번호를 검색하고, 그 시간대에 결제 서비스 로그를 또 열어서 같은 주문 번호를 찾고, 재고 서비스 로그도 뒤지고...

이 과정이 얼마나 고통스러운지 상상이 되시나요? 더 큰 문제는 시간 정보가 정확하지 않다는 점이었습니다.

각 서비스가 서로 다른 서버에서 실행되고, 시간 동기화가 완벽하지 않으면 로그의 순서조차 제대로 파악할 수 없었습니다. 한 번은 김개발 씨의 동료가 이런 방식으로 장애 원인을 찾는 데 무려 6시간이 걸렸다고 합니다.

바로 이런 문제를 해결하기 위해 분산 추적이 등장했습니다. 분산 추적을 사용하면 하나의 요청에 고유한 추적 ID가 부여됩니다.

이 ID는 요청이 모든 서비스를 거쳐가는 동안 함께 전달됩니다. 마치 택배 송장 번호처럼 말이죠.

덕분에 개발자는 대시보드에서 한 번의 클릭만으로 전체 요청 흐름을 시각적으로 확인할 수 있습니다. 또한 각 서비스에서 소요된 시간을 정확하게 측정할 수 있습니다.

주문 서비스에서 100ms, 결제 서비스에서 50ms, 재고 서비스에서 2000ms가 걸렸다면, 재고 서비스가 병목 지점임을 즉시 알 수 있습니다. 무엇보다 장애가 발생했을 때 어느 서비스에서 문제가 생겼는지 몇 초 만에 파악할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 2번째 줄에서 tracer 객체를 생성합니다.

이것은 추적을 위한 핵심 도구입니다. 6번째 줄에서는 'process-order'라는 이름의 span을 시작합니다.

Span은 하나의 작업 단위를 의미하는데, 다음 섹션에서 자세히 다루겠습니다. 10번째 줄을 보면 현재 span의 traceId를 출력합니다.

이 ID가 바로 택배 송장 번호와 같은 역할을 합니다. 13번째 줄에서 결제 서비스를 호출할 때, 이 traceId가 자동으로 함께 전달됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 넷플릭스 같은 스트리밍 서비스를 개발한다고 가정해봅시다.

사용자가 영화를 재생할 때 인증 서비스, 추천 서비스, 과금 서비스, 영상 스트리밍 서비스가 순차적으로 호출됩니다. 만약 영상 재생이 느리다는 고객 불만이 들어오면, 분산 추적 대시보드를 열어서 해당 요청의 추적 ID를 검색하기만 하면 됩니다.

어느 서비스에서 지연이 발생했는지, 심지어 어떤 데이터베이스 쿼리가 느렸는지까지 상세하게 확인할 수 있습니다. 많은 글로벌 기업들이 이런 패턴을 적극적으로 사용하고 있습니다.

구글은 Dapper, 트위터는 Zipkin, 우버는 Jaeger라는 분산 추적 시스템을 개발해서 사용합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 모든 요청을 추적하려고 하는 것입니다. 트래픽이 많은 서비스에서 모든 요청을 추적하면 추적 데이터 자체가 너무 많아져서 오히려 성능 문제가 발생할 수 있습니다.

따라서 샘플링을 통해 일부 요청만 추적하는 전략이 필요합니다. 예를 들어 전체 요청의 1%만 추적하는 식입니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨가 보여준 분산 추적 대시보드를 보고 김개발 씨는 감탄했습니다.

"와, 정말 한눈에 보이네요!" 화면에는 주문 요청이 각 서비스를 거쳐가는 과정이 폭포수 차트처럼 시각화되어 있었고, 재고 서비스에서 유독 긴 막대가 보였습니다. 분산 추적을 제대로 이해하면 마이크로서비스 환경에서 훨씬 효율적으로 문제를 해결할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - OpenTelemetry는 분산 추적의 표준 라이브러리입니다. 벤더 중립적이라 나중에 추적 백엔드를 바꿔도 코드 수정이 최소화됩니다.

  • 로컬 개발 환경에서는 Jaeger를 Docker로 쉽게 실행할 수 있습니다. 실제 추적 데이터를 시각적으로 확인하면서 학습하세요.
  • 분산 추적은 로그, 메트릭과 함께 **관측성(Observability)**의 3대 요소입니다. 세 가지를 함께 활용하면 시스템을 더 깊이 이해할 수 있습니다.

2. Trace와 Span 개념

김개발 씨가 분산 추적 대시보드를 자세히 들여다보던 중 궁금한 점이 생겼습니다. "이 화면에서 긴 막대 하나하나가 뭔가요?" 박시니어 씨가 웃으며 답했습니다.

"그게 바로 Span이에요. 그리고 이 전체 흐름을 Trace라고 하죠."

Trace는 하나의 요청이 시스템을 통과하는 전체 여정을 나타냅니다. Span은 그 여정 속의 개별 작업 단위를 의미합니다.

하나의 Trace는 여러 개의 Span으로 구성되며, 각 Span은 시작 시간, 종료 시간, 소요 시간, 서비스 이름, 작업 이름 등의 정보를 포함합니다. 마치 여행 일정에서 전체 여행이 Trace라면, 비행기 탑승, 호텔 체크인, 관광 등 각각의 활동이 Span인 것과 같습니다.

다음 코드를 살펴봅시다.

// Trace와 Span의 관계
const tracer = require('opentelemetry').trace.getTracer('order-service');

async function createOrder(userId, items) {
  // 최상위 Span (Root Span) - 전체 주문 프로세스
  const parentSpan = tracer.startSpan('create-order');

  try {
    // 첫 번째 자식 Span - 재고 확인
    const inventorySpan = tracer.startSpan('check-inventory', {
      parent: parentSpan
    });
    await checkInventory(items);
    inventorySpan.end();

    // 두 번째 자식 Span - 결제 처리
    const paymentSpan = tracer.startSpan('process-payment', {
      parent: parentSpan
    });
    await processPayment(userId, calculateTotal(items));
    paymentSpan.end();

    // 세 번째 자식 Span - 주문 저장
    const saveSpan = tracer.startSpan('save-order', {
      parent: parentSpan
    });
    const order = await saveToDatabase({ userId, items });
    saveSpan.end();

    return order;
  } finally {
    parentSpan.end(); // 최상위 Span 종료
  }
}

김개발 씨는 분산 추적의 기본 개념은 이해했지만, 대시보드에 표시된 복잡한 도표는 여전히 어려웠습니다. 화면에는 긴 막대들이 폭포수처럼 배열되어 있었고, 어떤 막대는 다른 막대를 포함하고 있었습니다.

"왜 어떤 막대는 들여쓰기가 되어 있나요?" 김개발 씨가 물었습니다. 박시니어 씨는 커피를 한 모금 마시고 설명을 시작했습니다.

그렇다면 TraceSpan은 정확히 무엇일까요? 쉽게 비유하자면, Trace는 여행 전체 일정이고, Span은 그 안의 각각의 활동입니다.

예를 들어 여러분이 제주도 3박 4일 여행을 간다고 해봅시다. 이 전체 여행이 하나의 Trace입니다.

그 안에는 "인천공항에서 비행기 탑승", "제주공항 도착", "렌터카 픽업", "호텔 체크인", "성산일출봉 관광" 등 여러 활동이 있습니다. 이 각각의 활동이 바로 Span입니다.

더 중요한 점은 Span들이 계층 구조를 가진다는 것입니다. "제주도 여행" 안에 "첫째 날 일정"이 있고, 그 안에 또 "아침: 호텔 조식", "오전: 성산일출봉", "점심: 해물 칼국수" 같은 세부 활동이 있는 것처럼 말이죠.

Trace와 Span 개념이 없던 시절에는 어땠을까요? 개발자들은 각 서비스의 시작과 끝 시간만 기록했습니다.

"주문 서비스가 500ms 걸렸다"는 것은 알 수 있었지만, 그 500ms 안에서 무슨 일이 일어났는지는 알 수 없었습니다. 데이터베이스 조회가 느렸는지, 외부 API 호출이 느렸는지, 아니면 내부 로직이 복잡해서 느렸는지 파악할 방법이 없었습니다.

더 큰 문제는 여러 서비스 간의 관계를 이해하기 어렵다는 점이었습니다. 주문 서비스가 결제 서비스를 호출하고, 결제 서비스가 다시 카드사 API를 호출한다면, 이 세 단계의 연관성을 추적하기가 거의 불가능했습니다.

바로 이런 문제를 해결하기 위해 Trace와 Span이라는 개념이 등장했습니다. Trace를 사용하면 하나의 요청에 대한 전체 스토리를 하나로 묶을 수 있습니다.

모든 서비스를 거쳐가는 과정이 하나의 Trace 안에 기록됩니다. 또한 Span을 사용하면 각 작업을 세밀하게 측정할 수 있습니다.

주문 서비스 내부에서도 "재고 확인 Span", "결제 처리 Span", "주문 저장 Span"을 각각 만들어서 어느 부분이 느린지 정확히 파악할 수 있습니다. 무엇보다 부모-자식 관계를 통해 계층적 시각화가 가능하다는 큰 이점이 있습니다.

대시보드에서 들여쓰기로 표현되는 이 계층 구조 덕분에 개발자는 직관적으로 호출 흐름을 이해할 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 6번째 줄에서 parentSpan이라는 최상위 Span을 만듭니다. 이것을 Root Span이라고 부릅니다.

하나의 Trace에는 반드시 하나의 Root Span이 있습니다. 10번째 줄을 보면 inventorySpan을 만들면서 parent: parentSpan 옵션을 전달합니다.

이렇게 하면 inventorySpan이 parentSpan의 자식이 됩니다. 13번째 줄에서 작업이 끝나면 inventorySpan.end()를 호출하여 Span을 종료합니다.

이때 종료 시간이 자동으로 기록됩니다. 17번째 줄과 24번째 줄에서도 같은 방식으로 paymentSpan과 saveSpan을 만듭니다.

세 개의 자식 Span이 모두 같은 부모 parentSpan을 가지므로, 이들은 형제 관계가 됩니다. 마지막으로 32번째 줄에서 parentSpan을 종료하면, 이 Trace의 전체 소요 시간이 계산됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 은행 앱의 계좌 이체 기능을 개발한다고 가정해봅시다.

사용자가 이체 버튼을 누르면 "계좌 이체 Trace"가 시작됩니다. 그 안에는 "인증 Span"(지문 인식), "잔액 확인 Span"(출금 계좌 조회), "이체 실행 Span"(입출금 처리), "알림 전송 Span"(푸시 알림) 등이 포함됩니다.

만약 고객이 "이체가 느리다"고 불만을 제기하면, 해당 Trace를 열어서 어느 Span에서 시간이 오래 걸렸는지 확인합니다. 알고 보니 "알림 전송 Span"에서 10초가 걸렸다면, 푸시 알림 서비스에 문제가 있음을 즉시 파악할 수 있습니다.

많은 금융 기업들이 이런 방식으로 고객 경험을 개선하고 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 너무 세밀하게 Span을 만드는 것입니다. 예를 들어 변수 할당 하나하나마다 Span을 만들면, Span이 너무 많아져서 오히려 성능에 악영향을 줍니다.

따라서 의미 있는 작업 단위에만 Span을 만들어야 합니다. 일반적으로 데이터베이스 쿼리, 외부 API 호출, 중요한 비즈니스 로직 등에 Span을 사용하는 것이 좋습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 대시보드를 다시 보았습니다.

"아, 이제 이해했어요! 이 큰 막대가 전체 Trace고, 들여쓰기된 작은 막대들이 각각의 Span이군요!" Trace와 Span을 제대로 이해하면 복잡한 마이크로서비스 시스템도 명확하게 파악할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Span 이름은 동사 형태로 짓는 것이 관례입니다. 'get-user', 'save-order', 'send-email' 같은 식으로 작성하세요.

  • Span에는 attributes를 추가할 수 있습니다. span.setAttribute('user.id', userId) 같은 방식으로 추가 정보를 기록하면 나중에 디버깅할 때 매우 유용합니다.
  • Span Events를 활용하면 Span 내부의 특정 시점에 발생한 이벤트를 기록할 수 있습니다. 예를 들어 '캐시 미스 발생', '재시도 시작' 같은 정보를 남길 수 있습니다.

3. Trace ID 전파

김개발 씨는 코드를 작성하다가 의문이 들었습니다. "주문 서비스에서 만든 Trace ID가 어떻게 결제 서비스까지 자동으로 전달되는 거지?" 각 서비스는 독립적인 프로세스로 실행되는데, 심지어 서로 다른 서버에서 돌아갈 수도 있습니다.

이 ID가 마법처럼 전파되는 비밀이 궁금해졌습니다.

Trace ID 전파는 하나의 요청이 여러 서비스를 거쳐갈 때 동일한 Trace ID를 유지하는 메커니즘입니다. HTTP 헤더, 메시지 큐의 메타데이터, gRPC 메타데이터 등을 통해 Trace ID와 Span ID가 다음 서비스로 전달됩니다.

이를 통해 분산 시스템 전체에서 하나의 요청을 일관되게 추적할 수 있습니다.

다음 코드를 살펴봅시다.

// HTTP 헤더를 통한 Trace ID 전파
const { propagation, context } = require('@opentelemetry/api');
const axios = require('axios');

async function callPaymentService(orderId, amount) {
  const span = tracer.startSpan('call-payment-service');

  try {
    // 현재 컨텍스트를 HTTP 헤더로 변환
    const headers = {};
    propagation.inject(context.active(), headers);

    // 헤더에 traceparent가 자동으로 추가됨
    // 예: traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
    console.log('Propagated headers:', headers);

    // 결제 서비스 호출 시 헤더 포함
    const response = await axios.post('http://payment-service/api/pay', {
      orderId,
      amount
    }, { headers });

    return response.data;
  } finally {
    span.end();
  }
}

// 결제 서비스에서 Trace ID 추출
function paymentServiceHandler(req, res) {
  // HTTP 헤더에서 컨텍스트 추출
  const ctx = propagation.extract(context.active(), req.headers);

  // 추출된 컨텍스트를 사용하여 새로운 Span 생성
  const span = tracer.startSpan('process-payment', undefined, ctx);

  // 이제 이 Span은 원래 Trace의 일부가 됨
  console.log('Same Trace ID:', span.spanContext().traceId);

  // 결제 처리 로직...
  span.end();
}

김개발 씨는 코드를 실행하면서 신기한 점을 발견했습니다. 주문 서비스와 결제 서비스가 완전히 다른 프로세스인데도, 분산 추적 대시보드에서 두 서비스의 Span이 하나의 Trace로 연결되어 표시됐습니다.

"이게 어떻게 가능한 거지?" 김개발 씨는 네트워크 탭을 열어서 HTTP 요청을 살펴봤습니다. 그러다가 눈에 띄는 헤더를 발견했습니다.

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 이게 뭘까요? 박시니어 씨가 지나가다 김개발 씨의 화면을 보고 웃었습니다.

"아, Trace ID 전파를 발견하셨네요. 분산 추적의 핵심 비밀이죠." 그렇다면 Trace ID 전파란 정확히 무엇일까요?

쉽게 비유하자면, Trace ID 전파는 마치 우체국의 등기우편 번호와 같습니다. 여러분이 서울에서 부산으로 등기우편을 보낸다고 해봅시다.

편지는 서울 우체국에서 출발해서, 서울 집배국, 부산 집배국, 부산 우체국을 거칩니다. 각 우체국이 독립적으로 운영되지만, 등기번호 하나로 전체 배송 과정이 추적됩니다.

이 등기번호가 각 우체국으로 전달되는 방식이 바로 Trace ID 전파와 같습니다. Trace ID 전파가 없던 시절에는 어땠을까요?

각 서비스가 자기만의 독립적인 Trace를 만들었습니다. 주문 서비스에서 trace-12345, 결제 서비스에서 trace-67890, 재고 서비스에서 trace-abcde처럼 완전히 다른 ID가 생성됐습니다.

결과적으로 하나의 요청이지만, 분산 추적 시스템에서는 세 개의 독립적인 Trace로 기록됐습니다. 개발자가 전체 흐름을 파악하려면 각 Trace를 일일이 열어보고, 시간 정보와 주문 번호 같은 힌트를 조합해서 수동으로 연결해야 했습니다.

이것은 마치 퍼즐 조각을 맞추는 것과 같았고, 매우 비효율적이었습니다. 바로 이런 문제를 해결하기 위해 Trace ID 전파가 등장했습니다.

Trace ID 전파를 사용하면 첫 번째 서비스에서 생성된 Trace ID가 모든 후속 서비스로 자동으로 전달됩니다. HTTP 통신을 사용한다면 traceparent 헤더에 Trace ID가 포함됩니다.

메시지 큐를 사용한다면 메시지의 메타데이터에 포함됩니다. gRPC를 사용한다면 gRPC 메타데이터에 포함됩니다.

또한 표준화된 포맷 덕분에 서로 다른 언어로 작성된 서비스들도 문제없이 Trace ID를 주고받을 수 있습니다. 주문 서비스는 Node.js, 결제 서비스는 Java, 재고 서비스는 Go로 작성되어 있어도, 모두 같은 W3C Trace Context 표준을 따르기 때문에 완벽하게 연동됩니다.

무엇보다 개발자가 수동으로 ID를 전달할 필요가 없다는 큰 이점이 있습니다. OpenTelemetry 같은 라이브러리가 자동으로 헤더를 추가하고 추출해주므로, 개발자는 비즈니스 로직에만 집중할 수 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 10번째 줄에서 빈 headers 객체를 만듭니다.

11번째 줄의 propagation.inject()가 핵심입니다. 이 함수는 현재 활성화된 Span의 컨텍스트를 읽어서, headers 객체에 traceparent 필드를 자동으로 추가합니다.

14번째 줄의 주석을 보면 traceparent 헤더의 예시가 나옵니다. 이 헤더는 네 부분으로 구성됩니다.

00은 버전, 4bf92f3577b34da6a3ce929d0e0e4736은 Trace ID, 00f067aa0ba902b7은 부모 Span ID, 01은 플래그입니다. 18번째 줄에서 axios로 HTTP 요청을 보낼 때 이 headers를 포함시킵니다.

결제 서비스는 이 헤더를 받게 됩니다. 32번째 줄을 보면 결제 서비스 쪽 코드입니다.

propagation.extract()를 사용해서 HTTP 헤더에서 컨텍스트를 추출합니다. 35번째 줄에서 이 컨텍스트를 세 번째 인자로 전달하여 새로운 Span을 만들면, 자동으로 원래 Trace의 일부가 됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 배달 앱을 개발한다고 가정해봅시다.

사용자가 주문을 하면 주문 서비스, 결제 서비스, 음식점 알림 서비스, 배달원 매칭 서비스, 위치 추적 서비스가 순차적으로 호출됩니다. 각 서비스는 서로 다른 팀이 관리하고, 서로 다른 언어로 작성되어 있을 수 있습니다.

만약 "주문이 음식점에 전달되지 않았다"는 불만이 들어오면, 해당 주문의 Trace ID로 검색합니다. 전체 흐름을 보니 결제는 성공했지만, 음식점 알림 서비스에서 에러가 발생한 것을 확인할 수 있습니다.

Trace ID 전파 덕분에 다섯 개의 서비스를 거친 복잡한 흐름도 하나의 Trace로 깔끔하게 추적됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 레거시 시스템과 통합할 때 발생합니다. 오래된 서비스가 traceparent 헤더를 이해하지 못하면, Trace ID 전파가 그 지점에서 끊깁니다.

따라서 점진적으로 마이그레이션할 때는 전파 중단 지점을 명확히 파악하고, 필요하면 수동으로 ID를 기록하는 임시 방편을 마련해야 합니다. 또한 비동기 처리에서는 주의가 필요합니다.

메시지 큐를 사용할 때는 메시지를 발행하는 시점에 컨텍스트를 메타데이터에 명시적으로 포함시켜야 합니다. 그렇지 않으면 컨슈머 쪽에서 새로운 Trace가 시작되어 연결이 끊깁니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. traceparent 헤더의 비밀을 알게 된 김개발 씨는 직접 테스트해보았습니다.

주문 서비스에서 로그를 찍고, 결제 서비스에서도 로그를 찍었더니 정말 같은 Trace ID가 출력됐습니다. Trace ID 전파를 제대로 이해하면 분산 시스템에서 요청의 전체 여정을 완벽하게 추적할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - W3C Trace Context는 업계 표준입니다. 이 표준을 따르면 Zipkin, Jaeger, AWS X-Ray 등 어떤 백엔드를 사용하든 호환됩니다.

  • 메시지 큐 사용 시 propagation.inject()를 메시지 발행 전에 호출하세요. 많은 개발자들이 이 단계를 놓쳐서 전파가 끊깁니다.
  • 로드 밸런서나 API 게이트웨이도 traceparent 헤더를 전달하도록 설정해야 합니다. NGINX나 Kong 같은 도구들은 이를 위한 플러그인을 제공합니다.

4. Span 계층 구조

김개발 씨는 분산 추적 대시보드에서 복잡한 폭포수 차트를 보며 감탄했습니다. 어떤 Span은 다른 Span을 완전히 감싸고 있었고, 어떤 Span들은 나란히 배치되어 있었습니다.

"이 계층 구조가 어떻게 만들어지는 거죠?" 박시니어 씨가 답했습니다. "부모-자식 관계를 명시하면 자동으로 계층이 만들어져요."

Span 계층 구조는 Span들 간의 부모-자식 관계를 통해 만들어지는 트리 구조입니다. 각 Span은 자신의 부모 Span ID를 기록하며, 이를 통해 호출 스택을 시각화할 수 있습니다.

Root Span은 부모가 없는 최상위 Span이며, 모든 다른 Span들은 직접적이거나 간접적으로 Root Span의 자손입니다. 이 계층 구조를 통해 복잡한 요청 흐름도 직관적으로 이해할 수 있습니다.

다음 코드를 살펴봅시다.

// Span 계층 구조 만들기
const tracer = require('opentelemetry').trace.getTracer('ecommerce');

async function fulfillOrder(orderId) {
  // Level 1: Root Span
  const rootSpan = tracer.startSpan('fulfill-order');

  try {
    // Level 2: 직접 자식들 (병렬 처리)
    const validateSpan = tracer.startSpan('validate-order', {
      parent: rootSpan
    });
    await validateOrder(orderId);
    validateSpan.end();

    // Level 2: 또 다른 직접 자식
    const processSpan = tracer.startSpan('process-order', {
      parent: rootSpan
    });

    // Level 3: processSpan의 자식들 (순차 처리)
    const inventorySpan = tracer.startSpan('reserve-inventory', {
      parent: processSpan
    });
    await reserveInventory(orderId);
    inventorySpan.end();

    const paymentSpan = tracer.startSpan('charge-payment', {
      parent: processSpan
    });

    // Level 4: paymentSpan의 자식 (중첩 깊이 증가)
    const cardSpan = tracer.startSpan('authorize-card', {
      parent: paymentSpan
    });
    await authorizeCard();
    cardSpan.end();

    paymentSpan.end();
    processSpan.end();

    // Level 2: 마지막 직접 자식
    const notifySpan = tracer.startSpan('send-notifications', {
      parent: rootSpan
    });
    await sendConfirmationEmail(orderId);
    notifySpan.end();

  } finally {
    rootSpan.end();
  }
}

김개발 씨는 점점 더 복잡한 기능을 개발하게 되었습니다. 간단한 CRUD 작업을 넘어서, 여러 단계를 거치는 복잡한 비즈니스 프로세스를 구현해야 했습니다.

주문 처리 하나만 해도 검증, 재고 예약, 결제, 배송 준비, 알림 발송 등 수많은 단계가 있었습니다. 처음에 김개발 씨는 모든 Span을 평평하게 만들었습니다.

Root Span 하나 아래에 모든 작업을 자식으로 배치했습니다. 하지만 대시보드를 보니 뭔가 이상했습니다.

실제로는 "결제" 안에서 "카드 승인"이 일어나는데, 화면에서는 둘이 형제처럼 나란히 표시됐습니다. 박시니어 씨가 코드를 리뷰하다가 말했습니다.

"김개발 씨, Span 계층 구조를 제대로 활용해보세요. 실제 비즈니스 흐름을 그대로 반영할 수 있어요." 그렇다면 Span 계층 구조란 정확히 무엇일까요?

쉽게 비유하자면, Span 계층 구조는 마치 회사 조직도와 같습니다. CEO가 있고, 그 아래 부사장들이 있고, 부사장 아래 팀장들이 있고, 팀장 아래 팀원들이 있습니다.

각 사람은 정확히 하나의 상사를 가집니다(CEO 제외). 이런 식으로 명확한 보고 체계가 만들어집니다.

Span도 마찬가지로 각 Span은 정확히 하나의 부모를 가지며(Root Span 제외), 이를 통해 명확한 계층 구조가 만들어집니다. Span 계층 구조가 제대로 구성되지 않으면 어떻게 될까요?

모든 Span이 Root Span의 직접 자식이 되어 버립니다. 이것을 flat structure라고 부릅니다.

대시보드에서 보면 수십 개의 Span이 모두 같은 깊이로 나열되어, 어떤 작업이 어떤 작업 안에서 일어났는지 알 수 없습니다. 예를 들어 "주문 처리"라는 큰 작업이 있고, 그 안에 "재고 확인", "결제 처리", "배송 준비"가 있다고 해봅시다.

계층 구조가 없으면 네 개의 작업이 모두 동등하게 보입니다. 하지만 실제로는 "주문 처리"가 나머지 세 작업을 포함하는 상위 개념입니다.

이런 관계를 표현하지 못하면 코드의 실제 흐름을 이해하기 어렵습니다. 바로 이런 문제를 해결하기 위해 Span 계층 구조가 중요합니다.

계층 구조를 제대로 만들면 대시보드에서 들여쓰기로 관계가 명확하게 표시됩니다. "주문 처리" Span이 가장 왼쪽에 있고, 그 안의 "재고 확인", "결제 처리", "배송 준비"가 한 단계 들여써져 표시됩니다.

만약 "결제 처리" 안에 "카드 승인"이라는 세부 작업이 있다면, 이것은 두 단계 들여써져 표시됩니다. 또한 시간 관계도 자동으로 계산됩니다.

부모 Span의 소요 시간은 모든 자식 Span의 시간을 포함합니다. 예를 들어 "결제 처리"가 500ms 걸렸고, 그 안의 "카드 승인"이 300ms 걸렸다면, 나머지 200ms는 "결제 처리"의 다른 로직에 소요된 것입니다.

무엇보다 문제 진단이 훨씬 쉬워집니다. 대시보드에서 느린 Span을 클릭하면 그 자식 Span들을 펼쳐볼 수 있습니다.

마치 폴더를 여는 것처럼 계층을 따라 내려가면서 정확히 어느 부분이 느린지 찾을 수 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 6번째 줄에서 rootSpan을 만듭니다. 이것은 Level 1, 즉 최상위 Span입니다.

10번째 줄의 validateSpan과 17번째 줄의 processSpan은 모두 rootSpan을 부모로 가지므로 Level 2 Span입니다. 이 둘은 형제 관계입니다.

22번째 줄의 inventorySpan은 processSpan을 부모로 가지므로 Level 3 Span입니다. 여기서 계층이 한 단계 더 깊어집니다.

28번째 줄의 paymentSpan도 마찬가지로 Level 3입니다. 33번째 줄의 cardSpan이 흥미롭습니다.

이것은 paymentSpan을 부모로 가지므로 Level 4 Span입니다. 중첩 깊이가 4단계까지 깊어진 것입니다.

이런 식으로 필요한 만큼 계층을 깊게 만들 수 있습니다. 중요한 것은 Span을 종료하는 순서입니다.

37번째 줄에서 cardSpan을 먼저 종료하고, 39번째 줄에서 paymentSpan을 종료하고, 40번째 줄에서 processSpan을 종료합니다. 자식을 먼저 닫고 부모를 나중에 닫는 것이 원칙입니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 소셜 미디어 앱의 "게시물 작성" 기능을 개발한다고 가정해봅시다.

사용자가 사진과 텍스트를 올리면, 다음과 같은 계층 구조가 만들어질 수 있습니다. Root: "create-post" - 전체 게시물 작성 프로세스 - Level 2: "validate-content" - 콘텐츠 검증 - Level 3: "check-profanity" - 욕설 필터링 - Level 3: "scan-image" - 부적절한 이미지 검사 - Level 2: "process-media" - 미디어 처리 - Level 3: "compress-image" - 이미지 압축 - Level 3: "generate-thumbnail" - 썸네일 생성 - Level 2: "save-to-database" - 데이터베이스 저장 - Level 2: "notify-followers" - 팔로워 알림 이런 계층 구조를 보면 게시물 작성이라는 복잡한 프로세스가 어떤 단계로 구성되는지 한눈에 파악할 수 있습니다.

만약 이미지 압축이 느리다면, "process-media"를 펼쳐서 "compress-image"를 확인하면 됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 계층이 너무 깊어지는 것입니다. 10단계, 20단계까지 중첩되면 오히려 파악하기 어려워집니다.

일반적으로 3-5단계 정도가 적당합니다. 너무 깊어지면 Span을 병합하는 것을 고려하세요.

또한 잘못된 부모 설정도 흔한 실수입니다. 예를 들어 비동기 작업에서 콜백 안에서 Span을 만들 때, 부모를 명시하지 않으면 전혀 엉뚱한 Span이 부모가 될 수 있습니다.

따라서 항상 parent 옵션을 명시적으로 지정하는 습관을 들이세요. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 코드를 수정한 김개발 씨는 대시보드를 다시 열었습니다. 이번에는 들여쓰기가 제대로 표시되어, 실제 비즈니스 흐름이 그대로 시각화되었습니다.

"와, 이제 훨씬 이해하기 쉽네요!" Span 계층 구조를 제대로 활용하면 복잡한 시스템도 직관적으로 파악할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 비즈니스 로직의 계층Span의 계층을 일치시키세요. 실제 코드 구조를 반영하면 다른 개발자들이 이해하기 쉽습니다.

  • 비동기 작업에서는 **context.with()**를 사용하여 부모 컨텍스트를 명시적으로 전달하세요.
  • flame graph 보기를 활용하면 계층 구조를 더 직관적으로 볼 수 있습니다. Jaeger나 Zipkin UI에서 제공하는 이 기능을 적극 활용하세요.

5. 추적 컨텍스트

김개발 씨는 Node.js의 비동기 코드를 작성하다가 이상한 현상을 발견했습니다. setTimeout 안에서 만든 Span이 엉뚱한 부모를 가지고 있었습니다.

심지어 어떤 때는 아예 새로운 Trace가 시작되기도 했습니다. "비동기 코드에서는 부모-자식 관계가 자동으로 유지되지 않나요?" 박시니어 씨가 답했습니다.

"그게 바로 컨텍스트 관리의 중요성이에요."

추적 컨텍스트는 현재 활성화된 Span 정보를 저장하고 전달하는 메커니즘입니다. 동기 코드에서는 자동으로 유지되지만, 비동기 콜백, Promise, async/await, 이벤트 핸들러 등에서는 명시적으로 관리해야 할 때가 있습니다.

OpenTelemetry는 Context API를 제공하여 스레드, 코루틴, 비동기 경계를 넘어서도 컨텍스트를 안전하게 전달할 수 있게 합니다.

다음 코드를 살펴봅시다.

// 추적 컨텍스트 관리
const { context, trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('context-example');

// 잘못된 예시 - 컨텍스트가 유실됨
async function badExample() {
  const parentSpan = tracer.startSpan('parent');

  setTimeout(() => {
    // 여기서는 parentSpan이 활성 컨텍스트가 아님!
    const childSpan = tracer.startSpan('child');
    // childSpan이 parentSpan의 자식이 되지 않음
    childSpan.end();
  }, 100);

  parentSpan.end();
}

// 올바른 예시 - 컨텍스트를 명시적으로 전달
async function goodExample() {
  const parentSpan = tracer.startSpan('parent');

  // 현재 컨텍스트를 캡처
  const currentContext = context.active();

  setTimeout(() => {
    // 캡처한 컨텍스트를 복원하고 그 안에서 실행
    context.with(currentContext, () => {
      const childSpan = tracer.startSpan('child');
      // 이제 childSpan이 올바르게 parentSpan의 자식이 됨
      childSpan.end();
    });
  }, 100);

  parentSpan.end();
}

// Promise 체인에서의 컨텍스트 관리
async function promiseExample() {
  const rootSpan = tracer.startSpan('fetch-user-data');

  // context.with()를 사용하여 컨텍스트 유지
  await context.with(trace.setSpan(context.active(), rootSpan), async () => {
    const userData = await fetchUser();
    const orderData = await fetchOrders(userData.id);
    const enrichedData = await enrichData(orderData);
    return enrichedData;
  });

  rootSpan.end();
}

김개발 씨는 분산 추적을 열심히 적용하고 있었습니다. 동기 코드에서는 모든 것이 완벽하게 작동했습니다.

부모-자식 관계도 자동으로 잘 만들어졌고, Trace ID도 제대로 전파됐습니다. 그런데 비동기 코드에서 문제가 생겼습니다.

setTimeout 안에서 Span을 만들었더니 전혀 엉뚱한 부모를 가지고 있었습니다. Promise 체인 안에서도 비슷한 문제가 발생했습니다.

심지어 어떤 경우에는 아예 새로운 Trace가 시작되어, 원래 요청과 완전히 분리되어 버렸습니다. 김개발 씨는 코드를 몇 번이고 다시 확인했지만, 분명히 parent 옵션을 전달하지 않았음에도 동기 코드에서는 잘 작동했습니다.

"뭐가 다른 거지?" 고민하던 중 박시니어 씨가 지나가다 물었습니다. "혹시 비동기 코드에서 문제 있으세요?" 그렇다면 추적 컨텍스트란 정확히 무엇일까요?

쉽게 비유하자면, 추적 컨텍스트는 마치 전화 통화의 보류 기능과 같습니다. 여러분이 고객과 통화 중인데 잠깐 다른 부서에 확인할 일이 생겼다고 해봅시다.

고객을 보류 상태로 두고 다른 부서에 전화합니다. 확인이 끝나면 다시 원래 통화로 돌아옵니다.

이때 "어떤 고객과 통화 중이었는지" 정보가 보존되어야 합니다. 추적 컨텍스트도 이와 같이 "현재 어떤 Span이 활성화되어 있는지" 정보를 보존하고 복원하는 역할을 합니다.

추적 컨텍스트 관리가 없다면 어떻게 될까요? 동기 코드에서는 큰 문제가 없습니다.

코드가 순차적으로 실행되므로, 현재 활성 Span을 전역 변수처럼 관리할 수 있습니다. 하지만 비동기 코드는 다릅니다.

setTimeout, setInterval, Promise, async/await, 이벤트 리스너 등은 나중에 실행됩니다. 예를 들어 두 개의 요청이 동시에 들어왔다고 해봅시다.

첫 번째 요청이 Span A를 만들고, 비동기 작업을 시작합니다. 그 사이에 두 번째 요청이 들어와서 Span B를 만듭니다.

첫 번째 요청의 콜백이 실행될 때, 전역 변수를 보면 Span B가 저장되어 있습니다. 결과적으로 첫 번째 요청의 자식 Span이 Span B의 자식으로 잘못 연결되는 것입니다.

바로 이런 문제를 해결하기 위해 추적 컨텍스트 관리가 필요합니다. OpenTelemetry의 Context API는 실행 컨텍스트별로 Span 정보를 저장합니다.

Node.js에서는 AsyncLocalStorage라는 기술을 내부적으로 사용합니다. 이것은 비동기 호출 체인 전체에 걸쳐 데이터를 안전하게 전달할 수 있는 메커니즘입니다.

또한 context.with() 함수를 사용하면 특정 컨텍스트 안에서 코드를 실행할 수 있습니다. 마치 은행 금고의 안전 상자를 여는 것처럼, 특정 컨텍스트를 "활성화"하고 그 안에서 작업을 수행한 뒤, 자동으로 원래 컨텍스트로 돌아옵니다.

무엇보다 이런 메커니즘 덕분에 개발자는 대부분의 경우 자동으로 올바른 부모-자식 관계를 얻을 수 있습니다. HTTP 요청 핸들러나 메시지 큐 컨슈머 같은 엔트리 포인트에서는 프레임워크가 자동으로 컨텍스트를 설정해주므로, 개발자는 신경 쓸 필요가 없습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 6-17번째 줄의 badExample을 보겠습니다.

7번째 줄에서 parentSpan을 만들지만, 9번째 줄의 setTimeout 콜백은 나중에 실행됩니다. 그때쯤이면 parentSpan은 이미 종료된 상태입니다(16번째 줄).

더 큰 문제는 11번째 줄에서 childSpan을 만들 때, 현재 활성 컨텍스트가 parentSpan이 아니라는 점입니다. 결과적으로 둘의 관계가 끊깁니다.

20-36번째 줄의 goodExample이 올바른 방법입니다. 24번째 줄에서 context.active()로 현재 컨텍스트를 캡처합니다.

이것은 parentSpan이 활성화된 컨텍스트입니다. 28번째 줄의 context.with()가 핵심입니다.

이 함수는 캡처한 컨텍스트를 복원하고, 그 안에서 전달된 함수를 실행합니다. 따라서 30번째 줄에서 만든 childSpan은 올바르게 parentSpan의 자식이 됩니다.

41-50번째 줄의 promiseExample은 조금 다른 패턴을 보여줍니다. 43번째 줄에서 trace.setSpan()을 사용하여 rootSpan을 컨텍스트에 명시적으로 설정합니다.

그리고 context.with()로 이 컨텍스트를 활성화합니다. 이렇게 하면 async 함수 안의 모든 await 포인트를 거쳐도 컨텍스트가 유지됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 실시간 채팅 앱을 개발한다고 가정해봅시다.

WebSocket을 통해 메시지를 받으면 이벤트 핸들러가 실행됩니다. 이 핸들러 안에서 여러 비동기 작업이 일어납니다: 메시지 유효성 검사, 데이터베이스 저장, 다른 사용자들에게 브로드캐스트, 읽음 확인 전송 등.

각 WebSocket 메시지에 대해 새로운 Trace를 시작하고, context.with()로 감싸서 실행합니다. 그러면 모든 비동기 작업들이 하나의 Trace 안에 올바르게 기록됩니다.

만약 메시지 전송이 실패했다면, Trace를 보고 정확히 어느 단계에서 문제가 생겼는지 파악할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 컨텍스트를 너무 일찍 캡처하는 것입니다. 예를 들어 모듈 로딩 시점에 context.active()를 호출하면, 그때는 아무 Span도 활성화되지 않은 상태입니다.

따라서 항상 Span을 만든 직후에 컨텍스트를 캡처해야 합니다. 또한 이벤트 이미터를 사용할 때도 주의가 필요합니다.

이벤트를 emit할 때와 리스너가 실행될 때의 컨텍스트가 다를 수 있습니다. 이런 경우 이벤트와 함께 컨텍스트를 전달하거나, 리스너 등록 시점에 컨텍스트를 캡처해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. context.with()를 사용하여 코드를 수정한 김개발 씨는 다시 테스트했습니다.

이번에는 비동기 코드에서도 부모-자식 관계가 완벽하게 유지됐습니다. "이제 이해했어요!

비동기 경계를 넘을 때는 컨텍스트를 명시적으로 관리해야 하는군요!" 추적 컨텍스트를 제대로 이해하면 복잡한 비동기 코드에서도 완벽한 분산 추적을 구현할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Express나 Fastify 같은 프레임워크는 자동으로 요청별 컨텍스트를 관리해줍니다. OpenTelemetry 계측 라이브러리를 사용하면 수동 관리가 거의 필요 없습니다.

  • Worker Threads나 Child Processes를 사용할 때는 컨텍스트가 자동으로 전파되지 않습니다. 명시적으로 Trace ID를 메시지에 포함시켜야 합니다.
  • context.active()는 가볍고 빠른 작업이므로, 필요할 때마다 호출해도 성능 문제가 없습니다. 미리 변수에 저장해둘 필요는 없습니다.

6. 샘플링 전략

김개발 씨의 서비스가 성공하여 트래픽이 폭발적으로 늘어났습니다. 하루에 수백만 건의 요청이 들어왔습니다.

그런데 문제가 생겼습니다. 분산 추적 데이터가 너무 많아져서 저장 비용이 급증했고, Jaeger UI가 느려졌습니다.

박시니어 씨가 말했습니다. "이제 샘플링을 고려할 때가 됐네요.

모든 요청을 추적할 필요는 없어요."

샘플링은 전체 요청 중 일부만 선택적으로 추적하는 기술입니다. 트래픽이 많은 시스템에서 모든 요청을 추적하면 데이터 양이 너무 많아지고, 추적 자체가 성능에 영향을 줄 수 있습니다.

샘플링 전략에는 Always-on, Always-off, 확률 기반, 비율 기반, 규칙 기반 등이 있으며, 각각 장단점이 있습니다. 적절한 샘플링 전략을 선택하면 비용과 관측성 사이의 균형을 맞출 수 있습니다.

다음 코드를 살펴봅시다.

// 다양한 샘플링 전략
const { TraceIdRatioBasedSampler, ParentBasedSampler, AlwaysOnSampler } = require('@opentelemetry/sdk-trace-base');

// 1. 확률 기반 샘플링 - 10% 요청만 추적
const probabilitySampler = new TraceIdRatioBasedSampler(0.1);

// 2. 부모 기반 샘플링 - 부모가 샘플링되면 자식도 샘플링
const parentBasedSampler = new ParentBasedSampler({
  root: new TraceIdRatioBasedSampler(0.1) // Root Span은 10%
});

// 3. 커스텀 샘플링 - 조건부 로직
class CustomSampler {
  shouldSample(context, traceId, spanName, spanKind, attributes) {
    // 에러가 있는 요청은 항상 샘플링
    if (attributes['http.status_code'] >= 400) {
      return { decision: 1 }; // RECORD_AND_SAMPLED
    }

    // 특정 경로는 높은 비율로 샘플링
    if (attributes['http.route'] === '/api/payment') {
      return Math.random() < 0.5 ? { decision: 1 } : { decision: 0 };
    }

    // VIP 사용자는 항상 샘플링
    if (attributes['user.tier'] === 'premium') {
      return { decision: 1 };
    }

    // 기본적으로 1% 샘플링
    return Math.random() < 0.01 ? { decision: 1 } : { decision: 0 };
  }
}

// 샘플링 적용
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');

const provider = new NodeTracerProvider({
  sampler: new ParentBasedSampler({
    root: new CustomSampler()
  })
});

// 런타임에 샘플링 결정 변경
function createSpanWithSampling(name, shouldSample) {
  const span = tracer.startSpan(name);

  if (!shouldSample) {
    // 샘플링하지 않으려면 Span을 기록만 하고 전송하지 않음
    span.spanContext().traceFlags = 0; // NOT_SAMPLED
  }

  return span;
}

김개발 씨의 서비스는 성공했습니다. 처음에는 하루에 몇천 건의 요청만 처리했지만, 이제는 수백만 건의 요청이 들어옵니다.

사용자들이 늘어나면서 매출도 늘었지만, 인프라 비용도 함께 증가했습니다. 특히 분산 추적 데이터의 저장 비용이 문제였습니다.

Jaeger 백엔드에 저장되는 Trace 데이터가 하루에 수백 GB에 달했습니다. 클라우드 스토리지 비용이 급증했고, Jaeger UI도 느려졌습니다.

너무 많은 Trace가 쌓여서 검색하는 것조차 어려워졌습니다. 김개발 씨가 고민하고 있을 때, 박시니어 씨가 다가왔습니다.

"김개발 씨, 샘플링 설정은 하셨어요? 트래픽이 많으면 모든 요청을 추적할 필요는 없어요." 그렇다면 샘플링이란 정확히 무엇일까요?

쉽게 비유하자면, 샘플링은 마치 여론조사와 같습니다. 대통령 선거 여론조사를 할 때 5천만 국민 전체에게 물어볼 필요는 없습니다.

통계적으로 의미 있는 표본, 예를 들어 1000명만 조사해도 전체 경향을 파악할 수 있습니다. 분산 추적의 샘플링도 마찬가지입니다.

100만 건의 요청 중 1만 건만 추적해도, 시스템의 전반적인 성능 특성을 이해하기에 충분합니다. 샘플링이 없다면 어떻게 될까요?

트래픽이 적을 때는 문제가 없습니다. 하루에 1만 건 요청이라면 모두 추적해도 데이터양이 많지 않습니다.

하지만 트래픽이 늘어나면 문제가 시작됩니다. 하루에 100만 건 요청이라면, 100배의 저장 공간이 필요합니다.

더 큰 문제는 네트워크 대역폭백엔드 부하입니다. 모든 Span 데이터를 실시간으로 Jaeger 백엔드로 전송하면 네트워크 비용이 증가합니다.

Jaeger 백엔드도 이 모든 데이터를 받아서 저장하고 인덱싱해야 하므로, CPU와 메모리 사용량이 급증합니다. 심지어 추적 자체가 애플리케이션 성능에 영향을 주기 시작합니다.

바로 이런 문제를 해결하기 위해 샘플링 전략이 필요합니다. 가장 간단한 방법은 확률 기반 샘플링입니다.

예를 들어 10% 샘플링을 설정하면, 무작위로 10%의 요청만 추적합니다. Trace ID를 해시해서 0-99 범위의 숫자를 만들고, 10 미만이면 샘플링하는 식입니다.

이 방법은 간단하지만 효과적입니다. 데이터양이 90% 줄어들고, 그래도 시스템의 전반적인 패턴은 파악할 수 있습니다.

또한 부모 기반 샘플링도 중요합니다. 만약 Root Span이 샘플링되었다면, 모든 자식 Span도 샘플링해야 합니다.

그렇지 않으면 Trace가 중간에 끊겨서 의미가 없어집니다. 반대로 Root Span이 샘플링되지 않았다면, 자식 Span도 샘플링하지 않아서 리소스를 절약합니다.

무엇보다 지능형 샘플링을 사용하면 더 효율적입니다. 예를 들어 에러가 발생한 요청은 항상 샘플링하고, 성공한 요청은 1%만 샘플링할 수 있습니다.

중요한 API 엔드포인트는 높은 비율로 샘플링하고, 헬스 체크 같은 불필요한 요청은 샘플링하지 않을 수도 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 5번째 줄의 TraceIdRatioBasedSampler가 기본적인 확률 기반 샘플러입니다. 0.1은 10%를 의미합니다.

이 샘플러는 Trace ID를 기반으로 결정하므로, 같은 요청을 재시도해도 같은 샘플링 결과를 얻습니다. 8번째 줄의 ParentBasedSampler가 중요합니다.

이것은 부모 Span이 샘플링되었는지 확인하고, 그에 따라 자식 Span의 샘플링을 결정합니다. root 옵션으로 Root Span의 샘플링 전략을 지정합니다.

14번째 줄부터 CustomSampler 클래스가 나옵니다. shouldSample 메서드가 핵심입니다.

이 메서드는 Span을 만들기 전에 호출되어, 샘플링 여부를 결정합니다. 17-19번째 줄을 보면 HTTP 상태 코드가 400 이상이면 무조건 샘플링합니다.

에러는 중요하므로 모두 추적하는 것이 좋습니다. 22-24번째 줄은 특정 경로(/api/payment)는 50% 샘플링합니다.

결제는 중요하므로 높은 비율로 추적합니다. 27-29번째 줄은 프리미엄 사용자의 요청을 항상 샘플링합니다.

VIP 고객 경험을 우선적으로 모니터링하는 전략입니다. 32번째 줄은 나머지는 1%만 샘플링하는 기본 정책입니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 동영상 스트리밍 서비스를 운영한다고 가정해봅시다.

하루에 수천만 건의 요청이 들어오는데, 대부분은 단순한 영상 재생 요청입니다. 이런 요청은 0.1%만 샘플링해도 충분합니다.

패턴이 반복적이고 예측 가능하기 때문입니다. 하지만 결제 요청은 다릅니다.

결제 실패는 매출에 직접적인 영향을 주므로, 100% 샘플링해서 모든 케이스를 추적해야 합니다. 회원 가입도 중요하므로 50% 정도 샘플링합니다.

이런 식으로 비즈니스 중요도에 따라 차등 샘플링을 적용하면, 비용은 줄이면서 중요한 정보는 놓치지 않습니다. 많은 글로벌 기업들이 이런 전략을 사용합니다.

구글은 Dapper 논문에서 0.01% 샘플링으로도 충분한 인사이트를 얻었다고 밝혔습니다. 물론 에러나 이상 징후는 별도로 추적했습니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 샘플링 비율을 너무 낮게 설정하는 것입니다.

0.001% 같은 극단적으로 낮은 비율을 사용하면, 드물게 발생하는 버그를 놓칠 수 있습니다. 일반적으로 최소 0.1-1% 정도는 유지하는 것이 좋습니다.

또한 Head-based 샘플링의 한계를 이해해야 합니다. 위에서 본 샘플링은 모두 Span을 만들기 전에 결정됩니다.

따라서 실제로 에러가 발생했는지 미리 알 수 없습니다. 이를 해결하려면 Tail-based 샘플링을 고려해야 합니다.

모든 Span을 일단 메모리에 버퍼링하고, Trace가 완료된 후에 샘플링 여부를 결정하는 방식입니다. 복잡하지만 더 똑똑한 결정을 내릴 수 있습니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 조언대로 샘플링을 적용한 김개발 씨는 결과를 확인했습니다.

저장 비용이 95% 줄어들었고, Jaeger UI도 빨라졌습니다. 그러면서도 성능 병목이나 에러는 여전히 명확하게 파악할 수 있었습니다.

샘플링 전략을 제대로 이해하면 대규모 시스템에서도 효율적으로 분산 추적을 운영할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 샘플링 비율은 환경 변수로 관리하세요. 코드 수정 없이 런타임에 조정할 수 있어 유연합니다.

  • AWS X-Ray나 Google Cloud Trace 같은 관리형 서비스는 지능형 샘플링을 자동으로 제공합니다. 직접 구현하기 어렵다면 이런 서비스를 고려하세요.
  • 샘플링 메트릭을 모니터링하세요. 실제로 몇 퍼센트가 샘플링되고 있는지, 에러 요청은 모두 잡히고 있는지 주기적으로 확인해야 합니다.

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#분산추적#Trace#Span#TraceID#Observability#마이크로서비스

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.