Published on

OpenTelemetry로 MSA 분산 트랜잭션 추적 실전

Authors

MSA에서 “분산 트랜잭션”을 운영 관점에서 다룬다는 것은, 단일 DB 트랜잭션처럼 원자성을 보장한다는 뜻이 아니라 여러 서비스에 걸친 하나의 비즈니스 요청 흐름을 끝까지 관찰하고, 실패 지점을 정확히 찾아내며, 보상 트랜잭션까지 포함해 일관성 문제를 통제한다는 의미에 가깝습니다.

문제는 요청이 서비스 경계를 넘는 순간부터입니다. API Gateway, BFF, 인증 서비스, 주문 서비스, 결제 서비스, 재고 서비스, 메시지 브로커, 워커, 외부 PG까지 이어지는 호출 체인에서 어느 한 구간의 지연이나 오류가 전체 경험을 망칩니다. 로그만으로는 상관관계가 끊기기 쉽고, 메트릭만으로는 “어떤 요청이 왜 느렸는지”를 특정하기 어렵습니다.

이 글은 OpenTelemetry(이하 OTel)로 Trace 기반 분산 트랜잭션 추적을 구축하는 방법을, 실무에서 바로 적용할 수 있는 형태로 정리합니다.

  • HTTP 동기 호출에서 TraceContext 전파
  • 메시지 큐 기반 비동기 흐름에서 컨텍스트 연결
  • Saga 같은 보상 트랜잭션 흐름을 Span으로 표현
  • Collector 기반 표준 파이프라인 구성
  • 장애/지연을 찾기 위한 쿼리 및 태깅(Attributes) 전략

관련해서 Saga 보상 트랜잭션을 설계할 때 흔히 놓치는 함정은 아래 글도 함께 보면 좋습니다.

OpenTelemetry로 “분산 트랜잭션”을 보는 관점

OTel은 크게 세 가지 신호를 다룹니다.

  • Traces: 요청의 인과관계(부모-자식 Span)와 타임라인
  • Metrics: 집계 지표(레이트, 에러율, p95 등)
  • Logs: 이벤트 기록(구조화 로그 권장)

분산 트랜잭션 추적의 핵심은 Traces입니다. Trace는 여러 Span의 집합이고, 각 Span은 “특정 작업의 시작-종료 구간”입니다.

  • 주문 생성 API 처리 전체가 하나의 Trace
  • 주문 서비스 내부에서 DB insert가 하나의 Span
  • 결제 서비스 호출이 또 하나의 Span
  • 메시지 발행과 소비가 각각 Span

여기서 중요한 포인트는 서비스 간 컨텍스트 전파입니다. 즉, “이 결제 호출이 방금 그 주문 요청에서 파생된 것”임을 Trace로 연결해야 합니다.

OTel은 기본적으로 W3C Trace Context를 사용합니다.

  • traceparent 헤더
  • tracestate 헤더

이 헤더들이 HTTP 호출을 따라 전파되면, Jaeger, Tempo, Zipkin 같은 백엔드에서 “한 줄로 이어진 호출 체인”을 볼 수 있습니다.

전체 아키텍처: SDK - Collector - Backend

운영 환경에서는 보통 아래 구성을 권장합니다.

  • 애플리케이션에 OTel SDK(혹은 Auto Instrumentation) 적용
  • 애플리케이션은 OTel Collector로 OTLP 전송
  • Collector가 샘플링/가공/라우팅 후 백엔드로 전달

Collector를 두는 이유는 명확합니다.

  • 앱 설정을 단순화(백엔드 변경 시 앱 재배포 최소화)
  • 샘플링 정책을 중앙에서 통제
  • 민감정보 필터링/속성 변환
  • 멀티 백엔드(예: Trace는 Tempo, Metric은 Prometheus)로 분기

Docker Compose 예시: Collector와 Jaeger

아래 예시는 로컬에서 빠르게 Trace를 확인하기 위한 구성이며, 운영에서는 Tempo나 상용 APM을 붙이되 Collector는 그대로 가져가는 형태가 일반적입니다.

