spring/스프링 DB 2편

8. 스프링 트랜잭션 이해(3)

sh1mj1 2023. 2. 23. 21:07
이 글은 배민 기술이사 김영한 이사님의 인프런 강의 "스프링 DB 2편 - 데이터 접근 활용 기술" 을 기반으로 작성되었습니다. 문제 시 삭제 조치하겠습니다.

 

아래와 같은 순서로 공부를 진행합니다.

 

 

  • 트랜잭션 옵션 소개
  • 예외와 트랜잭션 커밋, 롤백 - 기본
  • 예외와 트랜잭션 커밋, 롤백 - 활용

 

 

6. 트랜잭션 옵션 소개

@Transactional

public @interface Transactional {

    String value() default "";
    String transactionManager() default "";

    Class<? extends Throwable>[] rollbackFor() default {};
    Class<? extends Throwable>[] noRollbackFor() default {};

    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.DEFAULT;
    int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
    boolean readOnly() default false;
    String[] label() default {};
}

value, transactionManager

트랜잭션을 사용하려면 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 합니다.

생각해보면 코드로 직접 트랜잭션을 사용할 때 분명 트랜잭션 매니저를 주입 받아서 사용했었습니다.

 

@Transactional에서도 트랜잭션 프록시가 사용할 트랜잭션 매니저를 지정해주어야 합니다. 사용할 트랜잭션 매니저를 지정할 때는 value , transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 됩니다.

이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략합니다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분해주어야 합니다.

 

public class TxService {

    @Transactional("memberTxManager")
    public void member() {...}

    @Transactional("orderTxManager")
    public void order() {...}
}

참고로 애노테이션에서 속성이 하나인 경우 위 예처럼 value는 생략하고 값을 바로 넣을 수 있습니다.

 

rollbackFor

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

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

 

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

예를 들어서 아래처럼 지정하면 체크 예외인 Exception이 발생해도 롤백할 수 있습니다. (하위 예외들도 대상에 포함)

@Transactional(rollbackFor = Exception.class)

rollbackForClassName도 있는데, rollbackFor는 예외 클래스를 직접 지정하고 rollbackForClassName는 예외 이름을 문자로 넣으면 됩니다.

 

noRollbackFor

앞서 설명한 rollbackFor와 반대입니다. 기본 정책에 추가로 어떤 예외가 발생했을 때 롤백하면 안되는지 지정합니다.

예외 이름을 문자로 넣을 수 있는 noRollbackForClassName도 있습니다.

 

propagation

트랜잭션 전파에 대한 옵션입니다.

 

isolation

트랜잭션 격리 수준을 지정하는 옵션입니다.

기본 값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 DEFAULT입니다. 대부분 데이터베이스에서 설정한 기준을 따릅니다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물어요

  • DEFAULT: 데이터베이스에서 설정한 격리 수준을 따름
  • READ_UNCOMMITTED: 커밋되지 않은 읽기
  • READ_COMMITTED: 커밋된 읽기
  • REPEATABLE_READ: 반복 가능한 읽기
  • SERIALIZABLE: 직렬화 가능

 

timeout

트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정합니다. 기본 값은 트랜잭션 시스템의 타임아웃을 사용합니다. 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 합니다. timeoutString도 있는데, 숫자 대신 문자 값으로 지정할 수 있습니다.

 

label

트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용합니다. 일반적으로 사용하지 않습니다.

 

readOnly

트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성됩니다. readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성됩니다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 작동합니다 (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있음) 그리고 readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있습니다.

readOnly 옵션은 크게 3곳에서 적용됩니다.

 

프레임워크

  • JdbcTemplate 은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던집니다.
  • JPA(하이버네이트)는 읽기 전용 트랜잭션의 경우 커밋 시점에 플러시를 호출하지 않습니다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없습니다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않습니다. 이렇게 JPA에서는 다양한 최적화가 발생합니다.

 

JDBC 드라이버

  • 여기서 설명하는 내용들은 DB와 드라이버 버전에 따라서 다르게 동작하기 때문에 사전에 확인이 필요합니다.
  • 읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던집니다.
  • 읽기, 쓰기(마스터, 슬레이브) 데이터베이스를 구분해서 요청합니다. 읽기 전용 트랜잭션의 경우 읽기 (슬레이브) 데이터베이스의 커넥션을 획득해서 사용합니다

 

데이터베이스

  • 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생합니다.

 

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

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

 

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

 

https://user-images.githubusercontent.com/52024566/205658158-8650af5e-4aff-417b-8c71-e392451b657a.png

  • 언체크 예외인 RuntimeException, Error와 그 하위 예외가 발생하면 트랜잭션을 롤백합니다.
  • 체크 예외인 Exception과 그 하위 예외가 발생하면 트랜잭션을 커밋합니다.
  • 물론 정상 응답(리턴)하면 트랜잭션을 커밋합니다.

 

