Published on

Temporal로 분산 트랜잭션 재시도·보상 패턴

Authors

분산 환경에서 결제, 재고 차감, 배송 생성 같은 작업을 한 번에 묶어 “모두 성공 아니면 모두 롤백”으로 만들고 싶지만, 현실은 네트워크 타임아웃과 부분 성공, 중복 요청, 서비스 장애로 가득합니다. 2PC 같은 전통적 분산 트랜잭션은 운영 복잡도와 성능 비용이 커서 마이크로서비스에서 잘 맞지 않는 경우가 많습니다.

Temporal은 이런 문제를 “워크플로우 실행 상태를 내구적으로 저장하고, 실패를 재현 가능하게 만들며, 재시도와 보상 로직을 코드로 명시”하는 방식으로 풀어줍니다. 이 글에서는 Temporal로 분산 트랜잭션 워크플로우를 설계할 때 핵심이 되는 재시도·보상(Saga) 패턴을 실전 관점에서 정리합니다.

참고로 재시도 정책을 설계할 때는 외부 API의 레이트 리밋과 백오프 전략도 같이 고려해야 합니다. 백오프/큐/배치 관점은 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치 글의 원칙을 그대로 응용할 수 있습니다.

Temporal이 분산 트랜잭션에 맞는 이유

Temporal은 “실행 중인 비즈니스 프로세스”를 워크플로우로 모델링하고, 워크플로우의 진행 상태를 이벤트 히스토리로 저장합니다. 그래서 프로세스가 수 분, 수 시간, 심지어 수 일에 걸쳐 이어져도 다음이 가능합니다.

  • 워커 프로세스가 죽어도 워크플로우는 중단되지 않고 재개됨
  • 재시도/타임아웃/보상 같은 정책을 코드로 선언하고, 실행은 Temporal이 책임짐
  • 외부 호출은 Activity로 분리해 실패 격리 및 재시도를 적용
  • 워크플로우는 결정적(deterministic)으로 재실행되며, 상태는 히스토리로 복원됨

Temporal을 “분산 트랜잭션 프레임워크”로 오해하면 안 됩니다. DB 트랜잭션을 대신해 주는 게 아니라, 분산된 단계들의 성공/실패를 오케스트레이션하고, 실패 시 재시도 또는 보상을 통해 최종 일관성(eventual consistency)을 달성하게 해줍니다.

기본 전략: 재시도와 보상의 역할 분리

분산 트랜잭션을 Temporal로 설계할 때 가장 중요한 원칙은 다음입니다.

  • 재시도는 “일시적 실패”를 흡수한다
    • 네트워크 오류, 5xx, 타임아웃, 레이트 리밋 등
  • 보상은 “이미 커밋된 변경”을 되돌리거나 상쇄한다
    • 결제 승인 완료 후 재고 차감 실패라면 결제 취소(환불) 같은 보상 필요

즉, “재시도하면 해결될 가능성이 있는가?”를 먼저 판단하고, 그렇지 않다면 보상으로 전환합니다.

재시도 설계 체크리스트

  • 어떤 에러를 재시도할지 분류(예: 429, 503, 네트워크 오류)
  • 백오프(지수 백오프 + 지터) 적용
  • 최대 시도 횟수/최대 재시도 기간 설정
  • 타임아웃을 짧게 잡아 빠르게 실패하고 재시도(특히 외부 API)
  • 아이템포턴시 키(중복 호출 방지)

보상 설계 체크리스트

  • 보상은 “완벽한 롤백”이 아니라 “비즈니스적으로 수용 가능한 상쇄”일 수 있음
  • 보상도 실패할 수 있으므로 보상 자체의 재시도/에스컬레이션 필요
  • 보상 순서는 일반적으로 성공한 단계의 역순
  • 보상은 멱등(idempotent)해야 함

예시 시나리오: 주문 생성 분산 트랜잭션

