Published on

Kubernetes에서 Saga 보상 누락 잡기 - OTel·Kafka

Authors

마이크로서비스에서 Saga는 “분산 트랜잭션을 결국 일관성으로 만든다”는 약속을 합니다. 하지만 Kubernetes 운영 환경에서는 그 약속이 자주 깨집니다. 특히 문제가 되는 케이스가 보상 트랜잭션(Compensation) 누락입니다. 주문은 취소되어야 하는데 결제 취소 이벤트가 안 나가거나, 재고 롤백이 실행되지 않는 식입니다.

이 글은 Kubernetes에서 OpenTelemetry(이하 OTel)Kafka를 이용해 보상 누락을 “재현 가능한 증상”으로 바꾸고, “관측 가능한 신호”로 잡아내는 방법을 다룹니다. 단순히 트레이스를 켠다는 수준이 아니라, 사가 단위 상관관계 키, Kafka 레코드 메타데이터, OTel span 이벤트/속성, 탐지 규칙과 알람까지 연결해 운영적으로 누락을 잡는 흐름을 제시합니다.

관련 개념(사가 설계, 보상 전략)을 먼저 정리하고 싶다면 아래 글도 함께 보면 좋습니다.

보상 누락이 발생하는 전형적인 원인 6가지

Kubernetes에서 “로컬에서는 잘 되던 보상”이 운영에서 누락되는 이유는 대부분 아래 범주로 수렴합니다.

1) 이벤트 발행은 했는데 커밋이 안 됨

프로듀서가 send는 했지만 트랜잭션 커밋이 실패하거나, 프로세스가 죽어 메시지가 실제로 브로커에 기록되지 않는 경우입니다.

  • 파드 재시작, 노드 축출, OOM 등으로 프로듀서가 종료
  • acks=1 같은 설정으로 리더 장애 시 유실
  • 트랜잭션 프로듀서 설정 미흡

2) 소비는 했는데 처리 완료 전에 죽음

컨슈머가 레코드를 읽고 보상 로직을 실행하던 중 프로세스가 죽거나 리밸런스가 발생하면, 오프셋 커밋 정책에 따라 중복 처리 또는 처리 누락처럼 보이는 현상이 생깁니다.

3) 상관관계 키가 엉킴

사가를 한 덩어리로 묶는 키가 서비스마다 다르면, 트레이스와 이벤트를 연결할 수 없고 “누락”인지 “다른 사가의 보상”인지 구분이 안 됩니다.

  • orderId만 쓰다가 결제는 paymentId로 추적
  • 재시도에서 새 sagaId를 만들어버림

4) 보상 이벤트가 DLQ로 빠짐

스키마 호환성, 역직렬화 실패, 권한 문제 등으로 컨슈머가 죽거나 DLQ로 보내는 구성에서, 운영자는 “보상이 안 됨”으로 인지합니다.

5) 타임아웃/서킷브레이커로 보상 경로가 스킵됨

보상은 “실패했을 때 실행되는 경로”라서, 타임아웃이나 서킷브레이커로 인해 호출 자체가 스킵되면 보상이 누락됩니다.

6) 관측이 없어서 누락을 늦게 발견

가장 치명적인 이유입니다. 보상은 정상 경로가 아니라서 대시보드도, SLO도, 알람도 없는 경우가 많습니다.

목표: “보상 누락”을 신호로 정의하기

운영에서 잡을 수 있으려면, 먼저 누락을 정의 가능한 조건으로 바꿔야 합니다.

아래처럼 “사가 상태 머신” 관점에서 규칙을 세우면 탐지가 쉬워집니다.

  • 규칙 A: SAGA_FAILED가 발생했는데 COMPENSATION_COMPLETED가 일정 시간 내 없다
  • 규칙 B: 보상 이벤트 compensate.*가 발행되었는데, 소비 완료 span이 없다
  • 규칙 C: 특정 단계가 실패했을 때 반드시 실행되어야 하는 보상 단계가 트레이스에 없다

