복잡한 보상 트랜잭션 대신 선택한 Temporal

최근 사가 패턴(Saga Pattern)으로 포인트 적립 -> 결제 흐름 같은 프로젝트를 해보면서 여러 가지 서킷, Fallback, 분산락 등을 적용하고  성능 테스트를 해보고 있다.

그런데, 최근 핫한 주제인 Temporal에 대해서 여기저기서 이야기가 많이 들리는것 같다.

나도 이론적인 내용만 알고 있었고 실제로 사용해본적은 없는데  보상 트랜잭션 같은걸 처리하면서 Temporal로 한번 해볼까? 라는 생각이 들어서 토이프로젝트로 하나 만들어보면서 공부한 내용을 정리하려고 한다.

 

이번 포스팅의 목적은

"이거 결국 Temporal 해주는 아닌가?"라는 생각이 들어서 직접 같은 문제를 Temporal 다시 풀어봤다.  
일반적인 보상 트랜잭션과 Temporal을 이용한 방법을 비교해보자!

 

Temporal 이 뭐야?

우선 간단한 문제 상황을 하나 정의해보자.

커머스에서 상품 구매 시, 포인트 적립과 결제 승인 그리고 확정 까지의 과정을 거친다.

따라서, 여러 단계로 이뤄진 프로세스(포인트 적립 -> 결제 승인 -> 확정)의 단계를 안정적으로 실행하려면 이런 것들을 전부 신경써야한다.

- 중간 서버가 죽으면? -> 어디까지 진행됐는지 어딘가에 기록해둬야한다.

- 외부 호출이 실패하면? -> 재시도해야 하는데, 얼마나 자주, 몇 번, 어떤 실패는 재시도하면 안 되는지 판단해야한다.

- 뒷단계가 실패하면? -> 앞 단계를 되돌리는 보상 로직이 필요하다.

- 같은 요청이 중복으로 들어오면? -> 멱등성을 별도로 챙겨야 한다.

 

보통 위와 같은 문제가 발생하면 예외 처리 상황과 멱등성 키 등등을 직접 구현한다. 즉, DB 테이블의 유니크 키를 만들어 멱등성을 제공해 같은 요청이 처리되지 않도록 하거나, 보상 트랜잭션으로 보상 로직을 구현하거나 서킷이나 폴백 처리를 통해서 구현했다.

 

Temporal을 한마디로 정리하면, 서비스의 비즈니스 프로세스(Workflow)를 구현하기 위한 엔진이라고 볼 수 있다.

Temporal과 맥락은 조금 다르지만 좀 더 큰 단위로 보자면 Airflow가 있겠다. AirFlow도 워크플로우 엔진이지만 AirFlow는 데이터 파이프라인(ETL, Batch)을 실행하기 위한 엔진이라고 볼 수 있겠다.

 

Temporal을 이용한 문제 해결 접근

Temporal은 "복구/재시도/보상"을 애플리케이션 코드가 아니라 플랫폼이 대신하게 만드는 워크플로우 엔진이다. 핵심 아이디어는 durable execution  철학이라고 한다. 

 

내가 이해한 durable execution은 다음과 같다.

일반 프로그램은 서버가 종료되면 실행 상태도 함께 사라진다. Temporal은 실행 상태를 이벤트 히스토리로 영속화하여, 서버가 종료되거나 며칠이 지나도 프로그램을 같은 지점에서 안전하게 이어서 실행할 수 있게 해준다. 

 

즉, 워크플로우 코드를 평범한 순차 코드처럼 작성한다면 Temporal 서버가 이벤트 소싱 방식으로 실행 이력을 전부 기록해뒀다가, 워커가 죽어도 정확히 멈춘 지점부터 이어서 실행한다.

payment()

↓

History 저장

↓

inventory()

↓

History 저장

↓

coupon()

↓

History 저장

 

