- Published on
Kubernetes에서 Saga 타임아웃·중복실행 막는 6단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버리스나 쿠버네티스 기반 마이크로서비스에서 Saga(오케스트레이션/코레오그래피)는 분산 트랜잭션의 현실적인 대안이지만, 운영 단계에서 가장 자주 터지는 문제는 두 가지입니다.
- 타임아웃: 오케스트레이터는 다음 스텝을 기다리다 실패로 판단하고 보상(Compensation)을 실행하는데, 실제 작업은 뒤늦게 성공해 데이터가 뒤틀립니다.
- 중복 실행: 네트워크 재시도, 워커 재기동, 메시지 중복 전달로 동일 Saga 스텝이 2번 이상 수행되어 결제/차감/예약이 중복됩니다.
Kubernetes는 장애를 “빨리 복구”해주지만, 그 과정에서 같은 작업이 다시 실행될 확률도 올립니다. 따라서 Saga는 애플리케이션 레벨에서 멱등성, 타임아웃, 락/리더선출, 재시도 정책, 관측을 함께 설계해야 합니다.
아래는 현업에서 효과가 큰 6단계 체크리스트입니다.
1단계: 타임아웃 예산을 계층별로 쪼개고 정렬하기
Saga 타임아웃은 하나만 설정한다고 끝나지 않습니다. 최소 4개의 타임아웃이 서로 맞물립니다.
- Ingress/ALB 타임아웃
- 서비스 간 호출 타임아웃(HTTP/gRPC)
- 워커 처리 타임아웃(잡/컨슈머)
- Saga 전체 데드라인(비즈니스 SLA)
문제는 이 값들이 서로 어긋나면, 상위 계층이 먼저 끊고 하위 계층은 계속 처리하는 좀비 실행이 발생한다는 점입니다.
권장 정렬 규칙
- 요청 타임아웃
<=워커 처리 타임아웃<=Saga 스텝 타임아웃<=Saga 전체 데드라인 - 상위 계층이 먼저 끊는다면, 하위 계층은 반드시 취소 신호를 받아 중단해야 합니다.
gRPC/HTTP 데드라인 전파 예시
아래처럼 데드라인을 상위에서 정하고, 하위 호출은 그 예산 안에서만 수행되게 만듭니다.
// Go gRPC 클라이언트 예시: 데드라인 전파
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := orderClient.ReserveStock(ctx, &pb.ReserveStockRequest{OrderId: orderID})
if err != nil {
// deadline exceeded면 "재시도"가 아니라 "보상/중단"이 맞는 경우가 많음
return err
}
_ = resp
gRPC에서 context deadline exceeded가 자주 보인다면, 단순히 타임아웃을 늘리기보다 어느 계층이 먼저 끊는지부터 역추적해야 합니다. 이 주제는 Go gRPC context deadline exceeded 원인·해결도 함께 참고하면 좋습니다.
2단계: Saga 실행을 "요청"과 "처리"로 분리하고, 처리자는 At-least-once를 전제로 설계하기
Kubernetes에서 Pod는 언제든 재시작될 수 있고, 메시지 브로커는 기본적으로 at-least-once 전달이 많습니다. 즉, 처리자는 항상 “중복 메시지”를 받을 수 있다고 가정해야 합니다.
가장 안전한 구조는 다음입니다.
- API는 Saga를 생성만 하고 즉시
202 Accepted로 응답 - 실제 스텝 실행은 비동기 워커가 수행
- 워커는 중복 실행을 막는 락/멱등성 키를 확인하고 진행
Kubernetes Job만으로는 부족한 이유
Job은 실패 시 재시작을 보장하지만, “같은 Saga 스텝이 2번 실행되지 않음”을 보장하지 않습니다. 노드 장애, 네트워크 분할, 워커 중복 실행 등은 여전히 발생합니다.
따라서 중복 방지는 Job이 아니라 **데이터 저장소(락/상태머신)**에서 해결해야 합니다.
3단계: 멱등성 키를 "비즈니스 키"로 고정하고, 스텝별로 Idempotency 테이블을 둔다
중복 실행을 막는 핵심은 “같은 요청인지”를 판별할 멱등성 키입니다.
좋은 멱등성 키의 조건
- 클라이언트 재시도에도 동일해야 함
- 서버 재시작에도 동일해야 함
- 비즈니스 의미가 있어야 함(주문번호, 결제요청ID 등)
권장 패턴은 다음 두 가지 중 하나입니다.
- Saga 단위:
saga_id+step_name - 비즈니스 단위:
payment_request_id,shipment_reservation_id등
PostgreSQL 멱등성 테이블 예시
create table saga_step_execution (
saga_id text not null,
step_name text not null,
idempotency_key text not null,
status text not null, -- RUNNING, SUCCEEDED, FAILED
result_json jsonb,
updated_at timestamptz not null default now(),
primary key (saga_id, step_name)
);
-- 같은 step에 대해 두 번 insert 시도하면 PK 충돌로 중복 실행을 차단
애플리케이션 로직 요지
- 스텝 시작 시
insert시도 - PK 충돌이면 이미 처리 중이거나 완료된 것이므로 재실행하지 않음
- 필요하면
status를 보고 결과를 재사용
이때 DB 트랜잭션 경계가 매우 중요합니다. 스텝 시작 기록과 외부 사이드이펙트(결제 승인, 재고 차감)를 분리하면 다시 꼬입니다. 트랜잭션 관련 함정은 Spring Boot 3에서 @Transactional이 안먹는 6가지도 같이 보면 도움이 됩니다.
4단계: 분산 락은 "짧게" 잡고, 리더 선출은 "길게" 잡는다
중복 실행을 막기 위해 분산 락을 쓰는 경우가 많지만, 락을 과하게 신뢰하면 오히려 장애가 커집니다.
원칙
- 스텝 실행 락: 짧게, TTL 필수, 재진입 가능하게 설계
- 오케스트레이터 리더 선출: 길게, 안정적으로(Lease)
Kubernetes Lease로 리더 선출 예시
여러 오케스트레이터 Pod가 떠 있어도 “한 명만 스케줄링”하도록 만들 수 있습니다.
apiVersion: coordination.k8s.io/v1
kind: Lease
metadata:
name: saga-orchestrator-leader
namespace: default
spec:
holderIdentity: ""
leaseDurationSeconds: 15
애플리케이션은 Lease를 갱신하는 Pod만 “스캐너/타임아웃 처리” 같은 배치성 작업을 수행하게 합니다.
락을 잡고 외부 호출을 하면 안 되는 이유
락을 잡은 상태에서 결제 API 호출처럼 느린 I/O를 하면, 락 점유 시간이 길어져 병목과 장애 전파가 생깁니다. 락은 “중복 진입 차단” 정도로만 쓰고, 실제 사이드이펙트는 멱등성으로 막는 편이 더 안전합니다.
5단계: 재시도는 "무조건"이 아니라, 에러를 분류하고 백오프·지터를 적용한다
Saga가 흔들리는 또 다른 이유는 “재시도 폭풍”입니다. 특히 타임아웃이 발생하면 모든 워커가 동시에 재시도하면서 큐가 밀리고, 결국 더 많은 타임아웃이 발생합니다.
재시도 분류(실무 기준)
- 즉시 실패(재시도 금지): 인증 실패, 검증 실패, 잔액 부족 등 비즈니스 오류
- 조건부 재시도:
429,503, 일시적 네트워크 오류 - 재시도 대신 보상/중단: 데드라인 초과, 상위 요청 취소
지수 백오프 + 지터 예시(의사코드)
base = 200ms
max = 5s
attempt n:
sleep = min(max, base * 2^n) * random(0.5..1.5)
외부 API나 내부 공용 컴포넌트에 부하가 걸릴 때는 지터가 사실상 필수입니다. 재시도 설계는 OpenAI API 429·Rate Limit 재시도 백오프 설계의 패턴을 그대로 가져와도 좋습니다.
6단계: 타임아웃·중복 실행을 "보이게" 만들기(관측 + 운영 가드레일)
마지막 단계는 관측입니다. Saga는 분산되어 있어 “어디서 끊겼는지”를 눈으로 못 보면, 결국 타임아웃만 늘리다 끝납니다.
필수 관측 항목
- Saga 전체:
saga_id, 현재 스텝, 시작/종료 시각, 최종 상태 - 스텝별: 실행 횟수(중복), 평균/최대 지연, 실패 사유
- 큐/브로커: lag, DLQ 유입량
- 네트워크:
5xx,timeout,reset비율
Prometheus 메트릭 예시
saga_step_started_total{step="reserve_stock"}
saga_step_succeeded_total{step="reserve_stock"}
saga_step_duplicate_total{step="reserve_stock"}
saga_step_duration_seconds_bucket{step="reserve_stock",le="0.5"}
운영 가드레일
- DLQ(Dead Letter Queue): 보상 실패, 반복 실패는 격리
- 최대 실행 횟수 제한: 스텝별
maxAttempts를 두고 초과 시 수동 개입 - 서킷 브레이커: 특정 의존성이 죽으면 빠르게 실패시키고 큐 적체를 막기
인프라 계층 타임아웃도 함께 점검해야 합니다. 예를 들어 ALB/Ingress 타임아웃이 짧으면 Saga 시작 요청 자체가 불안정해집니다. 이 부분은 AWS ALB 502/504 급증? 타임아웃 7곳 점검 같은 체크리스트가 유용합니다.
구현 예시: "스텝 시작 기록"으로 중복 실행 차단하기
아래는 핵심 로직만 뽑은 예시입니다. 포인트는 “스텝 시작을 DB에 먼저 고정”하고, 그 결과로 중복을 차단하는 것입니다.
// Kotlin + Spring 의사코드 (개념 전달용)
@Service
class SagaStepRunner(
private val repo: SagaStepRepo,
private val payment: PaymentClient,
) {
@Transactional
fun tryStartStep(sagaId: String, step: String, idemKey: String): Boolean {
return repo.insertIfAbsent(sagaId, step, idemKey) // PK 기반
}
fun runPaymentStep(sagaId: String, orderId: String, idemKey: String) {
val started = tryStartStep(sagaId, "payment", idemKey)
if (!started) return // 중복 실행 차단
try {
// 외부 호출은 멱등성 키를 함께 전달
payment.charge(orderId = orderId, idempotencyKey = idemKey)
repo.markSucceeded(sagaId, "payment")
} catch (e: Exception) {
repo.markFailed(sagaId, "payment", reason = e.message ?: "unknown")
throw e
}
}
}
여기서도 중요한 점이 있습니다.
- 외부 결제 API도
idempotencyKey를 지원해야 가장 안전합니다. - 지원하지 않는다면, 결제 요청 로그/승인번호를 저장하고 “이미 승인된 결제인지”를 조회로 판별하는 보강이 필요합니다.
정리: 6단계는 "하나라도" 빠지면 다시 터진다
Kubernetes에서 Saga를 안정화하려면 특정 기술 하나로 끝나지 않습니다. 아래 6단계는 세트로 봐야 합니다.
- 타임아웃 예산을 계층별로 쪼개고 정렬
- 요청과 처리를 분리하고 at-least-once 전제
- 비즈니스 키 기반 멱등성으로 중복 실행 차단
- 락은 짧게, 리더 선출은 길게
- 재시도는 에러 분류 + 백오프 + 지터
- 관측과 운영 가드레일(DLQ, 제한, 서킷 브레이커)
이 6가지를 적용하면 “타임아웃을 늘려서 덮는 운영”에서 벗어나, 중복 실행과 타임아웃을 설계로 흡수하는 방향으로 전환할 수 있습니다.