Cadence 완벽 가이드 — Uber 워크플로우 엔진

Cadence 완벽 가이드 — Uber 워크플로우 엔진

이 글의 핵심

Cadence는 Uber가 공개한 내구성 있는 워크플로우 실행 엔진으로, 마이크로서비스·레거시 시스템을 아우르는 업무 절차를 코드로 표현합니다. Domain·Tasklist, Workflow·Activity, 시그널·쿼리, 재시도 정책, Temporal과의 관계, 주문 처리 실전 예제를 다룹니다.

이 글의 핵심

Cadence는 Uber가 오픈소스로 공개한 분산 워크플로우 오케스트레이션 플랫폼입니다. 장애·배포·프로세스 재시작 이후에도 동일한 비즈니스 절차를 재현하려면, 실행 상태를 애플리케이션 프로세스 밖에 지속적으로 기록하고, 워커가 그 기록을 따라 결정론적으로 워크플로우 코드를 다시 실행하는 모델이 필요합니다. Cadence는 그 역할을 서비스형 실행 엔진과 이벤트 히스토리로 제공합니다.

본문에서는 Domain·Tasklist로의 배치, Workflow·Activity의 책임 분리, 외부와의 비동기 연동을 위한 시그널·쿼리, 외부 시스템 특성에 맞춘 재시도 정책, 커뮤니티에서 널리 쓰이는 Temporal과의 비교, 그리고 주문 처리를 중심으로 한 실전 워크플로우를 정리합니다. Cadence는 Go·Java SDK가 성숙한 편이므로, 예제는 Go 스타일 위주로 제시하되 개념은 언어에 무관하게 적용할 수 있습니다.


1. Cadence가 해결하는 문제

분산 환경에서 “주문 생성 → 결제 → 재고 확보 → 배송 요청” 같은 절차를 직접 구현하면, 단계마다 상태 저장, 재시도, 타임아웃, 멱등성, 수동 복구 스크립트가 코드베이스 곳곳에 흩어집니다. Cadence는 이런 횡단 관심사를 워크플로우 실행 서비스기록된 이벤트 히스토리로 모읍니다. 워커 프로세스가 종료되어도 서버가 히스토리를 보존하고, 새 워커가 같은 워크플로우 정의로 리플레이(replay)하여 진행 지점을 복원합니다.

운영 관점에서는 배치 스크립트와 달리, 실행 중인 절차를 식별자(워크플로우 ID) 단위로 조회·시그널·취소할 수 있다는 점이 큽니다. 지원·정산·현장 운영이 “어떤 주문이 어느 단계에서 멈췄는지”를 제품처럼 다룰 수 있게 됩니다.


2. 아키텍처 개요

Cadence 배포는 일반적으로 다음 구성요소로 이해할 수 있습니다.

  • Frontend Service: 클라이언트·워커가 붙는 gRPC 진입점. 워크플로우 시작, 시그널, 쿼리 요청을 받습니다.
  • History Service: 워크플로우 실행별 이벤트 히스토리를 저장·재생하는 핵심입니다. 내구성의 근거입니다.
  • Matching Service: Tasklist에 대한 태스크 큐잉과 워커로의 전달을 담당합니다.
  • Worker: 사용자가 운영하는 프로세스로, 워크플로우 태스크와 액티비티 태스크를 폴링해 실행합니다. 수평 확장의 단위입니다.

데이터 플로우는 “클라이언트가 실행 시작 → 히스토리에 이벤트 적재 → 매칭 서비스가 Tasklist에 태스크 적재 → 워커가 처리 → 결과가 다시 히스토리에 기록”의 순환입니다. 이 고리가 끊기지 않는 한 한 번 시작한 절차는 정의된 정책 안에서 완료까지 추적됩니다.


3. 핵심 개념

3.1 내구성 있는 실행과 결정론