주문 프로세스를 단순화하면 다음 단계가 흔합니다.

  1. 결제 승인(외부 PG)
  2. 재고 차감(Inventory 서비스)
  3. 배송 요청(Shipping 서비스)
  4. 주문 상태 확정(Order 서비스)

여기서 1번이 성공한 뒤 2번이 실패하면, 1번을 보상(결제 취소)해야 합니다. 반대로 2번이 일시적 장애라면 재시도로 해결할 수 있어야 합니다.

Temporal 워크플로우/액티비티 코드 예제(TypeScript)

아래 예시는 Temporal TypeScript SDK 기준의 전형적인 구조입니다. 핵심은 Activity에 재시도/타임아웃을 걸고, 워크플로우에서는 보상 스택을 관리하는 방식입니다.

Activity 정의(외부 시스템 호출)

// activities.ts
export async function authorizePayment(input: {
  orderId: string;
  amount: number;
  idempotencyKey: string;
}) {
  // 외부 PG 호출 (예: fetch/axios)
  // 반드시 idempotencyKey를 PG에 전달하거나 내부적으로 중복 방지
  return { paymentId: "pay_123" };
}

export async function captureInventory(input: {
  orderId: string;
  skuId: string;
  qty: number;
  idempotencyKey: string;
}) {
  return { reservationId: "res_456" };
}

export async function createShipment(input: {
  orderId: string;
  addressId: string;
  idempotencyKey: string;
}) {
  return { shipmentId: "shp_789" };
}

export async function cancelPayment(input: {
  paymentId: string;
  reason: string;
  idempotencyKey: string;
}) {
  return { canceled: true };
}

export async function releaseInventory(input: {
  reservationId: string;
  reason: string;
  idempotencyKey: string;
}) {
  return { released: true };
}

워크플로우: 재시도는 Activity 옵션으로, 보상은 코드로

주의: 워크플로우 코드에서는 비결정적 연산(현재 시간 직접 조회, 랜덤, 외부 I/O)을 피해야 합니다. 랜덤이 필요하면 Temporal API를 사용하거나 Activity로 빼야 합니다.

// workflows.ts
import * as wf from "@temporalio/workflow";
import type * as acts from "./activities";

const {
  authorizePayment,
  captureInventory,
  createShipment,
  cancelPayment,
  releaseInventory,
} = wf.proxyActivities<typeof acts>({
  startToCloseTimeout: "20s",
  scheduleToCloseTimeout: "2m",
  retry: {
    initialInterval: "1s",
    backoffCoefficient: 2,
    maximumInterval: "30s",
    maximumAttempts: 8,
  },
});

type OrderWorkflowInput = {
  orderId: string;
  amount: number;
  skuId: string;
  qty: number;
  addressId: string;
};

export async function orderWorkflow(input: OrderWorkflowInput) {
  const compensationStack: Array<() => Promise<void>> = [];

  // 워크플로우 실행 단위에서 공통으로 쓸 멱등 키 프리픽스
  const runId = wf.workflowInfo().runId;

  try {
    const payment = await authorizePayment({
      orderId: input.orderId,
      amount: input.amount,
      idempotencyKey: `pay-auth:${input.orderId}:${runId}`,
    });

    compensationStack.push(async () => {
      await cancelPayment({
        paymentId: payment.paymentId,
        reason: "workflow_failed",
        idempotencyKey: `pay-cancel:${input.orderId}:${runId}`,
      });
    });

    const inventory = await captureInventory({
      orderId: input.orderId,
      skuId: input.skuId,
      qty: input.qty,
      idempotencyKey: `inv-cap:${input.orderId}:${runId}`,
    });

    compensationStack.push(async () => {
      await releaseInventory({
        reservationId: inventory.reservationId,
        reason: "workflow_failed",
        idempotencyKey: `inv-rel:${input.orderId}:${runId}`,
      });
    });

    const shipment = await createShipment({
      orderId: input.orderId,
      addressId: input.addressId,
      idempotencyKey: `ship-create:${input.orderId}:${runId}`,
    });

    // 여기까지 오면 주문 확정 등 후속 처리
    return {
      ok: true,
      paymentId: payment.paymentId,
      reservationId: inventory.reservationId,
      shipmentId: shipment.shipmentId,
    };
  } catch (err) {
    // 역순 보상
    for (let i = compensationStack.length - 1; i >= 0; i--) {
      try {
        await compensationStack[i]();
      } catch (compErr) {
        // 보상 실패는 별도 알림/티켓/재처리 큐로 보내는 게 일반적
        // Temporal에서는 보상도 Activity 재시도를 통해 상당 부분 흡수 가능
      }
    }

    throw err;
  }
}

