Cadence 워크플로우 엔진: 재실행으로 상태를 복원하는 원리와 Temporal과의 차이

이 글의 핵심

여러 서비스를 거치는 주문 처리 같은 절차를 큐와 크론으로 엮으면 중간 실패 시 어디까지 진행됐는지 추적하기 어려워집니다. Cadence는 워크플로 코드를 결정론적으로 재실행해 이 문제를 풀지만, 그 대가로 시간·난수·외부 호출을 워크플로 안에서 직접 쓰면 안 된다는 제약이 생깁니다. 버전 관리와 Continue-As-New를 포함한 모범 사례와 함정도 함께 정리했습니다.

이 글의 핵심

Cadence는 Uber가 오픈소스로 공개한 분산 워크플로우 오케스트레이션 플랫폼입니다. 장애, 배포, 프로세스 재시작 뒤에도 비즈니스 절차를 이어 가려면 실행 상태를 애플리케이션 프로세스 밖에 기록하고, 워커가 그 기록을 따라 워크플로우 코드를 결정론적으로 다시 실행해야 합니다. Cadence는 이 역할을 서버와 이벤트 히스토리로 제공합니다.

이 글에서는 Domain과 Tasklist 구성, Workflow와 Activity의 책임 분리, 시그널과 쿼리, 재시도 정책, 버전 관리를 설명하고, Go 클라이언트로 작성한 주문 처리 워크플로우와 Temporal과의 차이를 다룹니다. Cadence는 Go와 Java 클라이언트가 가장 성숙하므로 예제는 Go로 작성했습니다.


Cadence가 해결하는 문제

“주문 생성, 결제, 재고 확보, 배송 요청” 같은 절차를 메시지 큐와 크론으로 직접 엮으면, 단계마다 상태 저장, 재시도, 타임아웃, 멱등성, 수동 복구 스크립트가 코드베이스 곳곳에 흩어집니다. 중간에 프로세스가 죽으면 어디까지 진행됐는지 DB 상태를 보고 추측해야 합니다.

Cadence에서는 이 절차를 평범한 함수처럼 순서대로 작성하고, 각 단계의 시작과 결과를 서버가 이벤트 히스토리로 기록합니다. 워커 프로세스가 죽어도 히스토리는 남아 있으므로, 다른 워커가 같은 워크플로우 코드를 처음부터 다시 실행하되 이미 기록된 단계는 실제로 실행하지 않고 기록된 결과를 돌려받으며 마지막 지점까지 따라옵니다. 이것이 리플레이(replay)입니다.

운영 측면에서는 실행 중인 절차를 워크플로우 ID 단위로 조회하고, 시그널을 보내고, 취소할 수 있다는 점이 큽니다. 어떤 주문이 어느 단계에서 멈췄는지를 Cadence Web UI나 CLI로 바로 확인할 수 있습니다.


아키텍처 개요

Cadence 서버는 역할별 서비스로 나뉩니다. Frontend 서비스는 클라이언트와 워커가 붙는 API 진입점으로, 워크플로우 시작, 시그널, 쿼리 요청을 받습니다(초기에는 TChannel/Thrift를 썼고 이후 gRPC도 지원합니다). History 서비스는 워크플로우 실행별 이벤트 히스토리와 상태를 관리하는 핵심으로, 내구성의 근거입니다. Matching 서비스는 Tasklist별 태스크 큐를 관리하고 폴링하는 워커에 태스크를 전달합니다. 저장소로는 Cassandra, MySQL, PostgreSQL을 쓸 수 있습니다.

워커는 사용자가 운영하는 프로세스로, 워크플로우 태스크와 Activity 태스크를 폴링해 실행하며 수평 확장의 단위입니다. 전체 흐름은 클라이언트가 실행을 시작하면 History가 이벤트를 기록하고, Matching이 Tasklist에 태스크를 넣고, 워커가 태스크를 가져가 처리한 결과가 다시 히스토리에 기록되는 순환입니다.


핵심 개념

내구성 있는 실행과 결정론

워크플로우 함수는 같은 이벤트 히스토리로 리플레이할 때 항상 같은 분기와 같은 순서로 Activity를 스케줄해야 합니다. 이를 워크플로우 결정론(determinism)이라고 합니다. 리플레이 중에 코드가 히스토리와 다른 명령을 내리면 워커는 이를 비결정론 오류로 판단하고 해당 워크플로우 태스크를 실패시킵니다.

