본문 바로가기
Spring/DB 2편

스프링 트랜잭션 이해

by JHyun0302 2023. 8. 11.
728x90

스프링 트랜잭션 추상화

 

  • 스프링은 트랜잭션을 추상화해서 제공한다. 개발자는 필요한 구현체를 스프링 빈으로 등록하고 주입 받아서 사용하면 된다.
  • JdbcTemplate , MyBatis 를 사용하면 DataSourceTransactionManager(JdbcTransactionManager) 를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager 를 스프링 빈으로 등록해준다.

 

 

 

 

 

 

PlatformTransactionManager 를 사용하는 방법

  1. 선언적 트랜잭션 관리 (Declarative Transaction Management)
        @Transactional 애노테이션 하나만 선언

  2. 프로그래밍 방식의 트랜잭션 관리 (Programmatic Transaction Management)
         트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것

 

 

 

선언적 트랜잭션과 AOP


@Transactional 을 통한 선언적 트랜잭션 관리 방식을 사용하게 되면 기본적으로 프록시 방식의 AOP가 적용된다.

 

 

 

 

프록시 도입 전

 

 

 

 

 

 

프록시 도입 후

 

 

 

트랜잭션 프록시 코드 예시

public class TransactionProxy {
    
    private MemberService target;
    
    public void logic() { 
	    //트랜잭션 시작
        TransactionStatus status = transactionManager.getTransaction(..);
        try {
        //실제 대상 호출
        target.logic();
        transactionManager.commit(status); //성공시 커밋 
        } catch (Exception e) {
            transactionManager.rollback(status); //실패시 롤백
            throw new IllegalStateException(e);
    	}
    } 
}

 

 

 

트랜잭션 프록시 적용 후 서비스 코드 예시

public class Service {
    public void logic() {
        //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
        bizLogic(fromId, toId, money);
    }
}

 

 

 

  • 프록시 도입 전: 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.
  • 프록시 도입 후: 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다.
                            트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니즈 로직만 남길 수 있다.

 

 

 

프록시 도입 후 전체 과정

 

  • 트랜잭션은 커넥션에 con.setAutocommit(false) 를 지정하면서 시작한다.
  • 같은 트랜잭션을 유지하려면 같은 데이터베이스 커넥션을 사용해야 한다.
  • 이것을 위해 스프링 내부에서는 트랜잭션 동기화 매니저가 사용된다
  • JdbcTemplate 을 포함한 대부분의 데이터 접근 기술들은 트랜잭션을 유지하기 위해 내부에서 트랜잭션 동기화 매니저를 통해 리소스(커넥션)를 동기화 한다.

 

 

 

 

스프링이 제공하는 트랜잭션 AOP = @Transactional

 

 

 

 

 


트랜잭션 적용 확인

 

 

스프링 컨테이너에 트랜잭션 프록시 등록

 

  1. @Transactional이 특정 클래스나 메서드 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록
        ex) basicService 객체 대신에 프록시인 basicService$$CGLIB 를 스프링 빈에 등록
  2. 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점
  3. 클라이언트인 txBasicTest 스프링 컨테이너에 @Autowired BasicService basicService 로 의존관계 주입을 요청
  4. 프록시BasicService 를 상속해서 만들어지기 때문에 다형성을 활용할 수 있다
  5. BasicService 대신에 프록시인 BasicService$$CGLIB 를 주입

 

 

 

 

트랜잭션 프록시 동작 방식

 

basicService.tx() 호출

트랜잭션을 시작한 다음에 실제 basicService.tx() 를 호출한다.
그리고 실제 basicService.tx() 의 호출이 끝나서 프록시로 제어가(리턴) 돌아오면 프록시는

트랜잭션 로직을 커밋하거나 롤백해서 트랜잭션을 종료한다.

 

 

 

basicService.nonTx() 호출

  • 트랜잭션을 시작하지 않고, basicService.nonTx() 를 호출하고 종료한다.

 

 

 

application.properties

//  트랜잭션 프록시가 호출하는 트랜잭션의 시작과 종료를 명확하게 로그로 확인 가능
logging.level.org.springframework.transaction.interceptor=TRACE

 

 

 

 

 