이 패턴이 Temporal에서 널리 쓰이는 이유는 명확합니다.

  • “일시적 실패”는 Activity 재시도가 흡수
  • “부분 성공”은 워크플로우가 보상 스택으로 정리
  • 워커가 죽어도 히스토리 기반으로 동일한 보상 로직이 재개됨

재시도 정책을 더 현실적으로 다듬기

위 예제는 모든 Activity에 동일한 재시도를 걸었지만, 실무에서는 액티비티별로 다르게 주는 편이 안전합니다.

  • 결제 승인: 재시도는 하되, 아이템포턴시가 확실해야 함
  • 재고 차감: 내부 서비스면 비교적 공격적 재시도 가능
  • 배송 생성: 외부 택배사 API면 레이트 리밋과 장기 장애를 고려해 보수적으로

Temporal에서는 proxyActivities를 여러 개 만들어 액티비티 그룹별 옵션을 분리할 수 있습니다.

const paymentActs = wf.proxyActivities<typeof acts>({
  startToCloseTimeout: "15s",
  retry: { initialInterval: "1s", backoffCoefficient: 2, maximumAttempts: 6 },
});

const shippingActs = wf.proxyActivities<typeof acts>({
  startToCloseTimeout: "30s",
  retry: {
    initialInterval: "5s",
    backoffCoefficient: 2,
    maximumInterval: "2m",
    maximumAttempts: 10,
  },
});

또한 레이트 리밋이 있는 API를 호출한다면 “재시도만”으로는 부족합니다. 동시성 제한(큐잉), 배치, 지수 백오프 + 지터를 함께 고려해야 합니다. 이 관점은 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치에서 다룬 구조를 그대로 차용할 수 있습니다.

아이템포턴시: Temporal을 써도 반드시 필요하다

Temporal은 워크플로우 실행을 “정확히 한 번”처럼 보이게 해주지만, 외부 시스템 호출은 네트워크/타임아웃 때문에 다음이 발생할 수 있습니다.

  • PG에 요청은 도착했고 결제는 승인됐는데, 응답이 타임아웃
  • 워커가 재시도하면서 같은 승인 요청을 다시 보냄

따라서 외부 시스템은 아이템포턴시 키로 중복을 흡수해야 합니다. 지원하지 않는 시스템이라면 내부에 “요청 로그 테이블 + 유니크 키”를 두고 중복을 차단하는 어댑터를 두는 방식이 흔합니다.

아이템포턴시 키 설계 팁:

  • 워크플로우 실행 단위로 고유해야 함: orderId + runId 조합이 안전
  • 액션 단위로 구분해야 함: pay-auth, pay-cancel처럼 prefix를 분리
  • 외부 시스템이 키 길이에 제한이 있으면 해시를 사용

보상 트랜잭션(Saga)에서 자주 터지는 함정

1) 보상도 실패한다

보상은 “실패할 수 있는 또 다른 분산 호출”입니다. 그래서 보상 액티비티도 재시도/타임아웃이 필요하고, 그래도 실패하면 운영 프로세스가 있어야 합니다.

  • 보상 실패 시 알림(온콜) + 수동 처리 가이드
  • 재처리 워크플로우(별도 워크플로우로 보상만 재시도)
  • Dead letter queue처럼 “보상 실패 목록”을 관리

2) 보상은 완전한 롤백이 아닐 수 있다

