Spring/MSA

MSA에서 JPA 트랜잭션 문제 & SAGA 패턴 필요성

챛채 2025. 3. 17. 21:21

이번 글은 MSA 프로젝트를 진행하면서 겪었던 JPA 트랜잭션 문제와 해결 과정, 그리고 이를 통해 SAGA 패턴과 락(Optimistic Lock & Pessimistic Lock)의 필요성에 대해 깨달은 내용을 공유하고자 한다.

 

1. 문제 상황 : MSA에서 JPA 트랜잭션이 예상과 다르게 동작

Spring Boot 기반 Order Service를 개발하던 중, 주문 생성 과정에서 다음과 같은 오류가 발생하였다.

 

💥 💥 발생한 오류 (StaleObjectStateException) 💥 💥

org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction
  • 이 오류는 JPA에서 save() 실행 시 merge()가 발생하면서 기존 데이터와 충돌할 때 발생한다.
  • 모놀리틱에서는 발생하지 않는 문제인데 왜 MSA에서는 발생했을까??

2. 원인 분석 : 왜 모놀리틱과 다르게 MSA에서는 오류가 발생하는지?

@Transactional
public CreateOrderResponse createOrder(CreateOrderRequest request) {
    Order order = new Order(...);
    orderRepository.save(order); //JPA가 persist() 실행
    return orderMapper.fromEntity(order);
}
  • 같은 트랜잭션에서 실행되므로, save() 시 JPA가 자동으로 persist()를 실행하여 새 데이터를 삽입
  • JPA가 "새로운 데이터"라고 인식하므로 merge()가 실행되지 않는다!

MSA에서는 트랜잭션 오류 발생

  • OrderService 구조 (4계층)
Controller → Application Service → Domain Service → Repository
  • Order 객체가 생성되는 위치가 다르다.
@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;
    private final OrderDomainService orderDomainService;
    
    @Transactional
    public CreateOrderResponse createOrder(CreateOrderRequest request) {
        Order order = orderDomainService.createOrder(request); // JPA에서 관리되지 않음
        
        boolean isManaged = entityManager.contains(order);
		System.out.println("Order 엔티티 상태: " + order);
		System.out.println("Order 엔티티가 JPA에서 관리되는 상태인가? " + isManaged);

        
        orderRepository.save(order); // merge() 실행되면서 충돌 발생
        return orderMapper.fromEntity(order);
    }
}
Order 엔티티 상태: com.teamsparta8.order_service.domain.model.Order@63639759
Order 엔티티가 JPA에서 관리되는 상태인가? false
  • OrderDomainService.createOrder()에서 Order 객체를 생성했지만 JPA에서 관리되지 않는 상태 (Transient)
  • save()를 호출할 때, JPA가 orderId가 이미 존재하는 값인지 판단
  • JPA가 기존 데이터로 인식하여 merge()를 실행
  • merge() 실행 중 기존 데이터와 충돌이 발생하면서 StaleObjectStateException 발생

3. 해결 방법: JPA가 persist()를 실행하도록 변경

(1) Order 객체가 JPA에서 새로운 데이터로 인식되도록 수정

@Entity
@Builder
@Table(name = "p_order")
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Order {
    @Id
    private UUID orderId; // 💡 @GeneratedValue 제거

    @Column(nullable = false)
    private UUID supplierCompanyId;
    @Column(nullable = false)
    private UUID receiverCompanyId;
    @Column(nullable = false)
    private UUID productId;
    @Column(nullable = false)
    private UUID hubId;
    @Column(nullable = false)
    private UUID deliveryId;
    @Column(nullable = false)
    private int quantity;
    @Column(length = 255)
    private String requestDescription;

    // 새로운 Order 객체를 생성하는 정적 메서드 추가
    public static Order create(UUID supplierCompanyId, UUID receiverCompanyId, UUID productId,
                               UUID hubId, UUID deliveryId, int quantity, String requestDescription) {
        return Order.builder()
                .orderId(UUID.randomUUID()) // 직접 생성
                .supplierCompanyId(supplierCompanyId)
                .receiverCompanyId(receiverCompanyId)
                .productId(productId)
                .hubId(hubId)
                .deliveryId(deliveryId)
                .quantity(quantity)
                .requestDescription(requestDescription)
                .build();
    }
}
  • 이제 orderId가 명확하게 생성되므로 JPA가 새로운 데이터로 인식!
  • JPA가 persist()를 실행하여 merge() 충돌을 방지!