Cadence의 워크플로우 함수는 동일한 이벤트 히스토리를 입력으로 리플레이할 때 항상 같은 분기, 같은 Activity 스케줄 순서를 재현해야 합니다. 이를 워크플로우 결정론(determinism)이라고 부릅니다. 따라서 워크플로우 본문에는 다음을 두지 않는 것이 안전합니다.

  • 네트워크·DB·메시지 큐에 대한 직접 호출
  • 비고정 시드 난수, “지금 시각”을 임의로 읽는 코드(언어별로 안전한 API가 제공됨)
  • 워크플로우 외부와 공유되는 가변 전역 상태에 의존하는 분기

부작용은 Activity로 위임하고, 워크플로우는 스케줄링·분기·타이머·시그널 대기 등 오케스트레이션만 담당합니다.

3.2 Workflow 실행과 Run ID

Workflow는 등록된 함수 형태의 절차 정의입니다. 클라이언트가 Workflow ID(비즈니스 키에 매핑되는 경우가 많음)로 실행을 시작하면, 서버는 Run ID로 각 실행 시도를 구분합니다. 재시작·Continue-As-New 등에 따라 한 Workflow ID에 여러 Run이 이어질 수 있습니다. 운영 로그·지원 티켓에는 Workflow ID와 Run ID를 함께 남기는 편이 추적에 유리합니다.

3.3 Activity

Activity는 HTTP 호출, DB 트랜잭션, 메시지 발행 등 부작용이 있는 작업 단위입니다. Activity는 자체 타임아웃, 하트비트, 재시도 정책을 가질 수 있어 외부 API SLA와 맞출 수 있습니다. 동일 Activity가 여러 번 실행될 수 있으므로 멱등 키 설계가 필수에 가깝습니다.

3.4 Child Workflow와 Continue-As-New

실행 기간이 길거나 이벤트가 많아지면 히스토리가 커져 리플레이 비용이 증가합니다. Continue-As-New는 현재 실행을 마감하고 동일 Workflow ID로 새 Run을 이어 가며 히스토리를 줄이는 패턴입니다. 하위 절차를 독립 생명주기로 묶으려면 Child Workflow를 사용합니다.


4. Domain과 Tasklist

4.1 Domain

Domain은 Cadence에서 워크플로우 실행을 묶는 최상위 격리 경계입니다. 팀·제품·환경(예: 프로덕션·스테이징)별로 Domain을 나누면 설정(히스토리 보존, 아카이브 정책 등)과 운영 권한을 분리하기 쉽습니다. Workflow ID는 Domain 안에서 유일하면 되며, 다른 Domain과는 충돌하지 않습니다.

도메인 설계 시 고려할 점은 다음과 같습니다.

  • 규제·데이터 주권: 특정 지역 사용자 데이터만 분리해야 하면 Domain 단위 분리가 후보입니다.
  • 장애 폭발 반경: 한 Domain의 과부하가 다른 제품 라인까지 흔들리지 않도록 경계를 나눕니다.
  • 배포·버전: 워커 바이너리와 Domain 설정을 함께 문서화해 “어느 Domain이 어떤 워크플로우 타입을 처리하는지”를 명확히 합니다.

4.2 Tasklist

Tasklist는 워커가 태스크를 가져갈(poll) 큐의 논리적 이름입니다. 워크플로우 태스크와 액티비티 태스크는 각각 Tasklist에 스케줄되며, 워커는 하나 이상의 Tasklist를 구독합니다.

실무에서는 보통 다음처럼 나눕니다.

  • 서비스·팀 단위: orders-worker, payments-worker처럼 바이너리 경계와 맞춥니다.
  • 우선순위·SLA: 실시간 주문과 배치 정산을 다른 Tasklist로 분리해 상호 적체를 줄입니다.
  • 배포 격리: 카나리 배포 시 일부 워커만 새 Tasklist를 구독하게 하여 점진 전환을 할 수 있습니다.

