스프링 트랜잭션 이해

고동현·2024년 7월 4일
0

DB

목록 보기
10/13

스프링 트랜잭션 소개

앞서 이 글에서 스프링이 제공하는 트랜잭션 기능이 왜필요하고, 어떻게 동작하는지 알아보았다.

  1. 스프링 트랜잭션 추상화
    JDBC와 JPA의 트랜잭션 코드가 서로 다르다.
    따라서 서비스에서 트랜잭션을 수행할때, JDBC -> JPA로 바꾸면 서비스의 모든 코드를 뜯어 고쳐야한다.

    그래서 스프링은 PlatformTransactionManager라는 인터페이스로 트랜잭션을 추상화한다.

    스프링은 트랜잭션을 추상화하는것 뿐만아니라, 트랜잭션 매니저의 구현체도 제공한다.
    우리는 필요한 구현체를 스프링 빈으로 등록하고, 주입받아서 사용하기만 하면된다.
    이것 또한 라이브러리 다운받으면 빈에 자동등록해서 빈으로 등록해줌

    여기서 스프링 부트는 어떤 데이터 접근기술을 사용하는지 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록하므로 그냥 라이브러리만 다운 받으면, 트랜잭션 매니저를 선택하고 등록하는것까지 다해준다.
    jdbcTemplate,MyBatis를 사용하면 DataSourceTransactionmanager를 빈으로 등록, JPA를 사용하면 JpaTranasactionManager를 빈으로 등록한다.

  2. PlatformTransactionManager사용방법
    @Transaction애노테이션만 사용하면 됨, 대부분사용
    다른 방식으로는 앞에서 한것처럼 트랜잭션 매니저를 직접등록하는방법이 있음

  3. @Transactional 동작방식
    원래 우리는 서비스계층에서 직접 트랜잭션을 사용했다.

    TransactionStatus status = transactionManager.getTransaction(new 
    DefaultTransactionDefinition());
    try {
    //비즈니스 로직
    bizLogic(fromId, toId, money);
      transactionManager.commit(status); //성공시 커밋
    } catch (Exception e) {
      transactionManager.rollback(status); //실패시 롤백
    throw new IllegalStateException(e);
    }

하지만 트랜잭션을 처리하기위한 프록시사용하면, 트랜잭션 프록시가 트랜잭션 코드를 모두 가져가므로, 서비스에는 순수한 비즈니스로직만 남긴다.

항상 트랜잭션은 트랜잭션 매니저가 수행한다.
고로, 이 트랜잭션 매니저를 프록시가 부르느냐 아니면 서비스로직에 직접 작성하여 서비스가 부르느냐 이 차이이다.
동일한 트랜잭션유지를 위해 데이터베이스 커넥션을 사용, 이를 위해 트랜잭션 동기화 매니저가 사용

  1. 트낼잭션은 중요한 기능이고, 전세계 누구나 사용한다.
    스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 @Transactional로 제공한다.

트랜잭션 적용 확인

@Slf4j
@SpringBootTest
public class TxApplyBasicTest {

    @Autowired
    BasicService basicService;

    //빈으로 등록해야 트랜잭션 사용가능
    @TestConfiguration
    static class TxApplyBasicConfig{
        @Bean
        BasicService basicService(){
            return new BasicService();
        }
    }

    static class BasicService{
        @Transactional
        public void tx(){
            log.info("call tx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}",txActive);
        }

        public void nonTx(){
            log.info("call nonTx");
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}",txActive);
        }
    }

    @Test
    void proxyCheck(){
        log.info("aop class = {}",basicService.getClass());
        assertThat(AopUtils.isAopProxy(basicService)).isTrue();
    }

    @Test
    void txTest(){
        basicService.tx();
        basicService.nonTx();
    }
}


@Transactional 애노테이션이 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다.
즉, 스프링 컨테이너에는 실제 basicService객체 대신에 프록시인 basicService$$CGLIB가 등록이 된다. 그리고 이 프록시가 내부에서 실제 basicService를 참조한다.

