MSA 환경에서 트랜잭션을 다루다 보면 반드시 마주치는 키워드 두 개가 있다.
- 분산 트랜잭션을 “비즈니스적으로” 풀어내기 위한 SAGA 패턴
- 같은 작업이 여러 번 실행되어도 안전하게 만들기 위한 멱등성(Idempotency)
이 둘은 별도로 존재하는 개념이지만, 실제 서비스에서는 서로를 전제로 돌아간다.
SAGA 패턴
SAGA 패턴은 MSA 환경에서 분산 트랜잭션을 강한 ACID 트랜잭션으로 묶지 않고, 각 서비스의 로컬 트랜잭션 + 보상 트랜잭션을 통해 최종 일관성(Eventual Consistency) 을 맞추는 패턴이다.
예를 들어, 쇼핑몰 주문을 생각해보자.
- 주문 서비스 → 주문 생성
- 결제 서비스 → 결제 승인
- 재고 서비스 → 재고 차감
- 배송 서비스 → 배송 준비
여기서 중간에 실패가 나면?
- 결제 실패 → 주문 취소
- 재고 차감 실패 → 결제 취소, 주문 취소
이렇게 역순으로 “되돌리는 작업” 을 수행하는데, 이걸 보상 트랜잭션(Compensating Transaction) 이라고 부른다.
즉, SAGA는 "한 번에 롤백"이 아니라 "여러 단계에 나누어 되돌리는" 방식으로 트랜잭션을 처리한다.
멱등성(Idempotency)이란?
“같은 작업을 여러 번 실행해도, 결과가 한 번 실행했을 때와 동일한 성질”
예를 들어
- PUT /users/1/email 로 같은 이메일 주소를 여러 번 설정하는 것
- cancelOrder(orderId) 를 두 번 호출해도 주문이 “한 번만 취소된 상태”로 유지되는 것
- refund(paymentId) 를 여러 번 호출해도 실제 환불은 한 번만 이루어지는 것
이런 것들이 전형적인 멱등 동작이다.
왜 SAGA에서 멱등성이 그렇게 중요할까?
SAGA는 보통 이런 환경에서 돌아간다.
- 네트워크는 항상 불안정하고
- 호출은 재시도(retry) 될 수 있으며
- 메시지 브로커는 at-least-once 전달(한 번 이상 전달될 수 있음)을 기본으로 하고
- 오케스트레이터나 이벤트 핸들러는 같은 보상 트랜잭션을 여러 번 호출할 수 있다.
예를 들어 이런 상황이 생길 수 있다.