따라서, Temporal을 이용한다면 복구가 아니라 재생이라고 표현한다. 저장해준 상태르르 불러오는 게 아니라, 처음부터 다시 실행하면 이미 일어난 일은 건너뛰는 방식으로 같은 지점부터 다시 실행하는 방식이다.

payment()

→ 이미 끝났네

inventory()

→ 이미 끝났네

coupon()

→ 아직 안 했네

실행

 

그래서 워크플로우 코드를 작성할때 중요한건 Deterministic(결정론적) 이라고 한다.  같은 히스토리가 주어진 재생할 때마다 정확히 같은 순서로 같은 판단을 내려야 한다.  그렇기 때문에 `Random()`을 직접 쓰거나 `LocalDateTime.now()` 처럼 매번 달라지는 것은 사용하면 재생이 어긋나므로 사용하면 안된다.

그래서 workflow.randomUUID() 같은 함수를 별도로 제공하기도 한다고 한다.

 

지금까지 살펴본 내용으로 Temporal은 장/단점이 명확하다.

 

장점

  • 재시도/보상/멱등성을 코드로 작성하지 않아도 된다. 
    • `RetryOption`,  `Saga`와 같은 선언만 하면 플랫폼이 알아서 처리한다.
  • 워크플로우 코드가 순차 코드처럼 읽힌다.
    • 상태 컬럼이나 복구 배치 없이 `try/catch` 만으로 전체 흐름을 읽을 수 있다.
  • 실행 이력이 자동으로 남는다. 
    • Temporal Web UI에서 이 요청이 지금 어느단계인지, 몇번 재시도했는지 그대로 보인다. DB 상태 컬럼을 스캔할 필요가 없다.
  • 크래시 복구 비용이 매우 적다.

단점

  • 새 인프라를 운영해야한다. 
    • Temporal 서버(+ 백엔드 DB)를 별도로 띄워야 한다. 
  • 결정론 제약이 존재한다.
    • 워크플로우 코드는 replay를 통해 회복하므로 워크플로우 안에서 직접 HTTP 호출을 하거나 `Random()`을 호출하면 안된다.
  • 러닝커브가 높다.

Temporal 기본 문법

워크플로우 정의

@WorkflowInterface
interface PurchaseWorkflow {
    @WorkflowMethod          // 워크플로우의 진입점. 딱 하나만 있어야 한다.
    fun purchase(command: PurchaseCommand): PurchaseStatusView

    @QueryMethod              // 실행 중/완료된 워크플로우의 현재 상태를 조회하는 읽기 전용 메서드.
    fun getStatus(): PurchaseStatusView
}

 

Activity 정의

@ActivityInterface
interface PurchaseActivities {
    @ActivityMethod
    fun pay(orderId: String, amount: Long): String
}

 

워크플로우 코드 자체에는 실제 I/O(HTTP 호출, DB 접근)를 두면 안 되고, 전부 Activity로 감싸서 워크플로우가 그 Acitivity를 호출하는 형태로 짠다.

 

Spring Boot 연동 - 자동 등록

@WorkflowImpl(taskQueues = ["purchase-task-queue"])
class PurchaseWorkflowImpl : PurchaseWorkflow { ... }

@Component
@ActivityImpl(taskQueues = ["purchase-task-queue"])
class PurchaseActivitiesImpl(...) : PurchaseActivities { ... }

 

`taskQueue`만 맞춰주면 `temporal-spring-boot-starter`가 Worker 등록부터 기동까지 알아서 해준다.

 

재시도 정책

Workflow.newActivityStub(
    PurchaseActivities::class.java,
    ActivityOptions.newBuilder()
        .setScheduleToCloseTimeout(Duration.ofSeconds(30))   // 재시도 포함 전체 제한시간
        .setRetryOptions(
            RetryOptions.newBuilder()
                .setInitialInterval(Duration.ofSeconds(1))     // 첫 재시도까지 대기
                .setBackoffCoefficient(2.0)                     // 지수 백오프 배율
                .setMaximumInterval(Duration.ofSeconds(5))      // 재시도 간격 상한
                .setDoNotRetry(PaymentDeclinedException::class.java.name)  // 이 예외는 재시도 안 함
                .build(),
        )
        .build(),
)

 

