Published on

Temporal Saga 보상트랜잭션 중복 실행 막는 법

Authors

서버리스나 마이크로서비스 환경에서 Saga 패턴을 쓰다 보면, “보상 트랜잭션이 두 번 실행됐다”는 사고가 종종 터집니다. 결제 취소가 중복으로 나가거나, 재고 복구가 두 번 적용되거나, 외부 시스템에 동일한 환불 요청이 여러 번 전송되는 식입니다. Temporal은 워크플로 리플레이와 재시도 모델이 강력한 대신, 보상 로직을 아무 생각 없이 작성하면 중복 실행을 스스로 초대할 수 있습니다.

이 글에서는 Temporal Saga에서 보상(Compensation) 중복 실행이 왜 일어나는지부터, “중복 실행을 허용하되 결과는 한 번만 반영”하는 멱등성 설계, 그리고 “아예 실행 자체를 한 번만 하도록” 워크플로 상태와 조건부 로직으로 가드하는 패턴까지 단계적으로 정리합니다.

참고로, 재시도/백오프 설계 자체가 중복 실행을 유발하는 경우도 많습니다. 재시도 전략을 더 견고하게 만들고 싶다면 OpenAI 429·insufficient_quota 재시도와 백오프 설계도 같이 읽어보면 도움이 됩니다.

Temporal에서 보상 트랜잭션이 중복 실행되는 대표 원인

Temporal 워크플로는 “코드를 실행한다”기보다 “이벤트 히스토리를 기반으로 결정(Decision)을 재구성한다”는 모델입니다. 이 특성 때문에 아래 상황에서 보상이 중복으로 보일 수 있습니다.

1) Activity 재시도에 의한 중복 호출

보상 로직을 Activity로 구현했다면, 네트워크 타임아웃이나 워커 크래시로 Activity 결과가 워크플로에 보고되지 못할 수 있습니다. 이때 Temporal은 Activity를 재시도하고, 외부 시스템에는 동일한 보상 요청이 다시 날아갈 수 있습니다.

2) 워크플로 코드 변경/리플레이로 인한 “보상 등록” 중복

Saga 구현에서 흔히 “보상 함수를 리스트에 쌓아두고 실패 시 역순 실행”을 합니다. 그런데 리플레이 중에 동일한 보상 핸들러를 또 등록해버리면, 실패 시 보상이 두 번 실행될 수 있습니다.

Temporal에서 워크플로 코드는 리플레이 가능해야 하므로, “실행 중 한 번만 수행되어야 하는 로직”을 워크플로 코드에서 직접 수행하거나, 비결정적 조건(현재 시간, 랜덤, 외부 I/O)을 섞으면 위험합니다.

3) 워크플로 재시도/Continue-As-New/Signal 처리 레이스

워크플로 자체에 재시도 정책을 걸거나, ContinueAsNew로 히스토리를 잘라 운영하는 경우, 보상 단계가 여러 실행 경로에서 트리거될 수 있습니다. Signal로 상태를 갱신하는 중에 실패가 겹치면 “보상을 해야 하는 상태”가 중복으로 관측되는 레이스도 생깁니다.

핵심 원칙: 보상은 반드시 멱등해야 한다

Temporal에서 “중복 실행이 절대 일어나지 않는다”를 보장하려고 하면 설계가 과도하게 복잡해집니다. 현실적인 목표는 다음 중 하나입니다.

  • 목표 A: 보상 Activity가 여러 번 호출돼도 외부 효과는 한 번만 반영(멱등)
  • 목표 B: 워크플로 레벨에서 보상 실행 자체를 1회로 가드(상태 기반)

대부분은 A와 B를 함께 적용합니다. 즉, 워크플로에서 최대한 중복 실행을 막고, 그래도 발생 가능한 중복 호출은 멱등성으로 흡수합니다.

패턴 1: 보상 Activity에 멱등 키를 강제하기

가장 강력하고 실전적인 방법입니다. 보상 요청에 “멱등 키(idempotency key)”를 넣고, 외부 시스템(또는 우리 DB)에서 해당 키로 중복 요청을 차단합니다.

  • 멱등 키 구성 예시
    • workflowId + runId + stepName
    • 또는 “비즈니스적으로 동일 보상”을 표현하는 키(예: orderId + compensationType)

Temporal의 Activity는 activityId를 지정할 수 있지만, 이것만으로 외부 시스템 중복을 완전히 막을 수는 없습니다. 외부 호출이 이미 나갔는데 결과만 유실된 케이스가 있기 때문입니다. 따라서 “외부 시스템이 멱등 키를 이해하도록” 만드는 것이 정석입니다.

예시: TypeScript SDK에서 보상 Activity 멱등 처리

아래 예시는 “환불 보상”을 Activity로 호출할 때 멱등 키를 함께 전달하고, 서버는 해당 키로 중복 환불을 거부하거나 기존 결과를 반환합니다.

