[SpringDB2] 예외와 트랜잭션 커밋, 롤백
기본
예외가 발생했을 때 내부에서 예외를 처리하지 못하고, 트랜잭션 범위(@Transactional이 적용된 AOP
) 밖으로 예외를 던지면?
예외 발생 시 스프링 트랜잭션 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() 실행
MyException
은 Exception
을 상속받은 체크 예외이기 때문에, 예외가 발생해도 트랜잭션이 커밋된다.
실행 결과
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
활용
스프링이 체크 예외는 커밋하고, 런타임 예외는 롤백하는 이유는 스프링이 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구 불가능한 예외로 가정하기 때문이다.
- 체크 예외: 비즈니스 의미가 있을 때 사용
- 언체크 예외: 복구 불가능한 예외
그렇다면 의미가 있는 비즈니스 예외는 무엇인가?
예제
비즈니스 요구사항
- 정상: 주문 시 결제를 성공하면 주문 데이터를 저장하고, 결제 상태를
완료
로 처리한다. - 시스템 예외: 주문 시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
- 비즈니스 예외: 주문 시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를
대기
로 처리한다.
결제 잔고가 부족해서 발생하는 예외는 시스템에 문제가 있어서 발생하는 것이 아니다. 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것이다. 이런 예외를 비즈니스 예외라 한다. 비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.
예제 코드
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
댓글남기기