해당 코드에서 @Autowired BasicSerive basicservice는 스프링 컨테이너에서 해당 타입을 찾아 주입을 시켜줘야하므로, 실제 basicSerivce가 아닌 스프링 컨테이너에 들어가있는 프록시 basicService$$CGLIB가 등록된다.

tx() 호출시
프록시의 tx가 호출된다. 프록시가 먼저 tx()메서드가 트랜잭션을 사용하는지 확인하고, 있으므로, 트랜잭션 시작후 basicService.tx()를 호출, 리턴이 돌아오면 커밋또는 롤백

로그와 같이 tx는 transaction을 수행하는 것을 볼 수 있다.

nontx는 로그와 같이
클라이언트가 basicService.nonTx()호출시 트랜잭션 프록시의 nonTx()가 호출된다. 그러나 트랜잭션을 사용할수 없다. @Transactional이 없으므로, 트랜잭션 적용대상이 아니면 트랜잭션을 시작하지 않고 바로 실제 basicService에 위임한다.

그러면 진짜로 basicService가 컨테이너에 실제가 아니라 프록시가 등록되었는지 확인해보면,

CGLIB로 등록된것을 볼 수 있다.

이처럼 트랜잭션은 @Transactional을 통해서 스프링에게 해당 기능을 위임하여 사용하는 것이다. 스프링이 용빼는 재주가 있는것도 아니고, 해당 기능을 위임시켜 실행하려면 @Bean으로 등록해야한다. 그래서 basicService를 만들고 Config에서 Bean으로 등록시킨것이다.
당연히, bean으로 등록시켰으니까 컨테이너에서 가져와서 @Autowired가 가능하다.

트랜잭션 적용 위치

@SpringBootTest
public class TxLevelTest {
    @Autowired
    LevelService service;
    @Test
    void orderTest() {
        service.write();
        service.read();
    }
    @TestConfiguration
    static class TxApplyLevelConfig {
        @Bean
        LevelService levelService() {
            return new LevelService();
        }
    }
    @Slf4j
    @Transactional(readOnly = true)
    static class LevelService {
        @Transactional(readOnly = false)
        public void write() {
            log.info("call write");
            printTxInfo();
        }

        public void read() {
            log.info("call read");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive =
                    TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active={}", txActive);
            boolean readOnly =
                    TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly={}", readOnly);
        }
    }
}

당현이 구체적인게 우선순위가 높다.
wirte에는 readonly가 false이므로 readonly가 false이고 read는 LevelService class에 적용된 readOnly=true를 따라가므로 read는 readOnly가 true이다.

트랜잭션 AOP 주의사항 - 프록시 내부호출

@Transactional 사용시 프록시 객체가 요청을 먼저 받아서 트랜잭션 처리후, 실제 서비스의 메서드를 호출 하게 된다.
그래서, 트랜잭션을 사용하려면 항상 프록시를 통해서 Target객체를 호출해야한다.
만약 프록시를 거치지 않고 대상 객체를 직접 호출하면 트랜잭션이 적용되지 않는다.


@SpringBootTest
@Slf4j
public class InternalCallV1Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy(){
        log.info("callService class = {}",callService.getClass());
    }

    @Test
    void internalCall(){
        callService.internal();
    }

    @Test
    void externalCall(){
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1Config{
        @Bean
        CallService callService(){
            return new CallService();
        }
    }
    @Slf4j
    static class CallService{
        public void external(){
            log.info("call external");
            printTxInfo();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}",txActive);

        }
    }
}

printProxy를 보자

callService.getclass로 호출시 CGLIB로 프록시가 들어가 있는것을 확인 할 수 있다.

internal을 보자
internal에는 @Transactional이 있다.

callService의 트랜잭션 프록시가 호출되고, 트랜잭션 적용후 실제 callService(target)의 internal을 호출한다.
실제 callService가 처리가 완료되면 이제 응답이 트랜잭션 프록시로 돌아오고, 트랜잭션 프록시는 commit또는 rollback을 한다.

트랜잭션이 적용된것을 볼 수있다.

문제는 external메서드이다.
로그부ㅌ터 보면 internal을 호출했음에도 위의 로그와 달리 트랜잭션에 대한 로그가 남아있지 않다.

왜그런것일까?