그래서 Go 워크플로우 본문에서는 다음을 직접 쓰지 않습니다.

  • 네트워크, DB, 메시지 큐 호출: Activity로 옮깁니다.
  • time.Now(), time.Sleep(): workflow.Now(ctx), workflow.Sleep(ctx, d), workflow.NewTimer를 씁니다.
  • 난수나 UUID 생성: workflow.SideEffect로 감싸 결과를 히스토리에 기록합니다.
  • go 키워드와 Go 채널, select: workflow.Go, workflow.Channel, workflow.Selector를 씁니다.
  • Go 맵 순회 결과에 의존하는 분기: 순회 순서가 실행마다 달라지므로 키를 정렬해서 씁니다.

워크플로우는 스케줄링, 분기, 타이머, 시그널 대기 같은 오케스트레이션만 맡고, 부작용은 모두 Activity에 위임합니다.

Workflow ID와 Run ID

클라이언트는 Workflow ID로 실행을 시작합니다. 주문 ID 같은 비즈니스 키를 그대로 쓰는 경우가 많고, 같은 Domain 안에서 실행 중인 Workflow ID는 하나만 존재할 수 있습니다. 서버는 실행 시도마다 Run ID를 붙이며, Continue-As-New나 재시작으로 한 Workflow ID에 여러 Run이 이어질 수 있습니다. 로그와 지원 티켓에는 두 ID를 함께 남겨야 추적이 쉽습니다.

Activity

Activity는 HTTP 호출, DB 트랜잭션, 메시지 발행처럼 부작용이 있는 작업 단위입니다. 각 Activity는 타임아웃, 하트비트, 재시도 정책을 따로 가질 수 있어 외부 시스템의 특성에 맞출 수 있습니다. Activity는 완료 결과가 서버에 기록되기 전에 워커가 죽으면 다시 실행되므로 최소 한 번(at-least-once) 실행된다고 보고, 멱등 키를 설계해야 합니다.

Child Workflow와 Continue-As-New

실행이 길어지고 이벤트가 쌓이면 히스토리가 커져 리플레이 비용이 늘어나고, 서버가 정한 히스토리 크기 한도에 가까워지면 실행이 강제로 종료될 수 있습니다. Continue-As-New는 현재 Run을 완료 처리하고 필요한 상태만 인자로 넘겨 같은 Workflow ID로 새 Run을 시작하는 방식으로, 히스토리를 비웁니다. 하위 절차를 독립적인 수명 주기와 히스토리로 분리하려면 Child Workflow를 씁니다.


Domain과 Tasklist

Domain

Domain은 워크플로우 실행을 묶는 최상위 격리 경계입니다. 히스토리 보존 기간(retention), 아카이브 설정, 글로벌 도메인의 복제 클러스터 같은 설정이 Domain 단위로 정해지고, Workflow ID의 유일성도 Domain 안에서만 따집니다. 팀, 제품, 환경(프로덕션, 스테이징)별로 Domain을 나누면 설정과 운영 권한을 분리하기 쉽습니다. 특정 지역 데이터를 분리해야 하는 규제 요건이 있다면 Domain과 클러스터 배치를 함께 설계합니다.

Tasklist

Tasklist는 워커가 태스크를 폴링하는 큐의 이름입니다. 워크플로우 태스크와 Activity 태스크는 각각 지정된 Tasklist로 스케줄되며, 워커는 시작할 때 구독할 Tasklist를 정합니다. Tasklist는 미리 만들 필요 없이 이름만으로 생깁니다.

보통 orders, payments처럼 서비스나 바이너리 경계에 맞춰 나누고, 실시간 주문과 배치 정산처럼 지연 요구가 다른 작업은 다른 Tasklist로 분리해 서로의 적체가 번지지 않게 합니다. Tasklist는 문자열 하나지만 모니터링, 알람, 오토스케일 기준이 되므로 명명 규칙을 팀 표준으로 정해 두는 것이 좋습니다.


Workflow와 Activity의 계약과 실행

Activity 옵션

Activity를 스케줄할 때는 다음 타임아웃을 정합니다. Go 클라이언트에서는 ScheduleToStartTimeout과 StartToCloseTimeout을 모두 지정해야 하며, 빠뜨리면 Activity 스케줄이 실패합니다.