Tasklist 이름은 “문자열 하나”에 불과하지만, 운영에서는 모니터링·알람·오토스케일 정책과 직결되므로 명명 규칙을 팀 표준으로 두는 것이 좋습니다.


5. Workflow와 Activity — 계약과 실행

5.1 워크플로우 등록

워커 프로세스는 시작 시 워크플로우 함수액티비티 함수를 이름과 함께 등록합니다. 클라이언트가 해당 이름으로 실행을 시작하면, 히스토리에 스케줄 이벤트가 쌓이고 Tasklist로 워크플로우 태스크가 전달됩니다.

5.2 Activity 옵션

Activity 실행에는 보통 다음이 포함됩니다.

  • ScheduleToStartTimeout: 태스크가 큐에 올라온 뒤 워커에 할당되기까지의 상한. 워커 부족·적체를 드러냅니다.
  • StartToCloseTimeout: 워커가 Activity를 실행해 완료하기까지의 상한. 비즈니스 로직의 상한입니다.
  • HeartbeatTimeout: 장시간 작업이 살아 있는지 검사합니다. 하트비트와 함께 쓰입니다.

주문 도메인에서는 “결제 게이트웨이 응답 대기”와 “창고 WMS 연동”의 타임아웃이 다르므로, Activity 타입별로 옵션을 분리하는 것이 일반적입니다.

5.3 버전과 호환성

워크플로우 소스 변경은 리플레이와 충돌할 수 있습니다. 장기 실행 인스턴스가 남아 있는 동안에는 조건부 분기(예: 빌드 ID·도메인 상수·명시적 버전 플래그)로 구·신 로직을 공존시키고, 구 실행이 소멸한 뒤 분기를 정리합니다. Activity 입출력 스키마 변경 시에는 하위 호환 필드 추가 또는 새 Activity 이름으로 전환하는 방식이 안전합니다.


6. 시그널과 쿼리

6.1 시그널(Signal)

시그널은 실행 중인 워크플로우 인스턴스에 비동기로 전달되는 외부 이벤트입니다. 예를 들어 “결제 웹훅 수신”, “고객 취소 요청”, “재고 확보 완료 알림” 등을 시그널로 모델링합니다. 워크플로우는 시그널 채널을 통해 이벤트를 받고, Selector 패턴(Go) 등으로 여러 소스(타이머·시그널)를 함께 기다립니다.

시그널 핸들러도 리플레이 대상이므로, 결정론적으로 워크플로우 내부 상태만 갱신해야 합니다. 시그널 순서가 비즈니스 규칙과 맞는지(예: 취소가 결제 확정보다 먼저 처리되어야 하는지)를 명시적으로 검증하는 편이 안전합니다.

6.2 쿼리(Query)

쿼리는 워크플로우의 현재 상태를 읽기 전용으로 조회하는 경로입니다. 지원 도구·내부 대시보드에서 “이 주문이 어느 단계인지”를 보여 줄 때 사용합니다. 쿼리 핸들러는 부작용을 일으키지 않아야 하며, 워크플로우가 유지하는 상태의 일관된 스냅샷을 반환하도록 설계합니다.


7. 재시도 정책

7.1 Activity 재시도

외부 API는 타임아웃·일시적 오류·레이트 리밋으로 실패합니다. Cadence에서는 Activity에 RetryPolicy를 붙여 다음을 조정합니다.

  • InitialInterval: 첫 재시도 전 대기
  • BackoffCoefficient: 지수 백오프 배수
  • MaximumInterval: 백오프 상한
  • MaximumAttempts: 최대 시도 횟수(구현·버전에 따라 0이 무제한 의미일 수 있어 문서 확인)
  • NonRetriableErrorReasons: 재시도해도 의미 없는 오류(예: 잘못된 주문 ID)는 즉시 실패로 분류