- 오케스트레이터가 결제 취소(환불) API를 호출한다.
- 실제로 환불은 성공했지만, 응답이 오는 중에 네트워크 타임아웃이 발생한다.
- 오케스트레이터는 성공/실패 여부를 알 수 없으므로 안전을 위해 한 번 더 환불 요청을 보낸다.
이때 refund(paymentId) 가 멱등적이지 않다면,
- 환불이 두 번 일어나거나
- 포인트/쿠폰/로그/상태가 두 번씩 변경되는 문제가 발생할 수 있다.
SAGA의 핵심은 “실패했을 때 되돌리는 보상 트랜잭션”인데, 이 보상 트랜잭션이 두 번, 세 번 실행될 수도 있다는 걸 항상 전제로 해야 한다.
이러한 이유로
SAGA를 설계할 때 정방향 트랜잭션도, 보상 트랜잭션도 멱등적이어야 한다.
정방향 트랜잭션도 멱등해야 한다
멱등해야 하는 건 보상 트랜잭션만이 아니다.
SAGA 진행 중에 네트워크 오류가 발생하면, 오케스트레이터 입장에서는 “이 단계가 실제로 실행됐는지, 응답만 못 받은 건지” 를 확신할 수 없다.
이때 안전하게 재시도하려면, 정방향 트랜잭션도 멱등적이어야 한다.
예를 들어 decreaseStock(productId, qty, sagaId) 가 멱등하게 설계되어 있다면, 이미 한 번 재고가 차감된 상태에서 같은 (productId, qty, sagaId) 요청이 다시 들어와도 재고가 추가로 더 줄어들지 않도록 동작해야 한다.
즉, sagaId 나 별도의 식별자를 기준으로 “이 요청을 이미 처리했는지” 를 확인하고, 이미 처리된 요청이라면 추가 작업 없이 그대로 무시하거나 동일한 결과만 반환하는 방식으로 구현해야 한다.
예시 시나리오로 보는 SAGA + 멱등성
간단한 주문 시나리오를 보자.
- OrderService.createOrder() – 주문 생성
- PaymentService.approvePayment() – 결제 승인
- InventoryService.decreaseStock() – 재고 차감
결제 승인 후 재고 차감에서 실패했다면
- 보상 트랜잭션 A: PaymentService.refundPayment()
- 보상 트랜잭션 B: OrderService.cancelOrder()
그러나 네트워크 문제로 인해
- refundPayment() 가 두 번 호출될 수도 있고
- cancelOrder() 가 두 번 호출될 수도 있다.
이 상황에서 같은 보상 트랜잭션이 여러 번 호출되더라도, 도메인 상태가 일관되게 유지되도록 만드는 것이 멱등성의 역할이다.
SAGA에서 멱등성을 보장하는 대표적인 패턴들
1. 상태 기반 도메인 로직 (State Machine + No-op)
도메인 엔티티를 상태(State) 로 관리하고, “유효한 상태 전이에서만 동작하게” 만드는 방식이다.
예를 들어 주문 상태가 이렇게 있다고 하자.
- CREATED
- PAID
- CANCELLED
보상 트랜잭션 cancelOrder(orderId) 를 다음과 같이 구성하면
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(...);
if (order.getStatus() == OrderStatus.CANCELLED) {
// 이미 취소된 주문이면 아무 것도 하지 않음 → 멱등
return;
}
if (order.getStatus() != OrderStatus.PAID && order.getStatus() != OrderStatus.CREATED) {
throw new IllegalStateException("취소할 수 없는 상태입니다.");
}
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
}
동일 요청이 두 번 호출 되더라도
- 첫 번째: PAID → CANCELLED 로 변경
- 두 번째: 이미 CANCELLED → 조건에 걸려 No-op
이렇듯 도메인 상태 + 조건 체크만 잘 해줘도 상당 부분 멱등성이 확보된다.
장점
- 도메인 모델만으로 멱등성 확보 -> 직관적
- 코드만 보고도 "어떤 상태에서 어떤 행동이 가능한지" 파악 가능
- 보상 트랜잭션, 정방향 트랜잭션 모두에 적용 가능
단점
- 상태 설계가 엉성하면 상황이 더 심각해질 수 있음
- "이미 처리된 요청"을 요청 단위로 구분하기는 어렵다
언제 사용하면 좋을까?
- 주문, 재고, 결제처럼 상태 전이가 명확한 도메인
- "한 번 이상 호출될 수 있다"는 것을 비즈니스 규칙으로 자연스럽게 흡수하고 싶은 경우
- 가장 기본이 되는 멱등성 패턴으로, 사실상 무조건 적용해야 하는 패턴
👉 무조건 기본값으로 사용하자!
상태가 없는 도메인은 거의 존재하지 않기에, 멱등성과 관계없이 "도메인 설계" 차원에서도 필수에 가깝다.
2. 메시지/요청 처리 이력 저장 (Processed Message Log)
이건 “이 메시지를 이미 처리했는지”를 별도의 테이블에 기록하여 중복 실행을 막는 방법으로 동일한 메시지가 다시 들어온다면 스킵하는 방식이다.
CREATE TABLE processed_message (
message_id VARCHAR(100) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL
);
@Transactional
public void refundPayment(String paymentId, String messageId) {
if (processedMessageRepository.existsById(messageId)) {
// 이미 처리된 요청 → 멱등 처리
return;
}
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(...);
if (!payment.isRefunded()) {
payment.refund();
paymentRepository.save(payment);
}
processedMessageRepository.save(new ProcessedMessage(messageId, LocalDateTime.now()));
}
메시지 브로커, HTTP 재시도, 네트워크 재전송 등으로 인해, 동일 messageId가 두 번 들어오더라도, 실제 로직은 한 번만 수행된다.
messageId는 보통
- sagaId + stepName
- 또는 외부에서 들어온 Idempotency-Key
처럼 요청을 대표하는 유니크 키를 사용한다.
장점
- "이 요청을 이미 처리했는지"를 정확히 구분 가능
- 메시지 브로커의 at-least-once 전달과 잘 어울림
- 같은 트랜잭션이 여러 번 도착해도 한 번만 실행됨
단점
- processd_message 테이블 관리 비용 (쌓이는 로그, TTL, 정리 작업 등)
- 모든 멱등 기능이 여기 의존하는 경우 공통 DB가 병목될 수 있음
언제 사용하면 좋을까?
- Kafka, RabbitMQ 등 메시지 기반 SAGA에서 "메시지가 중복으로 날아올 수 있음"이 기본 전제일 경우
- 결제 환불, 포인트 적립 등 같은 요청이 중복 실행되면 안 되는 작업
- 내부 서비스 간 gRPC/REST 호출에 별도 메시지 ID를 둘 수 있는 경우
👉 메시지 기반 SAGA 혹은 재시도가 많은 환경에서는 "상태 기반 로직 + 로그 테이블" 조합이 가장 안정적이다.
3. UNIQUE 제약 / UPSERT 이용
환불, 보상 같은 작업을 별도 테이블에 기록할 때, 비즈니스 키 + UNIQUE 제약을 걸어도 좋다.
CREATE TABLE payment_refund (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
payment_id BIGINT NOT NULL,
saga_id VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (payment_id, saga_id)
);
- 동일한 (payment_id, saga_id) 조합으로는 환불이 한 번만 존재 가능
- 추가로 요청이 와도 INSERT 시도 시 UNIQUE 위반 → 이미 처리된 케이스로 판단
혹은 DB의 INSERT ... ON CONFLICT DO NOTHING(PostgreSQL) / INSERT ... ON DUPLICATE KEY UPDATE(MySQL)를 활용해도 된다.
장점
- DB가 최후의 gatekeeper 역할을 해준다.
- 애플리케이션 코드 실수, 동시성 문제를 DB 수준에서 한 번 더 막아준다.
- 설계만 잘 해둔다면 로직은 비교적 단순해진다.
단점
- 설계가 잘못되면, 정말 필요한 중복 케이스까지 막을 수 있다. (ex. 결제 재시도, 부분 취소 등)
- UPSERT 사용 시, DB 종류/버전, SQL 방언에 의존적
언제 사용하면 좋을까?
- "비즈니스 키 조합은 절대 두 번 이상 발생하면 안 된다"는 강한 불변식이 있는 경우
- ex: 한 주문당 승인 결제 1건
- ex: 한 쿠폰 ID는 한 번만 사용
- DB 스키마를 마음대로 설계할 수 있는 상황
👉 핵심 도메인 불변식은 가능하면 UNIQUE 제약으로 한 번 더 막자!
4. API 레벨 Idempotency-Key
HTTP/gRPC API를 제공한다면, 클라이언트(또는 오케스트레이터)가 요청마다 Idempotency-Key 를 보내게 하고 서버는 해당 키 기준으로 멱등성을 보장할 수도 있다.
POST /payments/refund
Idempotency-Key: 9c1a-saga-3
{ "paymentId": "P123" }
서버는 이 Key로 처리 이력을 저장해두고, 동일한 Key로 재요청이 와도 같은 결과를 반환하거나 무시할 수 있다.
장점
- 클라이언트의 중복 요청까지 커버 가능 (ex. 사용자가 버튼을 두 번 누르는 경우, 네트워크 재전송 등)
- 경계(외부 -> 내부)에서 멱등성을 한 번 걸러줄 수 있음
- 내부 SAGA 로직 단순화에 도움
단점
- 클라이언트가 Idempotency-Key를 올바르게 생성 및 전달해줘야 한다.
- 서버 측에서도 처리 이력 저장 로직이 필요하다.
- 내부 서비스 간 통신에는 변형해서 사용해야 한다.
언제 사용하면 좋을까?
- 외부 API가 호출하는 경계 지점
- 특히 "사용자가 재요청할 수 있는 위험성이 큰" 결제, 주문 생성, 환불, 포인트 적립 등
- 클라이언트를 직접 어느정도 처리 가능한 경우
👉 외부로 노출되는 중요한 POST API에는 가능하면 넣어두는 것이 좋다.
정리
| 패턴 | 관점 | 장점 | 단점 | 사용하는 경우 |
| 상태 기반 도메인 로직 | 도메인 모델 | 직관적 | 상태 설계 중요성 | 항상 |
| Processed Message Log | 메시지/요청 단위 | 중복 메시지 방어에 최적 | 로그 테이블 관리 비용 | 이벤트 기반, 재시도 많은 경우 |
| UNIQUE | DB 제약 | DB가 최후 방어선 | 설계 중요성 | 불변식이 있는 핵심 도메인 |
| Idempotency-Key | API 경계 | 클라이언트 중복 요청 커버 | 클라이언트의 부담 | 외부 공개 API |
SAGA + 멱등성 설계 시 체크리스트
1. 이 단계(정방향/보상 트랜잭션)는 두 번 이상 호출될 수 있음을 가정했는가?
2. 도메인 상태(State)만으로 "이미 처리된 상태"를 구분할 수 있는가?
- 아니면 별도의 처리 이력 테이블이 필요한가?
3. 멱등성을 보장하는 키는 무엇인가?
- sagaId, stepName, messageId, idempotencyKey 등
4. 중복 호출 시 동작은?
- 동일한 응답 반환?
- 아무 작업 없이 성공으로 처리?
- 로그 남기기?
5. 재시도 정책과 함께 설계했는가?
- 멱등성이 없다면 재시도는 오히려 데이터 정합성을 깨트릴 수 있다.
왜 이 조합을 반드시 이해해야 할까?
SAGA는 분산 트랜잭션을 “현실적으로” 다루기 위한 패턴이고, 멱등성은 그런 현실적인 세계에서 서비스가 망가지지 않게 해주는 안전장치다.
- 네트워크는 항상 신뢰할 수 없고
- 메시지는 중복으로 전달될 수 있으며
- 오케스트레이터는 재시도를 할 수 밖에 없다.
그러므로,
- SAGA는 분산 트랜잭션을 비즈니스 수준에서 조합하는 패턴
- 멱등성은 그 단계들이 여러 번 실행되더라도 결과를 한 번 실행한 것처럼 유지해 주는 필수 속성이다.
'아키텍처 & 설계(Architecture & Design) > MSA' 카테고리의 다른 글
| Choreography 기반 SAGA 패턴 이해하기 — 분산 환경에서의 트랜잭션 처리 방식 (0) | 2025.11.17 |
|---|---|
| SAGA 패턴과 에서의 격리성(Isolation) 문제 (0) | 2025.11.10 |
| 분산 트랜잭션의 한계와 SAGA 패턴 (0) | 2025.11.07 |
| [API Gateway] BFF 패턴 (0) | 2025.05.22 |
| [API Gateway] API Gateway에서 분산 로깅 및 추적 (0) | 2025.04.28 |