옵션의미
ScheduleToStartTimeout태스크가 Tasklist에 들어간 뒤 워커가 가져가기까지의 상한. 워커 부족이나 적체를 드러냅니다
StartToCloseTimeout워커가 가져간 뒤 완료하기까지 한 번의 시도 상한
ScheduleToCloseTimeout대기와 실행을 합친 전체 상한
HeartbeatTimeout오래 걸리는 Activity가 이 간격 안에 activity.RecordHeartbeat를 호출하지 않으면 실패로 간주

결제 게이트웨이 호출과 창고 시스템 연동은 적절한 타임아웃이 다르므로, Activity 종류별로 옵션을 따로 둡니다. 하트비트에는 진행 상황을 함께 기록할 수 있어, 재시도 시 activity.GetHeartbeatDetails로 이어서 처리할 수 있습니다.

버전과 호환성

워크플로우 코드를 바꾸면 이미 실행 중인 인스턴스를 리플레이할 때 히스토리와 맞지 않아 비결정론 오류가 날 수 있습니다. Cadence는 이를 위해 workflow.GetVersion을 제공합니다.

v := workflow.GetVersion(ctx, "add-fraud-check", workflow.DefaultVersion, 1)
if v == 1 {
    err := workflow.ExecuteActivity(actx, FraudCheckActivity, orderID).Get(actx, nil)
    if err != nil {
        return err
    }
}

변경 이전에 시작된 실행은 리플레이 시 DefaultVersion을 받아 이전 경로를 따르고, 새 실행은 버전 1을 히스토리에 기록하고 새 경로를 탑니다. 이전 버전의 실행이 모두 끝나면 최소 지원 버전을 올리고 이전 분기를 지웁니다. Activity의 입출력 형식을 바꿀 때는 필드를 추가하는 방식으로 하위 호환을 유지하거나, 새 이름의 Activity를 만들어 전환합니다.


시그널과 쿼리

시그널(Signal)

시그널은 실행 중인 워크플로우에 비동기로 전달되는 외부 이벤트입니다. 결제 웹훅 수신, 고객의 취소 요청, 재고 확보 완료 알림 등을 시그널로 모델링합니다. 시그널도 히스토리에 기록되므로, 워크플로우가 아직 받을 준비가 안 됐을 때 도착해도 채널에 쌓였다가 나중에 Receive로 꺼낼 수 있습니다. Go에서는 workflow.GetSignalChannel로 채널을 얻고, workflow.Selector로 타이머나 다른 시그널과 함께 기다립니다.

시그널 처리 코드도 리플레이 대상이므로 워크플로우 내부 상태만 갱신해야 합니다. 취소 요청과 결제 완료가 거의 동시에 오는 경우처럼 순서가 비즈니스 규칙에 영향을 주는 시나리오는 테스트로 확인해 두는 것이 좋습니다.

쿼리(Query)

쿼리는 워크플로우의 현재 상태를 읽기 전용으로 조회하는 경로입니다. 지원 도구나 내부 대시보드에서 주문의 진행 단계를 보여 줄 때 씁니다. 쿼리 핸들러는 워커에서 워크플로우 상태를 복원한 뒤 실행되므로 부작용이 없어야 하고, 쿼리 처리 결과는 히스토리에 기록되지 않습니다.


재시도 정책

Activity 재시도

외부 API는 타임아웃, 일시적 오류, 레이트 리밋으로 실패합니다. Activity 옵션에 RetryPolicy를 붙이면 서버가 재시도를 관리하므로, 워크플로우 히스토리에는 재시도마다 이벤트가 쌓이지 않습니다.

필드의미
InitialInterval첫 재시도 전 대기 시간
BackoffCoefficient재시도마다 대기 시간에 곱하는 배수
MaximumInterval대기 시간 상한
MaximumAttempts최대 시도 횟수(0이면 ExpirationInterval까지 무제한, 둘 중 하나는 지정해야 함)
ExpirationInterval재시도를 포함한 전체 허용 시간
NonRetriableErrorReasons재시도하지 않을 오류 사유 목록

잘못된 주문 ID나 결제 거절처럼 다시 시도해도 결과가 같은 오류는 Activity에서 cadence.NewCustomError("payment-declined")처럼 사유를 붙여 반환하고, 그 사유를 NonRetriableErrorReasons에 넣어 즉시 실패하게 합니다. 평범한 error를 반환하면 재시도 대상이 되므로, 이 분류를 빠뜨리면 영구 오류를 계속 재시도하게 됩니다.