재시도와 별개로, 동일 Activity 실행이 중복될 수 있다는 전제에서 멱등성을 설계합니다. 결제·재고에서는 외부 시스템이 지원하는 요청 키·토큰을 반드시 활용합니다.

7.2 Workflow Task와 워커 측 실패

워크플로우 결정(decision) 한 사이클이 너무 오래 걸리거나 버그로 인해 리플레이가 실패하면 Workflow Task 수준의 오류로 드러납니다. 이는 Activity 실패와 성격이 다르므로, 워크플로우 본문은 가벼운 제어 로직만 두고 무거운 처리는 Activity로 넘기는 것이 좋습니다.


8. Temporal과의 비교

Temporal은 Cadence 코드베이스를 포크하여 발전시킨 별도의 프로젝트입니다. 핵심 아이디어—이벤트 히스토리, 결정론적 워크플로우, Activity, 시그널·쿼리, Task Queue—는 같지만, 제품 로드맵·SDK·운영형 서비스는 갈라졌습니다.

구분CadenceTemporal
기원Uber 오픈소스Cadence 포크 이후 독립 커뮤니티
네임스페이스DomainNamespace(개념 유사)
태스크 라우팅TasklistTask Queue
SDK 성숙도Go·Java 중심Go·Java·TypeScript·Python·PHP·.NET 등 광범위
매니지드 서비스자체 운영·커뮤니티 배포Temporal Cloud 등

언제 Cadence를 볼까: 레거시 시스템이 Cadence 위에 이미 올라가 있거나, Uber 오픈소스 문서·패턴과의 정합이 중요한 경우입니다. 신규 표준을 고른다면 팀이 사용하는 언어 SDK·클라우드 운영 요구·엔터프라이즈 지원을 기준으로 Temporal과 Cadence를 비교하는 것이 일반적입니다. 개념 이해를 위해 Cadence 문서를 읽고, 구현은 Temporal SDK로 옮기는 팀도 많습니다.


9. 실전: 주문 처리 워크플로우(Go 스타일)

아래는 주문 생성 → 재고 예약 → 결제 요청 → 결제 결과 시그널 대기 → 실패 시 재고 해제를 단순화한 예시입니다. 프로덕션에서는 트랜잭션 경계, 사기 탐지, 감사 로그, PG사별 API를 추가해야 합니다.

9.1 시그널·쿼리와 상태

// 개념 예시: 실제 프로젝트의 패키지 경로·클라이언트 생성은 환경에 맞게 조정합니다.
const (
    SignalPaymentResult = "payment_result"
    QueryOrderState     = "order_state"
)

type PaymentResultPayload struct {
    Success bool
    TxID    string
}

type OrderState string

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

워크플로우가 노출하는 상태를 OrderState로 단순화하면, 쿼리 핸들러가 지원 화면에 바로 넘길 수 있습니다.

9.2 워크플로우 본문

import (
    "fmt"
    "time"

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

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

    workflow.SetQueryHandler(ctx, QueryOrderState, func() (OrderState, error) {
        return state, nil
    })

    paymentCh := workflow.GetSignalChannel(ctx, SignalPaymentResult)
    var pay PaymentResultPayload
    timerFired := false

    ao := workflow.ActivityOptions{
        StartToCloseTimeout: time.Minute,
        RetryPolicy: &cadence.RetryPolicy{
            InitialInterval:    time.Second,
            BackoffCoefficient: 2,
            MaximumInterval:    30 * time.Second,
            MaximumAttempts:    5,
        },
    }
    actx := workflow.WithActivityOptions(ctx, ao)

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

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

    sel := workflow.NewSelector(ctx)
    sel.AddReceive(paymentCh, func(c workflow.Channel, more bool) {
        c.Receive(ctx, &pay)
    })
    tf := workflow.NewTimer(ctx, 30*time.Minute)
    sel.AddFuture(tf, func(f workflow.Future) {
        timerFired = true
    })
    sel.Select(ctx)

    if timerFired {
        _ = workflow.ExecuteActivity(actx, ReleaseInventoryActivity, orderID).Get(actx, nil)
        state = StateCancelled
        return fmt.Errorf("payment timeout")
    }
    if !pay.Success {
        _ = workflow.ExecuteActivity(actx, ReleaseInventoryActivity, orderID).Get(actx, nil)
        state = StateCancelled
        return fmt.Errorf("payment declined")
    }

    // 4) 결제 확정(매입 등)
    if err := workflow.ExecuteActivity(actx, CapturePaymentActivity, orderID, pay.TxID).Get(actx, nil); err != nil {
        state = StateFailed
        return err
    }
    state = StatePaid
    return nil
}