보상(Saga)

val saga = Saga(Saga.Options.Builder().setParallelCompensation(false).build())

pointActivities.accumulatePoints(...)
saga.addCompensation {
    pointActivities.revokePoints(...)   // 나중에 실패하면 이 함수가 실행된다
}

try {
    paymentActivities.pay(...)
} catch (e: ActivityFailure) {
    saga.compensate()   // 등록해둔 보상들을 등록 역순으로 실행
}

 

워크플로우 시작(클라이언트쪽)

val workflow = workflowClient.newWorkflowStub(
    PurchaseWorkflow::class.java,
    WorkflowOptions.newBuilder()
        .setWorkflowId(request.orderId)   // 이 ID가 곧 멱등키 역할을 한다
        .setTaskQueue("purchase-task-queue")
        .build(),
)

val untyped = WorkflowStub.fromTyped(workflow)
untyped.start(command)
val result = untyped.getResult(3, TimeUnit.SECONDS, PurchaseStatusView::class.java)  // 타임아웃 지정 가능

 

같은 `workflowId`로 다시 시작하면 Temporal이 기존 실행을 그대로 재사용한다.(멱등성 제공)

 

전바적인 Application <-> Temporal 간 플로우는 아래와 같다.

 

1.Worker 내부 구조

Worker 프로세스는 같은 task queue 이름을 두 개의 별도 poller로 구독한다.

- WorkFlow Task Poller : 워크플로우를 진행/재개시켜야 할 때 받는 태스크

- Activity Task Poller : 액티비티를 실제 실행 해야 할 때 받는 태스크

현재 내 토이 프로젝트에서는 `purchase-task-queue`같은 하나의 큐 이름을 쓰지만, 서버 입장에선 워크플로우용/액티비용이 논리적으로 분리되어 있다.

 

2.Workflow Task를 전달 받은 경우

Worker가 워크플로우 태스크를 받으면 워크플로우 코드를 실제로 실행하는데, 여기서 중요한 게 "처음부터 다시 실행하는가?" 혹은 "이어서 실행하는가?"를 판단한다.

 

3.Workflow 코드가 Acitivity를 호출

워크플로우 코드 안에서 activities를 호출해도 실제 호출이 아닌 커맨드를 만드는것에 해당한다.

- 워크플로우 실행 스레드는 이 호출을 `ScheduleActivityTask`라는 커맨드로 변환

- 워크플로우 코드 실행은 여기서 suspend - 결과가 올 때 까지 이 지점에서 멈춘다.

- Worker는 이번 워크플로우 태스크 처리 결과로 서버에 `RespondWorkflowTaskCompled(commands=[ScheduleActivityTask])를 전달` -> 실제 실행이 아니라 커맨드를 만들었다라고 보고함

- 서버는 이 커맨드를 받아 `AcitivityTaskScheduled` 이벤트로 기록하고 액티비티 task queue에 태스크를 적재

 

4. Activity Task를 받았을 때 - 실제 실행

- 같은(또는 다른) worker의 Activity Task Poller가 이 이 태스크를 가져간다.

- 이번엔 replay 개념이 없다. Activity는 워크플로우와 달리 매 시도마다 실제로 실행된다.(비결정적이며, 따라서 실제 I/O, 외부 API 호출 등을 여기서 수행해야함)

- 실패시 Activity에 설정된 RetryOptions에 따라 서버가 재시도 스케줄링(백오프 포함)

- 완료되면 RespondActivityTaskCompleated(또는 failed)가 서버에 보고

 

5. 결과가 워크플로우로 돌아오는 과정

- 서버는 ActivityTaskCompleted 이벤트를 히스트로리에 기록

