예외와 트랜잭션 커밋, 롤백 - 기본
예외가 발생했는데,
내부에서 예외를 처리하지 못하고,
트랜잭션 범위( @Transactional가 적용된 AOP ) 밖으로 예외를 던지면 어떻게 될까?
예외 발생시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.
언체크 예외인 RuntimeException , Error 와 그 하위 예외가 발생하면 트랜잭션을 롤백한다.
체크 예외인 Exception 과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.
물론 정상 응답(리턴)하면 트랜잭션을 커밋한다
RollbackTest
@SpringBootTest
public class RollbackTest {
@Autowired
RollbackService service;
@Test
void runtimeException() {
Assertions.assertThatThrownBy(() -> service.runtimeException())
.isInstanceOf(RuntimeException.class);
}
@Test
void checkException() {
Assertions.assertThatThrownBy(() -> service.checkException())
.isInstanceOf(MyException.class);
}
@Test
void rollbackFor() {
Assertions.assertThatThrownBy(() -> service.rollbackFor())
.isInstanceOf(MyException.class);
}
@TestConfiguration
static class RollbackTestConfig{
@Bean
RollbackService rollbackService() {
return new RollbackService();
}
}
@Slf4j
static class RollbackService{
//런타임 예외 발생 : 롤백
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
//체크 예외 발생 : 커밋
@Transactional
public void checkException() throws MyException{
log.info("call checkException");
throw new MyException();
}
//체크 예외 rollbackFor 지정 : 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException{
log.info("call checkedException");
throw new MyException();
}
}
static class MyException extends Exception {
}
}
커밋이 되는지, 롤백이 되는지 확인하기 위해 로그 출력
application.properties
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
참고로 지금은 JPA를 사용하므로 트랜잭션 매니저로 JpaTransactionManager 가 실행되고,
여기의 로그를 출력 하게 된다.
//런타임 예외 발생 : 롤백
@Transactional
public void runtimeException() {
log.info("call runtimeException");
throw new RuntimeException();
}
//체크 예외 발생 : 커밋
@Transactional
public void checkException() throws MyException{
log.info("call checkException");
throw new MyException();
}
rollbackFor
이 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정할 수 있다.
@Transactional(rollbackFor = Exception.class)
예를 들어서 이렇게 지정하면 체크 예외인 Exception 이 발생해도 커밋 대신 롤백된다. (자식 타입도 롤백된다.)
//체크 예외 rollbackFor 지정 : 롤백
@Transactional(rollbackFor = MyException.class)
public void rollbackFor() throws MyException{
log.info("call checkedException");
throw new MyException();
}
기본 정책과 무관하게 특정 예외를 강제로 롤백하고 싶으면 rollbackFor 를 사용하면 된다.
(해당 예외의 자식 도 포함된다.)
rollbackFor = MyException.class 을 지정했기 때문에
MyException 이 발생하면 체크 예외이지만 트랜잭션이 롤백된다
예외와 트랜잭션 커밋, 롤백 - 활용
스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?
스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고,
런타임(언체크) 예외는 복구 불가능한 예외로 가정 한다.
체크 예외: 비즈니스 의미가 있을 때 사용
언체크 예외: 복구 불가능한 예외
참고로 꼭 이런 정책을 따를 필요는 없다.
그때는 앞서 배운 rollbackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 된다.
그런데 비즈니스 의미가 있는 비즈니스 예외라는 것이 무슨 뜻일까?
간단한 예제로 알아보자.
비즈니스 요구사항
주문을 하는데 상황에 따라 다음과 같이 조치한다.
1. 정상: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료로 처리한다.
2. 시스템 예외: 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
3. 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기로 처리한다.
이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.
이때 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크예외가 발생한다고 가정하겠다.
이 예외는 시스템에 문제가 있어서 발생하는 시스템 예외가 아니다.
시스템은 정상 동작했지만, 비즈니스 상황에서 문제가 되기 때문 에 발생한 예외이다.
더 자세히 설명하자면, 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아니다.
오히려 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것이다.
이런 예외를 비즈니스 예외라 한다.
그리고 비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.
시스템 예외( = 런타임 예외)는 다 복구 불가능하다는 전제가 있다.
( sql문법을 잘못 적어서 발생한 오류같이 해결이 안되는 문제와 같이 )
시스템에 대한 문제는 런타임 예외를 사용하고, 런타임 예외는 롤백이 된다.
비즈니스 예외를 위한 체크예외 생성 (잔고부족시)
public class NotEnoughMoneyException extends Exception{
public NotEnoughMoneyException(String message) {
super(message);
}
}
결제 잔고가 부족하면 발생하는 비즈니스 예외이다. Exception 을 상속 받아서 체크 예외가 된다
Order (주문) 엔티티 객체
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@GeneratedValue
private Long id;
private String username; //정상, 예외, 잔고부족
private String payStatus;//대기, 완료
}
JPA를 사용하는 Order 엔티티이다.
예제를 단순하게 하기 위해 @Getter , @Setter 를 사용했다.
참고로 실무에서 엔티티에 @Setter 를 남발해서 불필요한 변경 포인트를 노출하는 것은 좋지 않다.
주의!
@Table(name = "orders") 라고 했는데,
테이블 이름을 지정하지 않으면 테이블 이름이 클래스 이름인 order 가 된다.
order 는 데이터베이스 예약어( order by )여서 사용할 수 없다.
그래서 orders 라는 테 이블 이름을 따로 지정해주었다
데이터 접근 객체 ( spring data jpa 이용)
public interface OrderRepository extends JpaRepository<Order, Long> {
//spring data jpa 를 이용한다.
}
스프링 데이터 JPA를 사용한다.
서비스
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
//spring data jpa 가 등록해준 스프링빈을 주입받는다.
private final OrderRepository orderRepository;
//Jpa 는 트랜잭션 커밋 시점에 Order 데이터를 DB에 반영한다.
@Transactional
public void order(Order order) throws NotEnoughMoneyException {
log.info("order 호출");
orderRepository.save(order);
log.info("결제 프로세스 진입");
if (order.getUsername().equals("예외")) {
//사용자 이름을 이용해서 현재 어떤 상황인지 체크하는 간단한 예제
log.info("시스템 예외 발생");
throw new RuntimeException("시스템 예외");
} else if (order.getUsername().equals("잔고부족")) {
log.info("잔고 부족 비즈니스 예외 발생");
order.setPayStatus("대기");
throw new NotEnoughMoneyException("잔고가 부족합니다.");
}
else{ // 정상승인
log.info("정상 승인");
order.setPayStatus("완료");
}
log.info("결제 프로세스 완료");
}
}
여러 상황을 만들기 위해서 사용자 이름( username )에 따라서 처리 프로세스를 다르게 했다.
기본 : payStatus 를 완료 상태로 처리하고 정상 처리된다.
예외 : RuntimeException("시스템 예외") 런타임 예외가 발생한다.
잔고부족
-payStatus 를 대기 상태로 처리한다.
-NotEnoughMoneyException("잔고가 부족합니다") 체크 예외가 발생한다.
-잔고 부족은 payStatus 를 대기 상태로 두고, 체크 예외가 발생하지만,
order 데이터는 커밋되기를 기대한다.
실행하기 전에 다음을 추가하자. 이렇게 하면 JPA(하이버네이트)가 실행하는 SQL을 로그로 확인할 수 있다.
application.properties
#JPA SQL
logging.level.org.hibernate.SQL=DEBUG
지금처럼 메모리 DB를 통해 테스트를 수행하면 테이블 자동 생성 옵션이 활성화 된다.
JPA는 엔티티 정보를 참고해서 테이블을 자동으로 생성해준다
https://keeeeeepgoing.tistory.com/237
임베디드 DB 참고
Test
@Slf4j
@SpringBootTest
class OrderServiceTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void complete() throws NotEnoughMoneyException {
//given
Order order = new Order();
order.setUsername("정상");
//when
orderService.order(order);
//then
Order findOrder = orderRepository.findById(order.getId()).get();
Assertions.assertThat(findOrder.getPayStatus()).isEqualTo("완료");
}
@Test
void runtimeException() {
//given
Order order = new Order();
order.setUsername("예외");
//when
Assertions.assertThatThrownBy(() -> orderService.order(order))
.isInstanceOf(RuntimeException.class);
//런타임예외 -> 시스템 예외는 롤백되어야한다.
//then
Optional<Order> orderOptional = orderRepository.findById(order.getId());
Assertions.assertThat(orderOptional.isEmpty()).isTrue();
}
@Test
void bizException() {
//given
Order order = new Order();
order.setUsername("잔고부족");
//when
try {
orderService.order(order);
} catch (NotEnoughMoneyException e) {
log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
}
//런타임예외 -> 시스템 예외는 롤백되어야한다.
//then
Order findOrder = orderRepository.findById(order.getId()).get();
Assertions.assertThat(findOrder.getPayStatus()).isEqualTo("대기");
}
}
jpa를 이용하다보니 commit 된 후에 sql이 생성되고 진행되는것을 확인할 수 있다.
정리
NotEnoughMoneyException 은 시스템에 문제가 발생한 것이 아니라, 비즈니스 문제 상황을 예외를 통해 알려준다.
마치 예외가 리턴 값 처럼 사용된다. 따라서 이 경우에는 트랜잭션을 커밋하는 것이 맞다.
이 경우 롤백하면 생성한 Order 자체가 사라진다.
그러면 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해도 주문( Order ) 자체가 사라지기 때문에
문제가 된다.
그런데 비즈니스 상황에 따라 체크 예외의 경우에도 트랜잭션을 커밋하지 않고,
롤백하고 싶을 수 있다. 이때는rollbackFor 옵션을 사용하면 된다.
런타임 예외는 항상 롤백된다.
체크 예외의 경우 rollbackFor 옵션을 사용해서 비즈니스 상황에 따라서 커밋과 롤백을 선택하면 된다.
'인프런 > 스프링 DB 2편' 카테고리의 다른 글
12) 스프링 트랜잭션 전파 기본 (1) [미완] (1) | 2024.03.29 |
---|---|
11) 스프링 트랜잭션 이해 (2) (1) | 2024.02.16 |
10) 스프링 트랜잭션 이해 (0) | 2024.02.15 |
9) 데이터 접근 기술 - 활용 방안 (0) | 2024.02.15 |
7) 데이터 접근 기술 - 스프링 데이터 JPA (0) | 2024.02.15 |
댓글