한꺼번에 짊어지던 배치를 내려놓고, 하나씩 흘려보내는 워크플로로
Temporal 도입은 부담스러웠고, 그렇다고 배치를 그대로 둘 순 없었습니다 – 입맛대로 골라 Claude로 뚝딱 만든 워크플로 엔진 개발기
ℹ️ 이 글에 등장하는 수치와 설정값(대기 시간, 타임아웃, 재시도 횟수 등)은 이해를 돕기 위한 예시이며, 실제 운영 설정과는 다릅니다.
들어가는 말
배달의민족 사장님이 사용하는 포스(POS)의 백엔드를 만들다 보면, "지금 당장 처리할 일"보다 "시간이 흐른 뒤에 처리해야 할 일"이 의외로 많습니다. 주문이 들어왔는데 일정 시간 접수되지 않으면 알림을 보내고, 사장님 응답이 없으면 자동으로 거절하며, 픽업 후 일정 시간이 지나면 자동으로 주문을 완료 처리합니다. 이런 "긴 시간을 가지는 상태 머신"을 우리는 오랫동안 배치 잡으로 처리해 왔습니다.
배치는 편합니다. 주기적으로 돌면서 조건에 맞는 대상을 한꺼번에 훑어 처리하면 되니까요. 그런데 주문이 몰리는 피크타임이 되면 이야기가 달라집니다. 한 번 돌 때 훑어야 할 대상이 급격히 늘고, 그만큼 한 사이클의 부담도 커집니다. "한꺼번에" 처리하는 방식의 숙명입니다.
그래서 생각했습니다. 주문 하나하나를 "그 주문만의 흐름"으로 다룰 수 있다면, 즉 배치가 한꺼번에 훑던 일을 주문 1건 = 워크플로 1개로 쪼갤 수 있다면, 피크타임에도 부담 없이 흘려보낼 수 있지 않을까? 워크플로 모델이 바로 그 그림이었고, 그 생각을 Flowkit이라는 자체 라이브러리로 만들었습니다. Flowkit으로는 지금 여러 비즈니스를 워크플로로 옮겨가고 있는데, 이 글에서는 이해를 돕기 위해 그중 가장 단순한 접수지연 알림 하나를 바꾼 사례에 집중해 이야기하겠습니다.
배경
워크플로 하면 가장 먼저 떠오르는 것은 Temporal입니다. workflow / activity / signal이라는 깔끔한 추상화로 이 문제를 본질적으로 잘 풀어내는 엔진이죠. 실제로 우리도 가장 먼저 Temporal을 검토했습니다.
Temporal은 어떤 일을 하나?
Temporal은 "오래 걸리는 작업"을 코드로 안정적으로 다루게 해주는 워크플로 엔진입니다. 개발자는 흐름을 정의하는 워크플로와 외부 세계를 호출하는 액티비티를 평범한 함수처럼 작성하고, 클라이언트는 워크플로를 시작하거나 시그널을 보냅니다. 실제 코드는 워커(worker) 프로세스가 실행하고, Temporal Server는 워크플로의 모든 실행 단계를 이벤트 히스토리로 영속화합니다. 그래서 프로세스가 죽거나 재배포돼도, 서버에 남은 히스토리를 다시 재생(replay)해 중단 지점부터 정확히 이어갑니다. 재시도·타임아웃·타이머·시그널 대기 같은 신뢰성 처리를 엔진이 대신 맡아, 코드가 장애·재배포에도 중단 없이 이어지게 하는 것 – 이런 방식을 durable execution이라고 부릅니다. 덕분에 개발자는 비즈니스 로직에만 집중할 수 있습니다. 아래 그림에서 클라이언트와 워커는 작성·배포하는 애플리케이션이고, 서버와 스토리지는 별도로 운영해야 하는 Temporal 인프라입니다.