워크플로우 태스크 실패

워크플로우 코드에서 패닉이 나거나 리플레이 결과가 히스토리와 맞지 않으면 워크플로우 태스크가 실패합니다. 이는 Activity 실패와 성격이 달라서, 워크플로우 자체가 실패로 끝나는 것이 아니라 코드를 고쳐 배포할 때까지 태스크가 반복해서 재시도됩니다. 워크플로우 본문에는 가벼운 제어 로직만 두고 무거운 처리는 Activity로 넘겨야 워크플로우 태스크 타임아웃에도 걸리지 않습니다.


Temporal과의 비교

Temporal은 Cadence의 원작자들이 2019년에 Cadence 코드베이스를 포크해 만든 별도 프로젝트입니다. 이벤트 히스토리, 결정론적 워크플로우, Activity, 시그널과 쿼리라는 핵심 아이디어는 같지만, 이후 API와 SDK, 운영 서비스가 갈라졌습니다.

구분CadenceTemporal
기원Uber 오픈소스Cadence 포크 후 Temporal Technologies 주도
격리 단위DomainNamespace
태스크 라우팅TasklistTask Queue
통신TChannel/Thrift에서 시작, gRPC 추가처음부터 gRPC와 Protobuf
공식 SDKGo, Java 중심Go, Java, TypeScript, Python, .NET, PHP, Ruby 등
매니지드 서비스일부 클라우드 업체의 관리형 제공Temporal Cloud

이미 Cadence 위에 시스템이 올라가 있다면 Cadence를 계속 운영하는 것이 자연스럽습니다. 새로 고른다면 팀이 쓰는 언어의 SDK, 관리형 서비스 필요 여부, 커뮤니티 규모를 기준으로 비교하게 되는데, SDK 언어 폭과 관리형 서비스 면에서는 현재 Temporal 쪽 선택지가 넓습니다.


실전: 주문 처리 워크플로우(Go)

재고 예약, 결제 요청, 결제 결과 시그널 대기, 실패 시 재고 해제를 단순화한 예시입니다. 실제 서비스에서는 사기 탐지, 감사 로그, PG사별 연동이 더해집니다.

시그널, 쿼리, 상태 정의

const (
    SignalPaymentResult = "payment_result"
    QueryOrderState     = "order_state"
)

type PaymentResultPayload struct {
    EventID string // 웹훅 이벤트 ID, 중복 시그널 제거용
    Success bool
    TxID    string
}

type OrderState string

const (
    StatePending   OrderState = "pending"
    StateReserved  OrderState = "inventory_reserved"
    StatePaid      OrderState = "paid"
    StateCancelled OrderState = "cancelled"
    StateFailed    OrderState = "failed"
)

워크플로우 본문

import (
    "errors"
    "time"

    "go.uber.org/cadence"
    "go.uber.org/cadence/workflow"
    "go.uber.org/zap"
)

func OrderWorkflow(ctx workflow.Context, orderID string, amountCents int64) error {
    logger := workflow.GetLogger(ctx) // *zap.Logger
    state := StatePending

    if err := workflow.SetQueryHandler(ctx, QueryOrderState, func() (OrderState, error) {
        return state, nil
    }); err != nil {
        return err
    }

    ao := workflow.ActivityOptions{
        ScheduleToStartTimeout: time.Minute,
        StartToCloseTimeout:    time.Minute,
        RetryPolicy: &cadence.RetryPolicy{
            InitialInterval:          time.Second,
            BackoffCoefficient:       2,
            MaximumInterval:          30 * time.Second,
            MaximumAttempts:          5,
            NonRetriableErrorReasons: []string{"invalid-order"},
        },
    }
    actx := workflow.WithActivityOptions(ctx, ao)

    release := func() {
        if err := workflow.ExecuteActivity(actx, ReleaseInventoryActivity, orderID).Get(actx, nil); err != nil {
            logger.Error("release failed", zap.String("order", orderID), zap.Error(err))
        }
    }

    if err := workflow.ExecuteActivity(actx, ReserveInventoryActivity, orderID).Get(actx, nil); err != nil {
        logger.Error("reserve failed", zap.String("order", orderID), zap.Error(err))
        state = StateFailed
        return err
    }
    state = StateReserved

    if err := workflow.ExecuteActivity(actx, RequestPaymentActivity, orderID, amountCents).Get(actx, nil); err != nil {
        release()
        state = StateCancelled
        return err
    }

    // 결제 결과 시그널과 30분 타이머 중 먼저 오는 쪽을 처리
    timerCtx, cancelTimer := workflow.WithCancel(ctx)
    timer := workflow.NewTimer(timerCtx, 30*time.Minute)
    paymentCh := workflow.GetSignalChannel(ctx, SignalPaymentResult)

    var pay PaymentResultPayload
    timedOut := false

    sel := workflow.NewSelector(ctx)
    sel.AddReceive(paymentCh, func(c workflow.Channel, more bool) {
        c.Receive(ctx, &pay)
        cancelTimer() // 남은 타이머를 취소해 불필요한 타이머 이벤트를 막음
    })
    sel.AddFuture(timer, func(f workflow.Future) {
        timedOut = true
    })
    sel.Select(ctx)

    if timedOut {
        release()
        state = StateCancelled
        return errors.New("payment timeout")
    }
    if !pay.Success {
        release()
        state = StateCancelled
        return errors.New("payment declined")
    }

    if err := workflow.ExecuteActivity(actx, CapturePaymentActivity, orderID, pay.TxID).Get(actx, nil); err != nil {
        state = StateFailed
        return err
    }
    state = StatePaid
    return nil
}