클라이언트가 프록시의 external()메서드를 호출한다. 그러나 여기에는 트랜잭션이 적용되지 않는다. 그러면 해당 로직을 실제 target에 위임하고, 실제 callService의 external메서드를 호출하게된다.

메서드에서 앞에 별도의 참조가 없으면 this라는 뜻으로 자기 자신의 인스턴스를 가르킨다.
고로, external메서드 내부에서 호출한 internal메서드는 this.internal()로 실제 Target객체의 인스턴스의 메서드를 뜻한다.

이러면 결과적으로 내부호출은 프록시를 거치지 않게되고, 트랜잭션이 적용되지 않는다.

★★★★프록시 방식의 AOP의 한계
@Transactional을 사용하는 트랜잭션 프록시는 메서드 내부호출시에 프록시를 적용할수 없다.

내부호출 해결방안

내부호출을 해야하는 메서드는 따로 별도의 클래스로 분리한다.

@SpringBootTest
@Slf4j
public class InternalCallV2Test {

    @Autowired
    CallService callService;

    @Autowired
    InternalService internalService;

    @Test
    void printProxy(){
        log.info("callService class = {}",callService.getClass());
    }

    @Test
    void internalCall(){
        internalService.internal();
    }

    @Test
    void externalCall(){
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1Config{
        @Bean
        CallService callService(){
            return new CallService(internalService());
        }
        @Bean
        InternalService internalService(){
            return new InternalService();
        }
    }
    @Slf4j
    @RequiredArgsConstructor
    static class CallService{
        private final InternalService internalService;
        public void external(){
            log.info("call external");
            printTxInfo();
            internalService.internal();;
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}",txActive);

        }
    }

    @Slf4j
    static class InternalService{
        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}",txActive);

        }
    }
}

external메서드에서 Transactional이 적용되야하는 internal을 내부에서 호출하는것이 문제였으므로,
internal메서드를 따로 InternalService class로 분리하였다.

클라이언트가 external()을 호출하면 Transactional이 없으므로 실제 callService의 external이 호출된다.

현재 InternalService에는 트랜잭션이 적용된 코드가 1개 이상 있으므로 프록시가 만들어져있고,
그리고, callService.internal()을 호출하면, 해당 프록시의 internal메서드를 확인하는데 transactional이 적용되어있으므로
트랜잭션 적용 후, 실제 target의 internal()메서드를 호출한다.


로그르 보면 external은 정상적으로 트랜잭션 적용 안되고, internal은 트랜잭션이 적용 된것을 볼 수 있다.

참고: 스프링 트랜잭션의 AOP기능은 public 메서드에만 적용되도록 기본설정 되어있다.
=> 클래스 레벨에 트랜잭션 적용시, 모든 메서드에 트랜잭션이 걸린다.
그런데 대부분 비즈니스 로직에서 외부에서 접근가능한 메서드를 트랜잭션 시작점으로 사용하므로, private,protected까지 전부 트랜잭션을 거는것은 과하므로 적용하지 않는다.

그래서 public이 아닌곳에 @Transactional이 붙으면 예외가 발생하는게 아니라, 트랜잭션 적용을 무시한다. 고로, 잘 살펴봐야한다.

주의사항 2 초기화시점

@SpringBootTest
public class InitTxTest {

    @Autowired
    Hello hello;

    @Test
    void go(){
        //초기화는 스프링이 초기화 시점에 호출
    }

    @TestConfiguration
    static class InitTxTestConfig{
        @Bean
        Hello hello(){
            return new Hello();
        }
    }

    @Slf4j
    static class Hello{
        @PostConstruct
        @Transactional
        public void initV1(){
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("@PostConstruct tx active={}",isActive);
        }

        @EventListener(value = ApplicationReadyEvent.class)
        @Transactional
        public void initV2(){
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("@PostConstruct tx active={}",isActive);
        }
    }
}

Hello클래스의 initV1메서드는 PostConstruct,Transactional이 동시에 사용되었는데, 의존성 주입이 완료된 후에 PostConstruct로 뭐 기본 User를 넣는다던지 할 수 있다. 그러나 트랜잭션 AOP가 적용되지 않았다.
그 이유는 초기화 코드가 먼저 호출되고, 그다음에 트랜잭션 AOP가 적용되기 떄문이다.