하지만 도입을 앞두고 몇 가지 부담이 있었습니다.
-
러닝커브가 높다 – 워크플로 엔진은 중단됐던 작업을 나중에 이어서 실행하기 위해, 워크플로가 재개될 때 "지금까지의 실행 기록"을 처음부터 되짚어 상태를 복원합니다(history replay). 이 방식이 어긋나지 않으려면 워크플로 코드가 언제 다시 실행해도 항상 같은 순서로 같은 결과가 나오도록 짜여 있어야 하는데(이런 성질을 deterministic, 우리말로 ‘결정론적’이라고 합니다), 그래서 코드 안에서 현재 시각·난수·직접 DB 호출 같은 것을 마음대로 쓸 수 없습니다. 이런 개념들을 팀 전체가 익혀야 하는 학습 비용이 작지 않습니다.
-
별도 인프라를 직접 운영해야 한다 – Temporal Cloud(SaaS)를 쓰지 않는다면, Temporal cluster(frontend / matching / history / worker service + DB + UI)를 온프레미스로 직접 설치·구성해야 합니다. 구성요소가 여러 개로 나뉘어 있는 만큼, 이들 사이의 통신 경로와 인증·TLS 같은 네트워크 보안 설정도 일일이 잡아줘야 하는데 이게 꽤 까다롭습니다. 게다가 상태를 저장할 persistence store가 Cassandra·PostgreSQL·MySQL 정도로 제한적이고(가시성 저장소로는 Elasticsearch 등), 이미 숙련된 DBMS가 이 목록에 없다면 이 엔진을 위한 DB를 새로 도입·운영해야 하는 것 자체가 또 하나의 부담이 됩니다.
-
엔진 자체 이슈 대응이 어렵다 – 직접 만든 코드가 아니라 방대한 외부 엔진이다 보니, 엔진 내부에서 버그나 이슈가 생기면 그 내부를 파고들어 원인을 파악하고 대응하기가 까다롭습니다.
-
특정 기술에 종속(lock-in)되는 리스크 – 워크플로가 시스템의 중심 흐름을 담당하게 되면, 비즈니스 로직이 자연스럽게 Temporal의 SDK·실행 모델·영속 포맷에 강하게 결합됩니다. 한 번 깊이 얽히고 나면 나중에 다른 방식으로 갈아타거나 걷어내기가 어렵고, 엔진의 버전 정책·라이선스·SaaS 요금 같은 외부 요인의 변화에 우리 시스템이 끌려다닐 수 있습니다. 핵심 실행 기반을 외부 기술에 통째로 맡길 때 늘 따라오는 리스크입니다.
반면 채널 백엔드에는 이미 활용할 수 있는 자원이 충분했습니다. Kotlin coroutine + Spring WebFlux 런타임은 suspend / await / timer 추상화에 자연스럽게 매핑되고, 상태를 저장할 Reactive MongoDB(DocumentDB)도 이미 쓰고 있었습니다. 즉 Temporal의 핵심 개념만 압축해서, 외부 워크플로 엔진 없이 기존 인프라만으로 라이브러리화 하면 러닝커브·운영 부담·복구 난이도를 동시에 낮추면서도 워크플로 모델의 이점을 얻을 수 있겠다고 판단했습니다.
여기에 요즘의 개발 환경도 한몫했습니다. Temporal의 방대한 SDK를 전부 이해해 도입하는 대신, 우리에게 필요한 개념(workflow / activity / signal / scheduler) 네 가지만 골라 Claude와 함께 라이브러리 형태로 빠르게 구현할 수 있었습니다. "무겁게 통째로 들여오는" 대신 "입맛에 맞게 필요한 부분만 차용"하는 선택이 가능해진 것이죠. 그렇게 만든 것이 Flowkit입니다.
Flowkit – 주문접수채널 백엔드의 장기 실행 워크플로를 위한 Kotlin coroutine 기반 durable workflow 라이브러리. Temporal의 핵심 추상화(
Workflow/Activity/Signal/Scheduler)를 그대로 채택하되, 외부 워크플로 엔진 없이 기존 인프라(Kotlin coroutine, Spring WebFlux, Reactive MongoDB)만으로 동작합니다.
구성
Flowkit을 쓰는 사람이 알아야 할 개념은 네 가지뿐입니다.
- Workflow – 비즈니스 흐름을 정의하는 사용자 클래스.
execute()본문에 흐름을 직선적으로 작성합니다. - Activity – DB·HTTP·외부 API 등 바깥 세계와 닿는 표면. 모든 사이드이펙트는 여기에 위임합니다.
- Signal – 외부 이벤트로 대기 중인 워크플로를 깨우는 신호. 워크플로를 즉시 replay 합니다.
- Scheduler – 깨어날 시각이 된 인스턴스를 찾아 실행하는 폴링 워커. 사용자가 직접 만들 일은 없습니다.
핵심 인터페이스는 다음과 같은 모양입니다. 워크플로 본문 안에서 대기(await)·Activity 호출·시각 조회 같은 라이브러리 기능이 필요할 때는 모두 WorkflowContext를 거쳐 사용합니다. (Temporal을 써봤다면, Workflow.await(...)처럼 정적 헬퍼로 제공되던 기능들을 Flowkit에서는 WorkflowContext가 같은 역할로 제공한다고 보면 됩니다.)
// 비즈니스 흐름 정의
interface Workflow<I : Any, O> {
suspend fun execute(input: I): O
}
// 외부 호출 / 사이드이펙트 (marker)
interface Activity
// workflow 본문 안에서만 호출 가능한 helper
object WorkflowContext {
suspend fun await(timeout: Duration, predicate: () -> Boolean): Boolean
suspend fun sleep(duration: Duration)
fun <A : Activity> getActivity(clazz: Class<A>): A
fun now(): Instant // 결정적 시각
fun invocationToken(): String // Activity 멱등성 토큰
}
왜 무한정 대기해도 부담이 없을까?
워크플로 모델의 핵심은 "수분·수시간을 기다려도 비용이 0"이라는 점입니다. Flowkit은 Temporal의 Event Sourcing + History Replay를 단순화한 형태로 이를 구현합니다.
워크플로 WorkflowContext.await(3분) 같은 대기 지점을 만나면, Flowkit은 그 시점의 상태를 DocumentDB에 직렬화(체크포인트)한 뒤 코루틴에서 빠져나갑니다. 대기하는 동안 메모리도 스레드도 점유하지 않습니다. 그리고 3분이 지나거나 시그널이 도착하면, Scheduler가 인스턴스를 깨워 execute()를 처음부터 다시 실행(replay)합니다. 이때 이미 처리된 Activity·await 호출은 저장된 결과를 즉시 돌려주므로, 외부 호출을 반복하지 않고 마지막 대기 지점까지 빠르게 건너뛴 뒤 이어서 실행됩니다. 인스턴스(pod)가 죽거나 재시작해도 상태는 workflow_instance 컬렉션에 남아 있어 다른 pod가 그대로 이어받습니다.
| 항목 | Temporal | Flowkit |
|---|---|---|
| 상태 저장 | 일어난 모든 사건을 순서대로 쌓는 이벤트 로그 (액티비티 완료마다 즉시 기록) | 인스턴스마다 문서 1개를 두고 최신 상태로 덮어쓰기 (변수 + 지금까지의 await·Activity 결과). 체크포인트는 await·완료·실패 시점에만 |
| 재개 방식 | 본문을 처음부터 replay, 이벤트로 fast-forward | 동일 – 본문을 처음부터 replay, 캐시된 결과로 fast-forward |
| Activity 재시도 | RetryOptions 기반 자동 retry | 동일 – @Retry 기반, Scheduler로 재예약 |
| Signal | history 이벤트로 기록 후 replay | 시그널이 오면 바로 실행하지 않고 문서의 대기목록(pendingSignals)에 담아둠 → 다음 재개 때 핸들러 적용 후 본문 실행 |
개발사례 – 접수지연 알림
Flowkit의 첫 적용 대상은 접수지연 알림이었습니다. "주문이 미접수 상태로 3분 이상 머무르면 알림을 발송하되, 그 사이 접수되면 발송을 하지 않는다"는, 시간 경과 자체가 트리거가 되는 전형적인 후처리 시나리오입니다.
주문의 전체 생애주기 안에서 이 알림이 언제 끼어드는지 먼저 그림으로 보겠습니다. 신규 주문(NEW)이 3분 안에 접수(RECEIPT)되지 않으면 접수지연 알림을 보내고, 그러고도 5분이 지나도록 접수되지 않으면 주문이 취소(CANCELLED)됩니다. 3분 안에(혹은 알림 후 5분 안에) 접수된 주문은 조리·픽업 등 처리를 거쳐 완료(COMPLETED)됩니다.

