반응형

📝 분산트랜잭션

하나의 서비스를 호출했을 때 다양한 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 명령을 보냅니다.

 

⚠️문제

  1. 블로킹 문제
    • 참여자인 DB는 조정자의 결정을 기다리는 동안 트랜잭션 락을 걸어 이슈사항
  2. Coordinator 장애시 복구 어려움
    • A트랜잭션, B트랜잭션 둘다 YES상태이지만 Coordinator가 죽어서 Commit을 할 수 없기 때문에 블로킹문제도 생기고 나중에 로그보고 복구하기도 힘듭니다. (A,B처리 이후에 다른 작업도 추후 있는 경우)
  3. 높은 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);
    }
}

 

 

 

반응형