TransactionSynchronizationManager.isActualTransactionActive()

  • 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다.
  • 결과가 true 면 트랜잭션이 적용되어 있는 것이다. 

 

 

 

 

 

 

 


트랜잭션 적용 위치

 

 

스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다.

 

 

 

※ 스프링의 @Transactional 규칙

  1. 우선순위 규칙
  2. 클래스에 적용하면 메서드는 자동 적용

 

 

  • @Transactional == @Transactional(readOnly=false)
  • 기본 값: readOnly = false

 

 

 

TransactionSynchronizationManager.isCurrentTransactionReadOnly

  • 현재 트랜잭션에 적용된 readOnly 옵션의 값을 반환

 

 

 

 

 

 


인터페이스에 @Transactional 적용

 

 

  1. 클래스의 메서드 (우선순위가 가장 높다.)
  2.  클래스의 타입
  3. 인터페이스의 메서드
  4. 인터페이스의 타입 (우선순위가 가장 낮다.)

 

  • 인터페이스에 @Transactional 사용하는 것은 스프링 공식 메뉴얼에서 권장하지 않는 방법이다.
  • AOP를 적용하는 방식에 따라서 인터페이스에 애노테이션을 두면 AOP가 적용이 되지 않는 경우도 있기 때문

 

 

 

 

 


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

 

 

 

  • 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
  • 이렇게 되면 @Transactional 이 있어도 트랜잭션이 적용되지 않는다.

 

 

InternalCall

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    CallService callService;
    
    @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);
        } 
    }
}

 

 

 

 

 

Internal() 실행시 트랜잭션 적용 됨

 

 

 

 

external() 실행시 트랜잭션 적용 안됨

 

  1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다. 여기서 callService 는 트랜잭션 프록시이다.
  2. callService 의 트랜잭션 프록시가 호출된다.
  3. external() 메서드에는 @Transactional 이 없다. 따라서 트랜잭션 프록시는 트랜잭션을 적용하지 않는다.
  4. 트랜잭션 적용하지 않고, 실제 callService 객체 인스턴스의 external() 을 호출한다.
  5. external() 은 내부에서 internal() 메서드를 호출한다. 그런데 여기서 문제가 발생한다.

 

 

문제 원인 

  • 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다.
  • this.internal(), 즉 실제 대상 객체( target )의 인스턴스를 뜻한다.

 

 

프록시 방식의 AOP 한계 : 프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없다.

 

 

 

 

 


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

 

 

 

InternalCall

@SpringBootTest
public class InternalCallV2Test {
    
    @Autowired
    CallService callService;
    
    @Test
    void externalCallV2() {
    	callService.external();
    }

    //@TestConfiuration 생략
    
    @Slf4j
    @RequiredArgsConstructor
    static class CallService {
        
        private final InternalService internalService;
        
        public void external() {
            log.info("call external");
            printTxInfo();
            internalService.internal();
 		}
  
	// printTxInfo() 생략
    }
    
    @Slf4j
    static class InternalService {
        
        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }
          
    // printTxInfo() 생략
    }
}

 

  • InternalService 클래스를 만들고 internal() 메서드를 여기로 옮겼다.
  • 이렇게 메서드 내부 호출을 외부 호출로 변경했다.
  • CallService 에는 트랜잭션 관련 코드가 전혀 없으므로 트랜잭션 프록시가 적용되지 않는다.
  • InternalService 에는 트랜잭션 관련 코드가 있으므로 트랜잭션 프록시가 적용된다.

 

 

 

  1. 클라이언트인 테스트 코드는 callService.external() 을 호출한다.
  2. callService 는 실제 callService 객체 인스턴스이다.
  3. callService 는 주입 받은 internalService.internal() 을 호출한다.
  4. internalService 는 트랜잭션 프록시이다.
    internal() 메서드에 @Transactional 이 붙어 있으므로 트랜잭션 프록시는 트랜잭션을 적용한다.
  5. 트랜잭션 적용 후 실제 internalService 객체 인스턴스의 internal() 을 호출한다.

 

 

 

 

 

 

 

