반응형
📝 분산트랜잭션
하나의 서비스를 호출했을 때 다양한 DB에 접근해서 Write 작업이 있을 때 다양한 DB에 대한 트랜잭션을 하나로 처리하는 개념입니다.
서비스호출 → A트랜잭션 → A DB호출 → A서비스 처리 → A트랜잭션 종료
→ B트랜잭션 → B DB호출 → B서비스 처리 → Error (B트랜잭션만 롤백되고 A는 롤백 되지 않음)
2PC
분산트랜잭션을 해결하기 위해 나온 방식으로 두 단계로 구성됩니다.
0. DB 트랜잭션처리
- 일반적인 DB 트랜잭션 처리를 요청합니다.
1. 준비 단계 (Prepare Phase, Voting Phase)
- 조정자(Coordinator)가 모든 참여자(Participant)인 DB에게 "커밋해도 되냐?" 라고 물어봅니다. (동기성으로 대기)
- 각 참여자는 트랜잭션 작업을 임시로 저장하고 커밋 가능한지 확인 후 Yes, No로 응답합니다.
2. 커밋 단계 (Commit Phase)
- 모든 참여자가 Yes라고 하면 조정자가 Commit 명령을 보냅니다.
- 누군가 No라고 하면 Rollback 명령을 보냅니다.
⚠️문제
- 블로킹 문제
- 참여자인 DB는 조정자의 결정을 기다리는 동안 트랜잭션 락을 걸어 이슈사항
- Coordinator 장애시 복구 어려움
- A트랜잭션, B트랜잭션 둘다 YES상태이지만 Coordinator가 죽어서 Commit을 할 수 없기 때문에 블로킹문제도 생기고 나중에 로그보고 복구하기도 힘듭니다. (A,B처리 이후에 다른 작업도 추후 있는 경우)
- 높은 Latency
- Prepare, Commit 2번 왕복 필요 (DB가 N개이면 N*2회 왕복 통신 필요)
JTA, XA
- XA
- 2PC를 수행하기 위한 프로토콜
- JTA
- Java에서 XA 리소스를 제어하기 위한 표준 API
Saga
블로킹 문제와 Coordinator의 장애 처리 능력 등과 같은 2PC문제로 Saga 패턴이 나옵니다.
대규모, 분산, 클라우드 MSA 환경에는 Saga가 더 적합합니다.
취소/환불/보상이 자연스러운 도메인에 많이 쓰인다.
- 블로킹문제
- Coordinator가 모두 커밋상태가 될때까지 기다리는 형태가 아니라 처리 후 바로 커밋 이후에 후처리하기 때문에 블로킹 문제가 없음
- Coordinator 장애 처리
- Kafka등 메시징 시스템을 사용할 경우 장애 처리가 뛰어남
- XA 트랜잭션 DB만 지원
- XA 트랜잭션 외에 여러 DB나 파일시스템 등에 대해 처리가 가능
Choreography (이벤트 기반)
가장 많이 쓰이는 방식으로 kafka와 RabitMQ 등 메시징 시스템을 이용한다.
메시징 시스템이 잘 되어있기 때문에 장애 등에 대해 처리가 좋다.
프로세스 흐름
- 주문 → 재고처리 → 결제 → 완료
Order 처리
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
/** ---- 1. 주문 생성 ---- **/
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setStatus(OrderStatus.PENDING);
orderRepository.save(order);
// 로컬 트랜잭션이 성공하면 이벤트 발행
OrderCreatedEvent event = new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getProductId(),
order.getQuantity()
);
kafkaTemplate.send("order-created", event);
return order;
}
/** ---- 주문 성공(모든 프로세스 정상 처리) ---- **/
@Transactional
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("order not found"));
order.setStatus(OrderStatus.COMPLETED);
orderRepository.save(order);
}
/** ---- 주문 실패 (주문 실패상태 + 재고 원복) ---- **/
@Transactional
public void cancelOrder(Long orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("order not found"));
order.setStatus(OrderStatus.CANCELLED);
orderRepository.save(order);
OrderCancelledEvent event = new OrderCancelledEvent(orderId, reason);
kafkaTemplate.send("order-cancelled", event);
}
}
재고 처리
@Service
@RequiredArgsConstructor
public class InventorySagaHandler {
private final InventoryService inventoryService;
private final KafkaTemplate<String, Object> kafkaTemplate;
/** ---- 2. 재고 처리 ---- **/
@KafkaListener(topics = "order-created", groupId = "inventory-service")
@Transactional
public void handleOrderCreated(OrderCreatedEvent event) {
boolean reserved = inventoryService.reserve(
event.getProductId(), event.getQuantity());
if (reserved) {
InventoryReservedEvent reservedEvent = new InventoryReservedEvent(
event.getOrderId(),
event.getProductId(),
event.getQuantity()
);
kafkaTemplate.send("inventory-reserved", reservedEvent);
} else {
InventoryReservationFailedEvent failedEvent =
new InventoryReservationFailedEvent(
event.getOrderId(),
"Not enough stock"
);
kafkaTemplate.send("inventory-reservation-failed", failedEvent);
}
}
/** ---- 주문 취소시 재고 원복 ---- **/
@KafkaListener(topics = "order-cancelled", groupId = "inventory-service")
@Transactional
public void handleOrderCancelled(OrderCancelledEvent event) {
inventoryService.restore(event.getOrderId());
}
}
결제 처리
@Service
@RequiredArgsConstructor
public class PaymentSagaHandler {
private final PaymentService paymentService;
private final KafkaTemplate<String, Object> kafkaTemplate;
/** ---- 3. 결제 처리 ---- **/
@KafkaListener(topics = "inventory-reserved", groupId = "payment-service")
@Transactional
public void handleInventoryReserved(InventoryReservedEvent event) {
try {
Payment payment = paymentService.approve(event.getOrderId());
PaymentApprovedEvent approvedEvent =
new PaymentApprovedEvent(event.getOrderId(), payment.getId());
kafkaTemplate.send("payment-approved", approvedEvent);
// 결제 실패
} catch (Exception ex) {
PaymentFailedEvent failedEvent =
new PaymentFailedEvent(event.getOrderId(), ex.getMessage());
kafkaTemplate.send("payment-failed", failedEvent);
}
}
}
프로세스 성공 및 실패에 대한 카프카 처리
@Service
@RequiredArgsConstructor
public class OrderSagaHandler {
private final OrderService orderService;
@KafkaListener(topics = "payment-approved", groupId = "order-service")
public void handlePaymentApproved(PaymentApprovedEvent event) {
orderService.completeOrder(event.getOrderId());
}
@KafkaListener(topics = "payment-failed", groupId = "order-service")
public void handlePaymentFailed(PaymentFailedEvent event) {
orderService.cancelOrder(event.getOrderId(), event.getReason());
}
@KafkaListener(topics = "inventory-reservation-failed", groupId = "order-service")
public void handleInventoryReservationFailed(InventoryReservationFailedEvent event) {
orderService.cancelOrder(event.getOrderId(), event.getReason());
}
}
Orchestration
TX기반으로 롤백 시키는 것과 다르게 직접 try-catch를 통해 결과값에 대해 롤백 또는 보상행동을 시켜야한다.
class OrderSagaOrchestrator {
public void startCreateOrderSaga(CreateOrderCommand cmd) {
Long orderId = null;
boolean paymentDone = false;
boolean stockReserved = false;
try {
// 1. 주문 생성 (로컬 트랜잭션)
orderId = orderService.createOrder(cmd); // ★ 여기 안에서만 @Transactional
// 2. 결제
paymentService.pay(orderId, cmd.getPaymentInfo());
paymentDone = true;
// 3. 재고 차감
inventoryService.reserve(orderId, cmd.getItems());
stockReserved = true;
// 4. 배송 준비
shippingService.prepare(orderId);
} catch (Exception e) {
// 실패 시 보상(Compensation) 실행
if (stockReserved) {
inventoryService.cancelReserve(orderId); // 재고 원복
}
if (paymentDone) {
paymentService.refund(orderId); // 결제 취소
}
if (orderId != null) {
orderService.cancelOrder(orderId); // 주문 취소
}
throw e;
}
}
}
TCC
호텔 예약, 항공 예약같이 먼저 임시로 잡아두고 나중에 확정, 취소하는 방식에 많이 사용하는 분산트랜잭션 처리 방식
- Try
- 자원을 임시로 홀드(예약)상태로 만듦
- Confirm
- 전체 성공시 임시 예약을 진짜 확정
- Cancle
- 중간에 문제 나면 임시 예약 정리 (해제)
Orchestrator 관점
public class TripTccOrchestrator {
public void bookTrip(TripRequest req) {
// 각 단계의 컨텍스트 (id, key 등)
TccContext flightCtx = new TccContext();
TccContext hotelCtx = new TccContext();
TccContext paymentCtx = new TccContext();
try {
// 1. Try 단계: 모든 참여자에게 '임시 예약' 요청
flightService.tryReserveFlight(flightCtx, req.getFlightInfo());
hotelService.tryReserveHotel(hotelCtx, req.getHotelInfo());
paymentService.tryReservePayment(paymentCtx, req.getPaymentInfo());
// 여기까지 모두 OK면, 이제 Confirm 단계로 간다.
// 2. Confirm 단계: 실제로 확정
flightService.confirmFlight(flightCtx);
hotelService.confirmHotel(hotelCtx);
paymentService.confirmPayment(paymentCtx);
} catch (Exception e) {
// 중간에 뭔가 하나라도 실패하면
// 3. Cancel 단계: Try에서 잡아둔 예약/홀드를 해제
safeCancel(() -> flightService.cancelFlight(flightCtx));
safeCancel(() -> hotelService.cancelHotel(hotelCtx));
safeCancel(() -> paymentService.cancelPayment(paymentCtx));
throw e;
}
}
// Cancel 실패 시에도 재시도/로깅을 위해 감싸는 헬퍼
private void safeCancel(Runnable cancelAction) {
try {
cancelAction.run();
} catch (Exception ex) {
// 로그 남기고, 재시도 큐에 넣고 등등...
}
}
}
호텔 서비스 예시
public class HotelTccService {
// 1. Try: 방을 임시로 홀드(예약대기 상태)
@Transactional
public void tryReserveHotel(TccContext ctx, HotelInfo info) {
// 아직 '확정'은 아님. 다른 사람에게는 안 팔리게만 막는 상태.
HotelReservation r = new HotelReservation(
info.getRoomId(),
info.getDate(),
Status.PENDING // ★ 임시 상태
);
hotelRepo.save(r);
// 나중에 confirm/cancel 할 수 있도록 id를 컨텍스트에 박아둠
ctx.put("reservationId", r.getId());
}
// 2. Confirm: 진짜 확정
@Transactional
public void confirmHotel(TccContext ctx) {
Long reservationId = ctx.getLong("reservationId");
HotelReservation r = hotelRepo.findById(reservationId)
.orElseThrow();
if (r.getStatus() != Status.PENDING) {
// 멱등성 체크 등
return;
}
r.setStatus(Status.CONFIRMED); // ★ 진짜 확정
hotelRepo.save(r);
}
// 3. Cancel: 임시 홀드 해제
@Transactional
public void cancelHotel(TccContext ctx) {
Long reservationId = ctx.getLong("reservationId");
HotelReservation r = hotelRepo.findById(reservationId)
.orElseThrow();
if (r.getStatus() != Status.PENDING) {
// 이미 Confirm나 Cancel된 경우 -> 아무 것도 안 함
return;
}
r.setStatus(Status.CANCELED); // 또는 삭제
hotelRepo.save(r);
}
}
결제 서비스 패턴
public class PaymentTccService {
// Try: 금액을 실제로 빼지는 않고, "홀드"해두는 느낌
@Transactional
public void tryReservePayment(TccContext ctx, PaymentInfo info) {
PaymentHold hold = new PaymentHold(
info.getAccountId(),
info.getAmount(),
HoldStatus.PENDING
);
holdRepo.save(hold);
ctx.put("holdId", hold.getId());
}
// Confirm: 실제 결제 처리
@Transactional
public void confirmPayment(TccContext ctx) {
Long holdId = ctx.getLong("holdId");
PaymentHold hold = holdRepo.findById(holdId).orElseThrow();
if (hold.getStatus() != HoldStatus.PENDING) {
return;
}
// 잔액 차감, 거래 내역 기록 등 실제 결제 로직
accountRepo.debit(hold.getAccountId(), hold.getAmount());
hold.setStatus(HoldStatus.CONFIRMED);
holdRepo.save(hold);
}
// Cancel: 홀드만 해제 (실제 돈은 안 빠져나감)
@Transactional
public void cancelPayment(TccContext ctx) {
Long holdId = ctx.getLong("holdId");
PaymentHold hold = holdRepo.findById(holdId).orElseThrow();
if (hold.getStatus() != HoldStatus.PENDING) {
return;
}
hold.setStatus(HoldStatus.CANCELED);
holdRepo.save(hold);
}
}
반응형