// activities.ts
export async function refundPayment(input: {
  orderId: string;
  amount: number;
  idempotencyKey: string;
}) {
  const res = await fetch('https://pay.example.com/refunds', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'idempotency-key': input.idempotencyKey,
    },
    body: JSON.stringify({ orderId: input.orderId, amount: input.amount }),
  });

  if (!res.ok) {
    throw new Error(`refund failed: ${res.status}`);
  }

  return await res.json();
}

워크플로에서는 멱등 키를 결정적으로 만들어야 합니다. Date.now() 같은 값을 쓰면 리플레이 시 달라져서 위험합니다.

// workflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { refundPayment } = proxyActivities<typeof activities>({
  startToCloseTimeout: '30s',
  retry: {
    maximumAttempts: 8,
    initialInterval: '1s',
  },
});

export async function orderWorkflow(input: { orderId: string; amount: number }) {
  const idempotencyKey = `refund:${input.orderId}`;

  // ... 결제/재고 등 진행

  // 실패 시 보상
  await refundPayment({
    orderId: input.orderId,
    amount: input.amount,
    idempotencyKey,
  });
}

이렇게 하면 Activity가 재시도되거나 워커가 죽었다 살아나도 “외부 효과는 한 번만” 반영됩니다.

패턴 2: 워크플로 상태로 “보상 실행 여부”를 기록하고 조건부 실행

멱등성만으로 충분한 경우가 많지만, 보상 자체가 비용이 크거나(외부 API 비용, 레이트리밋), 부작용이 있는 경우 “아예 두 번째 호출을 하지 않도록” 워크플로에서 가드하는 게 좋습니다.

Temporal 워크플로는 상태를 로컬 변수로 유지할 수 있고, 그 상태는 히스토리 기반으로 리플레이 시 동일하게 재구성됩니다(결정적 코드라는 전제). 따라서 아래처럼 “보상 실행 플래그”를 두고, 보상 전에 확인합니다.

import { proxyActivities, defineSignal, setHandler } from '@temporalio/workflow';
import type * as activities from './activities';

const { refundPayment } = proxyActivities<typeof activities>({
  startToCloseTimeout: '30s',
});

const markRefunded = defineSignal('markRefunded');

export async function orderWorkflow(input: { orderId: string; amount: number }) {
  let refunded = false;

  setHandler(markRefunded, () => {
    refunded = true;
  });

  try {
    // ... 메인 트랜잭션
    throw new Error('force fail');
  } catch (e) {
    if (!refunded) {
      await refundPayment({
        orderId: input.orderId,
        amount: input.amount,
        idempotencyKey: `refund:${input.orderId}`,
      });
      refunded = true;
    }
    throw e;
  }
}

주의할 점은 “Activity 성공 후 플래그를 세팅하기 전에 워크플로 태스크가 실패”하는 경우입니다. 이 경우 리플레이 시 다시 보상을 호출할 수 있으므로, 결국 패턴 1(외부 멱등성)과 같이 써야 안전합니다.

패턴 3: 보상 실행을 ‘단일 원자 단계’로 만들기 (상태 저장소 활용)

보상 실행을 정말로 한 번만 하려면, 워크플로 내부 플래그만으로는 원자성을 보장하기 어렵습니다. 가장 흔한 해법은 “우리 DB에 보상 실행 레코드를 먼저 기록하고, 그 기록이 성공한 경우에만 보상 Activity를 수행”하는 방식입니다.

핵심은 DB의 유니크 제약으로 중복 실행을 차단하는 것입니다.

예시: PostgreSQL로 보상 실행 1회 보장

  1. 보상 실행 시도 시 compensations 테이블에 먼저 insert
  2. 유니크 키 충돌이면 이미 실행(또는 실행 중)이므로 스킵
  3. insert 성공이면 보상 수행 후 상태를 DONE으로 업데이트
create table if not exists compensations (
  key text primary key,
  status text not null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);
// activities.ts (DB 접근은 Activity에서)
export async function tryBeginCompensation(key: string): Promise<boolean> {
  // pseudo code
  // insert into compensations(key, status) values ($1, 'STARTED')
  // on conflict do nothing
  // return row inserted?
  return true;
}

export async function markCompensationDone(key: string): Promise<void> {
  // update compensations set status='DONE', updated_at=now() where key=$1
}
// workflow.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';

const { tryBeginCompensation, refundPayment, markCompensationDone } =
  proxyActivities<typeof activities>({ startToCloseTimeout: '30s' });

export async function orderWorkflow(input: { orderId: string; amount: number }) {
  const key = `refund:${input.orderId}`;

  try {
    // ...
    throw new Error('fail');
  } catch (e) {
    const shouldRun = await tryBeginCompensation(key);
    if (shouldRun) {
      await refundPayment({
        orderId: input.orderId,
        amount: input.amount,
        idempotencyKey: key,
      });
      await markCompensationDone(key);
    }
    throw e;
  }
}

