- Published on
Kotlin+Temporal로 분산 트랜잭션 워크플로우 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
분산 트랜잭션(특히 사가)은 “성공/실패”보다 “어디까지 진행됐고, 왜 그 지점에서 멈췄는지”를 설명하는 게 더 어렵습니다. Kotlin + Temporal 조합은 워크플로우 실행 이력(Event History)을 기반으로 결정론적으로 재생(replay)할 수 있어 원인 규명에 매우 강력하지만, 반대로 결정론 위반이나 잘못된 재시도 정책 같은 Temporal 특유의 함정도 있습니다.
이 글에서는 Kotlin으로 Temporal 워크플로우를 운영하면서 실제로 디버깅에 도움이 되는 방법을, “재현 가능한 단서 만들기 → Temporal 도구로 추적 → 코드 레벨에서 고치기” 흐름으로 정리합니다.
아키텍처 관점에서 사가/Outbox를 같이 쓰는 방식은 아래 글과도 연결됩니다.
Temporal 디버깅의 핵심: History를 믿고, 재생을 활용하기
Temporal에서 워크플로우는 “코드 실행”이라기보다 “이벤트 히스토리를 소비하며 상태를 갱신하는 상태 머신”에 가깝습니다.
- 워크플로우가 외부 시스템을 호출하면, 실제 호출은 Activity에서 일어나고 결과가 이벤트로 기록됩니다.
- 워크플로우 코드는 언제든 재시작/재생될 수 있으며, 같은 히스토리라면 항상 같은 결정을 내려야 합니다(결정론).
따라서 디버깅의 목표는 다음 두 가지로 정리됩니다.
- 어떤 이벤트가 기록됐는지(타임아웃, 리트라이, 취소, 시그널, Activity 실패 등)
- 그 이벤트를 기반으로 워크플로우가 왜 그 분기를 탔는지
예시 시나리오: 주문 사가(결제 → 재고 차감 → 배송 요청)
간단한 분산 트랜잭션을 Temporal 워크플로우로 구성해 보겠습니다.
ReserveInventoryActivityChargePaymentActivityRequestShipmentActivity- 실패 시 보상(예: 결제 취소, 재고 복원)
Kotlin 워크플로우 예시 코드
아래 코드는 디버깅 포인트를 의도적으로 넣은 형태입니다.
import io.temporal.activity.ActivityOptions
import io.temporal.common.RetryOptions
import io.temporal.workflow.Saga
import io.temporal.workflow.Workflow
import java.time.Duration
@kotlinx.serialization.Serializable
data class OrderRequest(
val orderId: String,
val userId: String,
val amount: Long,
val skuId: String,
)
interface OrderActivities {
fun reserveInventory(orderId: String, skuId: String)
fun releaseInventory(orderId: String, skuId: String)
fun chargePayment(orderId: String, userId: String, amount: Long)
fun refundPayment(orderId: String)
fun requestShipment(orderId: String)
fun cancelShipment(orderId: String)
}
interface OrderWorkflow {
fun run(req: OrderRequest)
}
class OrderWorkflowImpl : OrderWorkflow {
private val retry = RetryOptions.newBuilder()
.setInitialInterval(Duration.ofSeconds(1))
.setBackoffCoefficient(2.0)
.setMaximumInterval(Duration.ofSeconds(30))
.setMaximumAttempts(5)
.build()
private val activityOptions = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofSeconds(20))
.setRetryOptions(retry)
.build()
private val act = Workflow.newActivityStub(OrderActivities::class.java, activityOptions)
override fun run(req: OrderRequest) {
// 디버깅에 매우 유용: Search Attribute로 주문ID를 색인
Workflow.upsertSearchAttributes(
mapOf("OrderId" to req.orderId, "UserId" to req.userId)
)
val saga = Saga(Saga.Options.newBuilder().setParallelCompensation(false).build())
try {
Workflow.getLogger(this::class.java).info("start order saga orderId={}", req.orderId)
act.reserveInventory(req.orderId, req.skuId)
saga.addCompensation(act::releaseInventory, req.orderId, req.skuId)
act.chargePayment(req.orderId, req.userId, req.amount)
saga.addCompensation(act::refundPayment, req.orderId)
act.requestShipment(req.orderId)
saga.addCompensation(act::cancelShipment, req.orderId)
Workflow.getLogger(this::class.java).info("order saga completed orderId={}", req.orderId)
} catch (e: Exception) {
Workflow.getLogger(this::class.java)
.error("order saga failed orderId={} err={}", req.orderId, e.message)
saga.compensate()
throw e
}
}
}
이 코드만 봐도 디버깅 포인트가 몇 가지 보입니다.
upsertSearchAttributes로orderId를 색인하면, 나중에 “주문ID로 워크플로우 찾기”가 쉬워집니다.RetryOptions와StartToCloseTimeout은 장애 양상을 크게 좌우합니다(특히 중복 실행/중복 결제).- 보상은 “실패했을 때 정확히 한 번 실행되는가”가 핵심인데, Activity의 멱등성/중복 방지 설계가 없으면 위험합니다.
디버깅 1단계: Temporal Web UI에서 먼저 확인할 것
Temporal Web UI에서 워크플로우를 열면 가장 먼저 아래를 확인합니다.
1) Workflow의 최종 상태와 실패 타입
Completed인지Failed인지Timed Out인지Terminated인지- Failure가
ActivityFailure인지ChildWorkflowFailure인지 RetryState가 무엇인지(재시도 중인지, 더 이상 재시도 안 하는지)
여기서 중요한 점은 “겉으로는 워크플로우 실패”지만 실제 원인은 Activity의 타임아웃/리트라이 정책인 경우가 많다는 것입니다.
2) Event History에서 병목 이벤트 찾기
Event History에서 자주 보는 패턴입니다.
ActivityTaskScheduled는 있는데ActivityTaskStarted가 한참 뒤에 찍힘- 워커가 없거나, 태스크 큐가 막혔거나, 워커가 CrashLoop인 상황
ActivityTaskStarted는 있는데ActivityTaskCompleted가 없음- Activity가 hang, 외부 API 대기, DB 락, 커넥션 고갈 등
ActivityTaskFailed가 반복- 리트라이 폭주, 외부 시스템 429/5xx, 잘못된 예외 분류
커넥션 고갈/누수로 Activity가 멈춘 듯 보이는 경우도 많습니다. 이런 유형은 아래 글의 체크 포인트가 그대로 적용됩니다.
디버깅 2단계: “재시도 때문에 더 망가지는” 케이스를 분리하기
분산 트랜잭션 디버깅에서 제일 위험한 구간은 실패 자체가 아니라 재시도입니다.
- 결제 Activity가 타임아웃으로 실패 처리되었지만, 실제로 PG에는 결제가 성공했을 수 있음
- Temporal은 실패로 봤으니 재시도 → 중복 결제
이 문제는 사가 + Outbox/멱등키로 설계해야 합니다. 위에서 링크한 Outbox 글이 대표적인 해결책이고, Temporal에서도 다음 원칙이 중요합니다.
1) Activity는 반드시 멱등하게
orderId를 멱등 키로 사용- DB에
payment_status같은 상태를 기록하고 이미 성공이면 즉시 성공 반환
class OrderActivitiesImpl(
private val paymentRepo: PaymentRepository,
private val pgClient: PgClient,
) : OrderActivities {
override fun chargePayment(orderId: String, userId: String, amount: Long) {
val existing = paymentRepo.findByOrderId(orderId)
if (existing?.status == "PAID") return
// 외부 결제에는 반드시 idempotencyKey를 전달
val res = pgClient.charge(
userId = userId,
amount = amount,
idempotencyKey = orderId
)
paymentRepo.markPaid(orderId, res.transactionId)
}
override fun refundPayment(orderId: String) {
val existing = paymentRepo.findByOrderId(orderId) ?: return
if (existing.status != "PAID") return
pgClient.refund(transactionId = existing.transactionId, idempotencyKey = "refund-$orderId")
paymentRepo.markRefunded(orderId)
}
// 나머지 Activity도 동일하게 멱등 처리
override fun reserveInventory(orderId: String, skuId: String) { /* ... */ }
override fun releaseInventory(orderId: String, skuId: String) { /* ... */ }
override fun requestShipment(orderId: String) { /* ... */ }
override fun cancelShipment(orderId: String) { /* ... */ }
}
2) 재시도 정책을 “기술적 실패”에만 적용하기
- 네트워크 오류, 5xx, 일시적 타임아웃은 재시도 가치가 큼
- 4xx(검증 실패), 잔액 부족 같은 비즈니스 실패는 재시도하면 안 됨
Temporal Java/Kotlin SDK에서는 예외 타입에 따라 리트라이 제외를 걸 수 있습니다(예: ApplicationFailure.newNonRetryableFailure).
import io.temporal.failure.ApplicationFailure
fun validateOrThrow(req: OrderRequest) {
if (req.amount <= 0) {
throw ApplicationFailure.newNonRetryableFailure(
"invalid amount",
"VALIDATION_ERROR"
)
}
}
이렇게 분리하면 Event History에서 RetryState가 명확해지고, “왜 재시도가 멈췄는지”도 빠르게 파악됩니다.
디버깅 3단계: 결정론 위반(Non-determinism) 잡기
Temporal 워크플로우 디버깅에서 가장 당황스러운 에러 중 하나가 결정론 위반입니다. 보통 다음이 원인입니다.
- 워크플로우 코드에서
System.currentTimeMillis()같은 현재 시간 사용 - 랜덤 값 생성
- 워크플로우 코드에서 DB 조회/HTTP 호출 같은 외부 I/O 수행
- 컬렉션 순회 순서가 비결정적(예:
HashMap순회 결과에 의존)
해결 원칙
- 시간은
Workflow.currentTimeMillis()사용 - 랜덤은
Workflow.newRandom()사용 - 외부 I/O는 Activity로 이동
- 비결정적 순서를 정렬해서 고정
val now = Workflow.currentTimeMillis()
val rnd = Workflow.newRandom().nextInt()
val sortedKeys = someMap.keys.sorted() // 순서 고정
for (k in sortedKeys) {
// ...
}
결정론 위반은 “특정 배포 이후에만 재현”되는 경우가 많습니다. 워크플로우 코드는 배포 후에도 과거 실행을 재생할 수 있어야 하므로, **워크플로우 코드 변경은 호환성(Workflow versioning)**을 고려해야 합니다.
디버깅 4단계: Activity 타임아웃/하트비트로 “멈춤”을 식별하기
워크플로우가 멈춘 것처럼 보일 때, 실제로는 Activity가 오래 걸리거나 워커가 죽어있는 경우가 많습니다.
1) 타임아웃 종류를 구분하기
StartToCloseTimeout: Activity 실행 시작 후 완료까지ScheduleToStartTimeout: 큐에 잡혔는데 워커가 못 가져감HeartbeatTimeout: 장시간 작업에서 진행 신호가 끊김
장시간 작업(예: 대용량 재고 동기화, 파일 처리)은 HeartbeatTimeout을 넣어야 “진짜 멈춤”과 “느리지만 진행 중”을 구분할 수 있습니다.
val activityOptions = ActivityOptions.newBuilder()
.setStartToCloseTimeout(Duration.ofMinutes(10))
.setHeartbeatTimeout(Duration.ofSeconds(30))
.build()
Activity 구현에서 주기적으로 하트비트를 보내면, 워커 장애 시 빠르게 감지하고 재시도/재할당이 가능합니다.
디버깅 5단계: 로그를 “워크플로우 상관관계”로 묶기
분산 트랜잭션 디버깅은 결국 “같은 주문ID의 흔적을 끝까지 따라가기”입니다.
권장 조합은 다음입니다.
- Temporal Search Attributes:
OrderId,UserId,PaymentId등 - 로그 MDC:
workflowId,runId,orderId - (가능하면) 분산 트레이싱: Activity에서 외부 호출 span 생성
워크플로우에서 식별자 표준화
workflowId를orderId로 고정하면 찾기 쉬워집니다.- 단, 같은 주문ID로 “재주문”이 가능한 비즈니스라면 충돌이 나므로,
orderId-v2같은 규칙을 정하거나 별도 키를 둡니다.
흔한 장애 패턴별 빠른 체크리스트
1) 중복 결제/중복 예약이 발생했다
- Activity가 멱등한가(키 기반 중복 방지)
StartToCloseTimeout이 너무 짧아 “성공했는데 실패로 처리”되는가- 재시도가 비즈니스 실패에도 걸려 있지 않은가
- Outbox/상태 테이블로 “이미 처리됨”을 기록하는가
관련 설계 맥락: MSA 사가 패턴 중복결제 버그, Outbox로 해결
2) 워크플로우가 특정 Activity에서 오래 멈춘다
- Event History에서
Scheduled만 있고Started가 없다면 워커/태스크큐 문제 Started는 있는데Completed가 없다면 Activity 내부 hang/외부 의존성 문제- DB 커넥션 고갈/누수, 락(데드락 포함) 여부 확인
DB 락/데드락 추적은 이 방식이 도움이 됩니다.
3) 배포 후 과거 워크플로우가 실패한다(결정론 위반)
- 워크플로우 코드에 외부 I/O가 들어갔는지
- 시간/랜덤/컬렉션 순서가 결정론적인지
- 워크플로우 버저닝을 적용했는지
로컬/테스트에서 재현성을 높이는 방법
운영에서만 터지는 분산 트랜잭션 버그는 “재현”이 절반입니다.
1) Temporal Test Server 또는 Test Environment로 워크플로우 단위 테스트
- Activity를 fake/mock으로 두고 워크플로우 분기와 보상을 검증
- 특정 Activity에서 타임아웃/실패를 강제로 발생시켜 Event History 패턴을 학습
// 개념 예시(프로젝트 설정에 따라 TestWorkflowEnvironment 사용)
// 실패를 유도하는 Activity 구현을 주입해서 보상 호출 여부를 검증
2) 실패 주입(Chaos) 포인트를 Activity에 넣기
- 특정
orderId패턴에서만 지연/실패 - 외부 API 429/5xx를 모사
외부 API의 429 대응은 재시도 설계가 곧 디버깅 난이도를 결정합니다.
(Temporal을 쓰더라도 “어떤 실패는 즉시 실패, 어떤 실패는 백오프 재시도” 정책이 필요합니다.)
결론: 디버깅 가능성이 설계 품질을 결정한다
Kotlin + Temporal로 분산 트랜잭션 워크플로우를 만들면 “언젠가 실패할 것”을 전제로 설계하게 됩니다. 그때 중요한 건 실패 자체보다 실패를 설명할 수 있는가입니다.
정리하면, 디버깅을 쉽게 만드는 실전 원칙은 다음입니다.
- 워크플로우는 결정론을 지키고, 외부 I/O는 Activity로 격리
- Activity는 멱등하게 만들고, 재시도는 기술적 실패에만 제한
- Search Attributes로 도메인 키를 색인하고, 로그/트레이싱에 상관관계를 남김
- 타임아웃과 하트비트로 “멈춤”을 관측 가능하게 만들기
- Outbox/상태 테이블로 중복 실행의 피해를 구조적으로 차단
이 원칙을 지키면 Temporal Web UI의 Event History만으로도 대부분의 분산 트랜잭션 장애를 “재현 없이” 추적할 수 있고, 재현이 필요할 때도 훨씬 빠르게 케이스를 좁힐 수 있습니다.