- 서버는 새로운 workFlow Task를 생성해 다시 Task queue에 적재("이 워크플로우 재개 가능해졌다"라고 표기)

- workflow task poller가 다시 가져가서, 이어서 진행

- suspend지점이 액티비티 결과값을 받은 것 처럼 재개되고,워크플로우 코드의 다음줄 부터 계속 실행

 

[Workflow Task 도착]
  → Worker가 워크플로우 코드 실행 (또는 replay)
  → activity 호출 지점에서 커맨드 생성 후 suspend
  → 서버에 커맨드 보고 (ActivityTaskScheduled 기록)

[Activity Task 도착]
  → Worker가 실제 액티비티 메서드 실행 (I/O 포함)
  → 결과를 서버에 보고 (ActivityTaskCompleted 기록)
  → 서버가 새 Workflow Task 생성

[Workflow Task 재도착]
  → suspend됐던 지점부터 재개 (or replay로 그 지점까지 복원)
  → 다음 로직 진행, 반복

 

 

Temporal 서버 기본 대시보드

Temporal 공식 문서 기반으로 보면 기본적으로 제공하고 있는 매트릭을 기반으로 그라파나 대시보드를 구성해 볼 수 있다.

Temporal 서버 대시보드

 

Temporal 서버 주요 매트릭은 아래와 같다.

- 전체 요청량 / 에러율 / 요청 p95 지연시간 (Overview)
- 오퍼레이션별 요청량, 에러 타입별 발생량
- 오퍼레이션별 요청 p95 지연시간
- DB(persistence) p95 지연시간 — PostgreSQL 쿼리가 느려지는지(이벤트 히스토리 저장하는 DB로 변경가능)
- 태스크 큐 대기(asyncmatch) p95 지연시간 — Worker가 태스크 큐를 못 따라가고 있는지

 

Temporal  서버에 요청하는 애플리케이션 주요 매트릭
- 앱 Uptime / CPU 사용률 / JVM Heap / Live Thread 수
- 워크플로우 완료/실패/취소율 (`workflow_type`별)
- 워크플로우 end-to-end p95 지연시간
- 액티비티 실행 p95 지연시간, 실패율 (`activity_type`별)
- REST API(`POST /api/v1/purchases` 등) p95 지연시간

 

Temporal 워크플로우 VS Application Workflow(기존 방식)

직접 코드를 작성하고 비교해본 느낀 점을 작성해보려고 한다.

Temporal 워크플로우 기반의 Retry, 폴백, 타임아웃 등으로 장애 대응 프로세스를 개발해보니 Spring Batch를 처음 썻을 때의 느낌을 받았다.

기존 Batch Processor를 Spring Batch로 마이그레이션 할때도 러닝커브가 조금 있었는데, 정해진 프레임워크 안에서 비지니스로직을 녹이는것에 대한 안정감을 느꼈던것 같다.

Temporal는 러닝 커브가 좀더 높고 아무래도 Temporal 서버를 별도로 구성해야하기 때문에 인프라 영역에 대한 허들도 존재할것으로 보인다. 그뿐만 아니라 결정론 제약,  버저닝 문제라는 대가를 치른다. 결국 "재시도/보상 로직을 얼마나 자주, 얼마나 복잡하게 손으로 짜야 하는가"가 이 트레이드오프를 넘을 만한 값어치가 있는지를 가르는 기준인 것 같다.

 

- 토이 프로젝트 Temporal github (https://github.com/hoyo1744/spring-temporal)

참고

 

- [OSS Temporal Service metrics reference](https://docs.temporal.io/references/cluster-metrics)
- [Temporal SDK metrics reference](https://docs.temporal.io/references/sdk-metrics)
- [Monitor Temporal Platform metrics](https://docs.temporal.io/self-hosted-guide/monitoring)
- [temporalio/dashboards](https://github.com/temporalio/dashboards)