구독해서 새 게시물에 대한 알림을 받으세요.

Cloudflare Workflows를 위한 saga 롤백을 구축한 방법

2026-06-25

9분 읽기
이 게시물은 English日本��로도 이용할 수 있습니다.

본 콘텐츠는 사용자의 편의를 고려해 자동 기계 번역 서비스를 사용하였습니다. 영어 원문과 다른 오류, 누락 또는 해석상의 미묘한 차이가 포함될 수 있습니다. 필요하시다면 영어 원문을 참조하시기를 바랍니다.

Cloudflare Workflows를 사용하면 내장된 재시도 및 장기 실행 프로세스에서의 상태 지속성을 갖춘 내구성 있는 다단계 애플리케이션을 구축할 수 있습니다. Workflow가 실행되면 각 단계에서 외부 시스템을 호출하고, 실패를 재시도하며, 다시 시작할 때 상태를 유지할 수 있습니다. 그러나 한 단계가 실패하면 완료된 단계의 이전 작업이 일관성이 없거나 부분적인 상태로 남을 수 있습니다.

오늘 Workflows를 위한 saga 롤백을 출시하여, 실패할 경우 단계 자체 내에서 롤백 로직을 선언할 수 있습니다.

예를 들어, 서로 다른 두 은행의 계좌로 자금을 이체하는 워크플로우가 있다고 가정해 보겠습니다.

  1. 은행 A 계좌에서 인출

  2. 은행 B 계좌로의 입금

  3. 두 계정 소유자 모두에게 확인 이메일 전송

은행 B에 입금하는 2단계에 실패하면 어떻게 될까요? 은행 A에서 인출이 성공하면 거래가 확정되고 금액이 시스템에서 떠나게 됩니다. 트랜잭션의 조율자는 A 은행의 시스템에서 작업을 단순히 "실행 취소"할 수 없습니다. 대신 첫 번째 작업을 의미론적으로 전환하는 새로운 작업을 통해 돈을 은행 A의 계좌로 다시 입금해야 합니다.

BLOG-3317 image6

작업과 보상 로직을 이렇게 조합하는 것을 사가 패턴이라고 합니다.

이전에는 개발자가 단계의 직접적인 정의를 벗어나 성공, 실패, 실패 시 수행해야 하는 조치를 추적하기 위해 자체적인 보상 로직을 구현해야 했습니다. 이제 각 step.do()에 대한 보상 로직을 단계 자체의 인자로 정의할 수 있으며, 이를 통해 롤백 시에도 워크플로의 내구성을 유지할 수 있습니다.

// track what completed so we know what to undo
let debitA;
let creditB;
try {
  debitA = await step.do("debit-bank-a", () => bankA.debit(from, amount));
  creditB = await step.do("credit-bank-b", () => bankB.credit(to, amount));
  await step.do("notify", () => notifyBoth(from, to, amount));
} catch (error) {
  // unwind in reverse. each undo is its own durable step,
  // must be idempotent, and must keep going if one fails.
  if (creditB) {
    try {
      await step.do("reverse-credit-b", () => bankB.debit(to, amount, creditB.id));
    } catch (e) {
      await alertOnCall("reverse-credit-b failed", e);
    }
  }
  if (debitA) {
    try {
      await step.do("refund-debit-a", () => bankA.credit(from, amount, debitA.id));
    } catch (e) {
      await alertOnCall("refund-debit-a failed", e);
    }
  }
  throw error;
}

롤백 없음

// each step ships with its own undo. add a step,
// add its rollback right here. no growing catch
// block, no manual ordering, no replay logic.
await step.do("debit-bank-a", () => bankA.debit(from, amount), {
  rollback: async ({ output }) => bankA.credit(from, amount, output.id),
});
await step.do("credit-bank-b", () => bankB.credit(to, amount), {
  rollback: async ({ output }) => bankB.debit(to, amount, output.id),
});
await step.do("notify", () => notifyBoth(from, to, amount));

롤백 포함

사용해 보기

롤백을 사용하려면 rollback 함수가 포함된 옵션 객체를 step.do()의 마지막 인수로 전달하면 됩니다.

