이번 글은 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 공부 시급...
'Spring > MSA' 카테고리의 다른 글
MSA 환경에서 FeignClient 응답 데이터가 누락된 이유는? – CommonResponse<DTO>와 변수 범위 이슈 (0) | 2025.03.24 |
---|---|
MSA 주문 서비스에서 보상 트랜잭션(SAGA 오케스트레이션) 적용기 (0) | 2025.03.21 |
[MSA] MSA 기반 상품 주문 시스템 (1) (0) | 2025.03.07 |
서킷 브레이커 (Resilience4j) (1) | 2025.02.12 |
Spring cloud와 MSA (0) | 2025.02.11 |