스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?
스프링 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한
예외로 가정한다.
참고로 꼭 이런 정책을 따를 필요는 없다. 그때는 앞서 배운 rollbackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 된다.
그런데 비즈니스 의미가 있는 비즈니스 예외라는 것이 무슨 뜻일까? 간단한 예제로 알아보자.
비즈니스 요구사항
주문을 하는데 상황에 따라 다음과 같이 조치한다.
1. 정상: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료 로 처리한다.
2. 시스템 예외: 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
3. 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 대기 로 처리한다. 이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.
이때 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정하겠다. 이 예외는 시스템에 문제가 있어서 발생하는 시스템 예외가 아니다. 시스템은 정상 동작했지만, 비즈니스 상황에서 문제가 되기 때문에 발생한 예외이다. 더 자세히 설명하자면, 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아니다. 오히려 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것이다. 이런 예외를 비즈니스 예외라 한다. 그리고 비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있다.
실제 코드로 알아보자.
NotEnoughMoneyException
package hello.springtx.order;
public class NotEnoughMoneyException extends Exception{
public NotEnoughMoneyException(String message) {
super(message);
}
}
Order
package hello.springtx.order;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Table(name = "orders")
@Getter
@Setter
public class Order {
@Id
@GeneratedValue
private Long id;
private String username; //정상, 예외 , 잔고부족
private String payStatus; //대기, 완료
}
OrderRepository
package hello.springtx.order;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OrderService
package hello.springtx.order;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
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("결제 프로세스 완료");
}
}
OrderServiceTest
package hello.springtx.order;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;
@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();
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());
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();
assertThat(findOrder.getPayStatus()).isEqualTo("대기");
}
}
준비
실행하기 전에 다음을 추가하자. 이렇게 하면 JPA(하이버네이트)가 실행하는 SQL을 로그로 확인할 수 있다.
logging.level.org.hibernate.SQL=DEBUG
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 SQL
logging.level.org.hibernate.SQL=DEBUG
그런데 아직 테이블을 생성한 기억이 없을 것이다. 지금처럼 메모리 DB를 통해 테스트를 수행하면 테이블 자동 생성 옵션이 활성화 된다. JPA는 엔티티 정보를 참고해서 테이블을 자동으로 생성해준다.
참고로 테이블 자동 생성은 application.properties 에 spring.jpa.hibernate.ddl-auto 옵션을 조정할 수 있다.
complete()
사용자 이름을 정상 으로 설정했다. 모든 프로세스가 정상 수행된다.
다음을 통해서 데이터가 완료 상태로 저장 되었는지 검증한다.
assertThat(findOrder.getPayStatus()).isEqualTo("완료");
runtimeException()
사용자 이름을 예외 로 설정했다.
RuntimeException("시스템 예외") 이 발생한다.
런타임 예외로 롤백이 수행되었기 때문에 Order 데이터가 비어 있는 것을 확인할 수 있다.
bizException()
사용자 이름을 잔고부족 으로 설정했다.
NotEnoughMoneyException("잔고가 부족합니다") 이 발생한다.
체크 예외로 커밋이 수행되었기 때문에 Order 데이터가 저장된다.
다음을 통해서 데이터가 대기 상태로 잘 저장 되었는지 검증한다.
assertThat(findOrder.getPayStatus()).isEqualTo("대기");
정리