const debit = await step.do(
  "debit-account-a",
  async () => {
    return await bankA.debit({
      accountId: fromAccountId,
      amount,
      idempotencyKey: `${transferId}:debit-account-a`,
    });
  },
  {
    rollback: async () => {
      await bankA.credit({
        accountId: fromAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-debit-account-a`,
      });
    },
  }
);

// The idempotency keys make both the forward operations and rollback operations safe to retry without duplicating the transfer

const credit = await step.do(
  "credit-account-b",
  async () => {
    return await bankB.credit({
      accountId: toAccountId,
      amount,
      idempotencyKey: `${transferId}:credit-account-b`,
    });
  },
  {
    rollback: async ({ output }) => {
      if (output === undefined) {
        return;
      }

      await bankB.debit({
        accountId: toAccountId,
        amount,
        idempotencyKey: `${transferId}:rollback-credit-account-b`,
      });
    },
  }
);


// If we fail here, we may want to revert all previous payments. Users should not have to wrap their code in complex try-catch logic just to revert two small payments (see below)

await step.do("send-confirmation", async () => {
  await sendTransferConfirmation({ ... });
});

롤백 함수는 일반 Workflow 단계와 마찬가지로 멱등해야 합니다. 요금을 환불하는 경우에는 결제 공급자의 멱등 키를 사용하세요. 인벤토리를 릴리스하는 경우 안전한 상태로 두 번 이상 호출하도록 합니다.

단계가 실패하면 롤백 처리기가 역방향 step-start 순서로 실행됩니다. 간단히 말해서 문제가 발생하면 실행 취소 단계를 실행하면 됩니다. 실제로 APIs와 실행 모델을 중요하게 만드는 몇 가지 세부 사항이 있습니다.

1. 실패한 단계는 여전히 롤백이 필요할 수 있습니다. 실패한 step.do()라도 롤백 핸들러를 등록했다면 여전히 롤백 대상이 될 수 있습니다.

사용자 코드에 오류가 발생하고 Workflow가 계속 진행되는 경우 롤백이 시작되지 않지만, 단계 오류가 발생하고 Workflow가 나중에 다른 이유로 실패하는 경우, 롤백은 여전히 이전에 등록된 핸들러에 대해 실행될 수 있으며, 이 핸들러는 역방향 step-start 순서로 실행됩니다.

그 이유는 무엇일까요? 단계가 실패하기 전에 외부 시스템과 부분적으로 상호 작용했을 수 있습니다. 예를 들어 결제 공급자가 요금을 캡처하더라도 chargeId를 Workflows에 반환하기 전에 단계가 실패할 수 있습니다. 이것이 롤백 핸들러가 output 수신하지만 출력을 처리해야 하는 output === undefined.

2. 롤백은 Workflow가 실패할 때만 시작됩니다. 롤백 처리기를 추가해도 단계 오류가 있을 때마다 롤백이 트리거되는 것은 아닙니다. 사용자 코드에서 오류를 포착하고 계속하면 Workflow가 계속 진행됩니다. 롤백은 Workflow 자체가 최종적으로 실패하게 될 때 시작됩니다.

롤백이 시작되면 Workflows는 적합한 step.do()를 찾습니다. 롤백 핸들러를 실행한 다음, 최종 Workflow 실패를 기록합니다.

3. 순서를 예측할 수 있어야 합니다. 순차적 Workflows의 경우 롤백 순서는 다음과 같이 명확합니다.

  1. 인벤토리를 예약합니다.

  2. 차지 카드.

  3. 선적을 생성합니다.

  4. 배송이 실패하면 카드를 환불하고 재고를 반출합니다.

병행 단계들은 이를 더욱 미묘하게 만듭니다. 완료 순서는 시작 순서와 다를 수 있으므로 Workflows는 완료 순서를 반대로 바꾸는 대신 단계 시작 시작 순서를 반대로 사용합니다.

실제 규칙은 다음과 같습니다.

  1. 롤백 핸들러로 시작되거나 완료된 모든 단계가 적합합니다.

  2. 실패한 step.do()도 롤백 핸들러를 등록했다면 자격이 있습니다.

  3. 처리기는 완료 순서가 아닌 단계-시작 순서의 역순으로 실행됩니다.

Cloudflare에서 API를 설계한 방법

예상되는 행동을 염두에 두고 나면 이 새로운 패턴을 Workflows API에 추가해야 했습니다. 롤백은 롤백 옵션을 결정하기 전에 몇 번의 반복을 거쳤습니다. 

유창한 API나 빌더 API가 안 되는 이유는 무엇일까요?

첫 번째 접근 방식은 유창한 형식이었습니다. step.do(...).rollback(...) 읽기 쉽습니다. 포워드 액션과 보상은 나란히 배치되어 있고, 호출 사이트는 일반적인 JavaScript 체인처럼 보입니다.

문제는 step.do() 입니다. 이미 중요한 의미가 있습니다. 즉, 지속형 단계를 시작하고 단계 출력에 대한 약속을 반환합니다. Workers에서 프라미스류 값은 특히 의미가 있습니다. Workers RPC는 프라미스 파이프라인(이는 Cap'n Proto와 같은 시스템에서 상속된 패턴)을 지원하기 때문입니다.

프라미스 파이프라인을 사용하면 코드에서 미래 값이 호출자에게 완전히 반환되기 전에 해당 값에서 메서드를 호출할 수 있습니다. 예:

const session = api.authenticate(apiKey);
const name = await session.whoami();

여기에서 session은 아직 실제 세션 객체가 아닙니다. 이는 곧 존재하게 될 세션에 대한 핸들과도 같습니다. session.whoami()를 호출하면 Workers는 조기에 원격 측에 해당 호출을 보내고 "인증이 세션을 생성하면 whoami() 를 호출하라"고 말할 수 있습니다.

BLOG-3317 image4

왕복 시간이 절약됩니다. 호출자는 authenticate()가 완전히 완료될 때까지 기다렸다가 whoami()를 요청할 필요가 없습니다.

저희는 유창한 API로 간주했습니다.

step.do("charge-card", chargeCard).rollback(refundCharge);

독자에게는 이 코드가 “.rollback()charge-card의 결과에 대해 호출한다.”라고 보일 수 있습니다.”   하지만 롤백은 단계 출력에 포함되지 않습니다. 이는 step.do() 옵션의 일부로, 단계가 시작되기 전에 등록되므로, 이후 단계에서 오류가 발생할 경우 워크플로가 해당 단계를 어떻게 보정해야 할지 파악할 수 있습니다.

또한 API가 유창하면 단계 타이밍을 추론하기가 더 어려워집니다. 오늘날, step.do()는 호출되면 해당 단계를 시작하므로, 개발자는 단계를 시작한 후 다른 작업을 수행하고 나중에 첫 번째 단계가 완료되기를 기다릴 수 있습니다:

const first = step.do("first", () => serviceA.call());

await step.do("second", () => serviceB.call());

await first;

현재의 실행 모델에서는1순위가 즉시 시작되고 2순위가 그 뒤를 잇습니다. 유창한 API라면 상황은 복잡해집니다. 워크플로는 전체 단계 정의를 파악하기 전에 .rollback()이 연결되는지 여부를 기다려야 합니다. 이로 인해 단계가 엔진으로 전송되는 시점이 지연될 수 있습니다.

앞선 예제에서, second가 이미 완료된 후라면 firststep.do("first", ...)가 아닌 await first 지점에서 시작될 수 있습니다.

따라서 동시성 Workflows를 추론하기가 더 어려워집니다. 단계 타이밍은 반환된 Promise 가 소비되는 시점뿐만 아니라 step.do() 가 호출된 위치에 따라 달라집니다.

빌더 스타일 APIs도 고려했습니다.

const charge = await step
	.saga("charge")
	.do(() => chargeCard())
	.rollback(() => refundCharge())
	.run();

빌더 API는 프라미스 모호성을 방지합니다. 또한 향후 단계 수준 옵션에 대한 명확한 위치를 제공하고, 앞으로 작업과 롤백 작업이 동일한 사가 단계에 속해 있음을 명확하게 보여줍니다.

하지만 여기에는 예의가 추가됩니다. 모든 단계에는 마지막에 .run()이 필요하며, .run()을 빠뜨리는 일은 흔히 발생할 수 있고 도구 없이는 발견하기 어렵습니다. 또한 간단한 단일 단계의 경우도 구성 체인처럼 보이기 시작합니다. 또한 기존의 step. 패턴에서 벗어나, 새로운 step.saga() 빌더를 도입합니다. 무엇보다 중요한 점은, 이로 인해 step.do()가 주요 워크플로 기본 요소라기보다는 구식 API처럼 느껴지게 만든다는 것입니다. 롤백의 목적은 step.do()를 대체하는 것이 아니라 확장하는 것이었습니다.

단계 메타데이터로서의 롤백

step.do(..., { rollback })

궁극적으로 롤백이 단계에서의 메타데이터인 명시적 형식을 선택했습니다.

이렇게 하면 각 롤백이 포워드 단계 자체 내에서 정의됩니다. 각 핸들러는 롤백 시작의 원인이 된 오류, 단계 컨텍스트, 그리고 포워드 단계에서 반환된 지속된 값(정의되지 않을 수 있음)이거나 단계가 값을 유지하기 전에 실패한 경우 undefined인 출력 값을 받습니다.

롤백은 수명 주기 이벤트를 생성하므로 보상이 시작되었는지, 어떤 롤백 핸들러가 실패했는지, 롤백이 성공적으로 완료되었는지 여부를 알 수 있습니다.

결정적으로, 원래의 Workflow 오류는 별개로 유지됩니다. 롤백은 Workflow가 실패한 이유가 아니라 실패 후 Workflows에서 수행하는 것입니다.

WorkflowStepConfig를 통해 단계 구성 에서 사용자 지정 재시도 및 제한 시간 초과 동작을 정의할 수 있는 것처럼,rollbackConfig에 롤백 관련 값을 추가합니다.

{
  rollback: async ({ output }) => {
    await bankA.credit({ accountId: fromAccountId, amount, transferId: `${transferId}-reversal` });
  },
  rollbackConfig: {
    retries: { limit: 10, delay: '30 seconds', backoff: 'exponential' },
    timeout: '2 minutes',
  },
}

이는 우리가 원했던 수명주기 이벤트 멘탈 모델과 일치합니다. step.do()는 이미 Workflows에서 기록하고 재시도하며 나중에 로그에 표시하는 지속형 작업 단위를 설명합니다. 롤백은 동일한 작업 단위에 대한 또 다른 수명 주기 동작입니다. API는 별도의 래퍼 또는 빌더가 아니라 단계 정의와 함께 이동해야 합니다.

  • 이 단계는 step.do()가 정상적으로 시작될 때 여전히 시작됩니다.

  • 반환된 프라미스는 여전히 단계 출력을 나타냅니다.

  • Concurrent Workflow 코드는 동일한 실행 모델을 유지합니다.

  • 롤백 처리기 옆에 라이브 롤백 재시도 및 제한 시간 초과 옵션이 있습니다.

  • 기존 step.do() 오늘날과 똑같이 작동합니다.

이 형태는 유창한 API보다 약간 더 명시적이지만, 해당 명시성은 유용합니다. 작업과 보상이 여전히 한 곳에 있으며, APIs가 새로운 단계 빌더나 새로운 종류의 프라미스를 도입하지 않습니다. step.do()를 이미 이해하고 있는 개발자 하나의 추가 옵션 개체만 학습하면 됩니다.

마법 같은 것은 아니지만, 더 쉽게 채택할 수 있고 더 명확하게 이해할 수 있습니다.

내부 작동 방식

롤백은 API가 약간 추가된 것처럼 느껴지지만, 이에 따라 Workflows가 각 단계에 대해 기록해야 하는 내용이 변경됩니다.

일반적인 step.do() 이미 영구 기록이 있습니다. Workflows는 단계의 시작, 완료 여부, 반환된 내용, Workflow가 나중에 다시 시작될 경우 단계를 반복하는 대신 건너뛰어야 하는지 여부를 기록합니다.

롤백은 해당 레코드에 한 가지 더 추가됩니다. 단계가 보상 로직을 등록했는지 여부.

이는 Workflow가 실패할 때 통합할 수 있는 두 가지 정보가 Workflows에 있음을 의미합니다.

첫 번째는 지속성 단계 기록입니다. Workflow 엔진은 데이터를 저장하여 실행된 내용, 완료된 내용, 저장된 출력, 롤백이 등록되었는지 여부를 알 수 있습니다.

두 번째는 해당 단계를 보상하기 위해 작성된 함수인 롤백 핸들러 자체입니다. Workflows는 해당 함수의 텍스트를 데이터로 저장하지 않습니다. 대신 Workflow가 실행되는 동안 핸들러에 대한 호출 가능 참조를 유지합니다.

Workers RPC에서는 이러한 종류의 호출 가능 참조를 스텁이라고 합니다. 스텁을 사용하면 시스템의 한 부분이 다른 곳에서 실행 중인 코드를 호출할 수 있습니다. 또한 스텁에는 호출 또는 실행 컨텍스트가 끝날 때 삭제될 수 있는 수명이 있습니다. 이 시점이 지나도 스텁을 유지해야 하는 경우, Workers RPC는 동일한 대상에 대한 다른 핸들을 생성하는 dup() 메서드를 제공합니다.

롤백의 경우 이 모델은 유용합니다. 영구 걸음 수 이력에는 보상이 필요한 구간이 기록됩니다. 롤백 스텁은 Workflows에서 보상 코드를 호출하는 방법을 제공합니다. 또한 롤백 핸들러는 이를 등록한 바로 앞의 step.do() 호출보다 더 오래 유지되어야 할 수도 있으므로, Workflows는 롤백 단계를 위해 핸들러에 대한 자체 호출 가능 참조를 유지합니다.

일반적인 경우 동일한 엔진 수명 내에 Workflow가 롤백 상태가 되면 Workflows에는 이미 필요한 롤백 스텁이 있는 것입니다. 이는 지속형 단계 기록을 사용해 대상 단계를 찾은 다음 포워드 실행 중에 등록된 롤백 스텁을 호출할 수 있습니다.

이는 Workflows를 다시 시작한 후 복구 해야 하는 경우 더 미묘해집니다.

롤백이 필요한 동안 엔진이 퇴출되거나 충돌하거나 재시작하는 경우, Workflows에는 여전히 지속 단계 이력이 있지만, 더 이상 인메모리 롤백 스텁이 없을 수 있습니다. 복구를 위해 Workflows는 재생을 사용합니다. 재생은 완료된 정방향 단계 본문을 다시 실행하지 않고 Workflow 코드를 다시 실행할 수 있는 복구 모드입니다.

재생이 완료된 step.do()에 도달하면, Workflows는 단계 본문을 다시 실행하는 대신 지속된 결과를 읽습니다. 롤백 복구의 경우, Workflows는 롤백이 연결되어 롤백에 적합한 단계에 대한 핸들러만 재구축하면 됩니다. 해당 step.do() 롤백 옵션을 통해 호출 가능한 스텁이 다시 등록될 수 있습니다.

이를 통해 Workflows는 원래 외부 부작용을 복제하지 않고도 필요한 롤백 핸들러를 복구할 수 있습니다.

BLOG-3317 image5

이러한 부분들이 준비되면 핸들러가 메모리에서 여전히 사용 가능하든, 복구 중에 핸들러를 다시 빌드해야 하든 관계없이 롤백이 작동할 수 있습니다.

Workflows가 실패하려고 할 때 Workflows는 애플리케이션에 무슨 일이 일어났는지 재구성하도록 요청하지 않습니다. 이미 걸음 수 기록이 있습니다. DLP는 지속된 레코드를 살펴보고 다음과 같은 중요한 질문에 답할 수 있습니다.

  • 어떤 단계가 시작되었나요?

  • 어떤 단계가 완료되었나요?

  • 어떤 실패한 단계가 여전히 정리가 필요할까요?

  • 어떤 단계에서 롤백 처리기를 등록할까요?

  • 각 롤백 처리기는 어떤 출력을 받아야 할까요?

  • 어떤 순서로 보상을 실행해야 할까요?

그런 다음 Workflows는 롤백 컨텍스트(원래 오류, 단계 컨텍스트, 단계 출력(하나가 지속된 경우))를 사용하여 각 롤백 스텁을 호출합니다.

주문 세부 정보는 중요합니다. 일반적인 자바스크립트, 특히 Promise.all()의 경우, 완료 순서가 시작 순서와 항상 일치하는 것은 아닙니다. A 단계가 먼저 시작되고 B 단계가 두 번째로 시작되면 B 단계가 먼저 완료될 수 있습니다. 롤백의 경우, Workflows는 지속된 시작 순서를 안정적인 소스로 사용한 다음, 역순으로 해제합니다.

롤백 처리기는 Workflows의 일반 단계적 기계를 통해서도 실행됩니다. 즉, 재시도, 제한 시간 초과, 수명 주기 이벤트, 로그, 최종 기록 결과 등 여러분이 Workflows에서 기대하는 것과 동일한 운영 속성이 보상으로 주어집니다. 구성된 재시도 후에도 롤백 핸들러가 계속 실패하는 경우, Workflows는 롤백 결과를 실패로 기록하고 나머지 롤백 핸들러 실행을 중지하며, Workflow 인스턴스는 궁극적으로 오류 상태가 됩니다.

이것이 saga 롤백과 catch 블록의 주요 차이점입니다. catch 블록은 JavaScript가 실행된 정확한 시점에 메모리에 무엇이 남아 있는지만 알 수 있습니다. Workflows 롤백은 지속되는 단계 기록을 사용하여 이미 발생한 상황을 결정하고, 일반적인 사례에서 이미 보유한 스텁을 호출하고, 복구 중 필요 시 필요할 때 복구 중 누락된 스텁을 안전하게 재구축합니다.

바로 그 때문에 API는 step.do() 자체에 롤백 기능을 적용합니다. 롤백은 별도의 전역 오류 핸들러가 아니라 Workflows에서 이미 이해하고 있는 지속성 있는 작업 단위에 연결된 메타데이터입니다.

다음 단계

Cloudflare의 첫 번째 롤백에는 다음이 포함됩니다. 

  • step.do()를 위한 명시적 단계별 롤백 핸들러

  • 순차적 롤백 실행

  • 보상을 위한 재시도 및 제한 시간 초과 구성

다음으로 살펴볼 내용은 다음과 같습니다.

여러 단계로 구성된 애플리케이션이 중간에 실패할 때 가장 힘든 순간은 실패했다는 사실을 모르는 것입니다. 이미 일어난 일과 다음에 무엇이 일어나야 하는지 아는 것입니다.

Saga 롤백�� 사용하면 각 단계 옆에 해당 답변을 배치할 수 있습니다. Workflows로 다단계 애플리케이션을 구축하는 경우, saga 롤백을 시도하고 다음에 원하는 보상 패턴을 알려주세요. Workflows 문서 를 시작하고 Cloudflare 커뮤니티에서 피드백을 공유하세요.

Cloudflare에서는 전체 기업 네트워크를 보호하고, 고객이 인터넷 규모의 애플리케이션을 효과적으로 구축하도록 지원하며, 웹 사이트와 인터넷 애플리케이션을 가속화하고, DDoS 공격을 막으며, 해커를 막고, Zero Trust로 향하는 고객의 여정을 지원합니다.

어떤 장치로든 1.1.1.1에 방문해 인터넷을 더 빠르고 안전하게 만들어 주는 Cloudflare의 무료 애플리케이션을 사용해 보세요.

더 나은 인터넷을 만들기 위한 Cloudflare의 사명을 자세히 알아보려면 여기에서 시작하세요. 새로운 커리어 경로를 찾고 있다면 채용 공고를 확인해 보세요.
WorkflowsCloudflare Workers개발자

X에서 팔로우하기

Vaishnav Kavitha|@wishee0
Cloudflare|@cloudflare

관련 게시물

2026년 6월 19일

AI 에이전트용 임시 Cloudflare 계정

에이전트가 무언가를 배포해야 하는 순간, 인간을 위해 구축된 벽에 정면으로 부딪히게 됩니다. 오늘 Cloudflare Workers에서 임시 계정을 출시합니다. 이제 모든 에이전트에서 임시로 wrangler 배포를 실행하고 몇 초 만에 Worker를 활성화할 수 있습니다....

2026년 6월 17일

Flue를 시작으로 Cloudflare로 더 많은 에이전트 장치 및 프레임워크 가져오기

Agents SDK는 이제 모든 에이전트 프레임워크를 구축할 수 있는 기반이 되는 런타임입니다. 오늘 우리는 Agents SDK를 대상으로 하는 첫 번째 프레임워크로 Flue를 사용하여 Agents SDK 기본 요소를 개방하고 대시보드에서 에이전트를 출시합니다....