Spring

스프링 트랜잭션 rollbackFor

경딩 2026. 1. 8. 22:33

 

트랜잭션 옵션 - rollbackFor

예외 발생시 스프링 트랜잭션의 기본 정책은 다음과 같다.

  • 언체크 예외인 `RuntimeException` , 'Error' 와 그 하위 예외가 발생하면  롤백한다.
  • 체크 예외인 Exception과 그 하위 예외들은 커밋한다.

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

@Transactional(rollbackFor = Exception.class)

 

예를 들어서 이렇게 지정하면 체크 예외인 Exception 이 발생해도 롤백하게 된다.

(하위 예외들도 대상에 포함된다.)


 

예외와 트랜잭션 커밋, 롤백 기본

 

예외가 발생했는데, 내부에서 예외를 처리하지 못하고, 트랜잭션 범위(@Transactional가 적용된 AOP) 밖으로 예외를 던지면 어떻게 될까?

 

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

  • 언체크 예외인  `RuntimeException` , 'Error' 와 그 하위 예외가 발생하면  롤백한다.
  • 체크 예외인 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

 

package study.springtx.exception;

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 org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
public class RollbackTest {

    @Autowired RollbackService service;

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

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

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


    @TestConfiguration
    static class RollbackTestConfiguration {
        @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 {
    }
}

 

 

런타임 테스트시 롤백되는것을 확인할 수 있다.

실행 코드

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

 

 

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

 

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

 

 

 

예외와 트랜잭션 커밋, 롤백 

 

스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?

스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정한다.

 

체크 예외 : 비즈니스 의미가 있을 때 사용

언체크 예외 : 복구 불가능한 예외

 

체크 예외도 rollbackFor라는 옵션을 사용해서 체크예외도 롤백하면 된다.

 


비즈니스 의미가 있는 비즈니스 예외 

 

비즈니스 요구 사항

주문을 하는데 상황에 따라 다음과 같이 조치한다.

  • 정상: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 `완료`로 처리한다.
  • 시스템 예외: 주문시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다.
  • 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고, 결제 상태를 `대기` 로 처리한다.
    • 이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다.

이때 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정한다.

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

 

코드로 살펴보기

NotEnoughMoneyException

package study.springtx.order;

public class NotEnoughMoneyException extends Exception {

    public NotEnoughMoneyException(String message) {
        super(message);
    }
}

 

결제 잔고가 부족하면 발생하는 비즈니스  예외이다. Exception을 상속받아 체크 예외가 된다.

 

package study.springtx.order;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@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라는 테이블 이름을 따로 붙여주었다.

 

package study.springtx.order;

import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {
}

스프링 데이터 JPA를 사용한다.

 

OrderService

package study.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("결재 프로세스 완료");
    }


}

 

여러 상황은 만들기 위해서 사용자 이름 -username 에 따라서 처리 프로세스를 다르게 했다.

기본: payStatus를 완료 상태로 처리하고 정상 처리된다.

예외: RuntimeException("시스템 예외 발생") 런타임 예외가 발생한다.

잔고 부족

payStatus를 대기 상태로 처리한다.

NotEnoughMoneyException("잔고가 부족합니다") 체크 예외를 발생한다.

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

 

테스트 코드

package study.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.AssertionsForClassTypes.assertThat;
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 savedOrder = orderRepository.findById(order.getId()).get();
        Assertions.assertThat(savedOrder.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
        Assertions.assertThatThrownBy(() -> orderService.order(order))
                .isInstanceOf(NotEnoughMoneyException.class);

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

}

 

complete()

사용자 이름을 정상이라 저장했다. 모든 프로세스가 정상 수행된다.

다음을 통해서 데이터가 완료 상태로 저장되었는지 검증한다.

  Assertions.assertThat(savedOrder.getPayStatus()).isEqualTo("완료");

 

 

runTimeException()

사용자 이름을 예외라 저장했다.

RuntimeException("시스템 예외")

 발생한다.

 

런타임 에러로 롤백이 수행되었기 때문에 Order데이터가 비어있는 것을 확인할 수 있다.

 

 

 

bizException()

사용자 이름을 잔고 부족이라 저장했다.

NotEnoughMoneyException("잔고가 부족합니다")

가 발생한다.

 

체크 예외로 커밋이 수행되었기 때문에 Order 데이터가 저장된다.

다음을 통해서 대기 상태로 잘  저장되었는지 검증한다.

Assertions.assertThat(findOrder.getPayStatus()).isEqualTo("대기");

`NotEnoughMoneyException` 은 시스템에 문제가 발생한것이 아닌, 비즈니스 문제 상황을 예외를 통해 알려준다. 마치 예외가 리턴값 처럼 사용된다. 따라서 이 경우에는 트랜잭션을 커밋하는것이 맞다. 이 경우 롤백을 하면 새로 생성된 Order 자체가 사라진다. 그러면 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해도 주문 (Order)자체가 사라지기 때문에 문제가 된다.

 

그런테 비즈니스 상황에 따라 체크 예외의 경우에도 트랜잭션을 커밋하지 않고, 롤백하고 싶을 수 있다. 이때는 rollbackFor옵션을 사용하면 된다.

 

런타임 예외는 항상 롤백된다. 체크 예외의 경우 rollbackFor옵션을 사용해서 비즈니스 상황에 따라서 커밋과 롤백을 선택하면 된다.

 

 

참고자료: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2/dashboard