Published on

Temporal 사가 타임아웃·재시도 튜닝 실전

Authors

분산 트랜잭션 사가(Saga)를 Temporal로 구현하면, 오케스트레이션과 내구성(durability)은 강력해지지만 타임아웃·재시도 튜닝을 대충 잡는 순간 문제가 바로 터집니다.

  • 타임아웃이 짧으면 정상 요청도 실패로 처리되어 보상(Compensation)이 과도하게 실행됩니다.
  • 타임아웃이 길면 장애 감지가 늦고, 워커가 오래 붙잡히며 병목이 생깁니다.
  • 재시도가 공격적이면 다운스트림을 두들겨 장애를 증폭시키고, 보상 중복/경합이 늘어납니다.

이 글은 Temporal에서 사가를 운영 수준으로 만들기 위해 워크플로우/액티비티 타임아웃, 재시도 정책, 보상 액티비티, 외부 API 호출을 어떻게 분리해 설계하고 숫자를 잡는지 실전 관점에서 정리합니다.

관련해서 사가의 보상 실패·중복 처리 자체가 고민이라면 먼저 아래 글을 같이 보는 게 좋습니다.


Temporal에서 타임아웃이 3겹인 이유

Temporal은 “재시도”를 단순히 HTTP 재시도처럼 취급하지 않습니다. 최소 세 레이어를 분리해서 생각해야 튜닝이 됩니다.

  1. Workflow Task Timeout: 워크플로우 코드가 실행되는 단위(리플레이 포함)에서의 작업 시간 제한
  2. Activity Timeout: 실제 외부 작업(결제, 재고 차감, 배송 생성 등)을 수행하는 액티비티 실행 제한
  3. External Call Timeout: 액티비티 내부에서 호출하는 DB/HTTP/gRPC의 클라이언트 타임아웃

특히 3번(외부 호출 타임아웃)을 제대로 설정하지 않으면, 액티비티 타임아웃이 의미가 없어집니다. 예를 들어 HTTP 클라이언트가 무기한 대기하면 액티비티 워커 스레드/코루틴이 붙잡혀 워커 전체가 고갈될 수 있습니다.

운영 원칙은 다음처럼 잡는 게 안전합니다.

  • external_call_timeoutactivity_start_to_close_timeout 보다 짧게
  • activity_start_to_close_timeoutretry_total_window 보다 짧게
  • 보상 액티비티도 동일하게 타임아웃/재시도 정책을 별도 정의

사가에서 흔한 실패 패턴 5가지

1) 타임아웃은 짧고, 재시도는 길어서 “영원히 못 끝나는” 사가

짧은 타임아웃으로 계속 실패하고, 긴 재시도 윈도우로 계속 두들기는 형태입니다. 결과적으로 워크플로우는 오래 살아있고, 다운스트림은 지속적으로 압박받습니다.

2) 재시도 폭풍(retry storm)으로 다운스트림 장애를 증폭

지수 백오프 없이 고정 간격 재시도, 혹은 최대 시도 횟수 과다 설정이 원인입니다.

3) 보상(Compensation)도 똑같이 재시도해서 “이중 폭풍”

정방향 액티비티 실패로 재시도 폭풍이 발생했는데, 보상도 같은 정책으로 재시도해 더 악화됩니다.

4) 외부 API는 성공했는데 액티비티가 타임아웃으로 실패 처리

예: 결제 승인 요청은 성공했지만 응답이 늦어 타임아웃. 이후 재시도 시 결제 중복 승인 위험.

이 경우 idempotency key(멱등 키)와 “조회 기반 확인” 패턴이 필요합니다.

5) 워커 재시작/배포 시 장기 실행 액티비티가 끊겨 반복 수행

액티비티가 장시간 실행되면 배포/스케일링 이벤트에 취약합니다. 하트비트/청크 분할로 해결합니다.


타임아웃 설계: ScheduleToStart vs StartToClose부터 분리

Temporal 액티비티 타임아웃은 보통 아래 2개를 핵심으로 봅니다.

  • scheduleToStartTimeout: 큐에 쌓인 뒤 워커가 집어가기까지 허용 시간
  • startToCloseTimeout: 워커가 잡은 뒤 실제 실행 시간 허용

권장 설계

  • scheduleToStartTimeout 은 “워커 용량 문제를 감지하는 SLA”로 씁니다.
    • 예: 30초를 넘기면 워커가 밀리고 있다는 신호
  • startToCloseTimeout 은 “외부 시스템 SLA + 여유분”으로 잡습니다.
    • 예: 결제 승인 API p99 800ms라면 3초~5초 수준