Selector로 시그널과 타이머를 함께 기다리면, “30분 내 결제 확정이 없으면 취소” 같은 정책을 코드로 명시할 수 있습니다. 타임아웃 분기에서는 재고 해제 Activity를 호출해 보상 트랜잭션을 수행합니다.

9.3 Activity 측(개념)

func ReserveInventoryActivity(ctx context.Context, orderID string) error {
    // 재고 서비스 HTTP/gRPC — 멱등 키: orderID
    return nil
}

func RequestPaymentActivity(ctx context.Context, orderID string, amountCents int64) error {
    // PG에 결제 세션 생성 — 클라이언트는 웹훅에서 Workflow ID로 시그널 전송
    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를 매핑한 뒤 Cadence 클라이언트로 SignalWorkflow를 호출합니다. 웹훅은 서명 검증중복 전달에 대비해야 하며, 시그널이 두 번 와도 상태가 일관되도록 이벤트 ID 기반 중복 제거를 고려합니다.

9.4 워커와 Tasklist

// Worker 시작 시: OrderWorkflow를 등록하고 Tasklist "orders"를 구독하는 식으로 구성합니다.
// workflow.RegisterOptions{Name: "OrderWorkflow"} 와 함께 바인딩하는 패턴이 일반적입니다.

운영에서는 결제·재고·알림에 서로 다른 Activity 전용 워커를 두고 Tasklist를 나누면, 한 구간의 장애가 전체 폴링을 막는 것을 완화할 수 있습니다.


10. 모범 사례와 함정

  • 워크플로우는 얇게: 긴 절차는 Child Workflow로 모듈화하고, 단계별 타임아웃·보상 트랜잭션을 분리합니다.
  • 멱등성 우선: Activity는 “한 번만 실행된다”고 가정하지 않습니다. 주문·결제·재고 키를 외부 시스템 계약에 맞게 고정합니다.
  • 시그널 순서: 동시 시그널이 올 때 비즈니스 규칙과 모순되지 않는지 테스트합니다.
  • 비밀 정보: 카드 원번호 등은 워크플로우 인자에 넣지 않고 토큰·참조만 전달합니다.
  • 관측성: 로그·메트릭에 Workflow ID, Run ID, Activity 타입을 구조화해 남깁니다.

11. 정리

Cadence는 이벤트 히스토리결정론적 워크플로우 코드를 결합해, 분산 환경에서도 업무 절차를 끊기지 않게 실행하는 플랫폼입니다. Domain으로 격리 경계를, Tasklist로 워커와 부하를 나누고, Workflow·Activity로 오케스트레이션과 부작용을 분리하며, 시그널·쿼리로 외부 세계와 운영 도구를 연결합니다. 재시도 정책은 외부 API의 현실과 멱등성 설계를 함께 봐야 합니다. Temporal과의 관계를 이해한 뒤, 팀의 SDK·운영 조건에 맞는 스택을 선택하면 됩니다. 이 글의 주문 예제는 출발점으로 삼고, 실제 PG·재고·배송 연동과 장애 대응 런북을 조직 표준에 맞게 확장하기 바랍니다.


배포 전 git add, git commit, git pushnpm run deploy를 실행하는 것이 안전합니다.