version: "3.9"
services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.96.0
    command: ["--config=/etc/otelcol/config.yaml"]
    volumes:
      - ./otelcol-config.yaml:/etc/otelcol/config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "8888:8888"   # metrics for collector itself

  jaeger:
    image: jaegertracing/all-in-one:1.55
    ports:
      - "16686:16686" # UI
      - "14250:14250" # gRPC ingest

Collector 설정 파일 예시입니다.

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  batch:

exporters:
  jaeger:
    endpoint: jaeger:14250
    tls:
      insecure: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

서비스 계측 전략: 자동 계측 + 수동 Span

OTel은 자동 계측이 강력합니다. HTTP 서버/클라이언트, DB 드라이버, 메시징 라이브러리 등에서 기본 Span을 만들어줍니다.

하지만 “분산 트랜잭션” 관점에서 중요한 것은 비즈니스 단계입니다.

  • OrderSaga 시작
  • ReserveInventory
  • AuthorizePayment
  • ConfirmOrder
  • 실패 시 CompensateInventory, CancelPayment

이런 단계는 자동 계측만으로는 드러나지 않으므로 수동 Span이 필요합니다.

아래부터는 Node.js 예시로 설명합니다. 다른 언어도 개념은 동일합니다.

Node.js(Express)에서 Trace 전파와 Span 만들기

1) 기본 구성: SDK 초기화

// tracing.js
const { NodeSDK } = require("@opentelemetry/sdk-node");
const { getNodeAutoInstrumentations } = require("@opentelemetry/auto-instrumentations-node");
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-grpc");

const traceExporter = new OTLPTraceExporter({
  url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4317",
});