기존 방식 – 한꺼번에 훑는 배치
기존에는 이 로직이 배치를 비롯한 여러 방식에 나뉘어 있었습니다. 주기적으로 돌면서 "미접수인 채로 오래된 주문"을 한꺼번에 조회해 알림을 보내는 구조였죠. 이 방식에는 몇 가지 태생적인 한계가 있었습니다.
- 주문을 수신·처리하는 모듈과 접수지연을 판단하는 배치 모듈이 서로 달랐습니다. 그래서 한 주문의 흐름을 따라가려면 여러 모듈의 로그를 오가며 확인해야 했고, "이 주문이 어느 단계까지 갔는가"를 한눈에 보기 어려웠습니다.
- 타이머 정밀도가 떨어졌습니다. 접수지연 배치의 cron은 1분 주기로 돌기 때문에, 지연 판단에 최대 59초의 오차가 생겼습니다.
- 무엇보다 피크타임에 대상이 몰리면 한 사이클의 부담이 그대로 커졌습니다.
Flowkit 방식 – 주문 하나가 워크플로 하나
Flowkit으로 옮기면 시나리오 전체가 execute() 함수 하나에 직선적으로 담깁니다. 주문 1건이 곧 워크플로 1개입니다. 먼저 외부에서 주문 이벤트가 도착했을 때 워크플로가 어떻게 트리거되고, await를 만나는 순간 상태를 저장하고 스레드가 종료됐다가, 시그널 또는 타이머로 다시 재개되는지를 순서로 보면 다음과 같습니다.