숫자 잡는 간단한 방법

  1. 외부 호출 p95/p99를 측정한다(최소 1주 이상)
  2. p99 x 3 정도를 external_call_timeout 으로
  3. 그보다 약간 큰 값을 startToCloseTimeout 으로
  4. 워커 큐 지연 허용치를 scheduleToStartTimeout 으로

재시도 정책 튜닝: “일시적 실패”만 재시도하라

Temporal은 액티비티에 RetryPolicy를 붙일 수 있고, 실패 타입(에러)별로 재시도 제외도 가능합니다. 핵심은 재시도 가능한 실패를 엄격히 정의하는 것입니다.

  • 재시도 적합: 네트워크 타임아웃, 5xx, rate limit(429), 일시적 락 경합
  • 재시도 부적합: 4xx(검증 실패), 비즈니스 규칙 위반, 잔액 부족 같은 결정적 실패

또한 재시도는 “최대 시도 횟수”보다 총 재시도 윈도우(예: 2분, 10분)를 먼저 정하는 편이 운영에 유리합니다.


TypeScript 예제: 사가 워크플로우에서 타임아웃·재시도 분리

아래 예시는 Temporal TypeScript SDK 기준이며, 사가의 정방향 액티비티와 보상 액티비티에 서로 다른 타임아웃/재시도를 적용합니다.

// workflows/orderSaga.ts
import { proxyActivities } from '@temporalio/workflow';

// 액티비티 정의(실제 구현은 worker에서)
interface Activities {
  reserveInventory(input: { orderId: string; amount: number }): Promise<void>;
  releaseInventory(input: { orderId: string }): Promise<void>;

  authorizePayment(input: { orderId: string; idempotencyKey: string }): Promise<{ authId: string }>;
  cancelPayment(input: { authId: string; reason: string }): Promise<void>;

  createShipment(input: { orderId: string }): Promise<{ shipmentId: string }>;
  cancelShipment(input: { shipmentId: string }): Promise<void>;
}

// 정방향: 빠르게 실패 감지 + 적절한 백오프
const forward = proxyActivities<Activities>({
  startToCloseTimeout: '5s',
  scheduleToStartTimeout: '30s',
  retry: {
    initialInterval: '200ms',
    backoffCoefficient: 2,
    maximumInterval: '5s',
    maximumAttempts: 6,
    // 비즈니스/검증 오류는 재시도 제외(액티비티 구현에서 해당 에러 타입으로 throw)
    nonRetryableErrorTypes: ['ValidationError', 'InsufficientBalanceError'],
  },
});

// 보상: 다운스트림 보호를 위해 더 완만하게, 더 길게
const compensate = proxyActivities<Activities>({
  startToCloseTimeout: '10s',
  scheduleToStartTimeout: '1m',
  retry: {
    initialInterval: '1s',
    backoffCoefficient: 2,
    maximumInterval: '30s',
    maximumAttempts: 10,
    nonRetryableErrorTypes: ['NotFoundError'],
  },
});

export async function orderSaga(orderId: string) {
  const idempotencyKey = `payment:${orderId}`;

  let authId: string | undefined;
  let shipmentId: string | undefined;

  try {
    await forward.reserveInventory({ orderId, amount: 1 });

    const auth = await forward.authorizePayment({ orderId, idempotencyKey });
    authId = auth.authId;

    const shipment = await forward.createShipment({ orderId });
    shipmentId = shipment.shipmentId;

    return { ok: true, orderId, authId, shipmentId };
  } catch (err) {
    // 보상은 "역순"으로, 가능한 작업만 수행
    if (shipmentId) {
      await compensate.cancelShipment({ shipmentId });
    }
    if (authId) {
      await compensate.cancelPayment({ authId, reason: 'SAGA_FAILED' });
    }
    await compensate.releaseInventory({ orderId });

    throw err;
  }
}

포인트는 다음입니다.

  • 정방향은 scheduleToStartTimeout 을 짧게 잡아 워커 적체를 빠르게 드러냅니다.
  • 보상은 상대적으로 “급하지 않지만 반드시 해야 하는” 성격이므로 완만한 백오프와 긴 윈도우가 안전합니다.
  • 결제는 idempotencyKey 를 반드시 사용합니다. 타임아웃으로 실패 처리되어도 재시도 시 중복 승인을 막아야 합니다.

액티비티 내부 외부 호출 타임아웃: Temporal 타임아웃만 믿지 마라

Temporal의 startToCloseTimeout 은 “액티비티 전체 실행 시간” 상한입니다. 하지만 액티비티 내부에서 HTTP 클라이언트가 60초 기본 타임아웃(혹은 무제한)이라면, 워커 리소스가 먼저 고갈됩니다.

아래는 Node.js에서 fetch에 명시적으로 타임아웃을 거는 예시입니다.