이 패턴의 장점은 “워크플로가 어떤 이유로 재실행/재시도되어도 DB 유니크 키가 최후의 방어선”이 되어준다는 점입니다. 단점은 DB 의존성이 생기고, STARTED에서 멈춘 레코드(보상 실행 중 워커 다운 등)에 대한 청소/재개 정책이 필요합니다.

이런 “중복/유실/꼬임” 문제는 이벤트 소싱에서도 자주 발생합니다. 복구 전략 관점은 Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략 글도 결이 비슷합니다.

패턴 4: 보상 Activity의 Retry 정책을 ‘중복 비용’에 맞게 조정

Temporal의 기본 재시도는 강력하지만, 보상은 성격이 다릅니다.

  • 보상은 “실패해도 재시도해야 하는” 경우가 많지만
  • 동시에 “중복 호출 비용”이 큰 경우도 많습니다(환불 API, 이메일 발송, 재고 조정)

권장 접근:

  1. 보상 Activity는 반드시 멱등하게 만든다(필수)
  2. 재시도는 하되, 빠른 연타를 피하도록 백오프를 둔다
  3. nonRetryableErrorTypes로 “재시도해도 소용없는 오류”는 즉시 실패 처리

TypeScript 예시:

const { refundPayment } = proxyActivities<typeof activities>({
  startToCloseTimeout: '20s',
  retry: {
    initialInterval: '2s',
    backoffCoefficient: 2,
    maximumInterval: '1m',
    maximumAttempts: 10,
    nonRetryableErrorTypes: ['InvalidRequestError', 'PermissionDeniedError'],
  },
});

재시도/백오프는 외부 API 레이트리밋과도 직결됩니다. 레이트리밋 대응까지 포함한 설계는 OpenAI 429 Rate Limit 해결 - 백오프·큐·배치에서 설명한 패턴을 그대로 응용할 수 있습니다.

패턴 5: “보상 등록” 자체가 중복되지 않게 Saga 구현을 점검

Temporal에서 Saga를 직접 구현할 때 흔한 실수는 다음입니다.

  • 워크플로 실행 중 어떤 분기에서 보상 함수를 등록
  • 리플레이 시에도 같은 분기가 평가되며 보상 등록이 다시 일어남
  • 실패 시 보상 리스트에 동일 항목이 중복으로 들어가 역순 실행 때 두 번 호출

해결 방향:

  • 보상 등록을 “한 번만 일어나도록” 결정적 조건과 상태로 가드
  • 또는 보상 리스트에 넣을 때 키 기반으로 중복 제거

간단한 중복 제거 예시(워크플로 내 자료구조 사용):

type Compensation = {
  key: string;
  run: () => Promise<void>;
};

export async function wf() {
  const comps: Compensation[] = [];
  const seen = new Set<string>();

  function addCompensation(c: Compensation) {
    if (seen.has(c.key)) return;
    seen.add(c.key);
    comps.push(c);
  }

  addCompensation({
    key: 'refund:order-123',
    run: async () => {
      // call activity
    },
  });

  try {
    // ...
  } catch (e) {
    for (const c of comps.reverse()) {
      await c.run();
    }
    throw e;
  }
}

다만 이 방식은 “보상 실행 시점의 원자성”을 해결해주진 않습니다. 결국 외부 멱등 키 또는 DB 유니크 키가 함께 있어야 운영에서 안전합니다.

운영 체크리스트: 중복 보상을 ‘사고’로 만들지 않기

  • 보상 Activity는 무조건 멱등 키를 받는다
  • 외부 시스템이 멱등 키를 지원하지 않으면, 우리 DB로 멱등 레이어를 만든다
  • 워크플로 내부에서는 보상 실행 여부를 상태로 가드한다(불필요한 호출 감소)
  • 보상 Activity 재시도 정책은 백오프 포함으로 보수적으로 설정한다
  • STARTED에서 멈춘 보상 레코드(또는 외부 요청)의 재처리 정책을 문서화한다
  • 관측성: 보상 키를 로그/트레이스의 코어 필드로 남긴다(검색 가능하게)

마무리

Temporal Saga에서 보상 트랜잭션 중복 실행을 완전히 “없애는” 것보다, 중복이 발생해도 안전하도록 멱등성과 상태 기록을 설계하는 것이 현실적인 정답입니다. 가장 추천하는 조합은 다음입니다.

  • 1순위: 외부 호출에 멱등 키 적용
  • 2순위: 워크플로 상태로 중복 호출 가드
  • 3순위: DB 유니크 키로 보상 실행 1회 보장(필요 시)

이 3가지를 갖추면, 재시도/워커 재시작/리플레이가 일상인 분산 환경에서도 보상 트랜잭션을 “중복 실행돼도 안전한 시스템”으로 만들 수 있습니다.