const sdk = new NodeSDK({
  traceExporter,
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

process.on("SIGTERM", async () => {
  await sdk.shutdown();
  process.exit(0);
});

서버 엔트리포인트에서 가장 먼저 로드합니다.

require("./tracing");
const express = require("express");

const app = express();
app.use(express.json());

app.post("/orders", async (req, res) => {
  // 자동 계측만으로도 HTTP server span은 생성됨
  res.json({ ok: true });
});

app.listen(3000);

2) 수동 Span으로 “Saga 단계” 표현하기

const { trace, SpanStatusCode } = require("@opentelemetry/api");

const tracer = trace.getTracer("order-service");

async function createOrderSaga(input) {
  return await tracer.startActiveSpan("OrderSaga", async (sagaSpan) => {
    try {
      sagaSpan.setAttribute("order.customer_id", input.customerId);
      sagaSpan.setAttribute("order.amount", input.amount);

      await tracer.startActiveSpan("ReserveInventory", async (span) => {
        try {
          // 재고 서비스 호출
          await reserveInventory(input);
          span.setStatus({ code: SpanStatusCode.OK });
        } catch (e) {
          span.recordException(e);
          span.setStatus({ code: SpanStatusCode.ERROR, message: "reserve failed" });
          throw e;
        } finally {
          span.end();
        }
      });

      await tracer.startActiveSpan("AuthorizePayment", async (span) => {
        try {
          await authorizePayment(input);
          span.setStatus({ code: SpanStatusCode.OK });
        } catch (e) {
          span.recordException(e);
          span.setStatus({ code: SpanStatusCode.ERROR, message: "payment auth failed" });
          throw e;
        } finally {
          span.end();
        }
      });

      sagaSpan.setStatus({ code: SpanStatusCode.OK });
      return { ok: true };
    } catch (e) {
      // 보상 트랜잭션 단계도 Span으로 남기면 “실패 후 무엇을 했는지”가 한 눈에 보임
      await tracer.startActiveSpan("Compensate", async (span) => {
        try {
          await compensate(input);
          span.setStatus({ code: SpanStatusCode.OK });
        } catch (ce) {
          span.recordException(ce);
          span.setStatus({ code: SpanStatusCode.ERROR, message: "compensation failed" });
        } finally {
          span.end();
        }
      });

      sagaSpan.recordException(e);
      sagaSpan.setStatus({ code: SpanStatusCode.ERROR, message: "saga failed" });
      throw e;
    } finally {
      sagaSpan.end();
    }
  });
}

이렇게 하면 Jaeger나 Tempo에서 “OrderSaga 트리”가 비즈니스 단계로 정리되어 보여서, 단순히 HTTP POST /orders 가 느린지 아닌지를 넘어 어느 단계가 병목인지가 즉시 드러납니다.

비동기 경계: 메시지 큐에서 Trace를 이어붙이기

분산 트랜잭션이 어려워지는 지점은 비동기입니다.

  • 주문 서비스가 order.created 이벤트 발행
  • 결제 워커가 이벤트를 소비하고 결제 승인

이때 소비자 워커는 HTTP 헤더가 없으므로, TraceContext를 메시지 메타데이터로 실어야 합니다.

OTel의 일반적인 방법은 메시지 헤더에 traceparent 를 심고, 소비 시 추출하는 것입니다.

메시지 발행 시 컨텍스트 주입

const { context, propagation } = require("@opentelemetry/api");

function injectTraceHeaders(messageHeaders) {
  propagation.inject(context.active(), messageHeaders);
  return messageHeaders;
}

async function publishOrderCreated(bus, payload) {
  const headers = injectTraceHeaders({});
  await bus.publish("order.created", payload, { headers });
}

메시지 소비 시 컨텍스트 추출 후 Span 생성

const { context, propagation, trace } = require("@opentelemetry/api");

const tracer = trace.getTracer("payment-worker");

async function handleMessage(msg) {
  const extracted = propagation.extract(context.active(), msg.headers || {});

  return await context.with(extracted, async () => {
    return await tracer.startActiveSpan("ProcessOrderCreated", async (span) => {
      try {
        span.setAttribute("messaging.system", "your-bus");
        span.setAttribute("messaging.destination", "order.created");
        span.setAttribute("order.id", msg.body.orderId);

        await processPayment(msg.body);
        span.end();
      } catch (e) {
        span.recordException(e);
        span.end();
        throw e;
      }
    });
  });
}

이 연결이 되면, UI에서 주문 API 호출 Trace를 열었을 때 “메시지 소비 워커의 처리 Span”이 같은 Trace 아래로 이어져 보입니다.

Attributes 설계: 나중에 찾기 쉽게 태그를 박아라

Trace는 “보는 것”만으로 끝나지 않습니다. 운영에서는 결국 검색과 집계가 필요합니다.

권장하는 태그(Attributes) 전략은 다음과 같습니다.

  • 식별자
    • order.id, payment.id, customer.id
    • 단, 개인정보나 민감정보는 금지(이메일, 전화번호 등)
  • 비즈니스 상태
    • saga.name, saga.step, saga.outcome
    • compensation.executed 같은 불리언
  • 외부 의존성
    • peer.service, http.host, db.system
  • 멱등성 키
    • idempotency.key

특히 Saga 보상 트랜잭션은 중복 실행 문제가 실제 장애로 자주 이어집니다. 이때 Trace에 idempotency.keysaga.step 을 함께 남기면, “왜 같은 보상이 두 번 실행됐는지”를 타임라인으로 재구성하기 쉬워집니다.

샘플링: 전부 다 보내면 비용과 성능이 터진다

OTel에서 흔한 실수는 “일단 전부 수집”입니다. MSA에서 QPS가 조금만 올라가도 Trace는 비용과 저장소를 급격히 잡아먹습니다.

실무에서 많이 쓰는 접근은 아래 조합입니다.

  • Head-based sampling(초기 샘플링): 예를 들어 1퍼센트
  • Tail-based sampling(사후 샘플링): 에러 Trace는 100퍼센트
  • 특정 라우트/고객/테넌트만 선택적으로 100퍼센트

Tail sampling은 보통 Collector에서 설정합니다. 예를 들어 “에러가 포함된 Trace는 남긴다” 같은 룰이 가능합니다.

장애 지점 좁히기: Trace로 무엇을 어떻게 볼 것인가

분산 트랜잭션 추적을 붙여도, 팀이 “어떻게 봐야 하는지” 합의가 없으면 결국 UI는 열어보다가 닫게 됩니다. 아래는 운영에서 자주 쓰는 체크리스트입니다.

1) p95 지연 Trace를 샘플로 뽑아 병목 Span을 찾기

  • 특정 API의 p95가 튀었다
  • 해당 시간대 Trace 중 느린 것 몇 개를 연다
  • 공통으로 느린 Span이 있는지 확인한다
    • DB 쿼리
    • 외부 PG 호출
    • 특정 서비스 간 네트워크

