3 분 소요


기본

예외가 발생했을 때 내부에서 예외를 처리하지 못하고, 트랜잭션 범위(@Transactional이 적용된 AOP) 밖으로 예외를 던지면?

17
예외 발생 시 스프링 트랜잭션 AOP는 예외의 종류에 따라 트랜잭션을 커밋하거나 롤백한다.

  • 언체크 예외인 RuntimeException, Error와 그 하위 예외가 발생하면 트랜잭션을 롤백한다.
  • 체크 예외인 Exception과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.

예시 코드

@SpringBootTest
public class RollbackTest {

    @Autowired RollbackService service;

    @Test
    void runtimeException() {
        assertThatThrownBy(() -> service.runtimeException())
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(() -> service.checkedException())
                .isInstanceOf(MyException.class);
    }

    @Test
    void rollbackFor() {
        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 checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        //체크 예외 rollbackFor 지정: 롤백
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call rollbackFor");
            throw new MyException();
        }
    }

    static class MyException extends Exception {
    }
}

추가 설정

다음 코드를 추가하면 트랜잭션이 커밋되었는지 롤백 되었는지 로그로 확인할 수 있다.

application.properties

...
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG


runtimeException() 실행

RuntimeException이 발생하므로 트랜잭션이 롤백된다.

실행 결과

Getting transaction for [...RollbackService.runtimeException]
call runtimeException
Completing transaction for [...RollbackService.runtimeException] after exception: RuntimeException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager

checkedException() 실행

MyExceptionException을 상속받은 체크 예외이기 때문에, 예외가 발생해도 트랜잭션이 커밋된다.

실행 결과

Getting transaction for [...RollbackService.checkedException]
call checkedException
Completing transaction for [...RollbackService.checkedException] after exception: MyException
Initiating transaction commit
Committing JPA transaction on EntityManager

rollbackFor() 실행

rollbackFor
이 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할지 지정할 수 있다.

@Transactional(rollbackFor = Exception.class)

이렇게 지정하면 체크 예외인 Exception이 발생해도 커밋 대신 롤백한다.(자식 타입 포함)

실행 결과

Getting transaction for [...RollbackService.rollbackFor]
call rollbackFor
Completing transaction for [...RollbackService.rollbackFor] after exception: MyException
Initiating transaction rollback
Rolling back JPA transaction on EntityManager


활용

스프링이 체크 예외는 커밋하고, 런타임 예외는 롤백하는 이유는 스프링이 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구 불가능한 예외로 가정하기 때문이다.

  • 체크 예외: 비즈니스 의미가 있을 때 사용
  • 언체크 예외: 복구 불가능한 예외

그렇다면 의미가 있는 비즈니스 예외는 무엇인가?

예제

비즈니스 요구사항

  1. 정상: 주문 시 결제를 성공하면 주문 데이터를 저장하고, 결제 상태를 완료로 처리한다.
  2. 시스템 예외: 주문 시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
  3. 비즈니스 예외: 주문 시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기로 처리한다.

결제 잔고가 부족해서 발생하는 예외는 시스템에 문제가 있어서 발생하는 것이 아니다. 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것이다. 이런 예외를 비즈니스 예외라 한다. 비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.

예제 코드

NotEnoughMoneyException

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 엔티티이다.
  • 예제를 단순하게 하기 위해 @Setter도 사용했지만, 실무에서 엔티티에 @Setter를 남발해서 불필요한 변경 포인트를 노출하는 것은 좋지 않다.

[주의]
테이블 이름을 지정하지 않으면 테이블 이름이 클래스 이름인 order가 된다. order는 데이터베이스 예약어(order by)여서 사용할 수 없기 때문에 @Table(name = "orders")코드를 적어서 테이블 이름을 따로 지정해주었다.


OrderRepository

public interface OrderRepository extends JpaRepository<Order, Long> {

}
  • 스프링 데이터 JPA 사용

OrderService

@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    @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("결제 프로세스 완료");
    }
}

잔고 부족은 payStatus대기 상태로 두고, 체크 예외가 발생하지만, order 데이터는 커밋되기를 기대한다.


OrderServiceTest

@Slf4j
@SpringBootTest
class OrderServiceTest {

    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    void complete() throws NotEnoughMoneyException {

        Order order = new Order();
        order.setUsername("정상")

        orderService.order(order);

        Order findOrder = orderRepository.findById(order.getId()).get();
        assertThat(findOrder.getPayStatus()).isEqualTo("완료");
    }

    @Test
    void runtimeException() {

        Order order = new Order();
        order.setUsername("예외");

        assertThatThrownBy(() -> orderService.order(order))
                .isInstanceOf(RuntimeException.class);

        // 롤백되었으므로 데이터가 없어야 한다.
        Optional<Order> orderOptional = orderRepository.findById(order.getId());
        assertThat(orderOptional.isEmpty()).isTrue();
    }

    @Test
    void bizException() {

        Order order = new Order();
        order.setUsername("잔고부족");

        try {
            orderService.order(order);
            fail("잔고 부족 예외가 발생해야 한다.");
        } catch(NotEnoughMoneyException e) {
            log.info("고객에게 잔고 부족을 알림");
        }

        Order findOrder = orderRepository.findById(order.getId()).get();
        assertThat(findOrder.getPayStatus()).isEqualTo("대기");
    }
}

준비
application.properties에 다음 코드를 추가하면 JPA(하이버네이트)가 실행하는 SQL을 로그로 확인할 수 있다.
logging.level.org.hibernate.SQL=DEBUG


<출처 : 인프런 - 스프링 DB 2편 : 데이터 접근 활용 기술(김영한)>

댓글남기기