(2) OrderService에서 save()가 persist() 실행되도록 변경

@Service
@RequiredArgsConstructor
public class OrderService {
    private final OrderRepository orderRepository;

    @Transactional
    public CreateOrderResponse createOrder(CreateOrderRequest request) {
        UUID hubId = UUID.randomUUID();
        UUID deliveryId = UUID.randomUUID();

        //  새로운 Order 객체 생성
        Order order = Order.create(
            request.getSupplierCompanyId(),
            request.getReceiverCompanyId(),
            request.getProductId(),
            hubId,
            deliveryId,
            request.getQuantity(),
            request.getRequestDescription()
        );

        orderRepository.save(order); // persist() 실행됨!

        return orderMapper.fromEntity(order);
    }
}
  • 이제 save() 실행 시 JPA가 persist()를 실행하여 문제 해결!

4. SAGA 패턴의 필요성 (Optimistic Lock & Pessimistic Lock과의 비교)

낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)도 해결책이 될 수 있지만 MSA에서는 완벽한 해결책이 아니다!

  • 낙관적 락 (Optimistic Lock)
@Version
private Long version;  // 낙관적 락 추가

- 데이터 충돌 감지 가능

- 하지만 실패하면 롤백만 가능하고 서비스 간 데이터 불일치는 해결할 수 없다.

 

  • 비관적 락 (Pessimistic Lock)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM Order o WHERE o.orderId = :orderId")
Optional<Order> findByIdForUpdate(@Param("orderId") UUID orderId);

-트랜잭션 충돌 방지 가능

-하지만 MSA에서는 다른 서비스가 같은 데이터베이스를 사용하지 않기 때문에 적용 불가능!

 

그래서 필요한 게 SAGA 패턴!

 

 

"Order를 생성한 후 Stock(재고)을 차감해야 하는데 Order는 성공하고 Stock이 실패하면 어떻게 될까?"
"그럼 Order를 다시 취소해야 하는데 어떻게 롤백할 수 있을까?"

 

모놀리틱에서는 @Transactional 하나로 해결이 가능하지만 MSA에서는 각 서비스가 독립적인 트랜잭션을 가지므로 데이터의 일관성을 보장할 수 없다. 이 문제를 해결하기 위해서 SAGA 패턴 필요!

 

5. SAGA 패턴으로 해결하기

SAGA 패턴을 사용하면 트랜잭션이 실패했을 때 보상 트랜잭션을 실행하여 데이터를 원래 상태로 되돌릴 수 있다.

대표적으로 두 가지 방식이 있는데

1 . 오케스트레이션 방식 (중앙 관리)

 

  • SAGA Coordinator가 모든 서비스의 트랜잭션을 관리
  • OrderService가 주문을 생성하면 SAGA Coordinator가 StockService에 재고 차감 요청
  • 실패하면 SAGA Coordinator가 OrderService에 주문 취소 요청을 보냄
  • 장점 : 중앙에서 트랜잭션 관리 가능
  • 단점 : SAGA Coordinator가 많아질수록 복잡해진다.

 

 

2. 코레오그래피 방식(각 서비스가 이벤트 기반으로 처리)

 

  • OrderService가 주문을 생성하면 "ORDER_CREATED" 이벤트 발행
  • StockService가 이 이벤트를 받아 재고를 차감
  • StockService에서 재고가 부족하면 "ORDER_CANCEL" 이벤트 발행하여 OrderService가 주문을 취소
  • 장점 : 중앙 관리 없이 서비스 간 느슨한 결합 유지
  • 단점 : 서비스 간 이벤트 흐름 복잡해질 수 있다.

6. 결론

  • MSA에서는 JPA의 save()가 persist()가 아닌 merge()를 실행하면서 예상치 못한 충돌이 발생할 수 있다.
  • Order 객체를 JPA가 새로운 데이터로 인식하도록 orderId를 명시적으로 할당하여 해결 가능하다.
  • 하지만 MSA에서는 분산 트랜잭션 문제가 존재하므로 결국 SAGA 패턴이 필요하다.
  • SAGA 패턴을 적용하면 서비스 간 트랜잭션 일관성을 유지하면서 오류 발생 시 보상 트랜잭션을 실행 가능하다!

이번 경험을 통해 MSA에서 트랜잭션을 다루는 것이 모놀리틱보다 훨씬 복잡하다는 것을 깨달았고 이를 해결하기 위해 SAGA 패턴이 필요하다는 점을 배웠다. kafka 공부 시급...