이를 위해서는 최소한 다음이 필요합니다.

  • 모든 서비스에서 공유하는 saga.id (혹은 trace_id를 saga id로 사용)
  • Kafka 메시지 헤더에 traceparentsaga-id를 넣어 전파
  • OTel span에 saga.step, saga.action 같은 속성 부여
  • 보상 시작/완료를 span 이벤트로 남김

상관관계 키 설계: trace id와 saga id를 분리할지

권장안은 다음 중 하나입니다.

  1. trace_id를 saga id로 사용
  • 장점: 관측 도구에서 바로 사가 단위로 묶임
  • 단점: 사가가 매우 길면 트레이스가 과도하게 커질 수 있음
  1. saga.id를 별도로 두고 trace_id는 요청 단위로 유지
  • 장점: 요청 단위 트레이스는 가볍고, 사가 상관관계는 별도 키로 가능
  • 단점: 도구에서 조인 로직이 필요

실무에서는 2번을 추천합니다. 대신 Kafka 헤더와 OTel 속성으로 saga.id반드시 전파합니다.

예시(권장 필드):

  • saga.id: UUID
  • saga.step: 예) payment-authorize, inventory-reserve
  • saga.action: do 또는 compensate
  • saga.parent_id: 오케스트레이터가 있으면 상위 사가

Kafka 메시지에 trace context와 saga id 넣기

Kafka는 HTTP처럼 자동으로 trace context를 전파하지 않습니다. 메시지 헤더에 traceparent를 넣고, 컨슈머가 이를 꺼내 OTel 컨텍스트를 복원해야 합니다.

Node.js 예시: kafkajs + OpenTelemetry

아래 코드는 핵심 아이디어만 담은 예시입니다. 실제 운영에서는 에러 처리, 재시도, 배치 처리 등을 강화해야 합니다.

// producer.ts
import { Kafka } from 'kafkajs'
import { context, propagation, trace } from '@opentelemetry/api'

const kafka = new Kafka({ clientId: 'order', brokers: ['kafka:9092'] })
const producer = kafka.producer()

export async function emitCompensationRequested(params: {
  topic: string
  key: string
  sagaId: string
  payload: unknown
}) {
  const tracer = trace.getTracer('order-service')

  await tracer.startActiveSpan('kafka.produce compensate.requested', async (span) => {
    span.setAttribute('messaging.system', 'kafka')
    span.setAttribute('messaging.destination', params.topic)
    span.setAttribute('saga.id', params.sagaId)
    span.setAttribute('saga.action', 'compensate')

    const headers: Record<string, Buffer> = {
      'saga-id': Buffer.from(params.sagaId, 'utf8'),
    }

    // 현재 컨텍스트를 W3C trace context로 주입
    propagation.inject(context.active(), headers, {
      set: (carrier, key, value) => {
        carrier[key] = Buffer.from(String(value), 'utf8')
      },
    })

    await producer.send({
      topic: params.topic,
      messages: [
        {
          key: params.key,
          value: JSON.stringify(params.payload),
          headers,
        },
      ],
    })

    span.addEvent('compensation.requested')
    span.end()
  })
}

컨슈머는 헤더에서 traceparent를 꺼내 컨텍스트를 복원하고, 보상 처리 span을 해당 컨텍스트의 자식으로 연결합니다.

// consumer.ts
import { Kafka } from 'kafkajs'
import { context, propagation, trace } from '@opentelemetry/api'

const kafka = new Kafka({ clientId: 'payment', brokers: ['kafka:9092'] })
const consumer = kafka.consumer({ groupId: 'payment-compensator' })

function headersToCarrier(headers: Record<string, Buffer | string | undefined>) {
  const carrier: Record<string, string> = {}
  for (const [k, v] of Object.entries(headers || {})) {
    if (v === undefined) continue
    carrier[k] = Buffer.isBuffer(v) ? v.toString('utf8') : String(v)
  }
  return carrier
}

