Choreography
서비스가 완료·실패 이벤트에 반응한다. 중앙 흐름은 없지만 이벤트 계약과 구독 토폴로지가 프로세스를 이룬다.
직선형 단계가 적고 각 이벤트의 소유자와 소비자가 명확하며 팀별 독립성이 중요할 때분류: Layer 9 - 아키텍처 & 설계 패턴
Saga Pattern은 하나의 비즈니스 작업을 여러 서비스의 로컬 트랜잭션으로 나누고, 중간 단계가 실패하면 이미 커밋된 단계에 보상 트랜잭션을 실행해 허용된 비즈니스 상태로 수렴시키는 패턴이다.
로컬 트랜잭션은 한 서비스가 소유한 데이터베이스 경계 안에서 원자적으로 커밋되는 작업이다. 보상 트랜잭션은 과거 커밋을 지우는 ROLLBACK이 아니라, 환불·예약 해제·주문 취소처럼 앞선 효과를 상쇄하는 새 비즈니스 작업이다.
flowchart LR T1[Create Order T1] --> T2[Charge Payment T2] T2 --> T3[Reserve Inventory T3] T3 -->|failure| C2[Refund Payment C2] C2 --> C1[Cancel Order C1] C1 --> F[Saga Failed but Consistent]
T1과 T2는 이미 커밋됐다. Saga는 과거 커밋을 지우지 않고 C2와 C1이라는 새 기록으로 허용된 실패 상태에 도달한다.
Saga가 제공하는 것은 모든 순간의 완벽한 원자성이 아니다. 여러 로컬 상태가 잠시 다를 수 있음을 인정하고, 그 구간의 가시성·중복·순서·재시도·보상 실패를 명시적으로 설계하는 것이다.
MSA Patterns에서 서비스별 데이터 소유권을 분리하면 주문 서비스는 결제 DB나 재고 DB를 직접 수정할 수 없다. 독립 배포와 장애 격리를 얻는 대신, 다음 세 변경을 하나의 데이터베이스 COMMIT으로 묶을 수 없게 된다.
Order DB: order-001, PENDING 생성Payment DB: payment-001, 50,000원 승인Inventory: product-001, 1개 예약정상 경로는 단순하다.
t=0ms 주문 PENDING 커밋t=80ms 결제 50,000원 승인 커밋t=150ms 재고 1개 예약 커밋t=170ms 주문 CONFIRMED 커밋그러나 재고가 0이면 이미 끝난 결제 커밋을 주문 서비스가 ROLLBACK할 수 없다.
t=0ms 주문 PENDING 커밋t=80ms 결제 50,000원 승인 커밋t=150ms 재고 예약 실패: stock=0t=170ms 주문 COMPENSATING 커밋t=220ms 환불 REQUESTED 커밋t=300ms provider 결과 확인 후 REFUNDED + RefundCompleted outbox 로컬 커밋t=320ms RefundCompleted 수신 후 주문 CANCELLED 커밋t=80ms부터 환불이 끝날 때까지 사용자는 결제 승인과 미확정 주문을 동시에 볼 수 있다. 환불을 요청했다는 사실만으로 돈이 돌아온 것은 아니므로 주문은 COMPENSATING에 머문다. 이 예에서는 환불 성공을 나타내는 RefundCompleted만 CANCELLED 전이를 허용한다. 이 중간 상태를 숨길지, 처리 중으로 보여 줄지, 얼마 동안 허용할지가 Saga의 제품 계약이다. 단순히 “최종 일관성”이라고 부르는 것만으로는 수렴 시간이나 실패 의미가 정해지지 않는다.
2PC(Two-Phase Commit, 2단계 커밋)는 코디네이터가 여러 참여자의 커밋 결정을 하나로 맞추는 원자적 커밋 프로토콜이다.
Phase 1 - Prepare Coordinator -> Order DB: prepare -> YES, prepared state 유지 Coordinator -> Payment DB: prepare -> YES, prepared state 유지 Coordinator -> Inventory DB: prepare -> YES, prepared state 유지
Phase 2 - Commit or Abort Coordinator -> all: COMMITPrepare에 성공한 참여자는 최종 결정을 알기 전까지 관련 자원과 로그를 유지한다. 이때 코디네이터와 통신이 끊기면 참여자는 임의로 커밋하거나 취소할 수 없어 in-doubt transaction, 즉 결과가 미정인 트랜잭션으로 대기할 수 있다. 구현에 따라 잠금이나 버전 자원이 오래 유지되어 다른 요청을 막는다.
2PC가 항상 나쁜 것은 아니다. 모든 참여자가 같은 조직의 통제 아래 있고 프로토콜을 지원하며, 짧은 트랜잭션과 낮은 처리량에서 원자성이 가용성보다 중요하다면 합리적일 수 있다. 반대로 메시지 브로커, 외부 결제 API, 이메일처럼 prepare/commit 계약에 참여하지 못하는 자원이 섞이거나 서비스 독립성이 더 중요하면 적용 범위가 급격히 좁아진다.
Saga의 철학은 분산 잠금 없이 공짜 원자성을 얻는 것이 아니다. 긴 원자적 대기 대신 짧은 로컬 커밋과 명시적 회복 책임을 선택하는 것이다. 그 대가로 애플리케이션이 격리, 보상, 재시도, 관측성을 직접 맡는다.
Saga를 이벤트 목록부터 설계하면 “모든 단계가 언젠가 실행된다”는 흐름만 남고 업무 안전성은 빠지기 쉽다. 먼저 다음을 적는다.
| 질문 | 주문 사례 |
|---|---|
| 반드시 지킬 불변식 | 같은 주문을 두 번 청구하지 않는다 |
| 성공의 의미 | 결제 승인과 재고 예약이 끝나 주문이 CONFIRMED다 |
| 허용되는 중간 상태 | 최대 10분 동안 PENDING을 보여 줄 수 있다 |
| 실패의 의미 | 환불·예약 해제 후 CANCELLED, 또는 사람 확인이 필요한 MANUAL_REQUIRED다 |
| 되돌릴 수 없는 효과 | 발송된 이메일, 이미 출고된 배송, 발생한 환율 차이 |
이 표가 상태 머신, 타임아웃, 알람의 기준이 된다. “모든 서비스가 성공” 같은 기술 문구보다 사용자가 관찰할 상태를 먼저 정의해야 한다.
Saga의 정확성을 이해하려면 서로 다른 세 경계를 구분해야 한다.
이 중 하나라도 빠지면 Saga 다이어그램은 맞아 보여도 실행 중 상태가 갈라진다.
다음 코드는 두 시스템을 순서대로 호출할 뿐, 하나의 원자적 작업이 아니다.
const order = await orderRepository.save({ status: "PENDING" });await eventBus.publish({ type: "OrderCreated", orderId: order.id });두 줄 사이에는 최소 두 실패 창이 있다.
| 순서 | 실패 결과 |
|---|---|
| DB 커밋 후 publish 전 프로세스 종료 | 주문은 있지만 결제 서비스는 시작 사실을 모른다 |
| publish 성공 후 DB 커밋 실패 | 결제는 시작됐지만 주문을 조회할 수 없다 |
브로커와 데이터베이스가 같은 트랜잭션 프로토콜에 참여하지 않는 한 일반적인 애플리케이션 트랜잭션으로 두 효과를 원자화할 수 없다. 원격 publish를 DB 트랜잭션 안에서 호출해도 네트워크 응답을 기다리는 동안 DB 잠금만 길어질 뿐, publish 응답 유실 문제는 남는다.
Transactional Outbox는 도메인 변경과 outbox 이벤트를 같은 로컬 DB 트랜잭션에 저장한다. Relay(중계 프로세스)는 커밋된 outbox를 읽어 broker에 발행하는 별도 실행 단위다. outbox.event_id에는 DB 수준의 UNIQUE 제약을 두어 같은 논리 이벤트가 두 row로 저장되지 않게 한다.
BEGIN;
INSERT INTO orders(id, status, amount)VALUES ('order-001', 'PENDING', 50000);
INSERT INTO outbox(event_id, aggregate_id, event_type, payload)VALUES ('evt-101', 'order-001', 'OrderCreated', '{"amount":50000}');
COMMIT;이제 주문만 생기고 이벤트 의도가 사라지는 상태는 없다. 하지만 relay가 broker 발행에는 성공하고 published_at 갱신 전에 죽으면 evt-101을 다시 발행한다. Outbox는 유실 창을 중복 가능성으로 바꾸는 패턴이지 exactly-once 전달 장치가 아니다.
t=0ms orders + outbox 로컬 커밋t=5000ms relay가 evt-101 발행t=5010ms broker ACK 수신t=5011ms relay 종료, published_at 미기록t=10000ms 재시작한 relay가 evt-101 재발행5초 polling을 사용한 이 예에서 정상 발행 지연 하한은 대략 0~5초 + broker 지연이다. 30초 이상 지연되고 미발행 row가 계속 증가한다면 relay 처리량이 유입량보다 작거나 특정 row가 재시도를 반복하는지 확인한다. Polling Outbox와 CDC(Change Data Capture, DB 변경 로그를 읽어 이벤트로 전달하는 방식)의 구현 깊이는 선행 문서 CDC & Outbox에서 다룬다.
Inbox는 소비자가 처리한 메시지 ID를 로컬 DB에 기록해 재전달을 식별하는 패턴이고, 멱등성은 같은 논리 요청을 여러 번 실행해도 업무 효과가 한 번으로 수렴하는 성질이다.
At-least-once delivery는 메시지가 최소 한 번 전달되며 장애 시 중복될 수 있다는 계약이다. 소비자는 event_id를 처리 결과와 같은 로컬 트랜잭션에 기록해 중복을 흡수한다.
await db.transaction(async (tx) => { const marker = await tx.query(` INSERT INTO processed_events(consumer, event_id, processed_at) VALUES ('payment', 'evt-101', NOW()) ON CONFLICT (consumer, event_id) DO NOTHING RETURNING event_id `);
if (marker.rowCount === 0) return "DUPLICATE";
const transition = await tx.query(` UPDATE payment_requests SET status = 'REQUESTED' WHERE order_id = 'order-001' AND status = 'NEW' RETURNING order_id `);
if (transition.rowCount !== 1) { throw new Error("expected exactly one NEW payment request"); }
return "REQUESTED";});processed_events의 (consumer, event_id)도 UNIQUE여야 한다. 새 이벤트인데 업무 전이가 0행 또는 2행 이상이면 예외가 트랜잭션 전체를 롤백하므로 방금 삽입한 processed marker도 커밋되지 않는다. 반대로 marker가 이미 있어 0행이면 이전 트랜잭션이 payment request의 REQUESTED 전이까지 함께 커밋했다는 뜻이다. 이 중복 경로는 새 REQUESTED intent를 만들지 않고 DUPLICATE를 반환한 뒤 broker에 ACK한다. 다만 이 ACK가 이미 생성된 intent를 처리하는 worker의 provider 재호출까지 직접 막는 것은 아니다.
이 로컬 트랜잭션이 보장하는 범위는 processed marker + payment request의 REQUESTED 전이까지이다. REQUESTED는 외부 provider가 결제를 승인했다는 표시가 아니라 **외부 호출을 수행해야 한다는 durable intent(재시작 뒤에도 남는 실행 의도)**다. 별도 recovery worker가 REQUESTED를 원자적으로 PROCESSING으로 바꾸며 소유권을 얻은 뒤 외부 API를 호출한다. Lease(임대)는 lease_until까지 한 worker에게만 작업 소유권을 주는 시간 제한 계약이다. worker가 죽어 lease가 만료되면 다른 worker가 조건부 갱신으로 claim하고, provider에 전달한 안정적인 멱등성 키와 로컬에 저장한 provider 결과로 이후 외부 효과를 한 번으로 수렴시킨다.
메시지 ID가 broker의 전달 시도마다 바뀔 수 있다면 그것을 업무 멱등성 키로 쓰면 안 된다. 생산자가 만든 안정적인 event_id, 또는 tenant + operation + aggregate_id 같은 업무 키를 사용한다. 같은 키에 금액이 다른 요청이 오면 조용히 성공 처리하지 말고 payload hash를 비교해 충돌로 거부해야 한다.
SQS FIFO의 MessageDeduplicationId처럼 broker가 짧은 중복 제거 창을 제공해도 소비자 멱등성은 남는다. 기존 예시의 5분 창보다 늦은 재전달, 다른 queue를 통한 재처리, 운영자의 replay는 broker 창 밖에서 일어날 수 있다. processed_events를 7일 뒤 삭제한다면 최대 재시도·replay 기간도 7일보다 짧다는 근거가 있어야 한다. 결제처럼 같은 업무를 수개월 뒤에도 다시 만들면 안 되는 효과는 짧은 event dedup 기록과 별도로 주문 번호 같은 영속 업무 키에 unique 제약을 둔다.
결제 호출이 500ms 뒤 timeout됐다고 하자.
Orchestrator Payment Provider |--- charge(key=pay-42) ------>| | | 승인 커밋 | 응답 패킷 유실 | |<--------- 500ms timeout -----|호출자에게는 세 경우가 구별되지 않는다.
| 가능한 세계 | 실제 상태 | 다음 행동 |
|---|---|---|
| 요청 미도착 | 승인 없음 | 같은 업무 키로 재시도 |
| 아직 처리 중 | 결과 미정 | 조회 또는 대기 후 재시도 |
| 승인 후 응답 유실 | 승인 있음 | 같은 키의 저장된 결과 조회 |
따라서 timeout 직후 곧바로 PaymentFailed를 확정하고 주문을 취소하면 늦게 도착한 승인과 환불이 엇갈릴 수 있다. PAYMENT_UNKNOWN 같은 상태를 두고 결제사의 멱등성 키 조회 API나 webhook(provider가 결과 변경을 우리 HTTP endpoint로 알려 주는 callback)으로 결과를 확인한 뒤 전진 또는 보상을 결정한다. 로컬 unique key만으로 외부 결제사의 이중 승인을 막을 수는 없다.
Choreography Saga는 중앙 조율자 없이 각 서비스가 완료·실패 이벤트를 발행하고, 다른 서비스가 그 이벤트에 반응하는 방식이다. 서비스는 다른 서비스의 내부 API보다 이벤트 계약에 의존한다.
sequenceDiagram participant Order as Order Service participant Bus as Event Bus participant Payment as Payment Service participant Inventory as Inventory Service Order->>Bus: OrderCreated(evt-101) Bus-->>Payment: OrderCreated(evt-101) Payment->>Bus: PaymentCompleted(evt-201) Bus-->>Inventory: PaymentCompleted(evt-201) Inventory->>Bus: InventoryFailed(evt-301) Bus-->>Payment: InventoryFailed(evt-301) Payment->>Bus: RefundCompleted(evt-401) Bus-->>Order: RefundCompleted(evt-401) Order->>Order: CANCELLED
event_id는 중복 제거에, causation_id는 바로 앞 원인에, saga_id는 전체 실행을 묶는 데 사용한다.
1. Order Service orders + OrderCreated를 로컬 커밋
2. Payment Service OrderCreated 수신 payment request를 로컬 커밋하고 업무 키로 외부 승인을 호출 승인 결과를 확인한 뒤 payment 상태 + PaymentCompleted를 로컬 커밋 timeout이면 PaymentCompleted 대신 RESULT_UNKNOWN에서 조회·webhook 대기
3. Inventory Service PaymentCompleted 수신 stock=0 확인 InventoryFailed를 로컬 커밋
4. Payment Service InventoryFailed 수신 refund request + RefundRequested를 로컬 커밋 같은 업무 키로 외부 환불 호출 결과 확인 뒤 REFUNDED 상태 + RefundCompleted를 로컬 커밋 timeout이면 RESULT_UNKNOWN에서 조회·webhook 대기
5. Order Service RefundCompleted 수신 order를 CANCELLED로 변경각 단계의 “업무 상태 + 다음 이벤트”가 하나의 로컬 트랜잭션이어야 한다. 이벤트 발행 자체는 relay가 나중에 수행하므로 단계 사이에는 지연이 있다.
중앙 프로세스가 없으므로 서비스별 독립 배포와 수평 확장이 쉽다. 알림처럼 핵심 성공 여부와 분리 가능한 새 소비자를 추가하기도 편하다.
그러나 결합이 사라지는 것은 아니다. 결합이 호출 그래프에서 이벤트 의미와 구독 토폴로지로 이동한다. PaymentCompleted를 누가 구독하는지, 새 소비자가 같은 이벤트에 어떤 부작용을 추가하는지 한 파일만 읽어서는 알기 어렵다.
다음 변화는 choreography가 복잡해지는 신호다.
3개 이하나 5단계 이상은 설명용 휴리스틱일 뿐 경계값이 아니다. 서비스 수보다 실패 분기 수, 흐름 변경 주체, 이벤트 계약 소유권, 전체 상태를 한눈에 설명할 수 있는지가 더 중요한 기준이다.
중복과 순서는 다른 문제다. event_id 멱등성은 같은 이벤트를 두 번 적용하는 일을 막지만, 서로 다른 이벤트가 뒤집혀 도착하는 일은 막지 못한다.
의도한 순서: PaymentCompleted(version=4) -> RefundCompleted(version=5)도착한 순서: RefundCompleted(version=5) -> PaymentCompleted(version=4)소비자가 version 5를 적용한 뒤 늦은 version 4를 받아 상태를 PAID로 되돌리면 취소된 주문이 다시 살아난다. Aggregate(함께 일관성을 지키며 version을 올리는 업무 객체)별 단조 증가 version, 허용된 상태 전이, queue partition key를 조합한다.
UPDATE paymentsSET status = 'REFUNDED', version = 5WHERE id = 'payment-001' AND version = 4 AND status = 'PAID';영향받은 행이 0이면 무조건 재시도하지 않는다. 이미 version 5 이상인지, 아직 선행 이벤트가 오지 않았는지, 허용되지 않은 전이인지 구분한다. 전역 순서가 아니라 같은 aggregate 안에서 필요한 순서만 강제하는 것이 보통 더 저렴하다.
Orchestration Saga는 오케스트레이터가 현재 단계와 결과를 저장하고, 참여 서비스에 command를 보내며 다음 전이와 보상 순서를 결정하는 방식이다. 오케스트레이터는 모든 비즈니스 로직을 소유하는 거대 서비스가 아니라 프로세스 상태와 전이 규칙의 소유자다.
CREATE_ORDER -> PROCESS_PAYMENT -> RESERVE_INVENTORY -> CONFIRM_ORDER -> COMPLETED
RESERVE_INVENTORY 실패 -> REFUND_PAYMENT -> CANCEL_ORDER -> COMPENSATED오케스트레이터 메모리에만 현재 단계를 두면 프로세스 재시작 시 무엇을 다시 보내야 할지 모른다. 상태 전이와 outbound command를 같은 로컬 트랜잭션에 기록한다.
await transaction(async (tx) => { const affectedRows = await tx.sagaState.compareAndSet({ sagaId, from: "PAYMENT_COMPLETED", to: "INVENTORY_REQUESTED", });
if (affectedRows !== 1) { throw new ConcurrentTransitionError(sagaId); }
await tx.outbox.insert({ eventId: `${sagaId}:reserve-inventory:v3`, type: "ReserveInventory", sagaId, payload: { productId: "product-001", quantity: 1 }, });});compareAndSet은 기대한 이전 상태에서만 전이를 허용하는 조건부 갱신이며, 계약상 영향받은 행 수를 반환한다. 정확히 1이 아니면 예외를 던져 트랜잭션을 롤백하므로 outbox insert까지 진행되지 않는다. 호출자는 상태를 다시 읽어 중복 결과인지, 이미 더 앞선 전이인지, 잘못된 sagaId인지 구분한다. outbox.event_id의 UNIQUE 제약은 결정적 event ID를 실수로 다시 만들었을 때도 두 번째 이벤트 저장을 거부한다.
saga_id | step | status | attempt | deadlineabc-123 | RESERVE_INVENTORY | IN_PROGRESS | 1 | 10:31:00abc-124 | REFUND_PAYMENT | COMPENSATING | 3 | 10:35:00abc-125 | PAYMENT_RESULT_CHECK | RESULT_UNKNOWN | 2 | 10:33:00abc-126 | CANCEL_ORDER | MANUAL_REQUIRED | 3 | nullFAILED 하나로 모든 실패를 덮으면 자동 재시도 가능, 보상 중, 결과 불명확, 사람 개입 필요를 구분할 수 없다. 상태 이름은 운영 화면의 장식이 아니라 어떤 작업이 다음에 합법적인지를 제한하는 계약이다.
명시적 상태 머신은 일곱 단계, 여러 timeout, 사람 승인처럼 분기가 많은 흐름을 한곳에서 볼 수 있게 한다. 대신 모든 단계가 오케스트레이터 저장소를 통과하면 상태 쓰기와 timer가 집중된다.
가령 초당 1,000 saga가 시작되고 각 Saga가 정상 경로에서 5번 상태를 쓴다면 평균 5,000 writes/s다. 재시도와 보상이 10% 추가되면 약 5,500 writes/s가 된다. 4 vCPU / 8GB 한 인스턴스가 “수백~1,000 saga/s”를 처리할 것이라는 추정만으로 결정하면 안 된다. 실제 payload 크기, index, timer 수, 외부 응답 시간으로 부하 테스트하고 queue age와 DB commit latency를 측정한다.
지연 예산도 단순 합보다 먼저 명시해야 한다.
전체 목표: 1,000ms오케스트레이터 전이 4회: 각 30ms 예산 = 120ms내부 서비스 2회: 각 140ms 예산 = 280ms외부 PG: 평균 600ms합계: 1,000ms, 재시도·queue 대기 여유 0ms이 설계는 평균값만 맞고 꼬리 지연을 흡수할 여유가 없다. 단계당 p99 200ms라는 목표를 다섯 단계에 기계적으로 곱해 1초라고 부르는 것도 정확하지 않다. 직렬 단계의 분포와 queue 대기, 재시도 예산을 함께 측정해야 한다. 외부 PG가 평균 600ms라면 API 응답에서 전체 Saga 완료를 기다리지 않고 202 Accepted + status resource로 장기 실행을 드러내는 편이 더 정직할 수 있다.
서비스가 완료·실패 이벤트에 반응한다. 중앙 흐름은 없지만 이벤트 계약과 구독 토폴로지가 프로세스를 이룬다.
직선형 단계가 적고 각 이벤트의 소유자와 소비자가 명확하며 팀별 독립성이 중요할 때상태 머신이 command, timeout, 보상 순서를 명시한다. 중앙 상태 저장소와 조율 계층을 운영해야 한다.
실패 분기·timer·사람 개입이 많고 전체 프로세스의 한 소유자가 명확할 때다음 질문으로 결정한다.
| 질문 | Choreography 쪽 | Orchestration 쪽 |
|---|---|---|
| 흐름 형태 | 분기·timer가 적은 직선, 예: 3개 안팎 | 실패 분기·합류·timer 다수, 예: 5단계 이상 |
| 변경 소유권 | 각 이벤트 소비 팀이 독립 결정 | 한 프로세스 오너가 전체 규칙 결정 |
| 상태 가시성 | 분산 trace로 충분 | 명시적 상태 화면과 수동 전이가 필요 |
| 보상 복잡도 | 바로 앞 단계에 단순 이벤트 | 여러 성공 단계를 선택적으로 역순 보상 |
| 처리량 병목 | broker partition과 소비자 | state store, timer, orchestrator worker |
단계가 7개가 되었다는 이유만으로 즉시 특정 제품으로 옮길 필요는 없다. 하지만 timer, replay, durable state, manual intervention을 직접 구현하기 시작했다면 Step Functions나 Temporal 같은 durable workflow engine과 자체 구현의 총비용을 비교할 시점이다.
RDBMS의 ROLLBACK은 아직 커밋되지 않은 변경을 외부에서 보이지 않게 폐기한다. Saga 보상은 이미 공개된 효과 뒤에 새 효과를 추가한다. 감사 로그에는 승인과 환불이 모두 남고, 그 사이 사용자가 알림을 봤을 수도 있다.
| 전진 트랜잭션 | 가능한 보상 | 완전히 지워지지 않는 것 |
|---|---|---|
결제 $100 승인 | $100 환불 또는 승인 취소 | 수수료, 환율 차이, 승인 기록 |
재고 5개 예약 | 5개 예약 해제 | 예약 중 다른 고객이 본 품절 상태 |
주문 PENDING 생성 | CANCELLED 전이 | 주문 번호와 감사 이력 |
| 이메일 발송 | 정정 이메일 발송 | 최초 이메일을 읽은 사실 |
| 배송 요청 | 출고 전 취소, 출고 후 반품 | 운송 비용과 배송 이력 |
따라서 모든 보상 함수를 undoX()라고 부르면 의미가 흐려진다. refundPayment, releaseReservation, cancelOrder, requestReturn처럼 실제 업무 동사를 쓴다.
Saga 단계는 보통 다음 세 종류로 생각할 수 있다.
보상 불가능하거나 비용이 큰 작업은 가능한 한 pivot 뒤로 미룬다. 이메일을 결제 전에 보내거나 출고를 재고 확인 전에 시작하면 보상 비용이 커진다.
단순 직선 흐름에서는 성공한 단계의 역순이 안전한 출발점이다.
T1 Create Order -> T2 Charge Payment -> T3 Reserve Inventory 실패 -> C2 Refund Payment -> C1 Cancel Order하지만 병렬 단계가 있으면 의존성 그래프를 본다. 쿠폰 예약과 포인트 차감이 서로 독립이면 둘의 보상은 병렬 실행할 수 있다. 반대로 주문 취소가 환불 완료를 전제로 한다면 RefundCompleted 뒤에만 CANCELLED를 허용한다. “배열을 reverse하면 된다”는 구현은 업무 의존성을 숨긴다.
async function requestRefund( paymentId: string, sagaId: string, workerId: string,) { const key = `${sagaId}:refund:${paymentId}`; const claim = await refundRepository.claimOrLoad({ idempotencyKey: key, paymentId, workerId, leaseUntil: new Date(Date.now() + 30_000), });
if (claim.terminal) return claim.result; if (!claim.ownedByCaller) return { status: "PENDING" };
const fence = { idempotencyKey: key, claimToken: claim.claimToken, expectedStatus: "PROCESSING" as const, };
const known = await paymentProvider.findRefundByKey(key); const providerResult = known ?? (await paymentProvider.refund(paymentId, { idempotencyKey: key }));
if (providerResult === null || providerResult.status === "UNKNOWN") { return refundRepository.scheduleReconciliation({ ...fence, reason: "RESULT_UNKNOWN", }); }
if (providerResult.status === "PENDING") { return refundRepository.scheduleReconciliation({ ...fence, providerResult, reason: "PROVIDER_PENDING", }); }
if ( providerResult.status === "REJECTED" || providerResult.status === "FAILED" ) { return refundFailurePolicy.handle({ paymentId, sagaId, providerResult, fence, }); }
if ( providerResult.status !== "CONFIRMED" && providerResult.status !== "COMPLETED" ) { return refundRepository.scheduleReconciliation({ ...fence, providerResult, reason: "UNRECOGNIZED_PROVIDER_STATUS", }); }
const persisted = await refundRepository.persistProviderResult({ ...fence, providerRefundId: providerResult.id, result: providerResult, nextStatus: "REFUNDED", outboxEvent: { eventId: `${key}:completed`, type: "RefundCompleted", sagaId, paymentId, }, });
if (persisted.affectedRows === 1) return persisted.result;
const latest = await refundRepository.findByIdempotencyKey(key); if (latest?.terminal) return latest.result; return { status: "PENDING" };}claimOrLoad는 idempotency_key의 unique 제약을 이용해 최초 PROCESSING row 삽입과 기존 row 로드를 하나의 원자적 연산으로 제공한다. 기존 row가 실행 중이면 소유권을 주지 않고, lease_until < NOW()인 PROCESSING row만 compare-and-set으로 새 workerId와 lease를 기록해 재획득한다. claim이 성공할 때마다 단조 증가하는 leaseVersion과 이를 포함한 불투명 claimToken을 반환하므로, 두 worker가 먼저 find한 뒤 각각 claim하는 경쟁 창이 없다. 이 예제에서 실제 fencing predicate는 WHERE idempotency_key = ? AND claim_token = ? AND status = 'PROCESSING'이며, leaseVersion은 claimToken 발급에 반영되지만 저장 조건에 별도로 쓰지 않는다.
외부 API 호출을 긴 DB 트랜잭션 안에 넣어 원자적인 것처럼 보이게 만들지 않는다. claim 트랜잭션을 먼저 끝내고 외부 provider에는 항상 같은 key를 전달한다. 조회 또는 호출 결과가 CONFIRMED나 COMPLETED일 때만 persistProviderResult가 idempotency_key + claimToken + PROCESSING 조건으로 compare-and-set한다. 성공한 동일 로컬 트랜잭션에서 provider 환불 ID와 응답 payload를 저장하고 payment를 REFUNDED로 전이하며 RefundCompleted outbox row를 추가한다. PENDING은 대사·재시도로 넘기고, REJECTED·FAILED는 실패 정책으로 보내며, UNKNOWN·null 또는 알 수 없는 상태는 완료로 확정하지 않는다. 이래야 환불은 확정됐는데 완료 이벤트가 유실되는 창이 없다.
affectedRows === 0은 예전 worker의 lease가 만료된 뒤 새 worker가 더 큰 leaseVersion으로 claim했거나, 이미 terminal 결과가 커밋됐음을 뜻할 수 있다. 이때 오래된 worker는 자신의 결과로 덮어쓰지 않고 idempotency_key로 최신 상태를 다시 읽는다. 이미 REFUNDED와 같은 terminal이면 그 저장된 결과를 반환하고, 아직 다른 worker가 PROCESSING을 소유하면 하위 오프레이션을 다시 시작하지 않는다. 응답 전에 프로세스가 죽으면 provider 조회로 결과를 대사(reconciliation)한다.
PROCESSING에는 lease_until과 마지막 확인 시각을 함께 둔다. lease가 만료되면 한 worker만 조건부 갱신으로 소유권을 다시 얻고 provider 결과부터 조회한다. provider에 결과가 없다면 동일한 provider idempotency key로 환불을 다시 호출한다. 새 키로 환불을 바로 시작하면 오래된 실행과 겹칠 수 있다.
t=0ms refund(key=abc-123:refund:P-001) 요청t=300ms provider는 환불 성공t=301ms 로컬 SUCCEEDED 저장 전 프로세스 종료t=2s 재시작 후 같은 key로 provider 결과 조회t=2.1s REFUNDED와 응답을 로컬 저장, 보상 재개이 타임라인에서 새 키로 환불을 다시 호출하면 이중 환불 위험이 생긴다. 같은 업무 키로 결과를 조회하거나 재호출하고, provider가 멱등성이나 결과 조회를 지원하지 않으면 자동 재시도보다 대사·사람 확인 경계를 둔다.
세 번 재시도한 뒤 DLQ(Dead Letter Queue, 반복 실패 메시지를 격리하는 큐)로 보낸다는 정책은 출발점일 뿐이다. 1초, 2초, 4초의 exponential backoff를 적용해도 7초 뒤 provider 장애가 끝난다는 보장은 없다. 돈·재고처럼 미완료 비용이 큰 보상은 DLQ 적재로 끝내지 않고 MANUAL_REQUIRED 상태, 소유 팀, 대사 기한을 둔다. 기존 maxReceiveCount: 3도 업무 마감 시간과 provider 장애 분포에 맞춰 검증한다.
퀴즈
이미 공개된 결제·알림·배송 효과는 과거에서 지울 수 없다. 새 환불·취소 기록, 보상 중 상태, 멱등 재시도, 결과 대사, 사람 개입 정책을 별도로 설계해야 한다.
ACID의 Isolation(격리성)은 동시에 실행되는 트랜잭션이 서로의 중간 상태를 함부로 관찰하지 못하게 하는 성질이다. Saga는 각 로컬 단계가 즉시 커밋되므로 전체 프로세스 관점의 격리를 자동으로 제공하지 않는다.
Saga At=0ms order-001 = PENDING_PAYMENT 커밋t=80ms 결제 진행 중
Saga Bt=40ms order-001 조회t=50ms PENDING을 정상 주문으로 오해해 배송 시작이 문제는 흔히 dirty read와 비슷하게 보이지만, DB 관점에서는 커밋된 값을 읽은 것이다. 정확한 문제는 업무 프로세스가 끝나지 않은 상태를 다른 작업이 최종 상태로 해석한 것이다.
Semantic Lock(의미적 잠금)은 물리적 DB lock을 장시간 잡는 대신 PENDING_PAYMENT, RESERVING, COMPENSATING 같은 업무 상태로 “현재 어떤 작업이 합법적인가”를 제한하는 기법이다.
UPDATE ordersSET status = 'CONFIRMED', version = version + 1WHERE id = 'order-001' AND status = 'PENDING_PAYMENT' AND version = 3;status 컬럼을 추가하는 것만으로 semantic lock이 되지는 않는다. 모든 command와 중요한 조회가 상태 의미를 지켜야 한다.
CONFIRMED에서만 허용한다.PENDING_PAYMENT 주문은 목록에서 숨기거나 결제 확인 중으로 표시한다.COMPENSATING 주문에 같은 쿠폰을 다시 적용하지 않는다.기존 예시처럼 조회에서 PENDING_PAYMENT를 무조건 제외하면 사용자는 주문이 사라졌다고 느낄 수 있다. 내부 운영 조회는 보여 주되 진행 상태를 구분하고, 고객 조회는 제품 계약에 따라 숨기거나 처리 중으로 표현한다.
Commutative operation(교환 연산)은 실행 순서를 바꿔도 결과가 같은 연산이다.
stock 10에서 -2와 -3을 적용(-2 후 -3) = 5(-3 후 -2) = 5하지만 decrement라는 형태만으로 재고 불변식이 보장되지는 않는다. stock 2에 -3을 적용하면 음수가 된다. 조건부 갱신으로 “0 아래로 내려가지 않는다”를 같은 로컬 트랜잭션에 묶는다.
UPDATE inventorySET available = available - 3WHERE product_id = 'product-001' AND available >= 3;영향 행이 0이면 예약 실패다. 절대값 stock = newStock은 동시 갱신을 덮어쓸 수 있으므로 피한다. 예약 row를 별도로 만들고 만료 시간을 두는 방식은 재고를 즉시 최종 차감하지 않으면서도 다른 Saga가 사용할 수량을 제한한다.
Pessimistic lock(비관적 잠금)은 충돌할 가능성이 높다고 보고 다른 트랜잭션의 접근을 먼저 막는다. Saga 전체 시간 동안 분산 잠금을 유지하는 것은 피하지만, 한 서비스의 짧은 로컬 트랜잭션에서 결제 확정이나 재고 조건 검사를 직렬화하는 것은 가능하다.
BEGIN;SELECT status FROM payments WHERE id = 'payment-001' FOR UPDATE;-- 상태 확인과 한 번의 전이를 짧게 수행UPDATE payments SET status = 'REFUNDED' WHERE id = 'payment-001';COMMIT;외부 PG 호출을 이 lock 안에서 기다리면 600ms 이상의 네트워크 지연 동안 connection과 row lock을 붙잡는다. 외부 호출 전후를 REQUESTED -> SUCCEEDED 상태로 나누고 lock은 로컬 전이에만 사용한다.
기존 측정 예시를 의사결정 신호로 해석해 보자. connection pool이 100이고 lock을 기다리거나 보유한 요청이 70%에 도달하며, 500ms 이상인 holder 5건이 1분 넘게 반복된다면 처리량 포화가 가까운 신호다. 이 숫자는 보편 임계값이 아니라 자체 SLO(Service Level Objective, 서비스 수준 목표)와 pool 대기 시간에 맞춰 검증할 가설이다.
| 단계 | 불일치 비용 | 우선 검토할 장치 |
|---|---|---|
| 주문 생성, 24시간 내 회복 가능 | 중간 | semantic state + 대사 |
| 결제 확정, 이중 청구가 직접 손해 | 매우 큼 | provider 멱등성 + 조건부 전이 + 짧은 local lock |
| 재고 감소, 수량 연산 | 큼 | 조건부 decrement 또는 reservation |
“Saga가 lock을 쓰지 않는다”는 문장을 모든 lock 금지로 읽지 않는다. Saga가 피하려는 것은 서비스와 네트워크 호출 전체를 묶는 장기 분산 잠금이다. 로컬 불변식을 지키는 짧은 lock과 조건부 갱신은 여전히 유효하다.
Saga의 어려운 버그는 한 기능이 아니라 중복·순서·timer가 겹칠 때 생긴다.
먼저 전송 timeout을 업무 실패로 오판한 위험한 반례를 보자.
t=0ms ReserveInventory command 전송, attempt=1t=500ms transport timeout, 결과는 알 수 없음t=501ms [unsafe] orchestrator가 즉시 보상 시작t=650ms inventory 서비스의 늦은 Reserved 결과 도착t=700ms ReleaseInventory command 도착이 흐름은 timeout 시점에 reserve가 안 됐다는 근거 없이 서로 반대인 command를 겹친다. 안전한 기본 흐름은 다음과 같다.
t=0ms ReserveInventory command 전송, attempt=1t=500ms transport timeout -> RESULT_UNKNOWN, 보상하지 않음t=650ms 조회로 RESERVED 확인 -> 정상 전진조회나 webhook으로 REJECTED/미처리를 확인했거나, 미리 명시한 단계 deadline에 업무 abort 정책이 적용됐을 때만 보상을 시작한다. deadline abort는 시도를 종료된 세대로 fencing하고 보상으로 전이하는 명시적 업무 결정이지, transport timeout을 실패로 바꾸어 부르는 것이 아니다. 이후 늦은 Reserved가 도착해도 Saga를 다시 전진시키지 못해야 하며, inventory 서비스는 release가 reserve보다 먼저 도착해도 처리할 수 있어야 한다. 가능한 정책은 다음과 같다.
RESULT_UNKNOWN으로 두고 결과 조회 후 결정한다.saga_id + step + attempt를 넣고 이미 종료된 attempt의 결과를 무시한다.여기서 “무시”는 상태 머신이 다시 전진하지 않는다는 뜻이지 참여 서비스의 실제 효과가 사라진다는 뜻이 아니다. 늦은 결제 승인이나 예약이 확인되면 대사 작업이 현재 Saga 상태와 비교해 후속 환불·해제를 시작해야 한다.
세 번째 방식의 작은 반례를 보자.
Release(order-001) 먼저 도착 -> RELEASED tombstone 저장Reserve(order-001) 나중 도착 -> tombstone 확인, 예약하지 않음release를 “row가 없으니 성공”으로만 처리하고 흔적을 남기지 않으면 늦은 reserve가 재고를 다시 잡을 수 있다.
이 네 시간을 모두 10분 하나로 두면 원인이 다른 만료가 같은 보상을 시작한다. 예를 들어 결제 API 응답 timeout은 500ms여도 결제 결과 webhook은 2분 안에 올 수 있고, 재고 예약은 10분 유지할 수 있다. 각 timer가 만료됐을 때 허용되는 상태 전이를 따로 적는다.
오케스트레이터, broker, SDK가 각각 **최초 호출을 포함해 최대 3회 시도(max 3 total attempts)**하면 최악의 경우 하나의 논리 작업이 3 x 3 x 3 = 27번 호출될 수 있다. 즉 각 계층의 설정은 재시도 2회 + 최초 시도 1회다. 멱등성이 중복 효과를 막더라도 provider와 DB 부하는 27배가 될 수 있다. 재시도 책임을 한 계층에 모으고 전체 attempt budget과 deadline을 전달한다.
Choreography에는 중앙 상태가 없고 Orchestration의 상태 row도 참여 서비스의 실제 효과를 전부 말해 주지 않는다. 관측성의 목표는 로그를 많이 쌓는 것이 아니라 한 Saga의 의도, 시도, 결과, 보상을 시간순으로 재구성하는 것이다.
| 필드 | 질문 | 예시 |
|---|---|---|
saga_id 또는 correlation_id | 어떤 전체 실행인가 | abc-123 |
event_id | 어떤 한 이벤트인가 | evt-301 |
causation_id | 바로 어떤 command/event 때문에 생겼나 | evt-201 |
idempotency_key | 어떤 업무 효과를 한 번으로 합칠까 | abc-123:refund:P-001 |
trace ID는 한 네트워크 실행을 추적하는 데 유용하지만 며칠 뒤 재시도나 사람 개입에서 바뀔 수 있다. 장기 실행 Saga의 업무 식별자를 trace ID와 동일시하지 않는다.
현재 step=DECREASE_INVENTORY만 저장하면 그 단계에 진입했는지, command를 발행했는지, 응답이 유실됐는지 알 수 없다. Append-only 이력은 기존 전이를 덮어쓰지 않고 새 전이를 계속 추가하는 기록 방식이다.
10:30:01.000 saga=abc-123 ORDER_CREATED event=evt-10110:30:01.080 saga=abc-123 PAYMENT_REQUESTED attempt=110:30:01.680 saga=abc-123 PAYMENT_RESULT_UNKNOWN deadline=10:32:0010:30:43.000 saga=abc-123 PAYMENT_CONFIRMED source=webhook10:30:43.020 saga=abc-123 INVENTORY_REQUESTED attempt=110:30:43.100 saga=abc-123 INVENTORY_FAILED reason=OUT_OF_STOCK10:30:43.120 saga=abc-123 REFUND_REQUESTED attempt=110:30:44.000 saga=abc-123 COMPENSATED현재 상태 table과 전이 이력을 함께 두면 빠른 조회와 감사가 모두 가능하다. 이것이 Event Sourcing 전체를 도입해야 한다는 뜻은 아니다. Saga 실행 이력을 append-only로 남기는 것과 도메인 전체 상태를 이벤트로 저장하는 패턴은 범위가 다르다.
다음 지표는 서로 다른 질문에 답한다.
saga_started_total, completed_total, compensated_total: 결과 비율이 변했는가?saga_duration: 사용자가 기다리는 전체 시간의 p50/p95/p99는 얼마인가? p50·p95·p99는 각각 관측값의 50%·95%·99%가 그 값 이하인 percentile(백분위수)이다.in_progress_age: 가장 오래 끝나지 않은 Saga는 몇 분인가?step_attempts: 어느 단계에서 재시도가 늘었는가?result_unknown_count: timeout 뒤 결과를 모르는 작업이 쌓이는가?compensation_pending_age: 환불·예약 해제가 업무 기한 안에 끝나는가?outbox_oldest_age와 outbox_pending_count: relay가 유입량을 따라가는가?manual_required_count: 자동화가 사람에게 넘긴 미결 건수는 얼마인가?기존 조회 예시에서 IN_PROGRESS가 10분 이상이면 무조건 실패라고 단정하지 않는다. 정상 p99가 2분이고 제품 deadline이 10분이라면 15분 32초인 DECREASE_INVENTORY는 명확한 이상이지만, 8분 11초인 PROCESS_PAYMENT는 deadline 전 경고일 수 있다. 상태별 deadline과 비교한다.
SELECT saga_id, step, status, attempt, NOW() - updated_at AS state_age, deadlineFROM saga_stateWHERE status IN ('IN_PROGRESS', 'RESULT_UNKNOWN', 'COMPENSATING') AND deadline < NOW()ORDER BY deadline;Reconciliation(대사)은 서로 다른 시스템의 기록을 주기적으로 비교해 누락·중복·미결 상태를 찾는 과정이다. 이벤트가 완벽히 흐른다고 가정하지 않고 다음과 같은 불일치를 찾는다.
Payment Provider: payment P-001 = REFUNDEDLocal Payment DB: payment P-001 = REFUND_REQUESTED
Inventory DB: reservation R-001 = ACTIVESaga State: abc-123 = COMPENSATED첫 사례는 늦은 webhook이나 로컬 결과 저장 실패일 수 있고, 두 번째는 예약 해제 유실이다. 대사는 상태를 임의로 덮어쓰기 전에 어느 시스템이 각 사실의 원본인지와 수정 이벤트를 기록해야 한다.
Order PENDING -> CONFIRMED PENDING -> COMPENSATING COMPENSATING -> CANCELLED (RefundCompleted 수신만 허용) COMPENSATING (환불 미해결 시에도 유지)
Payment NEW -> REQUESTED -> SUCCEEDED -> REJECTED -> RESULT_UNKNOWN -> SUCCEEDED | REJECTED SUCCEEDED -> REFUND_REQUESTED -> REFUNDED -> RESULT_UNKNOWN -> REFUNDED | MANUAL_REQUIRED
Inventory NONE -> RESERVE_REQUESTED -> RESERVED RESERVED -> RELEASE_REQUESTED -> RELEASEDRESULT_UNKNOWN은 성공과 실패 사이의 세 번째 최종 결과가 아니다. 조회나 webhook으로 사실을 확인할 때까지 전진·보상을 보류하는 임시 상태다. 확인된 승인 성공은 SUCCEEDED로, 거절은 REJECTED로 전이한다. 환불 결과가 불명확하면 같은 업무 키로 조회해 REFUNDED를 확정하거나 업무 기한 뒤 MANUAL_REQUIRED로 넘긴다.
각 메시지 envelope에는 최소 다음 필드를 둔다.
{ "eventId": "evt-201", "eventType": "PaymentCompleted", "sagaId": "abc-123", "causationId": "evt-101", "aggregateId": "order-001", "aggregateVersion": 4, "occurredAt": "2026-04-09T10:30:02Z", "payload": { "paymentId": "P-001", "amount": 50000 }}occurredAt은 관측과 감사에 쓰되 순서 판정의 유일한 근거로 쓰지 않는다. 노드 시계가 어긋날 수 있으므로 aggregate version과 상태 전이 조건을 사용한다.
[10:30:01] Order T1orders(order-001, PENDING, 50,000)outbox(evt-101, OrderCreated)
[10:30:02] Payment T2payments(P-001, SUCCEEDED, 50,000)outbox(evt-201, PaymentCompleted)
[10:30:03] Inventory T3reservations(R-001, ACTIVE, quantity=1)outbox(evt-301, InventoryReserved)
[10:30:05] Order T4orders(order-001, CONFIRMED, 50,000)saga_state(abc-123, COMPLETED)각 줄은 다른 DB의 로컬 커밋이다. 10:30:01과 10:30:05 사이에 부분 상태가 보인다는 사실이 설계에 포함되어야 한다.
[10:30:01] CreateOrder 완료[10:30:02] ChargePayment 완료: P-001, 50,000원[10:30:03] ReserveInventory 실패: stock=0[10:30:03] RefundPayment 요청: key=abc-123:refund:P-001[10:30:04] RefundCompleted 완료[10:30:05] CancelOrder 완료[10:30:05] Saga COMPENSATED
최종 상태orders: order-001, CANCELLEDpayments: P-001, REFUNDEDinventory: stock=0, reservation 없음saga_state: abc-123, COMPENSATED같은 RefundPayment가 두 번 와도 idempotency key와 상태 조건이 두 번째 효과를 막는다. CANCELLED는 주문이 존재하지 않았다는 뜻이 아니라 실패를 업무적으로 종결했다는 뜻이다.
[10:30:03] RefundPayment attempt=1 -> provider 503[10:30:04] attempt=2 -> timeout, RESULT_UNKNOWN[10:32:00] provider 조회 -> 아직 SUCCEEDED, 환불 없음[10:32:01] attempt=3 -> provider 503[10:32:01] refund responsibility -> MANUAL_REQUIRED[10:32:01] order -> COMPENSATING 유지[10:32:02] 담당 queue에 업무 항목 생성, deadline=11:00:00이 상태에서 주문을 CANCELLED로 바꾸면 환불이 완료된 것처럼 잘못 보이게 한다. 주문은 COMPENSATING에 머물고, 환불 책임에 MANUAL_REQUIRED·소유자·deadline을 남긴다. RefundCompleted가 로컬 outbox를 통해 확인된 뒤에만 주문을 CANCELLED로 종결한다. 자동 재시도 종료와 비즈니스 책임 종료는 다르다.
SQS -> Payment: OrderCreated(evt-101)Payment Inbox transaction: processed_events(evt-101) + REQUESTED intent local commitSQS: ACK 유실Payment worker: lease로 REQUESTED claimPayment worker -> provider: charge(key=order-001:charge)Provider: 같은 key의 승인 결과로 수렴Payment worker local transaction: fence 충족 시 SUCCEEDED + PaymentCompleted outbox commitSQS -> Payment: OrderCreated(evt-101) 재전달Payment Inbox: DUPLICATE 반환, 새 intent 없이 ACK첫 Inbox 트랜잭션은 processed_events(evt-101) marker와 payment request의 REQUESTED intent만 원자적으로 커밋한다. 재전달 처리는 DUPLICATE를 반환하고 ACK하여 새 intent 생성을 막지만, 이미 존재하는 intent를 맡은 worker의 provider 재호출 자체를 직접 막지는 않는다. worker 소유권과 오래된 결과의 로컬 반영은 lease와 claimToken fencing으로 제어하고, lease 만료 뒤 provider 호출이 반복되어도 외부 승인 효과는 안정적인 provider idempotency key로 수렴시킨다. 외부 승인과 로컬 결과 커밋은 서로 다른 경계다.
Saga는 마이크로서비스의 기본값이 아니다. 다음 조건에서는 다른 선택이 더 단순하거나 더 안전하다.
주문과 재고가 같은 소유권·배포 경계에 있고 반드시 함께 바뀐다면 모듈러 모놀리스와 한 DB 트랜잭션이 더 낫다. 독립성 근거 없이 서비스를 나눈 뒤 Saga로 복잡성을 메우는 것은 문제를 스스로 만든 것이다.
모든 관찰자에게 즉시 원자적으로 보여야 하고 semantic lock이나 reservation으로도 불변식을 보호할 수 없다면 Saga의 최종 수렴 모델이 맞지 않는다. 한 저장소로 경계를 모으거나, 참여자가 지원하는 강한 coordination 프로토콜을 검토한다.
이미 공개된 법적 신고, 되돌릴 수 없는 물리 작업처럼 보상도 재시도도 불가능한 단계를 초반에 둔 Saga는 안전하지 않다. 작업 순서를 바꾸고 사전 승인·예약·사람 확인을 pivot 전에 둔다. 설계해도 허용 가능한 실패 상태가 없다면 자동 Saga로 만들지 않는다.
주문 성공 뒤 분석 이벤트나 이메일을 보내는 흐름이 핵심 주문 결과를 바꾸지 않는다면 전체를 Saga로 묶을 필요가 없다. Outbox와 멱등 consumer, 재시도·DLQ만으로 충분할 수 있다. 모든 이벤트 체인이 Saga는 아니다.
회계 원장에서 과거 분개를 삭제하는 대신 반대 분개를 추가하는 것은 좋은 보상 의미다. 그러나 “나중에 보상할 수 있다”는 이유로 잔액 불변식이나 이중 승인 방지를 느슨하게 해서는 안 된다. 각 로컬 원장의 강한 제약을 먼저 지키고, Saga는 서비스 사이 업무 프로세스를 조율한다.
| 상황 | 우선 선택 |
|---|---|
| 같은 DB와 소유권, 짧은 작업 | 로컬 ACID 트랜잭션 |
| 모든 참여자가 2PC 지원, 원자성이 가용성보다 중요 | 2PC를 비용과 함께 검토 |
| 독립 DB 여러 개, 중간 상태와 보상 허용 | Saga |
| 한 방향 상태 전파, 핵심 결과와 독립 | Outbox + event consumer |
| 긴 timer·사람 개입·복잡한 재개 | Saga + durable workflow engine 검토 |
| 보상 불가능, 중간 상태 불허 | 경계·순서 재설계 또는 강한 coordination |
본문의 설계가 먼저다. 아래는 특정 NestJS·SQS·Aurora 조합의 완성 구현이 아니라, 실제 시스템에서 개념이 지켜지는지 확인하는 최소 관찰점이다.
-- 상태별 deadline을 넘긴 SagaSELECT saga_id, step, status, attempt, NOW() - updated_at AS state_age, deadlineFROM saga_stateWHERE status IN ('IN_PROGRESS', 'RESULT_UNKNOWN', 'COMPENSATING') AND deadline < NOW()ORDER BY deadline;
-- relay가 아직 발행하지 못한 outbox의 양과 가장 오래된 ageSELECT COUNT(*) AS pending_count, NOW() - MIN(created_at) AS oldest_ageFROM outboxWHERE published_at IS NULL;outbox polling이 5초이고 batch가 100이라면 이론상 한 worker의 상한은 broker 지연을 무시해도 약 20 events/s다. 유입이 50 events/s이면 batch 크기만 그대로 둔 채 worker 하나로는 초당 30개가 쌓인다. 30초 뒤 약 900개 backlog가 생긴다. FOR UPDATE SKIP LOCKED로 여러 relay가 서로 다른 row를 가져가게 할 수 있지만, 발행 후 marker 갱신 실패에 따른 중복은 그대로다.
SELECT event_idFROM outboxWHERE published_at IS NULLORDER BY created_atLIMIT 100FOR UPDATE SKIP LOCKED;saga_id, event_id, causation_id, aggregate_id, aggregate_versionstep, from_status, to_status, attempt, deadlineidempotency_key, provider_request_idresult: succeeded | rejected | timeout | result_unknowncompensation_status, manual_ownerCloudWatch Logs Insights 같은 제품별 쿼리보다 이 구조화 필드 계약이 먼저다. 정상 로그가 CREATE_ORDER -> PROCESS_PAYMENT -> DECREASE_INVENTORY에서 멈췄다면 마지막 로그만 보고 재고 실패로 단정하지 않는다. command 발행, consumer 수신, 업무 커밋, 결과 이벤트 발행 중 어느 지점이 빠졌는지 causation chain으로 찾는다.
event_id가 재발행되어도 효과가 한 번인지 확인한다.RESULT_UNKNOWN과 결과 조회가 작동하는지 확인한다.5 이벤트 뒤 version 4를 보내 상태가 역행하지 않는지 확인한다.MANUAL_REQUIRED, 담당자, deadline이 생성되는지 확인한다.단위 테스트는 전이 함수와 멱등 조건을 빠르게 확인하고, integration test는 DB 트랜잭션·outbox·broker 중복을 확인한다. 전체 테스트 전략과 SQS test double·LocalStack의 선택은 후속 Testing Strategy에서 다룬다.
Saga 문서의 경계는 서비스 간 장기 실행 트랜잭션의 정확성이다. CQRS의 read/write 모델 분리와 Event Sourcing의 저장 모델 깊이는 다음 문서에 남긴다.
| 질문 | 핵심 답 |
|---|---|
| 왜 Saga인가 | 서비스별 DB와 외부 API를 한 로컬 트랜잭션으로 묶을 수 없기 때문이다 |
| 무엇을 보장하는가 | 로컬 원자성, 명시적 상태 전이, 보상과 대사를 통해 허용된 결과로 수렴한다 |
| 무엇을 포기하는가 | 전체 프로세스의 즉시 원자성과 자동 격리를 포기한다 |
| 메시지는 어떻게 안전하게 잇나 | transactional outbox + at-least-once relay + idempotent inbox를 조합한다 |
| timeout은 무엇인가 | 실패 확정이 아니라 결과 불명확이며 조회·멱등 재시도가 필요하다 |
| 보상은 무엇인가 | 과거를 지우는 rollback이 아니라 환불·해제·취소라는 새 업무 트랜잭션이다 |
| 격리는 어떻게 보완하나 | semantic state, version, 조건부 갱신, reservation, 짧은 local lock을 쓴다 |
| 조율 방식은 어떻게 고르나 | 서비스 수보다 흐름 소유권, 실패 분기, timer, 상태 가시성으로 고른다 |
| 언제 피하나 | 한 로컬 트랜잭션이면 충분하거나, 중간 상태·보상을 허용할 수 없을 때 피한다 |