// activities/http.ts
export async function fetchWithTimeout(url: string, ms: number) {
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), ms);

  try {
    const res = await fetch(url, { signal: ac.signal });
    if (!res.ok) {
      const body = await res.text().catch(() => '');
      throw new Error(`UpstreamError status=${res.status} body=${body}`);
    }
    return await res.json();
  } finally {
    clearTimeout(t);
  }
}

운영 팁:

  • external_call_timeout 을 액티비티 startToCloseTimeout 의 50~80% 수준으로 둡니다.
  • 업스트림이 429를 주는 경우, 재시도는 하되 Retry-After 를 존중하거나, 액티비티에서 지연 후 재시도하도록 설계합니다.

하트비트와 장기 실행 액티비티: 끊김을 “재시도”로 해결하지 말 것

배송 라벨 생성처럼 수 초~수 분이 걸리는 작업을 하나의 액티비티로 묶으면 배포/장애에 취약합니다.

Temporal은 액티비티 하트비트를 지원합니다. 하트비트를 쓰면 워커 장애 시에도 진행 상태를 기반으로 재개할 수 있고, 타임아웃도 더 정교하게 운영할 수 있습니다.

  • 긴 작업은 “청크”로 쪼개고
  • 하트비트에 진행률(예: 마지막 처리한 레코드 ID)을 넣고
  • 재시작 시 하트비트 값을 읽어 이어서 처리합니다.

이 패턴은 사가에서도 특히 중요합니다. 보상 액티비티가 길어질 수 있기 때문입니다.


사가 타임아웃·재시도 튜닝 체크리스트

1) 실패를 분류하고, 재시도 제외 목록을 먼저 만든다

  • 검증 실패/비즈니스 불가: 즉시 실패
  • 일시적 실패: 재시도
  • 알 수 없는 실패: 제한된 윈도우로만 재시도

2) 정방향과 보상 정책을 분리한다

  • 정방향: 빠른 감지, 짧은 윈도우
  • 보상: 시스템 보호, 완만한 백오프, 더 긴 윈도우

3) scheduleToStartTimeout 으로 워커 적체를 가시화한다

  • 워커 오토스케일링이나 큐 적체 알람을 붙일 수 있습니다.

4) 외부 호출 타임아웃은 액티비티보다 짧게

  • 액티비티 타임아웃만 믿으면 워커가 묶입니다.

5) 멱등성은 “재시도”의 전제 조건이다

  • 결제/주문 생성/포인트 차감 등은 반드시 멱등 키를 도입합니다.

6) 재시도 폭풍을 막는 가드레일

  • 지수 백오프 필수
  • 최대 시도 횟수보다 “총 윈도우”를 제한
  • 장애 시 서킷 브레이커(액티비티 내부)나 다운스트림 rate limit 고려

systemd에서 재시작 정책을 잘못 잡으면 무한 재시작 루프가 생기듯, Temporal 재시도도 비슷한 운영 사고를 만듭니다. 재시도는 회복을 돕지만, 잘못 튜닝하면 장애 증폭기가 됩니다.


운영 관점의 권장 기본값(출발점)

서비스마다 다르지만, “처음 적용해도 크게 위험하지 않은” 출발점은 다음과 같습니다.

  • 외부 HTTP/gRPC 호출 타임아웃: p99 x 3 (대개 1~3초)
  • 정방향 액티비티 startToCloseTimeout: 외부 호출 타임아웃 + 1~2초
  • 정방향 재시도: initial 200ms, backoff 2, max interval 5s, max attempts 5~7
  • 보상 액티비티 startToCloseTimeout: 10~30초(외부 시스템 특성 반영)
  • 보상 재시도: initial 1s, backoff 2, max interval 30s, max attempts 8~12

그리고 반드시 다음을 같이 도입하세요.

  • 멱등 키(idempotency key)
  • 재시도 제외 에러 타입 정의
  • 워커 큐 지연(scheduleToStart) 모니터링
  • 다운스트림 429/5xx 비율 기반 알람

마무리: “타임아웃은 SLA, 재시도는 복구 전략”으로 분리하라

Temporal로 사가를 안정화하는 핵심은 타임아웃과 재시도를 감(感)으로 정하지 않는 것입니다.

  • 타임아웃은 “얼마나 기다릴 것인가”가 아니라 어떤 병목을 드러낼 것인가(워커 적체 vs 외부 시스템 지연)를 정의하는 도구입니다.
  • 재시도는 “성공할 때까지”가 아니라 일시적 실패를 흡수하되 장애를 증폭시키지 않는 복구 전략입니다.
  • 사가에서는 정방향과 보상은 성격이 다르므로 정책을 분리해야 합니다.

이 원칙대로 숫자를 잡고, p95/p99와 큐 지연을 관측하면서 조금씩 조정하면 Temporal 기반 사가가 운영 환경에서도 예측 가능하게 동작합니다.