MSA 환경을 경험해보면서 여러개로 분산되어진 DB들이 어떻게 트랜잭션을 관리하고 데이터 일관성을 유지 할 수 있을까? 라는 생각이 들어 내용을 찾아보고 정리해보고자 합니다.
현재 재직중인 회사에서의 시스템은 수십여개의 서버들이 연결된 MSA 형태로 구성되어 있습니다.
시스템 별로 각기 DB를 분리하여 독립적으로 관리하고 트랜잭션의 가장 중요한 성질 중 하나는 '원자성' 입니다. 단일 DB를 구성할 때와 다르게 DB를 분산하여 운영하게 될 경우 원자성을 만족시키기 어려울 수 있습니다.
A와 B의 데이터베이스가 분산되어있는경우
다음과 같은 이유로 분산트랜잭션에 대한 관리가 필요합니다.
말로는 이해가 잘 안될 수 있습니다.
코드를 보면서 이야기를 해보겠습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final InventoryService inventoryService;
private final PaymentService paymentService;
public void orderProcess(Order order) {
inventoryService.decreaseStock(order.getItemId(), order.getQuantity());
paymentService.charge(order.getUserId(), order.getTotalPrice());
}
}
주문 과정의 일부를 코드로 나타내 보았습니다. 재고 서비스와 결제 서비스. 각 서비스는 각각의 데이터베이스를 가지고 있습니다. 주문이 들어올 때,결제는 성공했지만 재고가 없다면? 이 경우, 두 서비스의 트랜잭션은 롤백되어야 합니다.
위 코드에서 decreaseStock
과 charge
메서드가 각기 다른 데이터베이스에 연결된다면, 한 메서드는 성공하고 다른 메서드가 실패할 위험이 있습니다.
이러한 상황에서의 일관성을 보장하기 위해 우리는 분산트랜잭션을 관리할 프로세스의 필요성이 있습니다.
Spring Boot 대표적으로 2-Phase-Commit(2PC) 또는 SAGA 패턴을 사용하여 분산 트랜잭션을 관리합니다.
Spring Boot에서 2PC를 구현하는 한 가지 방법은 XA(eXtended Architecture) 프로토콜을 사용하는 것입니다. Spring Boot는 여러 서비스에서 트랜잭션을 관리하는 데 사용할 수 있는 Atomikos
및 Bitronix
트랜잭션 관리자를 통해 XA 트랜잭션을 지원합니다.
XA는 분산 컴퓨팅 환경에서 여러 리소스 (예: 데이터베이스, 메시징 시스템) 간의 트랜잭션을 조율하기 위한 표준 인터페이스입니다. 이 인터페이스는 2-Phase-Commit (2PC, Two-Phase Commit) 프로토콜을 사용하여 트랜잭션을 완료합니다.
XA 프로토콜은 2PC 알고리즘을 통해 작동됩니다.
2PC은 두 단계로 구분되어 작동됩니다.
Prepare Phase (준비 단계): 트랜잭션 매니저 (TM)는 모든 리소스 매니저 (RM)에게 트랜잭션 커밋 준비를 알립니다. RM들은 이 요청을 받고 필요한 모든 작업을 준비하며 준비가 완료되면 응답합니다.
Commit/Rollback Phase (커밋/롤백 단계): 모든 RM이 준비되면 TM은 트랜잭션을 커밋합니다. 만약 어떤 RM이 준비되지 않았다면, TM은 트랜잭션을 롤백합니다.
유저가 계좌 A에서 계좌 B로 자금을 이체하는 경우를 예시로 살펴보겠습니다.
public interface BankService {
void prepareTransfer(int amount);
void commit();
void rollback();
}
@Component
public class AccountAService implements BankService {
// 계좌 A에서 금액을 차감하기 전의 준비 작업(계좌호출 차감 금액 확인등..)
@Override
public void prepareTransfer(int amount) {
if(balance >= amount) {
balance -= amount;
return true;
} else {
return false; // 잔액 부족
}
}
}
@Component
public class AccountBService implements BankService {
// 계좌 B로 입금하기 전의 준비 작업(입금 계좌 확인 및 금액 확인 등..)
@Override
public void prepareTransfer(int amount) {
balance += amount;
return true;
}
}
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountAService accountA;
private final AccountBService accountB;
public void transfer(int amount) {
boolean isPrepared = accountA.prepareTransfer(amount) && accountB.prepareTransfer(amount);
if (isPrepared) {
accountA.commit();
accountB.commit();
} else {
accountA.rollback();
accountB.rollback();
}
}
}
단계별 설명 :
TransferService
)는 계좌 A (AccountAService
)와 계좌 B (AccountBService
)에게 prepare 상태인지 확인을 요청합니다 (= prepareTransfer
메서드 호출)commit
메서드 호출)commit
호출에 따라 계좌 A에서는 출금이 이루어지고, 계좌 B에서는 입금이 이루어집니다. rollback
메서드 호출)rollback
호출에 따라 이전 상태로 복구됩니다.
- 원자성 보장: 2PC의 핵심은 여러 데이터베이스나 서비스에 걸쳐 있는 트랜잭션도 하나의 트랜잭션처럼 다룰 수 있게 해준다는 것입니다.
즉, 모든 작업이 성공적으로 수행되거나 아무 작업도 수행되지 않은 것처럼 보장됩니다.- 코디네이터의 중요성: 코디네이터는 분산 트랜잭션을 관리하고 조율하는 중요한 역할을 합니다.
모든 작업의 상태를 모니터링하고, 최종 커밋 또는 롤백 결정을 내립니다.
2PC를 사용하였을 경우의 문제점
SAGA 패턴은 MSA환경에서 일관성을 지키기 여렵다는 것을 기반으로, 약간의 일관성을 포기하고 Eventual Consistency(최종 일관성)을 보장하여 효율성을 높이기 위한 패턴입니다.
2PC에서는 트랜잭션을 하나의 트랜잭션으로 묶어서 처리를 하지만, SAGA 패턴은 긴 트랜잭션을 여러 개의 짧은 로컬 트랜잭션으로 분리하는 접근 방식입니다. 각 트랜잭션은 다른 트랜잭션의 완료를 기다리지 않고 독립적으로 실행됩니다. 따라서 트랜잭션의 원자성을 지켜줄 방법이 필요합니다. 만약 중간에 문제가 발생하면, 보상(Compenstation) 트랜잭션이 실행되어 이전 트랜잭션을 롤백하는 것과 같은 효과를 가져옵니다.
각 로컬 트랜잭션은 자신의 트랜잭션을 끝내고 다음 트랜잭션을 호출하는 메시지, 이벤트를 생성하게 됩니다.
그럼 보상 트랜잭션이 뭔데?
보상 트랜잭션은 분산된 트랜잭션 중 일부가 실패할 경우, 그 실패 전에 성공적으로 완료된 트랜잭션을 보상 즉, 되돌리는 역할을 하는 트랜잭션입니다.
SAGA 패턴의 트랜잭션은 분산된 여러 독립적인 트랜잭션이기 떄문에, 어떤 서비스의 트랜잭션이 실패하면 단일 트랜잭션 처럼 롤백 메커니즘을 사용할 수 없습니다. 대신 보상 트랜잭션을 사용하여 이전에 성공한 트랜잭션의 효과를 취소합니다.
보상트랜잭션이 실패할 경우에는?
보상트랜잭션도 하나의 트랜잭션이기 때문에, 다양한 요인들로 인해 실패할 수 있습니다.
이에 대한 대비도 필요합니다!
사가 패턴은 이벤트기반으로 작동합니다.
보상 트랜잭션을 카프카 같은 데이터 스트리밍 서비스 같은곳에서 처리하게 하고 멱등키와 함께 재시도 프로세스를 추가합니다.
이후 N번이상 실패 할경우에는 어쩔수 없지만... 개발자가 수동으로 오류를 해결할 수 있게 알람을 주어야 합니다.
멱등키 활용 로직 예시
public class CompensationTransaction {
private IdempotencyKey key; // 멱등키를 활용
private Event event;
public CompensationTransaction(IdempotencyKey key, Event event) {
this.key = key;
this.event = event;
}
public void execute() {
if(!isProcessed(key)) {
// 보상 로직 수행
processCompensation(event);
markAsProcessed(key);
}
}
}
멱등키의 사용 이유는 링크를 참조해주세요.
(토스페이먼츠에서 너무 정리를 잘해주셔서 꼭 보셨으면 좋겠습니다)
https://velog.io/@tosspayments/%EB%A9%B1%EB%93%B1%EC%84%B1%EC%9D%B4-%EB%AD%94%EA%B0%80%EC%9A%94
SAGA 패턴을 구현하는 방법은 두가지가 있습니다.
Choreography 방식은 각 서비스끼리 이벤트를 주고 받는 방식입니다.
각 서비스가 다른 서비스의 로컬 트랜잭션을 이벤트 트리거하는 방식으로 이루어 집니다.
이 방식은 중앙집중된 지점이 없이 모든 서비스가 메시지 브로커(RabbitMQ, Kafka)를 통해 이벤트를 Pub/Sub 하는 구조입니다.
public class MessageBroker {
public static void publish(String event, double amount) {
// 이벤트 발행 로직
}
public static void subscribe(String event, Service service) {
// 이벤트 구독 로직
}
}
public class AAccountService {
public void deductAmount(double amount) {
if (canDeduct(amount)) {
MessageBroker.publish("AmountDeducted", amount);
} else {
MessageBroker.publish("TransferFailed", amount);
}
}
@Subscribe("CreditFailed")
public void revertDeduction(double amount) {
// 보상 로직
}
private boolean canDeduct(double amount) {
return true;
}
}
public class BAccountService {
@Subscribe("AmountDeducted")
public void creditAmount(double amount) {
if (canCredit(amount)) {
MessageBroker.publish("AmountCredited", amount);
} else {
MessageBroker.publish("CreditFailed", amount);
}
}
private boolean canCredit(double amount) {
return true;
}
}
오케스트레이션 사가는 중앙 집중형으로 실행 흐름을 관리하게 됩니다.
Ochestrator는 요청을 실행, 각 서비스의 상태를 확인하고, 실패에 대한 보상 트랜잭션을 실행합니다.
public class MessageBroker {
public static void publish(String event, double amount) {
// Publish event
}
public static void subscribe(String event, Service service) {
// Subscribe
}
}
public class Orchestrator {
AAccountService aAccountService;
BAccountService bAccountService;
public Orchestrator(AAccountService aService, BAccountService bService) {
this.aAccountService = aService;
this.bAccountService = bService;
}
public void transferAmount(double amount) {
if (aAccountService.deductAmount(amount)) {
if (!bAccountService.creditAmount(amount)) {
aAccountService.revertDeduction(amount);
}
}
}
}
public class AAccountService {
public boolean deductAmount(double amount) {
if (canDeduct(amount)) {
return true;
} else {
return false;
}
}
public void revertDeduction(double amount) {
// 보상 로직 구현
}
private boolean canDeduct(double amount) {
return true;
}
}
public class BAccountService {
public boolean creditAmount(double amount) {
if (canCredit(amount)) {
return true;
} else {
return false;
}
}
private boolean canCredit(double amount) {
return true;
}
}
이후에는 직접 코드를 짜보면서 Ochestration을 공부해보도록 하겠습니다.
https://d2.naver.com/helloworld/5812258
https://medium.com/@knowledge.cafe/distributed-transaction-in-spring-boot-microservices-7962e048adfc
https://www.baeldung.com/cs/saga-pattern-microservices
https://junhyunny.github.io/msa/design-pattern/distributed-transaction/
https://www.msaschool.io/operation/integration/integration-four/
https://waspro.tistory.com/735
POJO 예제를 통한 너무 명확한 설명이네요! 감사합니다!