RollbackTest

@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

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 가 실행되고, 여기의 로그를 출력합니다.

 

runtimeException() 실행 - 런타임 예외

//런타임 예외 발생: 롤백
@Transactional
public void runtimeException() {
    log.info("call runtimeException");
    throw new 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() 실행 - 체크 예외

//체크 예외 발생: 커밋
@Transactional
public void checkedException() throws MyException {
    log.info("call checkedException");
    throw new MyException();
}

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

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

@Transactional(rollbackFor = Exception.class)

이렇게 지정하면 체크 예외인 Exception 이 발생해도 커밋하지 않고 롤백합니다. (자식 타입도 롤백합니다.)

 

rollbackFor() 실행 - 체크 예외를 강제로 롤백

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

기본 정책과 무관하게 특정 예외를 강제로 롤백하고 싶으면 rollbackFor를 사용하면 됩니다. (해당 예외의 자식도 포함)

rollbackFor = MyException.class을 지정했기 때문에 MyException이 발생하면 체크 예외이지만 트랜잭션이 롤백됩니다.

 

실행 결과

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

 

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

그런데 스프링이 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백하는 이유는 무엇일까요?

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

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

 

참고로 꼭 이런 정책을 따를 필요는 없습니다. 그때는 앞서 배운 rollbackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 됩니다.

 

그런데 비즈니스 의미가 있는 비즈니스 예외라는 것은 무엇일까요?

아래 예시를 봅시다.

비즈니스 요구사항

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

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

 

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

 

이 예외는 시스템에 문제가 있어서 발생하는 시스템 예외가 아닙니다. 시스템은 정상 동작했지만, 비즈니스 상황에서 문제가 되기 때문에 발생한 예외입니다.

 

더 자세히 설명하자면, 고객의 잔고가 부족한 것은 시스템에 문제가 있는 것이 아니라 오히려 시스템은 문제 없이 동작한 것이고, 비즈니스 상황이 예외인 것입니다. 이런 예외를 비즈니스 예외라고 합니다.

비즈니스 예외는 매우 중요하고, 반드시 처리해야 하는 경우가 많으므로 체크 예외를 고려할 수 있습니다.

 

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 엔티티입니다.

예제를 단순하게 하기 위해 @Getter, @Setter를 사용하겠습니다. 참고로 실무에서 엔티티에 @Setter를 남발해서 불필요한 변경 포인트를 노출하는 것은 좋지 않습니다.

 

주의 - @Table(name = "orders")라고 했는데, 테이블 이름을 지정하지 않으면 테이블 이름이 클래스 이름인 order가 됩니다. order는 데이터베이스 예약어(order by)여서 사용할 수 없으므로 orders라는 테이블 이름을 따로 지정하였습니다.

 

OrderRepository

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

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

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

 

OrderService

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 데이터는 커밋되기를 기대하게 됩니다.

 

OrderServiceTest

@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, then
        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);
            Assertions.fail("잔고 부족 예외가 발생해야 합니다.");
        } 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.propertiesspring.jpa.hibernate.ddl-auto 옵션을 조정할 수 있습니다.

  • none: 테이블을 생성하지 않음
  • create: 애플리케이션 시작 시점에 테이블을 생성

 

실행 SQL

create table orders...

 

complete()

사용자 이름을 정상으로 설정합니다.. 모든 프로세스가 정상 수행합니다.

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

assertThat(findOrder.getPayStatus()).isEqualTo("완료");

 

runtimeException()

사용자 이름을 예외로 설정합니다.

 

RuntimeException("시스템 예외")이 발생합니다.

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

 

bizException()

사용자 이름을 잔고부족으로 설정합니다.

 

NotEnoughMoneyException("잔고가 부족합니다")이 발생합니다.

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

다음을 통해서 데이터가 대기 상태로 잘 저장 되었는지 검증합니다.

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

 

정리

NotEnoughMoneyException은 시스템에 문제가 발생한 것이 아니라, 비즈니스 문제 상황을 예외를 통해 알려주는 것입니다.

 

마치 예외가 리턴 값 처럼 사용됩니다. 따라서 이 경우에는 트랜잭션을 커밋하는 것이 맞습니다. 이 경우 롤백하면 생성한 Order 자체가 사라집니다.

그러면 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내해도 주문(Order) 자체가 사라지기 때문에 문제가 됩니다.

 

  • 그런데 비즈니스 상황에 따라 체크 예외의 경우에도 트랜잭션을 커밋하지 않고 롤백하고 싶을 수 있습니다. 이때는 rollbackFor 옵션을 사용하면 됩니다.
  • 런타임 예외는 항상 롤백됩니다. 체크 예외의 경우 rollbackFor 옵션을 사용해서 비즈니스 상황에 따라서 커밋과 롤백을 선택할 수 있습니다.