트랜잭션 관리의 복잡성
분산 트랜잭션의 필요성
1.마이크로서비스 환경에서 여러 서비스의 데이터 일관성 유지 필요
예: createOrder() 작업에서 여러 서비스 데이터 접근 필요
분산 트랜잭션의 문제점
사가 패턴 소개
주문 생성 사가 예제
사가의 롤백 메커니즘
주문 생성 사가의 트랜잭션 유형
간단한 보상트랜잭션 예시
public class CreateOrderSaga {
@Autowired
private OrderService orderService;
@Autowired
private KitchenService kitchenService;
@Autowired
private AccountingService accountingService;
public void createOrder(Order order) {
try {
// 1. 주문 생성
String orderId = orderService.createOrder(order);
// 2. 주방 티켓 생성
String ticketId = kitchenService.createTicket(orderId, order.getItems());
// 3. 신용카드 승인
boolean paymentAuthorized = accountingService.authorizePayment(order.getPaymentDetails());
if (!paymentAuthorized) {
// 신용카드 승인 실패 시 보상 트랜잭션 실행
compensateCreateOrder(orderId, ticketId);
throw new PaymentDeclinedException("Payment was declined");
}
// 4. 주문 승인
orderService.approveOrder(orderId);
kitchenService.approveTicket(ticketId);
} catch (Exception e) {
// 예외 발생 시 보상 트랜잭션 실행
compensateCreateOrder(orderId, ticketId);
throw e;
}
}
private void compensateCreateOrder(String orderId, String ticketId) {
// 보상 트랜잭션
if (ticketId != null) {
kitchenService.cancelTicket(ticketId);
}
if (orderId != null) {
orderService.cancelOrder(orderId);
}
}
}
-----------------------------------------------------------------
public class KitchenService {
public void cancelTicket(String ticketId) {
// 티켓 상태를 CANCELLED로 변경
Ticket ticket = ticketRepository.findById(ticketId);
ticket.setStatus(TicketStatus.CANCELLED);
ticketRepository.save(ticket);
// 필요한 경우 관련 리소스 해제 또는 알림 전송
notifyKitchenTicketCancelled(ticketId);
}
}
public class OrderService {
public void cancelOrder(String orderId) {
// 주문 상태를 CANCELLED로 변경
Order order = orderRepository.findById(orderId);
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
// 고객에게 주문 취소 알림
notifyCustomerOrderCancelled(orderId);
}
}
구체적인 롤백 예시
사가 편성 방식
코레오그래피 사가 구현 예시 (주문 생성 사가)
코레오그래피 사가의 통신 이슈
코레오그래피 사가의 장단점
코레오그래피 vs 오케스트레이션
오케스트레이션 사가 개념
주문 생성 사가 예시 (오케스트레이션 방식)
사가 오케스트레이터의 상태 기계 모델링
트랜잭셔널 메시징의 필요성
오케스트레이션 사가의 장단점
장점:
의존 관계 단순화: 순환 의존성 제거 (참여자는 오케스트레이터에 의존하지 않음)
낮은 결합도: 서비스는 오케스트레이터 API만 구현, 다른 참여자의 이벤트 몰라도 됨
관심사 분리와 비즈니스 로직 단순화:
사가 편성 로직이 오케스트레이터에 집중
도메인 객체 (예: Order 클래스)가 사가에 대해 알 필요 없음
도메인 객체의 상태 기계 모델 단순화 (중간 상태 없이 APPROVAL_PENDING → APPROVED)
비격리 문제
비격리 문제의 개요
소실된 업데이트 예시
더티 읽기 예시
ex) 소비자 서비스: 신용 잔고(available credit)를 늘립니다.
주문 서비스: 주문을 취소 상태로 변경합니다.
배달 서비스: 배달을 취소합니다
주문 취소 사가와 주문 생성 사가의 실행이 서로 겹쳐(interleaved) 실행 중인데, 소비자가 배달을 취소하기는 너무 늦어서 주문 취소 사가가 롤백되는 경우를 생각 소비자 서비스를 호출하는 트랜잭션 순서가 이렇게 엉켜 버릴 수 있겠죠.
주문 취소 사가: 신용 잔고를 늘립니다.
주문 생성 사가: 신용 잔고를 줄입니다.
주문 취소 사가: 신용 잔고를 줄이는 보상 트랜잭션이 가동됩니다.
결국 신용한도 초과 주문도 할 수 있게 되버림.
비격리 대책
레코드에 플래그를 세팅하여 처리 중임을 표시
예: Order.state 필드를 APPROVAL_PENDING 상태로 설정
장점: ACID 트랜잭션의 격리 기능 유사하게 구현 가능
단점: 락 관리 및 데드락 처리 로직 필요
각 단계에서 다음과 같은 상태를 사용합니다.
ORDER_CREATED
STOCK_CHECKING
PAYMENT_PROCESSING
SHIPPING_PREPARING
주문 A와 주문 B가 동시에 처리되고 있다고 가정해봅시다.
주문 A:
주문 생성 (ORDER_CREATED)
재고 확인 중 (STOCK_CHECKING)
결제 처리를 위해 대기 중
주문 B:
주문 생성 (ORDER_CREATED)
결제 처리 중 (PAYMENT_PROCESSING)
재고 확인을 위해 대기 중
이 상황에서 주문 A는 결제 시스템의 락을 기다리고 있고, 주문 B는 재고 시스템의 락을 기다리고 있습니다. 둘 다 서로가 가진 리소스를 기다리면서 영원히 진행되지 않는 데드락 상황이 발생할 수 있습니다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderProcessingSystem {
private static final Lock stockLock = new ReentrantLock();
private static final Lock paymentLock = new ReentrantLock();
public void processOrder(String orderId) {
try {
setOrderState(orderId, "ORDER_CREATED");
if (!acquireLock(stockLock, 5)) {
throw new DeadlockException("재고 확인 락 획득 실패");
}
try {
checkStock(orderId);
setOrderState(orderId, "STOCK_CHECKING");
if (!acquireLock(paymentLock, 5)) {
throw new DeadlockException("결제 처리 락 획득 실패");
}
try {
processPayment(orderId);
setOrderState(orderId, "PAYMENT_PROCESSING");
prepareShipping(orderId);
setOrderState(orderId, "SHIPPING_PREPARING");
} finally {
paymentLock.unlock();
}
} finally {
stockLock.unlock();
}
} catch (DeadlockException e) {
handleDeadlock(orderId, e.getMessage());
}
}
private boolean acquireLock(Lock lock, int timeoutSeconds) {
try {
return lock.tryLock(timeoutSeconds, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
private void setOrderState(String orderId, String state) {
System.out.println("주문 " + orderId + " 상태 변경: " + state);
// 실제로는 데이터베이스에 상태를 저장하는 로직이 들어갑니다.
}
private void checkStock(String orderId) {
System.out.println("주문 " + orderId + " 재고 확인 중");
// 재고 확인 로직
}
private void processPayment(String orderId) {
System.out.println("주문 " + orderId + " 결제 처리 중");
// 결제 처리 로직
}
private void prepareShipping(String orderId) {
System.out.println("주문 " + orderId + " 배송 준비 중");
// 배송 준비 로직
}
private void handleDeadlock(String orderId, String errorMessage) {
System.err.println("주문 " + orderId + "에서 데드락 발생: " + errorMessage);
rollbackOrder(orderId);
retryLater(orderId);
}
private void rollbackOrder(String orderId) {
System.out.println("주문 " + orderId + " 롤백 중");
// 주문 롤백 로직
}
private void retryLater(String orderId) {
System.out.println("주문 " + orderId + " 나중에 재시도 예정");
// 재시도 스케줄링 로직
}
private static class DeadlockException extends Exception {
public DeadlockException(String message) {
super(message);
}
}
public static void main(String[] args) {
OrderProcessingSystem system = new OrderProcessingSystem();
system.processOrder("ORDER-001");
}
}
업데이트를 순서에 상관없이 실행 가능하게 설계
예: 계좌의 입금과 출금 연산
장점: 소실된 업데이트 문제 방지
사가 단계의 순서를 재조정하여 비즈니스 리스크 최소화
예: 주문 취소 사가에서 신용 잔고 증가를 마지막 단계로 이동
장점: 더티 읽기로 인한 문제 감소
업데이트 전 값을 다시 읽어 변경 여부 확인
변경 시 사가 중단 후 재시작
장점: 소실된 업데이트 방지
단점: 성능 저하 가능성
레코드에 수행된 작업을 순서대로 기록
비교환적 작업을 교환적 작업으로 변환
장점: 순서가 맞지 않는 요청 처리 가능
비즈니스 위험성에 따라 동시성 메커니즘 선택
위험도 낮음: 사가 사용, 위험도 높음: 분산 트랜잭션 사용
장점: 유연한 전략 선택 가능
사가의 구조
사가는 세 가지 유형의 트랜잭션으로 구성
사가의 피봇 트랜잭션은 authorizeCreditCard()입니다. 소비자 신용카드가 승인되면 이 사가는 반드시 완료됩니다.
a) 보상 가능 트랜잭션 (Compensatable Transaction):
롤백이 가능한 트랜잭션
실패 시 보상 트랜잭션을 통해 변경사항을 취소할 수 있음
b) 피봇 트랜잭션 (Pivot Transaction):
사가의 진행/중단을 결정하는 지점
이 트랜잭션이 커밋되면 사가는 반드시 완료됨
보상 가능 트랜잭션도 아니고 재시도 가능 트랜잭션도 아님
마지막 보상 가능 트랜잭션이거나 첫 번째 재시도 가능 트랜잭션일 수 있음
c) 재시도 가능 트랜잭션 (Retriable Transaction):
피봇 트랜잭션 이후에 실행되는 트랜잭션
반드시 성공해야 함
실패 시 계속 재시도함
주문 생성 사가 예시:
비격리 대책들
a) 시맨틱 락 (Semantic Lock):
플래그를 세팅해서 다른 트랜잭션이 레코드에 접근하지 못하게 락(lock, 잠금)을 걸어 놓거나, 다른 트랜잭션이 해당 레코드를 처리할 때 조심하도록 경고(warning)합니다. 플래그는 재시도 가능 트랜잭션(사가 완료) 또는 보상 트랜잭션(사가 롤백)에 의해 해제됩니다.
Order.state 필드가 좋은 예입니다. *_PENDING 상태가 바로 시맨틱 락을 구현한 것
보상 가능 트랜잭션이 레코드를 생성/수정할 때 플래그를 설정
목적: 다른 트랜잭션에게 해당 레코드가 처리 중임을 알림
예: Order.state 필드를 APPROVAL_PENDING으로 설정
장점: ACID 트랜잭션의 격리 기능을 유사하게 구현 가능
단점: 락 관리 및 데드락 처리 로직 필요
구현 방법:
실패 처리 후 클라이언트에 재시도 요청
락 해제까지 블로킹
b) 교환적 업데이트 (Commutative Updates):
c) 비관적 관점 (Pessimistic View):
d) 값 다시 읽기 (Reread Value):
e) 버전 파일 (Version File):
레코드에 수행된 작업을 순서대로 기록
비교환적 작업을 교환적 작업으로 변환
장점: 순서가 맞지 않는 요청 처리 가능
예: 회계 서비스에서 신용카드 승인/취소 요청 순서 관리
주문취소 사가랑 생성 사가 동시실행시
주문생성 사가가 소비자 신용카드 승인전 주문 취소사가가 해당 신용카드 승인을 취소하는 말도 안되는 상황 벌어 질 수 있음.
순서가 안맞는 요청을 회계서비스가 받아 처리하려면, 작업이 도착하면 기록해두었다 정확한 순서대로 실행하면 됨.
회계서비스는 일단 승인 취소요청 기록하고, 나중에 승인 요청 도착하면 이미 승인 취소 요청이 접수 상태이니 승인 작업을 생략해도 되겠구나 인지.
f) 값에 의한 (By Value):
주문 서비스 구조
OrderService 클래스
@Transactional ← 서비스 메서드에 트랜잭션을 적용
public class OrderService {
@Autowired
private SagaManager<CreateOrderSagaState> createOrderSagaManager;
@Autowired
private OrderRepository orderRepository;
@Autowired
private DomainEventPublisher eventPublisher;
public Order createOrder(OrderDetails orderDetails) {
...
ResultWithEvents<Order> orderAndEvents = Order.createOrder(...); ← Order 생성
Order order = orderAndEvents.result;
OrderRepository.save(order); ← DB에 Order 저장
eventPublisher.publish(Order.class, ← 도메인 이벤트 발행
Long.toString(order.getId()),
orderAndEvents.events);
CreateOrderSagaState data =
new CreateOrderSagaState(order.getId(), orderDetails); ← CreateOrdersaga 생성
CreateOrderSagaManager.create(data, Order.class, order.getId());
return order;
}
...
}
주문 생성/관리를 담당하는 도메인 서비스
Order 생성/수정, OrderRepository를 통한 저장, SagaManager를 이용한 사가 생성
createOrder() 메서드:
a) Order 생성 및 저장
b) 도메인 이벤트 발행
c) CreateOrderSaga 생성
CreateOrderSaga: 사가의 상태 기계를 정의한 싱글턴 클래스(singleton class) Create OrderSagaState로 커맨드 메시지를 생성하고, 사가 참여자 프록시 클래스(예: Kitchen ServiceProxy)가 지정한 메시지 채널을 통해 참여자에게 메시지를 전달합니다.
CreateOrderSagaState: 사가의 저장 상태. 커맨드 메시지를 생성합니다.
사가 참여자 프록시 클래스: 프록시 클래스마다 커맨드 채널, 커맨드 메시지 타입, 반환형으로 구성된 사가 참여자의 메시징 API를 정의합니다.
CreateOrderSaga 클래스
사가의 상태 기계를 정의한 싱글턴 클래스
SimpleSaga 인터페이스 구현
사가 데피니션 정의:
a) 각 단계별 참여자 호출 (invokeParticipant)
b) 응답 처리 (onReply)
c) 보상 트랜잭션 정의 (withCompensation)
public class CreateOrderSaga implements SimpleSaga<CreateOrderSagaState> {
private SagaDefinition<CreateOrderSagaState> sagaDefinition;
public CreateOrderSaga(OrderServiceProxy orderService,
ConsumerServiceProxy consumerService,
KitchenServiceProxy kitchenService,
AccountingServiceProxy accountingService) {
this.sagaDefinition =
step()
.withCompensation(orderService.reject,
CreateOrderSagaState::makeRejectOrderCommand)
.step()
.invokeParticipant(consumerService.validateOrder,
CreateOrderSagaState::makeValidateOrderByConsumerCommand)
.step()
.invokeParticipant(kitchenService.create,
CreateOrderSagaState::makeCreateTicketCommand)
.onReply(CreateTicketReply.class,
CreateOrderSagaState::handleCreateTicketReply)
.withCompensation(kitchenService.cancel,
CreateOrderSagaState::makeCancelCreateTicketCommand)
.step()
.invokeParticipant(accountingService.authorize,
CreateOrderSagaState::makeAuthorizeCommand)
.step()
.invokeParticipant(kitchenService.confirmCreate,
CreateOrderSagaState::makeConfirmCreateTicketCommand)
.step()
.invokeParticipant(orderService.approve,
CreateOrderSagaState::makeApproveOrderCommand)
.build();
}
@Override
public SagaDefinition<CreateOrderSagaState> getSagaDefinition() {
return sagaDefinition;
}
}
blic class CreateOrderSaga ...
public CreateOrderSaga(..., KitchenServiceProxy kitchenService,
...) {
...
.step()
.invokeParticipant(kitchenService.create, ← 포워드 트랜잭션 정의
CreateOrderSagaState::makeCreateTicketCommand)
.onReply(CreateTicketReply.class,
CreateOrderSagaState::handleCreateTicketReply) ← 성공 응답을 수신하면 handleCreateTicketReply( ) 호출
.withCompensation(kitchenService.cancel, ← 보상 트랜잭션 정의
CreateOrderSagaState::makeCancelCreateTicketCommand)
...
;
CreateOrderSagaState 클래스
public class CreateOrderSagaState {
private Long orderId;
private OrderDetails orderDetails;
private long ticketId;
public Long getOrderId() {
return orderId;
}
private CreateOrderSagaState() {
}
public CreateOrderSagaState(Long orderId, OrderDetails orderDetails) { ← Orderservice가 호출하여 CreateOrdersagastate 인스턴스를 생성
this.orderId = orderId;
this.orderDetails = orderDetails;
}
CreateTicket makeCreateTicketCommand() { ← CreateTicket 커맨드 메시지 생성
return new CreateTicket(getOrderDetails().getRestaurantId(),
getOrderId(), makeTicketDetails(getOrderDetails()));
}
void handleCreateTicketReply(CreateTicketReply reply) { ← 새로 만든 티켓 ID 저장
logger.debug("getTicketId {}", reply.getTicketId());
setTicketId(reply.getTicketId());
}
CancelCreateTicket makeCancelCreateTicketCommand() { ← CancelCreateTicket 커맨드 메시지 생성
return new CancelCreateTicket(getOrderId());
}
...
KitchenServiceProxy 클래스
public class KitchenServiceProxy {
public final CommandEndpoint<CreateTicket> create =
CommandEndpointBuilder
.forCommand(CreateTicket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(CreateTicketReply.class)
.build();
public final CommandEndpoint<ConfirmCreateTicket> confirmCreate =
CommandEndpointBuilder
.forCommand(ConfirmCreateTicket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(Success.class)
.build();
public final CommandEndpoint<CancelCreateTicket> cancel =
CommandEndpointBuilder
.forCommand(CancelCreateTicket.class)
.withChannel(
KitchenServiceChannels.kitchenServiceChannel)
.withReply(Success.class)
.build();
}
이벤추에이트 트램 사가 프레임워크
OrderCommandHandlers 클래스
public class OrderCommandHandlers {
@Autowired
private OrderService orderService;
public CommandHandlers commandHandlers() { ← 커맨드 메시지를 각각 적절한 핸들러 메서드로 라우팅
return SagaCommandHandlersBuilder
.fromChannel("orderService")
.onMessage(ApproveOrderCommand.class, this::approveOrder)
.onMessage(RejectOrderCommand.class, this::rejectOrder)
...
.build();
}
public Message approveOrder(CommandMessage<ApproveOrderCommand> cm) {
long orderId = cm.getCommand().getOrderId();
OrderService.approveOrder(orderId); ← Order를 승인 상태로 변경
return withSuccess(); ← 제네릭 성공 메시지 반환
}
public Message rejectOrder(CommandMessage<RejectOrderCommand> cm) {
long orderId = cm.getCommand().getOrderId();
OrderService.rejectOrder(orderId); ← Order를 거부 상태로 변경
return withSuccess();
}
OrderServiceConfiguration 클래스
@Configuration
public class OrderServiceConfiguration {
@Bean
public OrderService orderService(RestaurantRepository restaurantRepository,
...
SagaManager<CreateOrderSagaState> createOrderSagaManager,
...) {
return new OrderService(restaurantRepository,
...
CreateOrderSagaManager
...);
}
@Bean
public SagaManager<CreateOrderSagaState> createOrderSagaManager(
CreateOrderSaga saga) {
return new SagaManagerImpl<>(saga);
}
@Bean
public CreateOrderSaga createOrderSaga(OrderServiceProxy orderService,
ConsumerServiceProxy consumerService, ...) {
return new CreateOrderSaga(orderService, consumerService, ...);
}
@Bean
public OrderCommandHandlers orderCommandHandlers() {
return new OrderCommandHandlers();
}
@Bean
public SagaCommandDispatcher orderCommandHandlersDispatcher(
OrderCommandHandlers orderCommandHandlers) {
return new SagaCommandDispatcher("orderService",
orderCommandHandlers.commandHandlers());
}
@Bean
public KitchenServiceProxy kitchenServiceProxy() {
return new KitchenServiceProxy();
}
@Bean
public OrderServiceProxy orderServiceProxy() {
return new OrderServiceProxy();
}
...
}