이 흐름 전체가 아래 execute() 함수 하나에 그대로 담깁니다.
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@WorkflowOptions(taskQueue = "worker")
class MissingNewOrderWorkflow : Workflow<MissingNewOrderInput, Unit> {
@WorkflowState
private var isReceived: Boolean = false
@SignalHandler("orderReceived")
fun onOrderReceived() {
isReceived = true
}
@WorkflowVersion(1)
override suspend fun execute(input: MissingNewOrderInput) {
val orderActivity = WorkflowContext.getActivity(OrderActivity::class.java)
val notifyActivity = WorkflowContext.getActivity(NotifyMissingNewOrderActivity::class.java)
val order = orderActivity.findById(input.orderId)
if (order.status == OrderStatus.RECEIPT) return // 이미 접수됨 → 알림 불필요, 종료
// await(3분)을 만나는 순간 Flowkit이 지금까지의 상태(변수 + activity/await 결과)를 DB에 저장하고
// execute()를 즉시 종료한다 - 3분 동안 스레드가 붙어 기다리지 않는다(메모리/스레드 점유 0).
// 3분 뒤(타이머) 또는 isReceived 시그널 도착 시 인스턴스가 깨어나 execute()를 처음부터 replay 하고,
// 저장된 activity 결과는 그대로 재사용된다.
val receivedInTime = WorkflowContext.await(Duration.ofMinutes(3)) { isReceived }
if (receivedInTime) return // 3분 내 접수 시그널 도착 → 알림 불필요, 종료
// 3분이 지났는데 여전히 미접수 - 현재 상태를 재조회해 확인 후 접수지연 알림을 한 번 발송하고 종료.
// (replay 시 위 findById 는 캐시된 결과를 그대로 쓰고, await 는 이미 만료돼 통과한다.)
val reloaded = orderActivity.findById(input.orderId)
if (reloaded.status == OrderStatus.NEW) {
notifyActivity.notify(input.orderId)
}
}
}
data class MissingNewOrderInput(val orderId: String)
핵심은 WorkflowContext.await(3분) { isReceived } 한 줄입니다. "3분이 지나거나, 그 사이 접수 시그널이 오거나 – 둘 중 빠른 쪽에서 깨어난다"는 페이드아웃의 도중 취소 패턴이 그대로 코드가 됩니다. 배치라면 별도의 상태 체크와 dedup 로직(중복발송을 막는 로직)이 필요했을 일입니다.
워크플로를 시작하고 시그널을 보내는 쪽은 이렇게 단순합니다. 주문이 NEW로 생성될 때 start, 접수되면 signal을 보냅니다. workflowId로 orderId를 쓰기 때문에 시그널을 보내는 쪽에서도 자연스럽습니다.
@Service
class OrderRegistrationService(
private val workflowClient: WorkflowClient,
) {
suspend fun onNewOrder(orderId: String) {
workflowClient.start(
workflowId = "missingNewOrder:$orderId",
workflowClass = MissingNewOrderWorkflow::class,
input = MissingNewOrderInput(orderId),
)
}
suspend fun onOrderReceived(orderId: String) {
workflowClient.signal(
workflowId = "missingNewOrder:$orderId",
signalName = "orderReceived",
)
}
}
바깥 세계와 닿는 부분은 모두 Activity에 있습니다. 재시도·타임아웃·멱등성·체크포인트 같은 정책은 인터페이스의 @ActivityMethod(그리고 그 안의 @Retry)에 선언적으로 붙습니다. 선언할 수 있는 필드는 다음과 같습니다.
startToCloseTimeout– 한 번의 시도(attempt)가 이 시간을 넘기면 실패로 보고 재시도를 평가합니다. (기본"30s")scheduleToCloseTimeout– 모든 재시도와 backoff 대기까지 포함한 총 시간 상한.""(기본)이면 무제한이며, 외부 장애로 재시도가 끝없이 늘어지는 걸 막는 안전장치입니다.retry– 아래@Retry로 재시도 정책을 지정합니다.maxAttempts(기본1= 재시도 없음),initialInterval·maxInterval·backoffCoefficient로 backoff 곡선,nonRetryable은 재시도 없이 즉시 실패시킬 예외 목록입니다.idempotencyRequired–true면 라이브러리가invocationToken()발급을 보장합니다. 그 토큰을 외부 API의 idempotency 키로 넘기면, 재시도·replay로 호출이 반복돼도 외부 시스템에서 중복 반영되지 않습니다.checkpoint–true면 이 Activity가 성공한 직후 워크플로 상태를 즉시 DB에 저장합니다. 기본(false)은 await·완료·실패 시점에만 저장하므로, await 없이 이어지는 구간에서 서버가 죽으면 그 구간 Activity가 재실행됩니다. 중복이 곤란한 외부 호출에true로 두면 재실행 대신 캐시된 결과로 replay됩니다.
아래는 알림 발송 Activity에 이 정책들을 모두 표시한 예입니다.
interface NotifyMissingNewOrderActivity : Activity {
@ActivityMethod(
startToCloseTimeout = "10s", // 한 번의 시도 제한 시간
scheduleToCloseTimeout = "5m", // 재시도·backoff 포함 총 시간 상한 ("" = 무제한)
retry = Retry(
maxAttempts = 3, // 첫 시도 포함 최대 시도 (1 = 재시도 없음)
initialInterval = "1s", // 첫 backoff
maxInterval = "30s", // backoff 상한
backoffCoefficient = 2.0, // backoff 증가율 (2.0 = 매번 2배)
nonRetryable = [ // 재시도 없이 즉시 실패시킬 예외
IllegalArgumentException::class,
],
),
idempotencyRequired = true, // invocationToken() 발급 보장 → 중복 반영 방지
checkpoint = true, // 성공 직후 즉시 체크포인트 → 크래시 후 재실행 대신 캐시 replay
)
suspend fun notify(orderId: String)
}
@Component
class NotifyMissingNewOrderActivityImpl(
private val notifyClient: NotifyClient,
) : NotifyMissingNewOrderActivity {
override suspend fun notify(orderId: String) {
notifyClient.sendMissingNewOrderNotification(
orderId = orderId,
idempotencyKey = WorkflowContext.invocationToken(),
)
}
}
replay를 한 걸음씩 따라가기
조금 더 깊이 들어가 볼까요. 위 워크플로가 3분을 기다렸다가 깨어나는 과정을, invocation 번호와 캐시의 관점에서 한 걸음씩 따라가 보겠습니다. Flowkit은 매 실행마다 Activity 호출과 await에 0부터 번호를 매기고(#0, await-0 …), 그 번호로 저장된 결과가 있으면 재실행 대신 캐시를 돌려줍니다.
사이클 1 – 최초 트리거. findById는 첫 Activity 호출이라 #0. 실제로 주문서비스를 호출해 결과(NEW)를 activityResults[#0]에 캐시합니다. 이어 await(3분)은 첫 await이라 await-0. isReceived가 아직 false이고 3분도 지나지 않았으니, Flowkit은 지금까지의 상태(변수, activityResults[#0], resumeAt = now + 3분)를 DB에 저장하고 메서드를 종료합니다. 스레드는 반납됩니다.
사이클 2 – 3분 뒤 타이머로 재개(replay). Scheduler가 인스턴스를 깨우고 execute()를 처음부터 다시 실행합니다. findById(#0)는 캐시 hit이라 주문서비스를 부르지 않고 저장된 NEW를 즉시 반환합니다. await-0은 이번엔 3분이 지나 타이머로 깨어난 것이라 현재 시각이 이미 저장해둔 resumeAt(= now + 3분)을 지났고, 그 사이 접수 시그널도 오지 않았습니다. 그래서 await-0은 대기시간이 다 되도록 조건이 충족되지 않았다고 판단해 false로 확정합니다. if (receivedInTime) return은 통과하고, 다음 줄의 findById는 이번엔 #1 – 캐시에 없으니 실제로 주문서비스를 다시 조회합니다. 여전히 NEW면 notify(#2)를 실제 호출하고 종료합니다.
| 호출 | 사이클 1 (최초) | 사이클 2 (replay) |
|---|---|---|
findById (#0) |
실제 호출 → NEW, 캐시 저장 | 캐시 hit → 즉시 NEW (외부 호출 없음) |
await-0 |
미확정 → suspend (스레드 반납) | 대기시간(resumeAt)이 다 지났고 그 사이 시그널 없음 → false 확정 |
findById (#1) |
(도달 못 함) | 실제 호출 → 현재 상태 재조회 |
notify (#2) |
(도달 못 함) | 미접수면 실제 발송 → 종료 |
만약 접수 시그널이 3분 안에 왔다면? 재개 직전 Flowkit이 @SignalHandler를 먼저 적용해 isReceived = true로 만들어 두므로, 사이클 2의 await-0에서 predicate가 참이 되어 true를 반환하고 워크플로는 알림 없이 종료됩니다.
흥미로운 점은, 이 워크플로 코드에는 "저장한다 / 복원한다"는 말이 한 줄도 없다는 것입니다. 평범한 함수 호출처럼 보이지만, 그 밑에서 Flowkit이 invocation 번호로 캐시를 맞춰가며 같은 코드를 여러 번 흘려도 "딱 한 번 실행한 것처럼" 만들어 줍니다. 개발자는 "3분 기다렸다가 확인한다"는 의도만 적으면 되고, 중단·복구·중복 방지는 라이브러리의 몫입니다.
기존과의 차이
| 항목 | 기존 (배치 등 혼합) | Flowkit |
|---|---|---|
| 시나리오 본문 위치 | 여러 모듈에 분산 | 하나의 execute() 함수 |
| 진행 상태 조회 | 여러 테이블 + 로그를 종합 | workflow_instance 1개 컬렉션 |
| 인스턴스 재시작 후 복구 | 케이스별 보강 | 자동 – 체크포인트로부터 정확한 시점 재개 |
| 대기 처리 방식 | 매 주기(예: 1분) 후보 전체를 재스캔해 지연 여부 판단 — 대상이 없어도 매 사이클 조회 | 각 인스턴스가 자기 대기 시각(resumeAt)에만 깨어남 — 대기 중 스레드/메모리 점유 0, 전체 재스캔 없이 due 대상만 처리 |
| 페이드아웃 정밀도 | 배치 1분 오차 | 정확한 resumeAt까지 sleep |
| 페이드아웃 도중 취소 | 별도 dedup / 상태 체크 필요 | await(timeout, predicate) 한 줄 |
| Race / 중복 처리 방어 | 추가 락 / dedup 로직 | 라이브러리가 자동 처리 |
"한꺼번에 훑던" 배치를 "건별로 흘려보내는" 워크플로로 바꾸자, 피크타임에 대상이 몰려도 각 주문은 자기 대기 시각까지 조용히 잠들어 있을 뿐입니다.
운영 가시성 – 한 화면에서 보기
워크플로로 옮기면서 가장 크게 달라진 것은 운영 가시성입니다. 모든 진행 상태가 workflow_instance 컬렉션 하나에 모이기 때문에, "이 주문의 알림 워크플로가 왜 안 떴지?" 같은 질문에 한 화면에서 답할 수 있습니다. Temporal Web UI를 레퍼런스 삼아, 대기 중(PENDING) / 진행 중(RUNNING) / 완료(COMPLETED·FAILED)를 나눠 보여주는 어드민을 붙였습니다.
대시보드에서는 상태별 카운트와 진행/대기/실패 리스트를 5초 간격으로 갱신해 시스템 전체 상태를 한눈에 봅니다.

리스트에서 특정 인스턴스를 클릭하면 상세 화면으로 들어갑니다. 현재 상태와 다음 깨움 시각, 진행 단계(replay 히스토리), @WorkflowState 변수 snapshot, 도착한 Signals, 그리고 각 Activity 결과가 기록된 시각과 traceId까지 한곳에서 확인할 수 있습니다. 인스턴스를 강제로 재시작해도 어느 시점부터 이어졌는지 그대로 드러납니다.

고민했던 부분
결정론(determinism)이라는 숙명
워크플로 모델의 힘은 replay에서 나오고, replay가 안전하려면 execute() 본문이 deterministic해야 합니다. 매 replay마다 같은 순서로 같은 Activity 호출이 일어나야 캐시 카운터가 어긋나지 않기 때문입니다. 그래서 본문 안에서 현재 시각·랜덤·직접 I/O를 쓰면 안 되고, 모두 Activity나 WorkflowContext helper로 위임해야 합니다.
// ❌ replay 시 분기가 달라지면 invocation 카운터 어긋남
if (System.currentTimeMillis() % 2 == 0L) activityA.foo() else activityB.bar()
// ✅ Activity가 결정해서 결과를 반환 → replay 시 같은 분기
val choice = decisionActivity.makeChoice()
if (choice == "A") activityA.foo() else activityB.bar()
대신 이 제약은 Activity 본문에는 적용되지 않습니다. Activity 안에서는 시각·외부 API·DB·랜덤을 자유롭게 써도 됩니다. 그 결과가 캐시되어 다음 replay 때 그대로 반환되기 때문에, Activity 안의 비결정성은 워크플로의 결정성을 깨지 않습니다.
단일 문서로 상태를 관리하는 replay 모델
Flowkit은 Temporal처럼 append-only 이벤트 히스토리를 쌓는 대신, 한 인스턴스의 상태를 workflow_instance 문서 하나에 모아 in-place로 갱신합니다. 이 문서에는 @WorkflowState 변수 스냅샷과 함께, 지금까지 확정된 모든 await 결과와 각 Activity invocation의 결과가 캐시됩니다. 재개될 때는 execute()를 처음부터 다시 실행하되, 이미 저장된 await·Activity 결과는 캐시에서 즉시 돌려주기 때문에 외부 호출이 중복되지 않고 마지막 대기 지점까지 빠르게 도달합니다.
여기서 Temporal과의 durability 세분도(granularity) 차이가 하나 있습니다. Temporal은 액티비티가 완료될 때마다 그 결과를 서버 히스토리에 즉시 기록하므로, 완료된 액티비티는 크래시 후에도 재실행되지 않습니다. 반면 Flowkit은 기본적으로 액티비티마다 즉시 영속화하지 않고 await·완료·실패 시점에만 체크포인트합니다. 그래서 await 없이 이어지는 구간에서 프로세스가 죽으면, 재개 시 그 구간의 액티비티가 다시 실행될 수 있고 – 이때 중복은 invocationToken() 멱등성으로 막습니다. (개별 Activity에 @ActivityMethod(checkpoint = true)를 주면 그 Activity는 성공 직후 즉시 저장되어 Temporal처럼 재실행 없이 캐시로 replay됩니다.)
이 구조 덕분에 구현이 단순하고 한 문서만 보면 진행 상태를 조회·디버깅할 수 있지만, 대가도 있습니다. 재개가 곧 "본문 재실행"이므로 execute() 본문은 반드시 deterministic해야 하고(그래서 시각·랜덤·직접 I/O를 Activity로 위임합니다), 이 규칙을 컨벤션과 코드 리뷰로 지킵니다.
분산 환경과 롤백 안전
여러 pod가 같은 인스턴스를 동시에 잡으면 안 됩니다. Flowkit은 tryAcquire의 atomic CAS(Compare-And-Swap)로 이를 막습니다. 두 pod가 같은 인스턴스를 발견해도, 먼저 version을 올린 쪽만 실행하고 다른 쪽은 그냥 넘어갑니다. Redis 락 같은 추가 인프라 없이 정합성이 보장됩니다.
코드 변경도 신경 썼습니다. @WorkflowVersion으로 코드 버전을 관리하고, Scheduler는 자기 코드가 다룰 수 있는 버전만 고릅니다. 덕분에 배포 후 롤백해도 "미래 버전"으로 생성된 인스턴스가 옛 코드에 잡혀 깨지는 일이 없습니다. 그 인스턴스는 DB에 보존된 채 일시정지했다가, 재배포되면 자동으로 재개됩니다. 데이터 손실은 0입니다.
배치 교체, 그다음 – Flowkit으로 더 해볼 수 있는 일
접수지연 알림은 시작일 뿐입니다. await·Activity·signal·@Retry라는 몇 개의 primitive만으로 표현할 수 있는 패턴이 꽤 많습니다. 아래는 워크플로 모델(Temporal 계열)이 잘 다루는 대표적인 활용들로, 같은 primitive 위에서 이렇게도 쓸 수 있다는 예시입니다.
보상 트랜잭션(Saga) – @Transactional 없이 걸쳐진 일관성
여러 마이크로서비스에 걸친 작업은 하나의 DB 트랜잭션으로 묶을 수 없습니다. 대신 각 단계를 Activity로 실행하고, 중간에 실패하면 이미 성공한 단계를 되돌리는 보상(compensation) Activity를 호출해 최종 일관성을 맞추는 것이 Saga 패턴입니다.
override suspend fun execute(input: OrderInput) {
val payment = paymentActivity.charge(input.orderId) // #0 결제
try {
deliveryActivity.reserve(input.orderId) // #1 배차 예약
stockActivity.deduct(input.orderId) // #2 재고 차감
} catch (e: ActivityFailedException) {
// #1 or #2 단계가 실패하면 앞에서 성공한 단계를 역순으로 보상
stockActivity.rollback(input.orderId)
deliveryActivity.rollback(input.orderId)
paymentActivity.refund(payment.id)
throw e
}
}
코드만 보면 평범한 try/catch라 특별할 게 없어 보입니다. 그런데 바로 그 점이 핵심입니다 – 별도의 Saga 오케스트레이터나 "어디까지 성공했는지" 기록하는 상태 테이블 없이, 평범해 보이는 이 코드가 프로세스가 죽어도 복구되어 동작하게 됩니다
동작 방식을 정확히 짚어보면 이렇습니다. Flowkit은 기본적으로 Activity가 끝날 때마다 DB를 갱신하는 게 아니라, await로 멈추거나·완료·실패하는 시점에만 진행 상태를 workflow_instance에 체크포인트합니다. 그래서 프로세스가 중간에 죽으면 그 인스턴스는 RUNNING인 채로 남고, 잠시 뒤 Scheduler의 회수(reaper)가 이를 되살려 다른 pod가 이어받아 execute()를 마지막 체크포인트부터 다시 실행합니다. 중간에 await가 있었다면 그 지점까지는 캐시로 통과하고, 없었다면 처음부터 다시 흐릅니다. 다시 흐르며 결제 같은 외부 호출이 재실행되더라도, invocationToken() 멱등성 덕분에 외부 시스템에서 두 번 반영되지 않습니다. 재고 차감이 끝내 실패하면 catch의 보상(refund)이 실행되어 결제를 되돌립니다. (재실행 자체가 곤란한 단계라면 그 Activity에 @ActivityMethod(checkpoint = true)를 주어 성공 직후 저장하게 하면, 재개 시 그 단계는 아예 다시 호출되지 않습니다.)
정리하면, "성공한 단계의 부수효과는 중복 없이, 실패 시 보상까지" 라는 흐름을 별도의 상태 관리 코드나 오케스트레이터 없이 평범한 예외 처리 코드로 얻는 것이 Flowkit Saga의 이점입니다.
여러 API 오케스트레이션 – 비동기 응답 엮기
여러 서비스를 조율하는 방식에는 두 가지가 있습니다. 각 서비스가 이벤트에 반응해 스스로 움직이는 코레오그래피(choreography)와, 중앙의 지휘자가 순서를 정해 각 서비스를 호출하는 오케스트레이션(orchestration)입니다. 워크플로 엔진은 후자입니다. execute() 하나가 지휘자가 되어 여러 서비스 호출을 직선적인 코드로 표현합니다. 흐름이 코드 한곳에 모여 있어 추적이 쉽고, 중간에 프로세스가 죽어도 replay로 정확히 이어집니다.
특히 응답이 비동기로 오는 API들도 자연스럽게 엮을 수 있습니다. Activity로 "요청만" 던지고, 실제 결과는 나중에 콜백/이벤트가 보내는 signal로 받아 await로 기다렸다가 다음 단계로 넘어가는 식입니다. 아래는 결제·배차 결과가 모두 비동기로 도착하는 체크아웃을 오케스트레이션하는 예로, notify.confirm(...)은 두 비동기 결과가 모두 도착한 뒤에만 실행됩니다.
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@WorkflowOptions(taskQueue = "worker")
class CheckoutWorkflow : Workflow<CheckoutInput, Unit> {
// 비동기 결과는 시그널로 도착 → @WorkflowState 로 보관
@WorkflowState private var payment: PaymentResult? = null
@WorkflowState private var delivery: DeliveryResult? = null
@SignalHandler("paymentCompleted")
fun onPaymentCompleted(result: PaymentResult) { payment = result }
@SignalHandler("deliveryAssigned")
fun onDeliveryAssigned(result: DeliveryResult) { delivery = result }
@WorkflowVersion(1)
override suspend fun execute(input: CheckoutInput) {
val paymentApi = WorkflowContext.getActivity(PaymentActivity::class.java)
val deliveryApi = WorkflowContext.getActivity(DeliveryActivity::class.java)
val notify = WorkflowContext.getActivity(NotifyActivity::class.java)
// 1) 결제 "요청만" 비동기로 제출 (응답은 나중에 시그널로 옴)
paymentApi.requestPayment(input.cartId)
// 2) 결제 완료 시그널이 올 때까지 대기 - 이 동안 스레드/메모리 점유 0
if (!WorkflowContext.await(Duration.ofMinutes(10)) { payment != null }) {
notify.failed(input.cartId, reason = "payment timeout"); return
}
// 3) 결제 결과가 있어야 배차 요청 (앞 결과에 의존)
deliveryApi.requestAssign(input.cartId, payment!!.paymentId)
// 4) 배차 완료 시그널 대기
if (!WorkflowContext.await(Duration.ofMinutes(10)) { delivery != null }) {
notify.failed(input.cartId, reason = "delivery timeout"); return
}
// 5) 두 비동기 흐름이 모두 끝난 뒤에야 최종 확정 알림
notify.confirm(input.cartId, payment!!.paymentId, delivery!!.riderId)
}
}
비동기 API가 처리를 마쳤을 때, 그 결과를 콜백/이벤트로 받은 쪽에서 워크플로에 시그널을 보냅니다.
// 결제 서비스가 "결제 완료" 이벤트/콜백을 보냈을 때
suspend fun onPaymentCallback(cartId: String, result: PaymentResult) {
workflowClient.signal(
workflowId = "CheckoutWorkflow:$cartId",
signalName = "paymentCompleted",
payload = result,
)
}
동기 호출이라면 응답을 그 자리에서 기다려야 하지만, 여기서는 await가 "결과가 채워졌는가"를 조건으로 잠들었다가 시그널이 상태를 채우면 깨어나 이어갑니다. 요청 제출과 결과 수신이 시간적으로 분리된 비동기 세계를 하나의 직선 코드로 오케스트레이션하는 셈입니다. (두 요청이 서로 의존하지 않으면, 둘을 먼저 제출한 뒤 await(...) { payment != null && delivery != null } 한 줄로 둘 다 도착할 때까지 기다리는 fan-out/fan-in 형태로도 쓸 수 있습니다.)
그 밖에 – 사람이 개입하는 장기 흐름, 주기 실행, 병렬 처리
- 휴먼-인-더-루프(human-in-the-loop) – 승인·응답을 며칠씩 기다렸다가 도착한
signal로 재개합니다.await로 무한정 대기해도 메모리/스레드 비용이 0이라, "정산 승인 대기", "사장님 응답 대기" 같은 흐름을 타임아웃과 함께 자연스럽게 표현할 수 있습니다. - 주기적 실행 –
WorkflowContext.sleep(1일)을 도는 루프로 별도 cron 없이 정기 작업(리마인더, 정기 점검)을 워크플로 안에서 돌릴 수 있습니다. - 병렬 fan-out / fan-in – 여러 Activity를 나눠 호출하고 결과를 모아 다음 단계로 넘기는 병렬 처리도 같은 방식으로 표현됩니다.
중요한 건, 이 모든 패턴이 새로운 프레임워크가 아니라 이미 익힌 네 개의 개념 위에서 그대로 나온다는 점입니다. 배치 하나를 옮기려고 만든 도구가, 사실은 "긴 시간을 가지는 상태 머신"이라면 무엇이든 담을 수 있는 그릇이 된 셈입니다.
짧은 소회
Flowkit이 Temporal의 모든 것을 대체한다고는 생각하지 않습니다. 오히려 반대입니다. Temporal이 훌륭하다는 것을 알면서도, 그 전부를 도입하는 대신 우리 도메인에 꼭 필요한 개념 네 가지만 골라 기존 인프라 위에 얇게 얹었습니다. Temporal이 모든 악기를 갖춘 풀편성 오케스트라라면, Flowkit은 이 곡에 필요한 몇 악기만 뽑아 가벼운 실내악으로 편곡한 셈입니다.
이런 선택이 가능했던 데에는 도구의 힘도 큽니다. 방대한 SDK를 통째로 학습해 들여오는 대신, 필요한 부분만 차용해 Claude와 함께 빠르게 라이브러리로 만들 수 있었습니다. "무거워서 못 쓰겠다"와 "그냥 배치로 버티자" 사이에서, 세 번째 선택지를 만들 수 있었습니다.
배치는 한꺼번에, 워크플로는 하나씩. 접수지연 알림을 워크플로로 옮기고 나니, 피크타임마다 무거워지던 한 사이클의 부담 대신 각자 조용히 잠든 수많은 주문이 남았습니다. 알려진 길이 아니라 필요한 만큼만 골라 새 길을 내는 것, 우아한형제들은 그런 시도가 가능한 곳이라고 생각합니다.