Cadence Go 클라이언트의 workflow.GetLogger는 *zap.Logger를 반환하므로 필드는 zap.String, zap.Error로 넘겨야 합니다(키-값 문자열을 나열하는 방식은 Temporal Go SDK의 로거 인터페이스입니다). Selector로 시그널과 타이머를 함께 기다리면 “30분 안에 결제 결과가 없으면 취소” 같은 정책을 코드에 그대로 드러낼 수 있고, 타임아웃이나 거절 분기에서는 재고 해제 Activity로 보상 처리를 합니다.

이 예제는 결제 결과 시그널을 한 번만 받습니다. 웹훅이 중복 전달되어 같은 시그널이 두 번 와도 두 번째 시그널은 채널에 남은 채 워크플로우가 끝나므로 상태가 바뀌지 않지만, 여러 번 시그널을 받는 구조라면 EventID로 이미 처리한 이벤트를 걸러야 합니다.

Activity

import "context"

func ReserveInventoryActivity(ctx context.Context, orderID string) error {
    // 재고 서비스 호출, 멱등 키로 orderID 사용
    // 존재하지 않는 주문이면 재시도하지 않도록 사유를 붙여 반환
    // return cadence.NewCustomError("invalid-order")
    return nil
}

func RequestPaymentActivity(ctx context.Context, orderID string, amountCents int64) error {
    // PG에 결제 요청, 결과는 웹훅으로 받음
    return nil
}

func ReleaseInventoryActivity(ctx context.Context, orderID string) error {
    return nil
}

func CapturePaymentActivity(ctx context.Context, orderID, txID string) error {
    return nil
}

웹훅 핸들러는 서명을 검증한 뒤 주문 ID로 Workflow ID를 찾아 클라이언트의 SignalWorkflow를 호출합니다.

워커와 Tasklist

import (
    "go.uber.org/cadence/.gen/go/cadence/workflowserviceclient"
    "go.uber.org/cadence/worker"
    "go.uber.org/cadence/workflow"
)

func startWorker(service workflowserviceclient.Interface) error {
    w := worker.New(service, "orders-domain", "orders", worker.Options{})
    w.RegisterWorkflowWithOptions(OrderWorkflow, workflow.RegisterOptions{Name: "OrderWorkflow"})
    w.RegisterActivity(ReserveInventoryActivity)
    w.RegisterActivity(RequestPaymentActivity)
    w.RegisterActivity(ReleaseInventoryActivity)
    w.RegisterActivity(CapturePaymentActivity)
    return w.Start()
}

워커는 Domain과 Tasklist를 지정해 만들고, 워크플로우와 Activity를 등록한 뒤 폴링을 시작합니다. 결제, 재고, 알림 Activity의 부하 특성이 많이 다르면 Activity별로 다른 Tasklist와 워커 풀을 두어, 한 외부 시스템이 느려져도 다른 작업의 폴링이 막히지 않게 할 수 있습니다.

운영에서 지킬 것

