Spring DB : 스프링 트랜잭션의 이해

    들어가기 전

    이 글은 인프런 영한님의 강의를 복습하며 작성한 글입니다. 


    스프링 트랜잭션 추상화

    스프링은 다양한 데이터 접근 기술을 지원한다. 다양한 데이터 접근 기술은 저마다 서로 다른 방식으로 트랜잭션을 사용하고 있었다. 아래에서 JPA와 JdbcTemplate의 트랜잭션 사용 코드를 볼 수 있다. 

    // JdbcTemplate
    public void accountTransfer(String fromId, String toId, int money) throws
            SQLException {
        Connection con = dataSource.getConnection();
        try {
            con.setAutoCommit(false); //트랜잭션 시작
            //비즈니스 로직
            bizLogic(con, fromId, toId, money);
            con.commit(); //성공시 커밋
        } catch (Exception e) {
            con.rollback(); //실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            release(con);
        }
    }
    
    // JPA
    public static void main(String[] args) {
        //엔티티 매니저 팩토리 생성
        EntityManagerFactory emf =
                Persistence.createEntityManagerFactory("jpabook");
        EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성
        EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득
        try {
            tx.begin(); //트랜잭션 시작
            logic(em); //비즈니스 로직
            tx.commit();//트랜잭션 커밋
        } catch (Exception e) {
            tx.rollback(); //트랜잭션 롤백
        } finally {
            em.close(); //엔티티 매니저 종료
        }
        emf.close(); //엔티티 매니저 팩토리 종료
    }

    JDBC, JPA는 서로 사용하는 트랜잭션 코드가 다르다. 만약 Repository 계층에서 JDBC를 사용하다가 JPA로 바뀌게 된다면, 트랜잭션 코드가 다르기 때문에 트랜잭션이 시작되는 Service 계층에서의 코드 역시 변경되어야한다. 이렇게 바라본다면 Repository와 Service 계층이 분리되어 있지 않은 셈이다. Repository의 변경점이 Service의 변경까지 촉발시켰기 때문이다. 

    스프링 트랜잭션 추상화

    스프링은 Repository / Service 계층의 분리를 위해서 트랜잭션의 추상화를 지원한다. 스프링은 PlatformTransactionManager 인터페이스를 도입했고, 각 데이터 기술은 TransactionManager의 인터페이스를 구현했다. 예를 들어 JDBC는 DataSourceTransactionManager, JPA는 JpaTransactionManager를 구현했다. 따라서 Service 계층은 PlatformTransactionManager 인터페이스에 의존하기만 하면 된다. 

    // Spring이 지원하는 PlatformTransactionManager
    public interface PlatformTransactionManager extends TransactionManager {
        TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
                throws TransactionException;
        void commit(TransactionStatus status) throws TransactionException;
        void rollback(TransactionStatus status) throws TransactionException;
    }

    아래는 이것을 클래스 다이어그램으로 작성한 그림이다. 

    • 서비스는 PlatformTransactionManager 인터페이스에 의존.
    • 각 DB 기술은 PlatformTransactionManager 인터페이스 구현 

    뿐만 아니라 스프링부트는 사용하는 데이터 접근 기술에 따라 적절한 TransactionManager 구현체를 스프링 빈으로 등록해준다. 따라서 개발자는 어떤 트랜잭션 매니저를 사용해야할지 고민하는 과정도 생략할 수 있다. 

     


    스프링 트랜잭션 사용방식

    스프링은 PlatformTransactionManager를 이용해서 트랜잭션의 추상화를 지원한다. 그리고 PlatformTransactionManager를 사용하는 방법은 두 가지가 존재한다.

    • 선언적 트랜잭션
      • @Transaction 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 의미함.
      • 이름 그대로 해당 로직에 트랜잭션을 적용하겠다라고 어딘가에 선언하기만 하면 트랜잭션이 적용되는 방식.
    • 프로그래밍 방식 트랜잭션
      • 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 방식.

    프로그래밍 방식의 트랜잭션 관리를 사용하면 어플리케이션 코드가 트랜잭션이라는 기술과 강하게 결합된다. 선언적 트랜잭션 관리는 AOP를 이용하기 때문에 트랜잭션 기술과 어플리케이션 코드의 결합이 느슨해지고, 코드 역시 간결해진다. 따라서 대부분 선언적 트랜잭션 관리를 사용한다.


    선언적 트랜잭션과 AOP

    @Transactional을 통한 선언적 트랜잭션 관리 방식을 사용하게 되면 기본적으로 프록시 방식의 AOP가 적용된다. 그렇다면 프록시 도입 전/후의 트랜잭션 처리는 어떻게 달라질까?

    프록시 도입 전 

    Service 계층의 메서드 내에서 비즈니스 로직 시작 전 트랜잭션을 시작, 비즈니스 로직이 끝난 후 트랜잭션을 커밋했다. 그림으로 살펴보면 아래와 같다.

    코드로 살펴보면 아래와 같다. 

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

    프록시 도입 후

    • 트랜잭션을 처리하기 위한 프록시를 적용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다. 
    • 프록시를 도입하면 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출해주고, 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있게 되었다. 

    아래는 트랜잭션 프록시 예시 코드다.

    // 트랜잭션 프록시 코드 예시
    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);
        }
    }

     


    트랜잭션 프록시 도입 후 전체 과정 (매우 중요)

    앞서 공부했던 내용은 결국 이 전체적인 흐름을 이해하기 위한 것이다. 

    1. 서비스 계층에서 트랜잭션 프록시를 호출함.
    2. 트랜잭션 프록시는 스프링 컨테이너에서 트랜잭션 매니저 빈을 획득한다. (스프링 부트가 자동으로 데이터 기술에 따라서 필요한 트랜잭션 매니절르 등록해줌) 
    3. 트랜잭션 매니저는 DataSource를 이용해서 커넥션을 생성하고, Conn.setAutoCommit(false)를 셋팅해서 트랜잭션을 시작해준다. (즉, 세션이 유지되는 상태에서 일시적인 업데이트만을 적용한다) 
    4. 셋팅된 커넥션(3번 과정)을 트랜잭션 동기화 매니저에 보관해준다. 이것을 이용해서 Service / Repository 계층의 트랜잭션을 동기화 해준다. (사용하지 않는다면 Service / Repository 계층이 직접 커넥션을 주고 받아야 한다)
    5. 비즈니스 로직을 실행하다가 데이터 접근 로직(Repository 계층)으로 이동한다. DB로 접근해야한다면 트랜잭션 동기화 매니저에 저장된 커넥션을 획득해서 DB에 접근한다.

    JdbcTemplate을 포함한 대부분의 데이터 접근 기술들은 트랜잭션을 유지하기 위해 내부에서 트랜잭션 동기화 매니저를 통해 리소스(커넥션)을 동기화한다. 

     

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

    • 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
    • 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 어노테이셔만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 어노테이션을 인식해서 트랜잭션 프록시를 자동으로 적용해준다. 

     

    스프링 트랜잭션 확인 위한 프로젝트 생성

    아래 요구사항으로 프로젝트를 생성한다.

    • java 11 
    • gradle
    • jar
    • spring data jpa, h2, lombok
    • 아티팩트 이름
      • group : hello
      • artifact : springtx
      • name : springtx
      • package name : hello.springtx

     

    트랜잭션 적용되었는지 확인

    선언적 트랜잭션 방식(@Transactional을 이용)을 사용하면 어노테이션 하나로 트랜잭션을 적용할 수 있다. 그렇지만 트랜잭션 관련 코드가 눈에 보이지 않고, AOP를 기반으로 동작하기 때문에 실제 트랜잭션이 적용되고 있는지 아닌지 확인하기어렵다. 여기서는 로그와 스프링에서 제공하는 몇 가지 기능을 이용해서 트랜잭션 적용되었는지 확인한다. 

    TxBasicTest 코드 (https://github.com/chickenchickenlove/springdb2/blob/chapter-8-spring-transaction-basic/src/test/java/hello/springtx/apply/TxApplyBasicTest.java)

    트랜잭션 로그 추가

    AOP Interceptor에 로그를 추가하면 트랜잭션의 시작과 종료에 대한 로그를 확인할 수 있다. 아래 로그 코드를 application.properties에 추가하면 된다.

    // application.properties
    logging.level.org.springframework.transaction.interceptor=TRACE
    
    // 이후 아래 로그 출력됨.
    o.s.t.i.TransactionInterceptor : Getting transaction for [hello.springtx.apply.TxApplyBasicTest$BasicService.tx]

     

     

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

    • @Transactional 어노테이션이 특정 클래스, 메서드에 하나라도 포함되어있으면 트랜잭션 AOP는 프록시를 만들어 스프링 컨테이너에 빈으로 등록한다. 이 때, BasicService가 아닌 BasicService 클래스를 상속한 CGLIB 프록시를 등록한다. CGLIB 프록시는 내부에 BasicService를 타겟으로 가지고 있다. 
    • txBasicTest에서는 BasicService를 요청하는데, 이 때 BasicService$$CGLIB가 스프링 빈으로 등록되어있어 이것이 DI 된다. 

     

    트랜잭션 프록시 동작 방식 

    • txBasicTest가 주입받은 basicService$$CGLIB는 트랜잭션을 적용하는 프록시다. 
    • txBasicTest가 basicService를 호출하면 basicService$$CGLIB가 호출되고, basicService$$CGLIB는 basicService를 호출한다. 

     

    TxBasicTest 코드 (https://github.com/chickenchickenlove/springdb2/blob/chapter-8-spring-transaction-basic/src/test/java/hello/springtx/apply/TxApplyBasicTest.java)

    basicService.tx() 

    • tx() 메서드에는 @Transactional이 있다. 포인트컷 대상이기 때문에 AOP가 적용되어 트랜잭션 프록시가 적용된다. 

    basicService.nonTx() 

    • nonTx() 메서드에는 @Transactional이 없다. 따라서 AOP가 적용되지 않는다. 

    TransactionSyncrhonzationManager.isActualTransactionActive()

    • 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 True면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다. 

     

    각 테스트의 실행 결과

    #tx() 호출
    TransactionInterceptor : Getting transaction for [..BasicService.tx]
    y.TxBasicTest$BasicService : call tx
    y.TxBasicTest$BasicService : tx active=true
    TransactionInterceptor : Completing transaction for 
    [..BasicService.tx]
    
    
    #nonTx() 호출
    y.TxBasicTest$BasicService : call nonTx
    y.TxBasicTest$BasicService : tx active=false

    테스트 코드를 수행하면 로그를 통해서 트랜잭션 적용 유무를 확인할 수 있다.

    • tx() 메서드 → Transaction이 호출되고 완료된 것을 볼 수 있다.
    • nonTx() 메서드 → Transaction이 호출되었는지 알 수 없다. 

     


    트랜잭션 적용 위치

    @Transactional은 여러 군데에서 사용할 수 있다. 예를 들어 메서드와 클래스에 동시에 @Transactional을 붙일 수 있다. 만약 @Transactional이 중첩으로 붙었다면 어디가 우선순위를 가질까?

    스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 예를 들어 메서드와 클래스에 각각 어노테이션을 붙였다면, 더 구체적인 메서드가 더 높은 우선순위를 가진다. 인터페이스와 구현체에 각각 어노테이션을 붙일 수 있다면, 구현체가 더 높은 우선순위를 가진다. 정리하면 다음의 우선순위를 가진다.

    • 클래스 메서드(우선 순위가 가장 높다) > 클래스 > 인터페이스 메서드 > 인터페이스 (우선 순위가 가장 낮다)

    스프링 트랜잭션은 위와 같은 우선순위를 인지하고 @Transactional을 잘 적용하지만, 스프링에서는 일반적으로 구현체 클래스와 메서드에 @Transactional 어노테이션을 사용할 것을 권장한다. 

     

    스프링은 인터페이스에 @Transactional을 사용하는 방식을 스프링 5.0에서 많은 부분을 개선했다. 과거에는 구체 클래스를 기반으로 프록시를 생성하는 CGLIB 방식을 사용하면 인터페이스에 있는 @Transactional을 인식하지 못했다. 스프링 5.0 부터는 이 부분을 개선해서 인터페이스에 있는 @Transactional도 인식한다. 하지만 다른 AOP 방식에서 또 적용되지 않을 수 있으므로 공식 메뉴얼의 가이드대로 가급적이면 구체 클래스에 @Transactional을 붙인다. 

     

    스프링 @Transactional 규칙

    스프링의 @Transactional은 다음 두 가지 규칙을 가지고 있다. 

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

    각 규칙을 모두 참고해서 스프링은 @Transactional 어노테이션을 선택적으로 적용한다.

     

    우선순위

    @Transactional을 사용할 때는 다양한 옵션을 사용할 수 있다.  어떤 경우에는 옵션을 주고, 어떤 경우에는 옵션을 주지 않으면 어떤 것이 선택될까? 예를 들어서 읽기 전용 트랜잭션 옵션을 사용하는 경우와 아닌 경우를 비교해보자. 

    @Transactional(readOnly = true)
    static class LevelService {
    
        @Transactional(readOnly = false)
        public void write() {
            log.info("call write");
            printTxInfo();
        }

    위와 같은 경우라면 클래스보다는 메서드가 더 구체적이므로 메서드에 있는 옵션이 적용된다.

     

    클래스에 적용하면 메서드는 자동 적용

    아마도 @Transactional은 어노테이션 기반 포인트 컷으로 작용한다고 생각한다. 따라서 @Transactional이 클래스에 붙는다면, 클래스 아래에 있는 모든 메서드에도 AOP 적용 대상이 된다. 

     

    테스트 및 실행 결과

    # write() 호출
    TransactionInterceptor : Getting transaction for 
    [..LevelService.write]
    y.TxLevelTest$LevelService : call write
    y.TxLevelTest$LevelService : tx active=true
    y.TxLevelTest$LevelService : tx readOnly=false
    TransactionInterceptor : Completing transaction for 
    [..LevelService.write]
    
    
    # read() 호출
    TransactionInterceptor : Getting transaction for 
    [..LevelService.read]
    y.TxLevelTest$LevelService : call read
    y.TxLevelTest$LevelService : tx active=true
    y.TxLevelTest$LevelService : tx readOnly=true
    TransactionInterceptor : Completing transaction for 
    [..LevelService.read]

    실행 결과를 살펴보면 각 메서드 호출마다 서로 다른 트랜잭션이 적용된 것을 볼 수 있다.

    • read() / write() 모두 Transaction이 Active 된 것을 확인할 수 있음.
    • read()에는 readOnly = True가 되고, write()에는 readOnly = False가 된다. 

     

     

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

    @Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용된다. 트랜잭션 AOP는 기본적으로 프록시 방식의 AOP를 사용한다. 앞서 배운 것처럼 @Transactional을 적용하면 프록시 객체가 요청을 먼저 받아서 트랜잭션을 처리하고 실제 객체를 호출해준다.  따라서 트랜잭션을 적용하려면 항상 프록시를 통해서 대상 객체를 호출해야 한다. 이렇게 해야 프록시에서 먼저 트랜잭션을 적용하고 이후에 대상 객체를 호출하게 된다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게 되면 AOP가 적용되지 않고, 트랜잭션도 적용되지 않는다. 

    AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 스프링은 의존관계 주입 시 항상 실제 객체 대신에 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다.  하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 이렇게 되면 @Transactional이 있어도 트랜잭션이 적용되지 않는다.

     

    각 테스트 코드의 실행 결과

    코드는 이곳에서 확인 가능하다(https://github.com/chickenchickenlove/springdb2/blob/chapter-8-spring-transaction-basic/src/test/java/hello/springtx/apply/InternalCallV1Test.java)

    printProxy()

    CallService 내부에 @Transactional 어노테이션이 있기 때문에 CallService는 트랜잭션 AOP 적용 대상이다. 따라서 CGLIB 프록시 클래스인 것을 확인할 수 있다. 

    internallCall() 실행

    @Transactional 적용 대상이기 때문에 프록시가 타겟 호출(CallService 클래스) 하기 전에 트랜잭션을 시작하고 커밋하는 작업을 진행한다. 

    실행 로그를 살펴보면 아래와 같이 느랜잭션이 시작되고 종료되었다는 것을 볼 수 있다.

    TransactionInterceptor : Getting transaction for 
    [..CallService.internal]
    ..rnalCallV1Test$CallService : call internal
    ..rnalCallV1Test$CallService : tx active=true
    TransactionInterceptor : Completing transaction for 
    [..CallService.internal]

     

    externallCall() 실행

    • external은 @Transactional 어노테이션이 없다. 따라서 트랜잭션 없이 시작한다. 그리고 내부적으로 internalCall()을 호출하지만, 이 때 CallService 클래스를 그대로 호출하기 때문에 트랜잭션 AOP는 적용되지 않는다. 로그에서 해당 내용을 확인할 수 있다. 
    CallService : call external
    CallService : tx active=false // 트랜잭션 적용 안됨. 
    CallService : call internal
    CallService : tx active=false // 트랜잭션 적용 안됨

    internalCall()에서는 트랜잭션이 적용되기를 바랬는데, 실제로는 트랜잭션이 적용되지 않았다. 왜 이런 결과가 발생한 것일까? 

     

    프록시와 내부 호출

    위 그림을 바탕으로 하나씩 이해를 해볼 수 있다.

    1. 클라이언트에는 CallService$$CGLIB 객체가 주입되었기 때문에 프록시가 호출된다.
    2. 프록시에서 external()을 호출할 때, @Transactional 적용 대상이 아니기 때문에 트랜잭션을 적용하지 않고 바로 target.external()을 호출한다.
    3. target은 CallService 클래스다. CallService 클래스는 내부적으로 CallService$$CGLIB를 가지지 않는다. 따라서 internalCall()을 한다면 this.internalCall() == callService.internalCall()을 하게 된다.

    위와 같은 이유 때문에 트랜잭션이 적용되지 않게 되는 것이다. 

    프록시 방식의 AOP 한계

    @Transactional을 사용하는 트랜잭션 AOP는 프록시를 사용한다. 프록시를 사용하면 메서드 내부 호출에 프록시를 적용할 수 없다. 그렇다면 이 문제를 어떻게 해결할 수 있을까? 여러가지 방법이 있지만, 가장 단순한 방법은 내부 호출을 피하기 위해 internal() 메서드를 별도의 클래스로 분리하는 것이다. 

     

    트랜잭션 AOP 주의사항 - 프록시 내부 호출2 (문제 해결)

    메서드 내부 호출 때문에 트랜잭션 프록시가 적용되지 않는 문제를 해결하기 위해 internal() 메서드를 별도의 클래스로 분리하자.

    코드는 다음(https://github.com/chickenchickenlove/springdb2/blob/chapter-8-spring-transaction-basic/src/test/java/hello/springtx/apply/InternalCallV2Test.java)에서 확인할 수 있음.

    주요 변경사항은 다음과 같다.

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

    external() 메서드가 호출되었을 때 각각이 어떻게 실행되는지를 살펴보면 다음과 같다.

    1. 클라이언트는 callService.external()을 호출한다.  이 때, callService는 프록시 객체가 아니다.
    2. callService.external()는 내부적으로 internalService.internal()을 호출한다. 이 때 internalService는 @Transactional 적용 대상이기 때문에 internalService$$CGLIB 객체다.
    3. internalService$$CGLIB 객체는 internalService.internal() 메서드를 호출하기 전에 @Transactional 적용 대상인지 확인한다. 적용 대상이므로 트랜잭션을 적용하면서 internalService.internal()을 호출한다. 

    실행 결과를 로그로 살펴보면 아래와 같은 것을 확인할 수 있다.

    • external 클래스는 트랜잭션 적용 X
    • Internal 클래스는 트랜잭션 적용 O
    #external()
    ..InternalCallV2Test$CallService : call external
    ..InternalCallV2Test$CallService : tx active=false // external 클래스는 트랜잭션 X
    
    
    #internal()
    TransactionInterceptor : Getting transaction for 
    [..InternalService.internal]
    ..rnalCallV2Test$InternalService : call internal
    ..rnalCallV2Test$InternalService : tx active=true // internal 클래스는 트랜잭션 O
    TransactionInterceptor : Completing transaction for 
    [..InternalService.internal

     


    트랜잭션 AOP 주의사항2 → public 메서드만 트랜잭션 적용

    • 스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정되어있다. 그래서 protected, private, package-visible에는 트랜잭션이 적용되지 않는다. 생각해보면 protected, package-visible도 외부에서 호출이 가능하다. 따라서 이 부분은 앞서 설명한 프록시의 내부 호출과는 무관하고 스프링이 막아둔 것이다. 
    • 클래스 레벨에 @Transactional을 적용하게 되면, 과도하게 많은 메서드에 트랜잭션이 모두 적용될 수 있기 때문이다. 따라서 트랜잭션 적용을 고려하는 메서드는 public으로 생성한다. 
    @Transactional
    public class Hello {
     	public method1();
     	method2(); // package-visible
     	protected method3();
     	private method4();
    }

    클래스 레벨에 트랜잭션을 적용하면 모든 메서드에 트랜잭션이 걸릴 수 있다. 그러면 트랜잭션을 의도하지 않는 곳까지 트랜잭션이 과도하게 적용된다. 트랜잭션은 주로 비즈니스 로직의 시작점에 걸기 때문에 대부분 외부에 열어준 곳을 시작점으로 사용한다. 이런 이유로 public 메서드에만 트랜잭션을 적용하도록 설정되어있다.

     


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

    스프링 초기화 시점에는 AOP가 적용되지 않을 수 있다. 아래 @PostConstruct + @Transactional이 있는 경우 정상적으로 동작하지 않는다. 왜냐하면 트랜잭션 AOP가 생성되는 시점은 @PostConstruct 이후이기 때문이다. 따라서 초기화 시점에 DB와 트랜잭션을 해야한다고 하면, @PostConstruct 메서드 + @Transactional을 한다면 원하는대로 동작하지 않는다.

    스프링 컨테이너 생성 → 빈 생성 → ... → 스프링 컨테이너 초기화 (@PostConsturuct 호출) ... → 트랜잭션 AOP 적용 ... → 스프링 컨테이너 Ready(ApplicationReadyEvent 발생)

    위와 같이 동작한다는 것을 잘 알아둬야한다.

    코드는 이곳에서 확인 가능함(
    https://github.com/chickenchickenlove/springdb2/blob/chapter-8-spring-transaction-basic/src/test/java/hello/springtx/apply/InitTxTest.java)

     

    위 코드에서 테스트 코드를 수행해보면 로그에 다음과 같이 기록된다.

    // @PostConstruct가 호출하는 코드
    // 트랜잭션 적용 X
    hello.springtx.apply.InitTxTest$Hello    : Hello init @PostConstruct tx active = false 
    
    // ApplicationReadyEvent 이벤트가 호출하는 코드
    // 트랜잭션 적용 O
    o.s.t.i.TransactionInterceptor           : Getting transaction for [hello.springtx.apply.InitTxTest$Hello.initV2]
    hello.springtx.apply.InitTxTest$Hello    : Hello init @EventListener tx active = true
    o.s.t.i.TransactionInterceptor           : Completing transaction for [hello.springtx.apply.InitTxTest$Hello.initV2]

     

     


    스프링 트랜잭션 옵션 

    스프링 트랜잭션은 다양한 옵션을 제공한다. 

    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 {};
    }

    @Transactional 안에서 위 옵션들을 제공하는데, 각 옵션이 어떤 역할을 하는지 살펴본다. 

     

    value, transactionManager

    • value와 transactionManager는 같은 속성을 의미한다.
    • 트랜잭션을 사용하려면 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다. 생각해보면 코드로 직접 트랜잭션을 사용할 때 분명 트랜잭션 매니저를 주입 받아서 사용했다. @Transactional에서도 트랜잭션 프록시가 사용할 트랜잭션 매니저를 지정해줘야한다.
    • 사용할 트랜잭션 매니저를 지정할 때는 value, transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다. 이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.
    // 서로 다른 트랜잭션 매니저를 사용할 수 있음. 
    
    public class TxService{
    
        @Transactional("memberTxManager")
        public void member() {...}
    
        @Transactional("orderTxManager")
        public void order() {...
    
    }

     

    rollbackFor

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

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

    rollbackFor 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할지를 지정할 수 있다. 예를 들어 예외가 발생했을 때 커밋하는 예외를 롤백을 할 수 있도록 지정할 수 있다.

    @Transactional(rollbackFor = Exception.class)

     

    noRollBackFor

    • 기본 정책에 추가로 어떤 예외가 발생했을 때 롤백하면 안되는지 지정할 수 있다. 

     

    propagation

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

     

    isolation

    트랜잭션 격리 수준을 지정할 수 있다. 기본 값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 DEFAULT다. 대부분 DB에서 설정한 기준을 따른다. 어플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다. (보통 DBA가 정함)

    • DEFAULT : 데이터베이스에서 설정한 격리 수준 따름
    • READ_UNCOMMITED
    • READ_COMMITED
    • REPEATABLE_READ
    • SERIALIZABLE 

     

    timeout

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

     

    label

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

     

    readOnly

    • 트랜잭션은 기본적으로 읽기/쓰기가 모두 가능한 트랜잭션이 생성된다.
    • readOnly = true 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 작동한다. (드라이버나 DB에 따라 정상 동작하지 않는 경우도 있다) 그리고 readOnly 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다. 주로 성능 최적화 때문에 readOnly를 사용한다.
    • readOnly 옵션은 크게 3곳에서 적용된다. readOnly라고 하면 데이터베이스까지 그 정보가 넘어가기 때문이다.

    프레임워크 (JdbcTemplate / JPA 관점)

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

    JDBC 드라이버

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

    데이터베이스

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

     


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

    트랜잭션 내에서 예외가 발생했지만, 내부에서 예외를 처리하지 못하고 트랜잭션 범위 밖으로 예외를 던지면 어떻게 될까? 

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

    • 언체크 예외인 RuntimeException, Error와 그 하위 예외가 발생하면 트랜잭션을 롤백한다.
    • 체크 예외인 Exception과 그 하위 예외가 발생하면 트랜잭션을 커밋한다.
    • 정상 응답하면 트랜잭션을 커밋한다.

    물론 이 사이에 @Repository Exception이 발생하면 JPA가 만든 PersistenceException은 예외변환 AOP에 의해서 스프링에서 사용하는 예외로 변환되기는 한다. 아무튼 트랜잭션 AOP 내에서 발생한 예외가 트랜잭션 밖으로까지 던져주면, 예외를 밖으로 던지기 전에 스프링은 트랜잭션을 롤백하거나 커밋한다는 것이다. 실제로 이렇게 동작하는지 코드로 확인해보자. 

     

    실습1 - 런타임 예외 발생 → 롤백

    @Transactional
    public void runtimeException() {
        // 런타임 예외 발생: 롤백
        log.info("call runtimeException");
        throw new RuntimeException();
    }
    • RunTimeException을 발생시킨다. 예외는 트랜잭션 밖으로 던져지고, 트랜잭션은 Runtime 예외를 만나게 되면 롤백을 수행한다. 
    • 아래는 발생한 로그다. 트랜잭션이 롤백되는 것을 확인할 수 있다. 
    // 로그 발생
    : Creating new transaction with name [hello.springtx.apply.RollbackTest$RollbackService.runtimeException]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
    : Opened new EntityManager [SessionImpl(2072709038<open>)] for JPA transaction
    : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@2558f65c]
    : Getting transaction for [hello.springtx.apply.RollbackTest$RollbackService.runtimeException]
    : call runtimeException
    : Completing transaction for [hello.springtx.apply.RollbackTest$RollbackService.runtimeException] after exception: java.lang.RuntimeException
    : Initiating transaction rollback // 트랜잭션 롤백됨.
    : Rolling back JPA transaction on EntityManager [SessionImpl(2072709038<open>)]
    : Closing JPA EntityManager [SessionImpl(2072709038<open>)] after transaction

     

    실습 2 - 체크 예외 발생 → 커밋

    @Transactional
    public void checkedException() throws MyException {
        // 체크 예외 발생: 커밋
        log.info("call checkedException");
        throw new MyException();
    }
    • 체크 예외를 발생시킨다. 체크 예외가 트랜잭션으로 넘어가면, 트랜잭션 AOP는 트랜잭션을 커밋시킨다.
    • 아래는 로그는 코드를 실행한 결과다. 로그에서 확인할 수 있듯이 트랜잭션이 커밋된다.
    : Creating new transaction with name [hello.springtx.apply.RollbackTest$RollbackService.checkedException]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
    : Opened new EntityManager [SessionImpl(2072709038<open>)] for JPA transaction
    : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@2558f65c]
    : Getting transaction for [hello.springtx.apply.RollbackTest$RollbackService.checkedException]
    : call checkedException
    : Completing transaction for [hello.springtx.apply.RollbackTest$RollbackService.checkedException] after exception: hello.springtx.apply.RollbackTest$MyException
    : Initiating transaction commit // 트랜잭션 커밋됨. 
    : Committing JPA transaction on EntityManager [SessionImpl(2072709038<open>)]
    : Closing JPA EntityManager [SessionImpl(2072709038<open>)] after transaction

     

    실습 3 - 체크 예외 발생 → 강제 롤백

    @Transactional(rollbackFor = MyException.class)
    public void rollbackFor() throws MyException {
        // 체크 예외 rollbackFor 지정: 롤백
        log.info("call rollbackFor");
        throw new MyException();
    }
    • 체크 예외를 발생시킨다. 체크 예외에 대해서 스프링은 기본적으로 트랜잭션을 커밋시킨다. 
    • rollBackFor를 이용해서 트랜잭션을 커밋하지 않고 롤백할 수 있다.
    • 아래 로그에서는 체크 예외가 발생했지만, 트랜잭션이 롤백되는 것을 볼 수 있다. 
    : Creating new transaction with name [hello.springtx.apply.RollbackTest$RollbackService.rollbackFor]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-hello.springtx.apply.RollbackTest$MyException
    : Opened new EntityManager [SessionImpl(891130813<open>)] for JPA transaction
    : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1a3b1f7e]
    : Getting transaction for [hello.springtx.apply.RollbackTest$RollbackService.rollbackFor]
    : call rollbackFor
    : Completing transaction for [hello.springtx.apply.RollbackTest$RollbackService.rollbackFor] after exception: hello.springtx.apply.RollbackTest$MyException
    : Initiating transaction rollback // 트랜잭션 롤백
    : Rolling back JPA transaction on EntityManager [SessionImpl(891130813<open>)]
    : Closing JPA EntityManager [SessionImpl(891130813<open>)] after transaction

     


    예외와 트랜잭션 커밋, 롤백 → 활용 방법

    스프링은 왜 체크 예외는 커밋하고 언체크(런타임) 예외는 롤백할까? 스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 언체크 예외는 복구 불가능한 예외로 가정한다. 앞으로 예외는 비즈니스 예외와 시스템 예외를 구별해서 생각하자. 비즈니스 예외는 시스템 문제가 아니기 때문에 다음으로 플로우가 가능하고, 시스템 예외는 시스템 문제이기 때문에 어플리케이션 레벨에서 복구가 불가능하다. 

    • 체크 예외 : 비즈니스 의미가 있을 때 사용
    • 언체크 예외 : 복구 불가능한 예외
      • 네트워크 에러
      • 타입이 안 맞을 때

    이 정책을 꼭 따를 필요는 없다. 다른 형태로 사용하고 싶다면 rollBackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 된다. 그렇지만 일반적으로는 스프링의 설정을 그대로 사용하는 것이 편리하다.

     

    비즈니스 예외와 비즈니스 요구사항

    앞에서 비즈니스 예외라는 말을 했다. 비즈니스 예외는 어떤 예외를 의미하는 것일까? 예를 들어 아래 상황을 가정해보자. 

    1. 정상 : 주문 시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 완료로 처리한다. 
    2. 시스템 예외 : 주문 시 내부에 복구 불가능한 예외가 발생하면 전체 데이터를 롤백한다. (결제에서 네트워크 오류가 발생한다든지)
    3. 비즈니스 예외 : 주문 시, 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 '대기'로 처리한다. 
      • 이 경우 고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다. 
      • 만약에 롤백을 하게 된다면 주문 정보가 모두 사라진다. (커밋을 해야함)

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

     

    실습

    먼저 관련된 코드는 아래에서 확인할 수 있다.

     

    OrderService 코드 설명

    • orderService는 order() 메서드만 생성됨.
    • 만약 사용자 이름이 예외인 경우 RuntimeException(시스템 예외)를 발생시킴.
    • 만약 사용자 이름이 잔고부족인 경우 NotEnoughMoneyException(비즈니스 예외)을 발생시킴. 
      • 이 때 order의 상태를 대기로 변경한다. 비즈니스 예외가 발생되면 트랜잭션은 커밋되기 때문에 order 객체에 셋팅된 status 값은 "대기" 상태로 되어 DB에 전달된다. → 이 부분이 핵심이다.
      • 왜 핵심이냐면 비즈니스 로직을 마치 리턴값처럼 사용할 수 있기 때문이다.
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private 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("결제 프로세스 완료");
        }
    
    
    
    }

     

     

    실습1 → 정상

    @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("완료");
    }
    • 정상인 경우에는 트랜잭션이 정상적으로 종료된다.

     

    실습2 → 시스템 에러 발생 (RuntimeException)

    @Test
    void runtimeException() {
        // given
        Order order = new Order();
        order.setUsername("예외");
    
        // when, then
        assertThatThrownBy(() -> orderService.order(order))
                .isInstanceOf(Exception.class);
    
        Optional<Order> byId = orderRepository.findById(order.getId());
        assertThat(byId.isEmpty()).isTrue();
    }
    • 시스템 에러가 발생하는 테스트 코드다. 시스템 에러는 RuntimeException으로 발생한다.
    • RuntimeException이 발생하면 트랜잭션은 롤백된다. order() 메서드에서 order가 DB에 저장되어야 하는데 롤백 되었기 때문에 아무것도 저장되지 않아야 한다. 따라서 repository에서는 어떠한 값도 조회되지 않아야 한다. 
     

    실습3 → 비즈니스 예외 발생 (NotEnoughMoneyException)

    @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("대기");
    }
    • 체크 예외를 스프링은 비즈니스 예외로 발생한다.
    • 비즈니스 예외가 발생하면 트랜잭션은 커밋된다. 따라서 예외는 발생하지만 Repository 영역에 "대기"라는 값으로 데이터는 들어가져 있다. 

    이 상황을 살펴보면 체크 예외는 마치 특별한 상태를 담은 반환값으로 이해를 할 수 있다. 만약 예외를 터뜨리지 않고 싶다면, 리턴 값을 enum으로 적절히 잘 작성해서 위와 동일한 형태로 구현할 수도 있다. 

     

    비즈니스 예외의 활용 방안

    비즈니스 예외는 트랜잭션에서 실패한다면 커밋된다. 트랜잭션 내에서 일어났던 DB와 관련된 작업들이 DB에 실제로 반영된다는 것을 의미한다. 이렇게 사용되기 때문에 트랜잭션 내에서 발생한 비즈니스 예외를 Throw로 잡고 위로 다시 던지지 않는다면 "비즈니스 예외는 리턴값처럼 사용"되는 것으로 볼 수 있다.

    이런 경우라면 비즈니스 예외를 던지는 대신 enum으로 적절히 잘 넘겨줘도 동일한 효과를 낼 수 있다. 비즈니스 예외를 직접 던지는 것도 방법이 될 수 있고, enum으로 전달해주는 것도 한 방법이 될 수 있다.

    'Spring > Spring DB' 카테고리의 다른 글

    스프링 DB : 스프링 데이터 JPA 관련  (0) 2023.01.23
    Spring DB : JPA  (0) 2023.01.23
    Spring DB : MyBatis  (0) 2022.07.08
    Spring DB : DB 테스트  (0) 2022.07.04
    Spring DB : JdbcTemplate  (0) 2022.06.11

    댓글

    Designed by JB FACTORY