export async function run() {
  const tracer = trace.getTracer('payment-service')

  await consumer.connect()
  await consumer.subscribe({ topic: 'compensate.payment', fromBeginning: false })

  await consumer.run({
    eachMessage: async ({ topic, partition, message }) => {
      const carrier = headersToCarrier(message.headers as any)
      const parentCtx = propagation.extract(context.active(), carrier)
      const sagaId = carrier['saga-id']

      await context.with(parentCtx, async () => {
        await tracer.startActiveSpan('compensate.payment', async (span) => {
          span.setAttribute('messaging.system', 'kafka')
          span.setAttribute('messaging.destination', topic)
          span.setAttribute('messaging.kafka.partition', partition)
          span.setAttribute('messaging.kafka.message_key', message.key?.toString('utf8') || '')
          span.setAttribute('saga.id', sagaId || '')
          span.setAttribute('saga.action', 'compensate')

          try {
            // 실제 보상 로직
            // 1) 결제 취소 API 호출
            // 2) DB 상태 업데이트

            span.addEvent('compensation.completed')
          } catch (e: any) {
            span.recordException(e)
            span.setStatus({ code: 2, message: e?.message })
            span.addEvent('compensation.failed')
            throw e
          } finally {
            span.end()
          }
        })
      })
    },
  })
}

핵심은 다음 3가지입니다.

  • Kafka 헤더에 traceparent를 넣어 “트레이스 단절”을 막기
  • saga-id를 별도 헤더로 넣어 “사가 단위 집계” 가능하게 하기
  • 보상 완료/실패를 span 이벤트로 남겨 탐지 규칙에 활용하기

OTel에서 “보상 누락”을 어떻게 쿼리 가능한 형태로 만들까

트레이싱만 켜면 화면에서 검색은 가능하지만, “누락”은 대개 부재이므로 쿼리하기 어렵습니다. 그래서 운영에서는 아래 중 하나를 추가합니다.

방법 1) 보상 상태를 메트릭으로 올린다

예를 들어 다음 카운터를 서비스별로 노출합니다.

  • saga_compensation_requested_total
  • saga_compensation_completed_total
  • saga_compensation_failed_total

그리고 requested - completed의 차이가 시간 창에서 계속 증가하면 알람을 울립니다.

OTel Metrics를 Prometheus로 스크랩하는 구성이면, PromQL로도 쉽게 감지할 수 있습니다.

sum(rate(saga_compensation_requested_total[5m]))
-
sum(rate(saga_compensation_completed_total[5m]))
> 0

이 방식은 “어떤 사가가 누락인지”까지는 바로 못 보지만, 운영 알람에는 매우 강합니다.

방법 2) 보상 완료 이벤트를 Kafka로도 남긴다

보상 완료를 별도 토픽(예: compensation.audit)에 남기고, 이를 기반으로 사가 오케스트레이터가 타임아웃 감시를 할 수 있습니다.

  • 장점: 누락 사가를 정확히 특정 가능
  • 단점: 이벤트가 늘고 설계가 복잡해짐

방법 3) 트레이스 기반 규칙 엔진을 둔다

Jaeger나 Tempo 같은 트레이스 저장소 위에 규칙을 얹는 방식입니다. 다만 구현 난도가 높아 보통은 메트릭 또는 감사 이벤트가 현실적입니다.

Kubernetes에서 누락이 잘 생기는 구간: 재시작, OOM, 리밸런스

보상 누락은 애플리케이션 버그도 있지만, Kubernetes 런타임 이벤트와 결합될 때 폭발합니다.

  • 파드가 OOM으로 죽어 “보상 발행 직전”에 종료
  • 롤링 업데이트 중 SIGTERM 처리 미흡으로 처리 중단
  • 컨슈머 리밸런스로 처리 중인 파티션이 이동

특히 OOM은 “가끔”이 아니라 “특정 트래픽 패턴에서 반복”되는 경우가 많습니다. 노드/파드 메모리 압박이 의심되면 아래 글의 흐름으로 원인을 빨리 좁힐 수 있습니다.

보상 누락을 줄이는 Kafka 처리 패턴

관측으로 찾는 것만큼 중요한 게 “애초에 덜 누락되게” 만드는 것입니다.

1) Outbox 패턴으로 발행 원자성 확보