워크플로우 인자와 Activity 결과는 히스토리에 그대로 저장되고 Web UI에서 보이므로, 카드 번호 같은 민감 정보는 넣지 말고 토큰이나 참조 ID만 넘깁니다. 로그와 메트릭에는 Workflow ID, Run ID, Activity 종류를 구조화된 필드로 남겨 두어야 장애 시 실행을 바로 찾아갈 수 있습니다.


워커 폴링, Sticky Execution, 히스토리 비용

폴링과 동시성

워커는 Frontend 서비스에 롱 폴링으로 태스크를 요청합니다. 워커 수가 부족하거나 워커 프로세스의 동시 실행 한도(worker.Options의 MaxConcurrentActivityExecutionSize 등)가 낮으면 ScheduleToStart 지연이 커지고, 반대로 한 워커가 너무 많은 Activity를 동시에 잡으면 메모리와 외부 API 연결이 병목이 됩니다. 두 값을 함께 보면서 조정합니다.

Sticky Execution

워크플로우 태스크가 올 때마다 히스토리 전체를 리플레이하면 비용이 크므로, 워커는 처리한 워크플로우의 상태를 메모리에 캐시하고 서버는 그 워크플로우의 다음 태스크를 같은 워커 전용 Tasklist(sticky tasklist)로 보냅니다. 그러면 워커는 리플레이 없이 새 이벤트만 적용해 이어서 실행합니다. 워커가 죽거나 정해진 시간 안에 태스크를 가져가지 않으면 서버는 일반 Tasklist로 태스크를 돌려보내고, 다른 워커가 히스토리 전체를 리플레이해 상태를 복원합니다. 워커 캐시 크기는 worker.SetStickyWorkflowCacheSize로 조절하며, 캐시가 작으면 리플레이가 잦아집니다.

히스토리 크기와 리플레이 비용

이벤트가 수만 개로 늘어나면 캐시를 잃었을 때 리플레이 한 번의 비용이 커지고, 서버에 설정된 히스토리 이벤트 수와 크기 한도를 넘으면 실행이 강제로 종료됩니다. 무한 루프로 도는 워크플로우는 일정 반복마다 Continue-As-New를 하고, 독립적인 하위 작업은 Child Workflow로 나눕니다. 대시보드에는 히스토리 길이와 워크플로우 태스크 백로그를 올려 두는 것이 좋습니다.


트러블슈팅

WorkflowExecutionAlreadyStartedError

같은 Workflow ID로 실행 중인 워크플로우가 있을 때 다시 시작하면 납니다. 같은 주문에 대해 워크플로우가 중복 생성되지 않게 해 주는 동작이므로, 클라이언트는 이 오류를 “이미 진행 중”으로 처리하면 됩니다. 종료된 ID를 다시 쓸 수 있는지는 시작 옵션의 WorkflowIDReusePolicy로 정합니다.

Activity가 끝없이 재시도됨

재시도해도 의미 없는 오류가 NonRetriableErrorReasons로 분류되지 않았거나, MaximumAttempts 없이 긴 ExpirationInterval만 둔 경우입니다. 결제 거절이나 검증 실패는 사유를 붙인 오류로 즉시 실패시키고 보상 처리로 넘깁니다.

워크플로우 태스크 실패와 비결정론 오류

워크플로우 본문에 time.Now(), 난수, 네이티브 고루틴, 숨은 I/O가 들어갔거나, GetVersion 없이 실행 중인 워크플로우의 코드를 바꿨을 가능성이 큽니다. 운영 히스토리를 내려받아 worker.ReplayWorkflowHistoryFromJSONFile 같은 리플레이 테스트로 새 코드와의 호환성을 배포 전에 확인할 수 있습니다.

Tasklist 적체

워커 수, ScheduleToStartTimeout, 워커 호스트의 CPU와 메모리를 함께 봅니다. 한 Tasklist에 SLO가 다른 작업을 섞으면 느린 작업이 빠른 작업을 가로막으므로 Tasklist 분리를 검토합니다.

시그널은 왔는데 상태가 바뀌지 않음

히스토리에 시그널 이벤트가 있는데 상태가 그대로라면, 워크플로우가 그 시그널 채널을 읽는 코드에 도달하지 않았거나 이미 다른 분기로 진행한 경우입니다. 시그널 이름 오타는 오류 없이 다른 채널에 쌓이기만 하므로 상수로 관리합니다.


같이 보면 좋은 글