- Published on
MSA 사가 패턴 - Temporal로 보상 트랜잭션 구현
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론: MSA에서 트랜잭션이 깨지는 순간
모놀리식에서는 하나의 DB 트랜잭션으로 주문 생성, 결제 승인, 재고 차감, 배송 요청을 COMMIT 혹은 ROLLBACK으로 깔끔하게 처리할 수 있습니다. 하지만 MSA에서는 서비스별 DB가 분리되고 네트워크 호출이 끼어들면서 단일 트랜잭션 경계가 사라집니다.
- 결제는 성공했는데 재고 차감이 실패
- 재고는 차감됐는데 배송 요청이 타임아웃
- 주문 상태 업데이트는 됐는데 메시지 발행이 누락
이때 흔히 2PC 같은 분산 트랜잭션을 떠올리지만, 운영 복잡도와 성능 비용이 크고 장애 전파 범위도 넓습니다. 그래서 실무에서는 사가(Saga) 패턴으로 “완벽한 원자성” 대신 “실용적인 일관성”을 맞추는 경우가 많습니다.
이 글은 사가 패턴 중에서도 오케스트레이션 사가를 Temporal로 구현하면서, 보상 트랜잭션 설계에서 자주 터지는 문제(멱등성, 재시도, 타임아웃, 중복 실행)를 어떻게 다루는지에 초점을 맞춥니다.
사가 패턴 빠른 정리: 오케스트레이션 vs 코레오그래피
사가 패턴은 “로컬 트랜잭션들의 연쇄”로 비즈니스 흐름을 구성하고, 중간에 실패하면 이미 완료된 단계들을 **보상 트랜잭션(Compensation)**으로 되돌리는 방식입니다.
코레오그래피(Choreography)
각 서비스가 이벤트를 발행하고 다음 서비스가 구독해 이어가는 방식입니다.
- 장점: 중앙 컨트롤러가 없어 느슨한 결합
- 단점: 흐름이 커질수록 디버깅이 어렵고, 실패 시나리오가 분산됨
오케스트레이션(Orchestration)
중앙 오케스트레이터가 “다음 단계 호출”과 “실패 시 보상 호출”을 제어합니다.
- 장점: 흐름 가시성, 실패 처리 일원화, 운영 편의
- 단점: 오케스트레이터가 설계의 중심이 됨
Temporal은 워크플로 엔진으로서 오케스트레이션 사가에 특히 잘 맞습니다. 워크플로 상태를 서버가 내구적으로 관리하고, 장애/재시작/재배포에도 워크플로가 이어지며, 재시도/타임아웃 같은 제어를 코드로 선언할 수 있습니다.
Temporal로 사가를 구현할 때의 핵심 모델
Temporal에서 사가 구현은 보통 아래 3가지로 정리됩니다.
- Workflow: 비즈니스 오케스트레이션. 순서 제어와 보상 호출 책임
- Activity: 외부 시스템 호출(결제 API, DB, 다른 서비스). 실패 가능성이 있는 I/O
- Compensation Activity: 이전 단계의 효과를 되돌리는 작업(결제 취소, 재고 복구)
Temporal 워크플로는 결정적 실행(deterministic execution)이 중요하므로, 네트워크 호출은 반드시 Activity로 분리합니다.
예시 시나리오: 주문 생성 사가
주문 플로우를 단순화해보겠습니다.
- 주문 생성(주문 서비스 DB)
- 결제 승인(결제 서비스)
- 재고 예약(재고 서비스)
- 배송 요청(배송 서비스)
실패 시 보상 규칙 예시는 아래와 같습니다.
- 4단계 실패: 재고 예약 취소, 결제 취소, 주문 취소
- 3단계 실패: 결제 취소, 주문 취소
- 2단계 실패: 주문 취소
여기서 중요한 점은 “보상은 역순으로” 호출된다는 것입니다.
구현: TypeScript Temporal 워크플로로 보상 트랜잭션 구성
아래 코드는 Temporal TypeScript SDK 기준의 예시입니다. (실제 서비스 호출은 HTTP/gRPC/DB 등으로 대체)
Activity 정의
// activities.ts
export async function createOrder(input: {
orderId: string;
userId: string;
amount: number;
}) {
// DB insert + 상태 CREATED
return { orderId: input.orderId };
}
export async function cancelOrder(input: { orderId: string; reason: string }) {
// DB update 상태 CANCELED
return { ok: true };
}
export async function authorizePayment(input: { orderId: string; amount: number }) {
// 결제 승인
return { paymentId: `pay_${input.orderId}` };
}
export async function cancelPayment(input: { paymentId: string }) {
// 결제 취소(보상)
return { ok: true };
}
export async function reserveInventory(input: { orderId: string }) {
// 재고 예약
return { reservationId: `inv_${input.orderId}` };
}
export async function releaseInventory(input: { reservationId: string }) {
// 재고 예약 해제(보상)
return { ok: true };
}
export async function requestShipment(input: { orderId: string }) {
// 배송 요청
return { shipmentId: `shp_${input.orderId}` };
}
export async function cancelShipment(input: { shipmentId: string }) {
// 배송 취소(보상) - 실제로는 취소 불가할 수도 있음
return { ok: true };
}
워크플로: 보상 스택(compensation stack)으로 구현
Temporal에는 Java SDK에 Saga 유틸이 있지만, TypeScript에서는 보통 “보상 함수 스택”을 직접 관리하는 방식이 명확합니다.
// workflows.ts
import { proxyActivities } from '@temporalio/workflow';
import type * as activities from './activities';
const {
createOrder,
cancelOrder,
authorizePayment,
cancelPayment,
reserveInventory,
releaseInventory,
requestShipment,
cancelShipment,
} = proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
// 외부 호출은 일시 장애가 흔하므로 기본 재시도 정책이 중요
initialInterval: '1s',
maximumInterval: '30s',
backoffCoefficient: 2,
maximumAttempts: 8,
},
});
export async function createOrderSaga(input: {
orderId: string;
userId: string;
amount: number;
}) {
const compensations: Array<() => Promise<void>> = [];
try {
const order = await createOrder(input);
compensations.push(async () => {
await cancelOrder({ orderId: order.orderId, reason: 'SAGA_FAILED' });
});
const payment = await authorizePayment({ orderId: input.orderId, amount: input.amount });
compensations.push(async () => {
await cancelPayment({ paymentId: payment.paymentId });
});
const inv = await reserveInventory({ orderId: input.orderId });
compensations.push(async () => {
await releaseInventory({ reservationId: inv.reservationId });
});
const shipment = await requestShipment({ orderId: input.orderId });
compensations.push(async () => {
await cancelShipment({ shipmentId: shipment.shipmentId });
});
return {
ok: true,
orderId: input.orderId,
shipmentId: shipment.shipmentId,
};
} catch (err) {
// 역순 보상
for (let i = compensations.length - 1; i >= 0; i -= 1) {
try {
await compensations[i]();
} catch (compErr) {
// 보상 실패는 별도 알림/재처리 큐로 보내는 전략이 필요
// 여기서는 워크플로 실패로 남겨 운영자가 추적 가능하게 함
}
}
throw err;
}
}
이 구조의 장점은 단순합니다.
- 성공한 단계만 보상 스택에 쌓인다
- 실패 시 역순 보상으로 일관된 롤백을 시도한다
- Temporal이 워크플로 상태를 저장하므로, 워커가 죽어도 이어서 보상이 진행된다
실무에서 반드시 고려할 6가지: “보상은 롤백이 아니다”
1) 보상 트랜잭션은 100% 되돌림이 아닐 수 있다
배송이 이미 출고되면 취소가 불가능할 수 있습니다. 이때는 보상 대신 “반품 프로세스” 같은 대체 흐름이 필요합니다. 즉, 보상은 DB 롤백처럼 완전한 역연산이 아니라 “비즈니스적으로 합의된 복구 절차”입니다.
Temporal 워크플로는 이런 복잡한 분기도 코드로 표현하기 쉬워서, 단순 실패 시 throw로 끝내지 말고 “복구 상태”를 별도 상태로 두는 게 운영에 유리합니다.
2) 멱등성(idempotency)은 선택이 아니라 필수
Temporal Activity는 재시도될 수 있고, 워커 재시작으로 같은 Activity가 다시 실행될 수도 있습니다. 따라서 cancelPayment 같은 보상 Activity도 멱등해야 합니다.
실무 패턴:
- 요청에
idempotencyKey를 포함 - DB에
orderId기준으로 “이미 처리됨” 레코드(또는 상태) 저장 - 결제/재고/배송 외부 API가 멱등 키를 지원하면 반드시 사용
3) 재시도 정책은 단계별로 다르게
모든 실패가 재시도로 해결되진 않습니다.
- 네트워크 타임아웃,
5xx: 재시도 유효 4xx(유효성 오류, 잔액 부족): 재시도 무의미
Temporal에서는 Activity 재시도 제외 오류를 지정할 수 있습니다.
const paymentActivities = proxyActivities<typeof activities>({
startToCloseTimeout: '20s',
retry: {
maximumAttempts: 5,
nonRetryableErrorTypes: ['InsufficientBalanceError', 'ValidationError'],
},
});
TypeScript에서 nonRetryableErrorTypes를 쓰려면 Activity에서 던지는 에러 타입을 일관되게 관리해야 합니다.
4) 타임아웃은 “장애 감지”의 핵심
외부 호출이 영원히 대기하면 워크플로가 잡아먹힙니다. Temporal은 startToCloseTimeout, scheduleToCloseTimeout, heartbeatTimeout 등 다양한 타임아웃을 제공합니다.
- 짧은 호출:
startToCloseTimeout - 워커가 죽어도 진행률을 감지해야 하는 긴 작업: heartbeat
예를 들어 재고 예약이 내부 배치/락에 의해 길어질 수 있다면 heartbeat를 넣어 워커 장애와 “진짜 장기 처리”를 구분합니다.
5) 보상 실패를 어떻게 운영할 것인가
보상이 실패하면 “부분 실패 상태”가 됩니다. 이때 선택지는 보통 3가지입니다.
- 워크플로를 실패로 남겨 사람이 처리(운영 비용 큼)
- 보상 Activity에 강한 재시도 정책을 적용
- 보상 실패를 별도 큐에 넣고 비동기 재처리(권장)
Temporal만으로도 재시도는 강력하지만, “외부 시스템의 영구 실패”는 재시도로 해결되지 않습니다. 따라서 보상 실패를 관측하고, 재처리 플레이북을 갖추는 것이 중요합니다.
운영 관점에서 로그/추적 중복을 줄이는 패턴은 데코레이터나 컨텍스트 매니저로 공통 로깅을 제거하는 접근과 유사합니다. 트랜잭션/로깅 중복 제거 아이디어는 이 글도 참고할 만합니다: Python 데코레이터·컨텍스트 매니저로 로깅·트랜잭션 중복 제거
6) 관측 가능성: 워크플로가 곧 “분산 트레이스의 뼈대”
사가의 가장 큰 고통은 “어디서 실패했는지”입니다. Temporal UI는 워크플로 히스토리를 제공하므로, 최소한 오케스트레이션 레벨에서는 강력한 가시성을 얻습니다.
추가로 권장:
orderId를 워크플로 ID로 사용하거나 검색 속성(Search Attributes)으로 등록- Activity 입력/출력에 민감정보는 제외하고, 추적 가능한 키만 남기기
- 외부 호출은 OpenTelemetry 트레이싱과 연동
보상 트랜잭션 설계 체크리스트
아래 항목을 문서화해두면 사가가 커져도 운영 난이도가 급격히 올라가지 않습니다.
- 각 단계의 “성공 조건”과 “실패 조건” 정의
- 보상 가능 여부(가능/불가/조건부)와 대체 절차
- 멱등 키 전략(키 포맷, 저장 위치, 만료 정책)
- 재시도 정책(일시 오류 vs 영구 오류 구분)
- 타임아웃 값과 근거(평균/최대 지연 기반)
- 보상 실패 시 알림과 재처리 플로우
자주 묻는 질문
Q1. Temporal을 쓰면 분산 트랜잭션이 해결되나요?
해결되는 건 “원자성”이 아니라 “복구 가능한 오케스트레이션”입니다. 사가의 본질은 실패를 인정하고, 보상으로 비즈니스 일관성을 맞추는 것입니다.
Q2. 코레오그래피로도 보상 트랜잭션을 만들 수 있나요?
가능하지만, 보상 이벤트의 순서/조건이 복잡해질수록 이벤트 폭발과 디버깅 난이도가 올라갑니다. 오케스트레이션이 더 적합한 경우가 많고, Temporal은 그 오케스트레이션을 안정적으로 운영하게 해줍니다.
Q3. TypeScript에서 타입 안정성은 어떻게 가져가나요?
Activity 입력/출력 타입을 엄격히 정의하고, 객체 리터럴의 타입 추론이 깨질 때는 satisfies 같은 도구가 도움이 됩니다. 관련 내용은 TS 5.x satisfies로 타입추론 깨짐 깔끔히 해결도 참고하세요.
결론
MSA에서 “중간 실패”는 정상 상태에 가깝습니다. 사가 패턴은 그 현실을 받아들이고, 보상 트랜잭션으로 시스템을 다시 일관된 상태로 수렴시키는 전략입니다.
Temporal로 사가를 구현하면 다음을 얻습니다.
- 워크플로 상태의 내구성: 워커가 죽어도 이어서 진행
- 재시도/타임아웃/히스토리 기반 디버깅
- 보상 호출을 코드로 명확히 표현
다만 보상은 DB 롤백이 아니라 비즈니스 복구 절차이므로, 멱등성·재시도 정책·보상 실패 운영까지 포함해 설계해야 “현장에서 버티는” 사가가 됩니다.