DB 업데이트와 이벤트 발행이 분리되면, 보상 요청 이벤트가 유실될 수 있습니다. Outbox 테이블에 기록하고 CDC나 폴링으로 Kafka에 발행하면 원자성을 크게 올릴 수 있습니다.

2) 컨슈머는 멱등하게

보상은 “중복 실행”이 더 안전한 경우가 많습니다. 따라서 보상 핸들러는 다음 중 하나로 멱등성을 확보합니다.

  • saga.id + saga.step + saga.action을 유니크 키로 처리 로그 테이블에 기록
  • 외부 API 호출은 idempotency key 사용

3) 재시도와 DLQ를 분리하고, DLQ를 관측 대상으로 포함

DLQ는 “버려지는 곳”이 아니라 “운영 큐”입니다.

  • DLQ 유입량 메트릭화
  • DLQ 메시지에도 saga-id, traceparent 유지
  • DLQ 컨슈머에서 재처리 시 원 트레이스에 연결

운영에서 바로 써먹는 탐지 플레이북

아래는 “보상 누락”이 의심될 때의 빠른 루틴입니다.

1) 사가 단위로 트레이스/로그를 묶는다

  • 로그에 saga.id를 반드시 찍고
  • 트레이스 span에도 saga.id 속성을 넣어
  • 관측 도구에서 saga.id로 검색 가능하게 합니다.

2) Kafka에서 해당 saga-id의 이벤트 흐름을 확인한다

  • 보상 요청 토픽에 레코드가 있는지
  • 파티션/오프셋이 어디인지
  • 컨슈머 그룹 lag이 급증했는지

이때 OTel span에 messaging.kafka.partition, message_key 같은 속성이 있으면 “토픽에서 무엇을 봐야 하는지”가 빨라집니다.

3) 파드 재시작/리밸런스 이벤트와 시간축을 맞춘다

  • 보상 요청 직후 파드가 재시작했는지
  • 롤링 업데이트 타이밍인지
  • HPA 스케일링으로 리밸런스가 잦았는지

4) 누락을 “재처리 가능”하게 복구한다

  • 보상 요청 이벤트가 없다면: Outbox 재발행 또는 DB 상태 기반 재생성
  • 보상 이벤트는 있는데 소비가 안 됐다면: 컨슈머 그룹 상태/권한/스키마 문제 해결 후 재처리
  • 소비했는데 실패했다면: DLQ에서 원인 수정 후 재처리

보상 누락을 위한 최소 계측 체크리스트

아래 10개가 갖춰지면 “보상 누락”은 더 이상 미궁이 아닙니다.

  1. Kafka 헤더에 traceparent 주입
  2. Kafka 헤더에 saga-id 주입
  3. 모든 서비스 span에 saga.id, saga.step, saga.action 속성
  4. 보상 시작/완료/실패를 span 이벤트로 기록
  5. compensation_requested_total, completed_total, failed_total 메트릭
  6. DLQ 유입량 메트릭 + DLQ 메시지에도 동일 헤더 유지
  7. 컨슈머 멱등성 키 설계
  8. Outbox 또는 트랜잭션 프로듀서로 발행 신뢰성 강화
  9. SIGTERM graceful shutdown에서 in-flight 처리/커밋 정책 점검
  10. “사가 실패 후 N분 내 보상 완료” SLO 또는 알람 규칙

마무리

Kubernetes에서 Saga 보상 누락은 “특정 코드 한 줄”의 문제가 아니라, 이벤트 발행 신뢰성, 컨슈머 처리 모델, 파드 생명주기, 관측 가능성이 얽힌 운영 문제로 나타납니다.

해결의 핵심은 두 가지입니다.

  • Kafka 메시지 흐름과 OTel 트레이스를 saga-id로 단단히 묶어, 누락을 추적 가능한 형태로 만들기
  • 누락을 “부재”로 보지 말고, 메트릭/감사 이벤트/규칙으로 탐지 가능한 신호로 바꾸기

이렇게 구성하면 “보상이 왜 안 됐지”를 사람의 추리로 푸는 시간이 줄고, 알람과 데이터로 바로 원인 구간(발행, 브로커, 소비, 런타임)을 좁힐 수 있습니다.