따라서, initV2처럼 EventLister를 통해 트랜잭션 AOP를 포함한 모든 스프링 컨테이너가 완전히 생성되고 난 다음에 이벤트가 붙은 메서드를 호출하여 주면 트랜잭션이 적용된것을 확인 할 수 있다.

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

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

  • 체크예외 Exception과 그 하위 예외 -> 커밋
  • 언체크 예외 RuntimeException,Error와 그 하위 예외 -> 롤백

프로퍼티에 로그레벨 추가

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

@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{
        //런타임 예외 - Rollback
        @Transactional
        public void runtimeException(){
            log.info("call runtimeException");
            throw new RuntimeException();
        }

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

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

    static class MyException extends Exception{}
}

RollbackService에는 3가지 메서드가 있다.

  • runtimeException()
    런타임 예외가 발생하면 트랜잭션이 롤백된다.

  • checkedException()
    MyException은 Exception을 상속받은 체크 예외이다. 따라서 예외가 발생하더라도 커밋된다.

  • rollbackfor()
    rollbackFor 옵션을 사용하면 어떤 예외가 발생할때 롤백할 수 있을지 지정할 수 있다.
    MyException은 체크 예외라 커밋되어야하지만 rollbackFor옵션으로 롤백하였다.

그렇다면 왜 체크예외는 커밋하고, 언체크 예외는 롤백할까?

스프링은 기본적으로 예외를 두가지로 본다.

  • 언체크예외, 런타임 예외: DB 컨넥션 오류등 복구가 불가능한 예외
  • 체크 예외: 비즈니스 로직 수행중 발생한 예외

고객이 주문을 한다고 치는데, DB 커넥션 오류같은 런타임 예외가 발생한다 치자 -> 롤백을 한다.
고객이 주문을 하는데 잔고가 부족하다. -> 롤백을 하지 않고, 결제 대기로 바꾸어 커밋하고, 고객에게 입금 계좌를 알려준다.
이렇게 하면, 결제 정보가 DB에 남기 때문에 다시 고객의 주문정보를 받지 않아도 된다.

public class NotEnoughMoneyException extends Exception{
    public NotEnoughMoneyException(String message){
        super(message);
    }
}

Exception을 받아서 체크예외이다.

@Entity
@Getter
@Setter
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue
    private Long id;

    private String username;
    private String payStatus;
}
public interface OrderRepository extends JpaRepository<Order,Long> {
}
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
    public 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("결제 프로세스 완료");
    }
}

order메서드에 @Transactional을 걸었다.

  • "예외" : RuntimeException 복구불가 - rollback
  • "잔고부족" : status를 대기로 바꾸고 - commit(NotEnoughMoneyException은 체크예외라 커밋됨)

@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
        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);
            fail("잔고 부족 예외가 발생해야 합니다.");
        }
        catch (NotEnoughMoneyException e) {
            log.info("고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내");
        }
        //then
        Order findOrder = orderRepository.findById(order.getId()).get();
        assertThat(findOrder.getPayStatus()).isEqualTo("대기");
    }

}
  • complete
    정상 흐름은 트랜잭션 시작후 커밋하고, insert 쿼리까지 날리는 걸 확인 할 수 있다.

  • runtimeException
    "예외"이름은 롤백을 하므로 insert쿼리를 날리지 않고, 당연히 DB에 반영도 안된다.

  • bizException
    bizException의 try catch문을 보면
    NotEnoughMoneyExceptino 즉 체크예외가 발생한다. -> 커밋을 한다.
    로그를 보면 inser쿼리문을 날리는 것을 볼 수 있다. 해당 DB에는 고객의 주문정보가 대기 상태로 들어가있다.

결국 체크예외는 커밋 언체크 예외는 롤백한다.
체크 예외를 커밋하는 이유는 비즈니스 로직에 오류가 발생하면, 해당 요청을 저장해두었다가 쓰라는 것이다.
물론 사용하지 않고 그냥 rollbackFor로 저장하지 않고 롤백쳐도 된다.
개발자가 상황에 맞게 쓰면 된다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글