이전에 Redisson 분산락을 AOP로 구현하는 과정에서 트랜젝션 전파 속성을 REQUIRES_NEW로 설정 했었다.
( SpringBoot Redisson AOP 적용기 - 재고 동시성 제어 )
내가 작성한 로직은 다음과 같은 순서로 진행 되었고
1. 숙소 재고 차감
2. 쿠폰 재고 차감
3. 예약 생성
재고 차감과 예약이 다른 트랜젝션에서 수행되기 때문에
예약 생성에서 예외 발생 시 재고가 롤백이 안되는 이슈가 발생하였다!
REQUIRES_NEW 옵션은 트랜잭션을 시작할 때 새 트랜잭션을 생성하고 현재 트랜잭션과 분리하는 옵션이다.
재고 차감은 REQUIRES_NEW로 새로운 트랜젝션에서 수행되기 때문에
예약 생성 트랜젝션의 롤백이 재고 차감 트랜젝션에게 아무런 영향을 끼치지 않는다.
REQUIRES_NEW 옵션은 사실 완벽하게 독립적인 트랜젝션이 아니다 !
스프링 트랜잭션의 독립이란 물리적인것이 아닌 논리적 독립을 의미하며,
실제로 REQUIRES_NEW로 시작한 트랜젝션도 현재 트랜젝션과 동일한 스레드에서 진행된다.
따라서 REQUIRES_NEW에서 발생한 예외는 이를 호출한 트랜젝션에 전파가 된다.
아래와 같은 호출 구조일 때
public class OuterService{
@Transactional
void reservation(){
reservatonRepository.save(reservation);
innerService.decreaseRoomStock();
}
}
public class InnerService{
@Transactional(propagation = Propagation.REQUIRES_NEW)
void decreaseRoomStock(){
roomStock.decrease();
roomStockRepository.save(roomStock);
}
}
부모 트랜젝션(reservation)에서 예외 발생 시
reservatonRepository.save(reservation); 에서 예외 발생!
-> reservatonRepository.save(reservation); 롤백
-> 트랜젝션 종료로 innerservice.decreaseRoomStock(); 호출 X
자식 트랜젝션(decreaseRoomStock)에서 예외 발생 시
decreaseRoomStock() 에서 예외 발생!
-> decreaseRoomStock() 롤백
-> 예외 처리(catch)가 없기 때문에 예외가 호출 지점으로 전파
-> reservation() 트랜젝션도 예외를 만나 reservatonRepository.save(reservation); 롤백
[2. 자식 트랜젝션에서 예외 발생 시] 예외가 전파되어 부모 트랜젝션도 함께 롤백되는 특징을 이용해서
예약 생성을 먼저 수행한 후 재고 차감 트랜젝션이 시작하도록 순서를 변경한다.
1. 예약 생성
2. 숙소 재고 차감
3. 쿠폰 재고 차감
1. 예약 생성
2. 숙소 재고 차감
3. 쿠폰 재고 차감
이 구조일때 2에서 예외가 발생하면 1, 2 모두 롤백이 가능하다.
하지만, 만약 3에서 예외가 발생하면 1은 롤백이 되지만
2의 트랜젝션은 이미 종료되었기 때문에 예외 전파에 영향을 받지 않는다.
즉, 쿠폰 재고 차감에서의 예외는 숙소 재고 차감을 롤백시킬 수 없다.
이미 커밋된 재고 차감 트랜젝션에 대한 롤백 처리를 어떻게 해결해야할 지 감이 잡히지 않아
동시성 제어 AOP 구현 시 참고했던 블로그 글을 작성하신 개발자분께 커피챗을 요청하였고
보상트랜젝션을 제안해주셨다.
보상트랜젝션은 차감한 재고를 다시 증가시키는 메소드를 수행하는 트랜젝션이다.
예를 들어, 쿠폰 재고 차감 트랜젝션에서 예외가 발생하면 숙소 재고를 다시 증가시키도록 하면 된다.
보상트랜젝션을 적용을 하면 예약 생성과 재고 차감의 순서를 뒤집을 필요도 없다고 생각되어
재고 차감 이후 예약 생성을 하는 순서로 변경하고 try-catch로 예외를 잡아 보상트랜젝션을 수행하도록 했다.
public class OuterService{
@Transactional
void reservation(){
innerService.decreaseRoomStock();
try{
innerService.decreaseCouponStock();
} catch(Exception e){ // 보상트랜젝션
innerService.increaseRoomStock();
}
try{
reservatonRepository.save(reservation);
} catch(Exception e){ // 보상트랜젝션
innerService.increaseRoomStock();
innerService.increaseCouponStock();
}
}
}
public class InnerService{
@Transactional(propagation = Propagation.REQUIRES_NEW)
void decreaseRoomStock(){}
@Transactional(propagation = Propagation.REQUIRES_NEW)
void decreaseCouponStock(){}
@Transactional(propagation = Propagation.REQUIRES_NEW)
void increaseRoomStock(){}
@Transactional(propagation = Propagation.REQUIRES_NEW)
void increaseCouponStock(){}
}
보상트랜젝션 코드를 작성하다보니
'그럼 재고 증가를 하다가 예외가 발생하면? 보상트랜젝션의 보상이 또 필요한 것일까?' 라는
걱정과 고민의 무한루프가 돌고 말았다.
이러한 고민에 대해 이야기를 나눴고
어느정도의 수기 작업과 모니터링이 필요하다는 답변을 주셨다.
롤백 이슈를 만나고 난 후 REQUIRES_NEW 옵션에 대해 더 깊게 알 수 있었던 것 같다.
그리고 보상트랜젝션과 모니터링 등 새로운 시각도 알 수 있어 너무 뜻깊은 시간이었다!!
reference
[Spring] Transactional REQUIRES_NEW 옵션에서의 Rollback
[Spring] Transaction PROPAGATION.REQUIRES_NEW 의 '독립'이란 의미?