public 메서드만 트랜잭션 적용

  • 스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다.
  • 그래서 protected , private , package-visible 에는 트랜잭션이 적용되지 않는다.
  • public 이 아닌곳에 @Transactional 이 붙어 있으면 예외가 발생하지는 않고, 트랜잭션 적용만 무시된다.

 

 

 

 

 

 

 

 


트랜잭션 AOP 주의 사항 - 초기화 시점

 

 

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

 

  • 초기화 코드(예: @PostConstruct )와 @Transactional 을 함께 사용하면 트랜잭션이 적용되지 않는다.
  • 왜냐하면 초기화 코드가 먼저 호출되고, 그 다음트랜잭션 AOP가 적용되기 때문이다.

 

  • 가장 확실한 대안은 ApplicationReadyEvent 이벤트를 사용하는 것이다.
  • 이 이벤트는 트랜잭션 AOP를 포함한 스프링이 컨테이너가 완전히 생성되고 난 다음에 이벤트가 붙은 메서드를 호출해준다.

 

 

 

 

 

 

 

 


트랜잭션 옵션 소개

 
 
 

value, transactionManager

 

  public class TxService {
      
      @Transactional("memberTxManager")
      public void member() {...}
      
      @Transactional("orderTxManager")
      public void order() {...}
  }

 

  • 트랜잭션 매니저를 지정할 때는 value , transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다.
  • 애노테이션에서 속성이 하나인 경우 value는 생략하고 값을 바로 넣을 수 있다.

 

 

 

 

rollbackFor

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

 

@Transactional(rollbackFor = Exception.class)
  • 이 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정 가능
        ex) 체크 예외인 Exception이 발생해도 롤백하게 된다.

 

 

  • rollbackFor 는 예외 클래스를 직접 지정
  • rollbackForClassName 는 예외 이름을 문자로 넣으면 된다.

 

 

 

 

 

 

 

 

 

noRollbackFor

  • rollbackFor 와 반대
  • 기본 정책에 추가로 어떤 예외가 발생했을 때 롤백하면 안되는지 지정 가능
    예외 이름을 문자로 넣을 수 있는 noRollbackForClassName 도 있다.

 

 

 

 

 

 

 

 

propagation

  • 트랜잭션 전파에 대한 옵션이다.

 

 

 

 

 

 

 

isolation

  • 트랜잭션 격리 수준을 지정
  • 기본 값 : DEFAULT

 

 

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

 

 

 

 

 

 

timeout

  • 트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정
  • timeoutString 도 있는데, 숫자 대신 문자 값으로 지정

 

 

 

 

 

 

label

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

 

 

 

 

readOnly

  • 트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성
  • readOnly=true 옵션을 사용하면 읽기 전용 트랜잭션이 생성

 

 

 

readOnly 옵션은 크게 3곳에서 적용

  1. 프레임워크
  2. JDBC 드라이버
  3. 데이터 베이스

 

 

 

 

 

 

 

 


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

 

 

 

 

  • 언체크 예외인 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

 

 

 

 

 

 


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

 

  • 정상 : payStatus 를 완료 상태로 처리하고 정상 처리된다.
  • 시스템 예외 : RuntimeException("시스템 예외") 런타임 예외가 발생한다.
  • 비지니스 예외 (잔고부족) : payStatus대기 상태로처리한다.     
                                            NotEnoughMoneyException("잔고가 부족합니다") 체크 예외가 발생한다.
                                            잔고 부족은 payStatus 대기 상태로 두고, 체크 예외가 발생하지만, order 데이터는 커밋되기를 기대 

 

 

 

 
application.properties : JPA(하이버네이트)가 실행하는 SQL을 로그로 확인
logging.level.org.hibernate.SQL=DEBUG

 

 

 

application.properties : 테이블 자동 생성

  spring.jpa.hibernate.ddl-auto
  • none : 테이블을 생성하지 않는다.
  • create : 애플리케이션 시작 시점에 테이블을 생성한다.

 

 

 

 

complete()

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

 

 

runtimeException()

  • 사용자 이름을 예외 로 설정했다.
  • RuntimeException("시스템 예외")이발생한다.
  • 런타임 예외로 롤백이 수행되었기 때문에 Order 데이터가 비어 있는 것을 확인할 수 있다.

 

 

 

bizException()

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

 

 

 

 

 

 

◎ 정리

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

 

반응형