2) 에러 Trace에서 “최초 실패 지점”을 찾기

  • 최상단에서 500이 보이더라도, 원인은 하위 서비스의 409나 타임아웃일 수 있음
  • Span status와 exception 이벤트를 기준으로 최초 에러 Span을 찾는다

3) 보상 트랜잭션이 실행됐는지, 성공했는지 확인하기

  • Compensate Span이 존재하는지
  • 그 하위에 CancelPayment, ReleaseInventory 같은 단계가 있는지
  • 보상 실패가 추가 장애를 만들었는지

Kubernetes 환경에서 장애가 발생하면, 종종 애플리케이션 레벨이 아니라 Pod 상태나 Probe, 리소스 이슈가 원인인 경우도 많습니다. Trace와 함께 아래 글의 관점으로 인프라 상태를 같이 보면 진단 속도가 크게 올라갑니다.

흔한 함정 6가지

1) Trace는 있는데 서비스 간 연결이 끊긴다

원인:

  • HTTP 클라이언트 계측 누락
  • API Gateway에서 traceparent 를 제거하거나 재작성
  • 메시지 큐 헤더 전파 누락

대응:

  • ingress, gateway, service mesh 구간에서 헤더 보존 확인
  • 비동기 메시지에 주입/추출 코드 표준화

2) Span 이름이 전부 GET 이거나 라이브러리 이름뿐이다

원인:

  • 자동 계측만 사용

대응:

  • 비즈니스 단계 Span을 최소 3개 이상 추가
  • 이름 규칙 합의: SagaName/StepName 혹은 StepName 고정

3) 카드번호, 이메일 같은 민감정보가 Attributes에 들어간다

대응:

  • 허용 목록(allowlist) 기반으로만 태그
  • Collector에서 속성 삭제/마스킹 프로세서 적용

4) 샘플링이 없어서 비용 폭발

대응:

  • 기본은 낮은 비율로 시작
  • 에러 Trace 보존 정책을 별도로 둔다

5) 로그와 Trace가 따로 논다

대응:

  • 구조화 로그에 trace_id, span_id 를 포함
  • 로그 백엔드에서 해당 필드로 클릭-이동이 되게 구성

6) “분산 트랜잭션”을 DB 트랜잭션처럼 만들려다 더 망가진다

대응:

  • 원자성 보장은 Saga, Outbox, 멱등성, 재시도 정책으로 다룬다
  • OTel은 그 흐름을 관찰하고 디버깅 가능하게 만드는 도구로 위치시킨다

운영 적용 체크리스트

  • 모든 외부 진입점(게이트웨이, BFF, public API)에 TraceContext 수신/전파가 되는가
  • 동기 호출(HTTP, gRPC)과 비동기 호출(메시지, 배치) 모두에서 컨텍스트가 이어지는가
  • 비즈니스 단계 Span이 최소한의 표준으로 들어가 있는가
  • order.id 같은 핵심 식별자가 개인정보 없이 남는가
  • 샘플링 정책이 있으며, 에러 Trace는 충분히 남는가
  • 로그에 trace_id 가 들어가서 상호 점프가 가능한가

마무리

OpenTelemetry를 MSA에 적용하는 핵심은 “계측을 붙였다”가 아니라, 분산 트랜잭션의 흐름을 비즈니스 단위로 쪼개서 Span으로 표현하고, 서비스 경계를 넘을 때 컨텍스트를 잃지 않으며, 나중에 검색 가능한 태그를 남기는 것입니다.

이 세 가지가 갖춰지면 장애 대응이 바뀝니다. “어딘가 느리다”에서 끝나는 것이 아니라, “어떤 요청이, 어떤 단계에서, 어떤 의존성 때문에 느려졌는지”를 분 단위로 좁힐 수 있습니다. 그리고 Saga 보상 트랜잭션까지 Trace로 연결해두면, 실패 이후의 일관성 회복 과정도 같은 타임라인에서 검증할 수 있습니다.

다음 단계로는 Collector에서 tail sampling과 속성 필터링을 강화하고, 서비스별 Span 네이밍과 태그 규약을 문서화해서 팀 전체가 같은 방식으로 Trace를 읽도록 만드는 것을 권장합니다.