MSA에서 트랜잭션 관리 SAGA 패턴
계좌이체로 이해하는 분산 트랜잭션
여는 글
이전 포스팅들에서 @Transactional의 동작 원리와 커넥션 병목 문제를 다뤘습니다. 하지만 이 모든 내용은 단일 서버, 단일 DB 환경을 전제로 한 것이었습니다.
만약 서비스가 MSA(Microservice Architecture)로 분리되어 있다면? 서버마다 DB가 다르다면?
@Transactional 하나로는 해결할 수 없습니다.
시나리오: 계좌 이체
A가 B에게 10만원을 이체하는 상황을 가정해봅시다.
모놀리식 환경 (단일 서버)
@Transactional
public void transfer(Long fromId, Long toId, int amount) {
Account from = accountRepository.findById(fromId);
Account to = accountRepository.findById(toId);
from.withdraw(amount); // A 잔액 -10만원
to.deposit(amount); // B 잔액 +10만원
accountRepository.save(from);
accountRepository.save(to);
}
하나의 트랜잭션으로 묶이므로, 중간에 실패하면 전체 롤백됩니다. 간단하죠.
MSA 환경 (서버 분리)
하지만 서버가 분리된 MSA 환경이라면 어떨까요.
┌─────────────────┐ ┌─────────────────┐
│ 잔액 서버 │ │ 입출금내역 서버 │
│ (Balance API) │ │ (Transfer API) │
│ │ │ │
│ [Balance DB] │ │ [Transfer DB] │
└─────────────────┘ └─────────────────┘
이제 이체 로직은 이렇게 됩니다.
- 잔액 서버: A 계좌에서 10만원 출금
- 입출금내역 서버: 이체 내역 저장
- 잔액 서버: B 계좌에 10만원 입금
문제: 2번에서 성공하고 3번에서 실패하면?
- A의 돈은 빠졌는데
- B에게는 안 들어갔고
- 이체 내역은 저장됨
돈이 증발합니다. 이게 분산 트랜잭션의 핵심 문제입니다.
전통적 해결책: 2PC (Two-Phase Commit)
분산 환경에서 트랜잭션을 보장하는 전통적인 방법입니다.
동작 방식
[코디네이터]
│
├── Phase 1: Prepare (준비)
│ ├── 잔액 서버: "커밋 준비됐나요?" → "네"
│ └── 입출금내역 서버: "커밋 준비됐나요?" → "네"
│
└── Phase 2: Commit (확정)
├── 잔액 서버: "커밋하세요" → 커밋
└── 입출금내역 서버: "커밋하세요" → 커밋
왜 MSA에서 안 쓸까?
| 문제점 | 설명 |
|---|---|
| 동기 블로킹 | 모든 참여자가 응답할 때까지 대기 |
| 단일 장애점 | 코디네이터가 죽으면 전체 마비 |
| 성능 저하 | 락을 오래 잡고 있음 |
| 확장성 한계 | 참여자가 많아질수록 복잡도 증가 |
결론: MSA의 “자율성”과 맞지 않습니다.
MSA의 해결책: SAGA 패턴
SAGA란?
SAGA는 1987년 Hector Garcia-Molina와 Kenneth Salem이 발표한 논문 “SAGAS”에서 처음 제안된 개념입니다.
어원: SAGA는 약어가 아닙니다. 북유럽에서 “긴 서사시(長篇)”를 뜻하는 단어입니다. 긴 트랜잭션을 여러 단계로 나눈 “긴 이야기”라는 의미에서 차용했습니다.
핵심 개념
기존 트랜잭션의 ACID 중 Atomicity(원자성)를 포기하고, 대신 Eventual Consistency(최종 일관성)를 선택합니다.
| 기존 트랜잭션 | SAGA |
|---|---|
| 전체가 성공하거나 전체가 실패 | 단계별로 커밋, 실패 시 보상 |
| 롤백 = DB가 자동으로 처리 | 롤백 = 개발자가 보상 로직 작성 |
| 강한 일관성 (Strong Consistency) | 최종 일관성 (Eventual Consistency) |
한 줄 정의:
SAGA는 긴 트랜잭션을 여러 개의 로컬 트랜잭션(Ti)으로 쪼개고, 실패 시 보상 트랜잭션(Ci)을 역순으로 실행하여 롤백하는 패턴입니다.
T1 → T2 → T3 → ... → Tn (정상 흐름)
T1 → T2 → T3(실패!) → C2 → C1 (보상 흐름)
계좌 이체 SAGA
[정상 흐름] T1: A 잔액 -10만원 (잔액 서버) T2: 이체 내역 저장 (입출금내역 서버) T3: B 잔액 +10만원 (잔액 서버)
[T3 실패 시 보상 트랜잭션] C2: 이체 내역 취소 (입출금내역 서버) C1: A 잔액 +10만원 복구 (잔액 서버)
핵심: 실패하면 “되돌리기”를 수행합니다.
SAGA 구현 방식 1: Choreography (안무)
각 서비스가 이벤트를 발행/구독하며 자율적으로 동작합니다.
┌──────────┐ 이벤트 ┌──────────┐ 이벤트 ┌──────────┐
│ 잔액 │ ─────────────▶│ 입출금 │ ─────────────▶│ 잔액 │
│ 서버 │ 출금완료 │ 내역서버 │ 내역저장완료 │ 서버 │
│ (출금) │ │ │ │ (입금) │
└──────────┘ └──────────┘ └──────────┘
코드 예시
// 잔액 서버 - 출금
@Service
public class BalanceService {
@Transactional
public void withdraw(Long accountId, int amount, String transferId) {
Account account = accountRepository.findById(accountId);
account.withdraw(amount);
accountRepository.save(account);
// 이벤트 발행
eventPublisher.publish(new WithdrawCompletedEvent(transferId, accountId, amount));
}
}
// 입출금내역 서버 - 이벤트 구독
@Service
public class TransferService {
@EventListener
public void onWithdrawCompleted(WithdrawCompletedEvent event) {
// 이체 내역 저장
Transfer transfer = new Transfer(event.getTransferId(), event.getAmount());
transferRepository.save(transfer);
// 다음 이벤트 발행
eventPublisher.publish(new TransferRecordedEvent(event.getTransferId()));
}
}
// 잔액 서버 - 입금 (이벤트 구독)
@Service
public class BalanceService {
@EventListener
public void onTransferRecorded(TransferRecordedEvent event) {
Transfer transfer = getTransferInfo(event.getTransferId());
Account toAccount = accountRepository.findById(transfer.getToAccountId());
toAccount.deposit(transfer.getAmount());
accountRepository.save(toAccount);
// 완료 이벤트
eventPublisher.publish(new TransferCompletedEvent(event.getTransferId()));
}
}
실패 시 보상 트랜잭션
// 입금 실패 시 보상
@EventListener
public void onDepositFailed(DepositFailedEvent event) {
// 1. 이체 내역 취소
transferService.cancelTransfer(event.getTransferId());
// 2. 출금 복구
balanceService.compensateWithdraw(event.getFromAccountId(), event.getAmount());
}
Choreography의 장단점
| 장점 | 단점 |
|---|---|
| 서비스 간 느슨한 결합 | 전체 흐름 파악이 어려움 |
| 단일 장애점 없음 | 디버깅이 복잡 |
| 확장성 좋음 | 순환 의존성 위험 |
SAGA 구현 방식 2: Orchestration (오케스트라)
중앙 오케스트레이터가 전체 흐름을 제어합니다.
┌──────────────┐
│ Orchestrator │
│ (이체 조율자) │
└──────────────┘
│ │ │
┌────────────┘ │ └────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 잔액 서버 │ │ 입출금내역 │ │ 잔액 서버 │
│ (출금) │ │ 서버 │ │ (입금) │
└──────────────┘ └──────────────┘ └──────────────┘
코드 예시
@Service
public class TransferOrchestrator {
public void executeTransfer(TransferRequest request) {
String transferId = UUID.randomUUID().toString();
try {
// Step 1: 출금
balanceClient.withdraw(request.getFromAccountId(), request.getAmount(), transferId);
// Step 2: 이체 내역 저장
transferClient.recordTransfer(transferId, request);
// Step 3: 입금
balanceClient.deposit(request.getToAccountId(), request.getAmount(), transferId);
// 완료
transferClient.completeTransfer(transferId);
} catch (WithdrawFailedException e) {
// 출금 실패 - 보상 불필요
throw new TransferFailedException("잔액 부족");
} catch (RecordFailedException e) {
// 내역 저장 실패 - 출금 복구
balanceClient.compensateWithdraw(request.getFromAccountId(), request.getAmount());
throw new TransferFailedException("이체 내역 저장 실패");
} catch (DepositFailedException e) {
// 입금 실패 - 내역 취소 + 출금 복구
transferClient.cancelTransfer(transferId);
balanceClient.compensateWithdraw(request.getFromAccountId(), request.getAmount());
throw new TransferFailedException("입금 실패");
}
}
}
Orchestration의 장단점
| 장점 | 단점 |
|---|---|
| 전체 흐름 명확 | 오케스트레이터가 단일 장애점 |
| 디버깅 용이 | 서비스 간 결합도 증가 |
| 복잡한 로직 처리 가능 | 오케스트레이터 로직 비대화 |
실무 고려사항: 멱등성(Idempotency)
네트워크 문제로 같은 요청이 중복 전송될 수 있습니다.
출금 요청 → 타임아웃 → 재시도 → 이미 출금됨 → 중복 출금?!
해결: 멱등키(Idempotency Key)
@Transactional
public void withdraw(Long accountId, int amount, String idempotencyKey) {
// 이미 처리된 요청인지 확인
if (transactionLogRepository.existsByIdempotencyKey(idempotencyKey)) {
return; // 중복 요청 무시
}
Account account = accountRepository.findById(accountId);
account.withdraw(amount);
accountRepository.save(account);
// 처리 완료 기록
transactionLogRepository.save(new TransactionLog(idempotencyKey));
}
실무 고려사항: 이벤트 발행 보장
@Transactional
public void withdraw(Long accountId, int amount) {
account.withdraw(amount);
accountRepository.save(account);
eventPublisher.publish(new WithdrawCompletedEvent(...)); // 여기서 실패하면?
}
DB는 커밋됐는데 이벤트 발행이 실패하면? 데이터 불일치 발생.
해결: Transactional Outbox 패턴
@Transactional
public void withdraw(Long accountId, int amount) {
account.withdraw(amount);
accountRepository.save(account);
// 이벤트를 DB에 저장 (같은 트랜잭션)
outboxRepository.save(new OutboxEvent("WithdrawCompleted", payload));
}
// 별도 스케줄러가 Outbox 테이블을 폴링하여 이벤트 발행
@Scheduled(fixedDelay = 1000)
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxRepository.findUnpublished();
for (OutboxEvent event : events) {
kafkaTemplate.send(event.getType(), event.getPayload());
event.markAsPublished();
outboxRepository.save(event);
}
}
Kafka 없이 HTTP API만으로 SAGA 구현하기
“저희 회사는 Kafka가 없는데요…”
Kafka는 좋은 미들웨어이지만, 비쌉니다. 하드웨어 리소스를 많이 차지하기도 하구요. 진입 난이도와 학습 난이도가 높아. 무작정 사용하기에 꺼려질수도 있습니다. 메시지 브로커 없이 HTTP API만으로도 SAGA를 구현할 수 있습니다.
HTTP 기반 Orchestration
사실 앞서 본 Orchestration 예시가 이미 HTTP 기반입니다.
@Service
public class TransferOrchestrator {
private final RestTemplate restTemplate; // 또는 WebClient,Feign
public void executeTransfer(TransferRequest request) {
String transferId = UUID.randomUUID().toString();
try {
// Step 1: 출금 API 호출
restTemplate.postForEntity(
"http://balance-service/api/withdraw",
new WithdrawRequest(request.getFromAccountId(), request.getAmount(), transferId),
Void.class
);
// Step 2: 이체 내역 API 호출
restTemplate.postForEntity(
"http://transfer-service/api/transfers",
new RecordTransferRequest(transferId, request),
Void.class
);
// Step 3: 입금 API 호출
restTemplate.postForEntity(
"http://balance-service/api/deposit",
new DepositRequest(request.getToAccountId(), request.getAmount(), transferId),
Void.class
);
} catch (HttpClientErrorException e) {
// 실패 시 보상 API 호출
compensate(transferId, request, e);
}
}
}
HTTP 기반의 추가 고려사항
이벤트 브로커가 없으면 직접 처리해야 할 것들이 있습니다:
1. 타임아웃 & 재시도
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 연결 타임아웃 3초
factory.setReadTimeout(5000); // 읽기 타임아웃 5초
return new RestTemplate(factory);
}
}
// 재시도 로직
@Retryable(
value = {RestClientException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public void callWithRetry(String url, Object request) {
restTemplate.postForEntity(url, request, Void.class);
}
2. Circuit Breaker (서킷 브레이커)
상대 서버가 죽었을 때 무한 재시도를 방지합니다.
@Service
public class BalanceClient {
private final CircuitBreaker circuitBreaker;
public void withdraw(WithdrawRequest request) {
circuitBreaker.run(
() -> restTemplate.postForEntity(WITHDRAW_URL, request, Void.class),
throwable -> {
// Fallback: 서킷이 열려있으면 바로 실패 처리
throw new ServiceUnavailableException("잔액 서버 일시 장애");
}
);
}
}
3. 상태 테이블로 진행 상황 추적
이벤트 브로커가 없으면 SAGA 상태를 DB에 저장해서 추적합니다.
@Entity
public class SagaState {
@Id
private String sagaId;
@Enumerated(EnumType.STRING)
private SagaStatus status; // STARTED, WITHDRAW_DONE, RECORDED, COMPLETED, COMPENSATING, FAILED
private String currentStep;
private String failureReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Service
public class TransferOrchestrator {
public void executeTransfer(TransferRequest request) {
String sagaId = UUID.randomUUID().toString();
// SAGA 시작 기록
sagaRepository.save(new SagaState(sagaId, STARTED));
try {
// Step 1
balanceClient.withdraw(request.getFromAccountId(), request.getAmount(), sagaId);
updateSagaState(sagaId, WITHDRAW_DONE);
// Step 2
transferClient.recordTransfer(sagaId, request);
updateSagaState(sagaId, RECORDED);
// Step 3
balanceClient.deposit(request.getToAccountId(), request.getAmount(), sagaId);
updateSagaState(sagaId, COMPLETED);
} catch (Exception e) {
updateSagaState(sagaId, COMPENSATING);
compensate(sagaId, request);
updateSagaState(sagaId, FAILED, e.getMessage());
}
}
}
4. 스케줄러로 미완료 SAGA 복구
서버가 중간에 죽으면? 재시작 후 미완료 SAGA를 찾아서 재처리합니다.
@Scheduled(fixedDelay = 60000) // 1분마다
public void recoverIncompleteSagas() {
// 10분 이상 STARTED나 COMPENSATING 상태인 SAGA 조회
List<SagaState> stuckSagas = sagaRepository.findStuckSagas(
List.of(STARTED, WITHDRAW_DONE, RECORDED, COMPENSATING),
LocalDateTime.now().minusMinutes(10)
);
for (SagaState saga : stuckSagas) {
if (saga.getStatus() == COMPENSATING) {
// 보상 트랜잭션 재시도
retryCompensation(saga);
} else {
// 진행 중이던 단계부터 재시도 또는 보상
recoverOrCompensate(saga);
}
}
}
HTTP vs 이벤트 비교
| 항목 | HTTP API | 이벤트 (Kafka 등) |
|---|---|---|
| 결합도 | 높음 (URL 직접 호출) | 낮음 (토픽만 알면 됨) |
| 장애 전파 | 동기라 즉시 전파 | 비동기라 버퍼링 가능 |
| 구현 복잡도 | 낮음 | 높음 (브로커 운영 필요) |
| 재시도 | 직접 구현 | 브로커가 보장 |
| 순서 보장 | 호출 순서대로 | 파티션 내 보장 |
| 적합한 상황 | 작은 규모, 빠른 응답 필요 | 대규모, 느슨한 결합 필요 |
결론: HTTP로도 충분하다
Kafka 같은 메시지 브로커가 없어도 SAGA는 구현 가능합니다. 다만,
- 재시도, 타임아웃, 서킷 브레이커를 직접 구현해야 하고
- 상태 테이블로 진행 상황을 추적해야 하며
- 복구 스케줄러로 미완료 건을 처리해야 합니다
규모가 커지면 메시지 브로커 도입을 고려하되, 처음부터 Kafka가 필수는 아닙니다.
정리: 언제 뭘 쓸까?
| 상황 | 추천 방식 |
|---|---|
| 단일 서버, 단일 DB | @Transactional |
| MSA, 간단한 흐름 | SAGA - Choreography |
| MSA, 복잡한 흐름 | SAGA - Orchestration |
| 강한 일관성 필수 | 2PC (제한적 사용) |
마치며
분산 트랜잭션은 단순히 “SAGA 패턴 쓰면 됩니다”로 끝나는 문제가 아닙니다.
- 멱등성은 어떻게 보장할 것인가?
- 이벤트 유실은 어떻게 방지할 것인가?
- 보상 트랜잭션이 실패하면 어떻게 할 것인가?
이런 세부 사항까지 고민해야 실무에서 제대로 쓸 수 있습니다.
트랜잭션 시리즈는 여기서 마무리합니다. 다음에는 또 다른 면접 단골 주제로 찾아오겠습니다!