배송 생성 이후 취소가 불가능한 구간이 있을 수 있습니다. 이런 경우 보상은 “배송 취소”가 아니라 “반품 프로세스 시작”처럼 비즈니스적으로 다른 흐름이 됩니다. Temporal 워크플로우는 이런 분기와 장기 프로세스를 표현하기 좋습니다.

3) 순서가 중요하다

일반적으로는 성공한 단계의 역순으로 보상합니다.

  • 재고 차감 후 결제 승인이라면: 재고 먼저 풀고 결제 취소
  • 배송 생성까지 갔다면: 배송 취소 가능 여부 확인 후 결제/재고 보상

타임아웃/취소 전파: 워크플로우를 “끝나게” 만들기

재시도를 무한히 걸면 언젠가 성공할 수도 있지만, 그 사이 주문이 영원히 PENDING에 갇힐 수 있습니다. 실무에서는 다음을 명시합니다.

  • 주문 워크플로우 전체에 대한 workflowRunTimeout
  • 각 Activity의 startToCloseTimeout (단일 실행 제한)
  • scheduleToCloseTimeout (재시도 포함 전체 제한)
  • 고객이 주문을 취소했을 때 워크플로우 취소 신호를 받아 중단/보상

TypeScript SDK에서는 워크플로우에 signal을 정의해 취소 요청을 받을 수 있습니다.

// workflows-signals.ts
import * as wf from "@temporalio/workflow";

export const cancelOrderSignal = wf.defineSignal<[string]>("cancelOrder");

export async function cancellableWorkflow() {
  let cancelReason: string | null = null;

  wf.setHandler(cancelOrderSignal, (reason) => {
    cancelReason = reason;
  });

  // 긴 작업 중간중간 cancelReason을 확인하고 보상 후 종료하는 식으로 설계
  // 또는 wf.CancellationScope로 Activity 취소 전파를 활용
}

관측성: 실패를 “재현 가능하게” 만드는 로그/메트릭

Temporal의 장점은 히스토리 기반 재현성입니다. 하지만 운영에서는 다음이 같이 있어야 원인 추적이 빨라집니다.

  • 워크플로우/액티비티에 공통 코릴레이션 ID: orderId, runId
  • 액티비티 실패 원인 분류: 재시도 대상 vs 즉시 실패 대상
  • 외부 API 응답 코드별 카운터 메트릭
  • 재시도 횟수, 최종 실패율, 보상 실행율

특히 재시도 폭주로 외부 시스템을 더 망가뜨리는 상황을 막으려면, 백오프뿐 아니라 “동시성 제한”도 중요합니다. 이런 운영 관점은 레이트 리밋 대응 글인 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치에서 소개한 큐 기반 완충 전략과 연결됩니다.

실전 권장 아키텍처 요약

  • 워크플로우는 “비즈니스 단계”를 표현하고, 외부 호출은 Activity로 분리
  • Activity에는 타임아웃과 재시도 정책을 걸되, 액티비티별로 차등 적용
  • 외부 시스템 호출은 반드시 아이템포턴시 키를 사용
  • 부분 성공이 가능한 흐름은 Saga 보상 패턴으로 설계하고, 보상 실패 시 운영 플랜을 마련
  • 워크플로우 전체 제한 시간과 취소 전파를 설계해 영원히 대기하는 실행을 방지

마무리

Temporal로 분산 트랜잭션을 다루는 핵심은 “원자적 커밋”을 흉내 내는 것이 아니라, 실패가 발생해도 시스템이 일관된 방향으로 수렴하도록 재시도와 보상을 설계하는 데 있습니다. 재시도는 일시적 실패를 흡수하고, 보상은 부분 성공을 정리하며, 아이템포턴시는 중복 호출을 현실적으로 통제합니다.

이 3가지를 워크플로우 코드에 명시하고, 타임아웃/취소/관측성까지 포함해 운영 가능한 형태로 만들면 Temporal은 장기 실행 비즈니스 프로세스를 안정적으로 굴리는 강력한 기반이 됩니다.