ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA Transaction
    백엔드 2023. 8. 25. 16:44
    728x90
    반응형
    SMALL

    Transaction

    Transaction은 DB에서 다루는 개념입니다. DB에서는 Transaction이라는 단위로 여러가지 명령어들의 묶어서 사용하고 있습니다.

    Jpa에서 Transaction을 설정하기 위해서는 @Transactional 어노테이션을 설정해주면 됩니다.

     

    예시)

    어떤 상품에 대한 주문이 일어날 경우, Order 테이블Payment 테이블insert가 동시에 모두 일어나야 합니다.

    Order라는 테이블에 insert가 정상적으로 동작하고 Payment 테이블에 insert 도중 오류가 발생해 실패했다면, 주문은 정상 동작했지만 결제는 되지 않은 현상이 발생할 수 있습니다. 그래서 이러한 상황에서는 결제와 주문을 하나의 Transaction 안에서 해결하도록 하여 하나의 테이블에서 오류가 발생할 경우 정상 작동한 다른 테이블로의 insert 또한 다시 이전으로 되돌려줍니다.

    즉, Transaction 내부 모든 동작이 성공적으로 완료되어 커밋되는 경우 정상 실행하고, Transaction이 실패하면 rollback되어 모든 동작이 이전으로 돌아가도록 해줍니다.

     

    Transaction의 특징 (ACID)

    • 원자성(atomicity) : 부분적인 성공을 허용하지 않는다. All or nothing
    • 일관성(consistency) : 데이터간의 정합성을 맞춘다.
    • 독립성(isolation) : Transaction 내의 데이터 조작은 다른 Transaction으로부터 독립적인 속성을 가지고 있다.
    • 지속성(durability) : 데이터는 영구적으로 보관된다.

     

    Transactional의 종류

    • javax.transaction : Spring에 대한 의존성 없이 사용 가능. 다른 컨테이너를 사용하더라도 사용가능합니다.
    • org.springframework.transaction : Spring에서 제공하는 더 많은 기능들을 사용할 수 있습니다.

     

    예시)

    기본 메서드에 @Transactional 어노테이션을 설정할 경우 어떤식으로 달라지는지 확인해보도록 하겠습니다.

    BookService

    @Service
    @AllArgsConstructor
    public class BookService {
        private final BookRepository bookRepository;
        private final AuthorRepository authorRepository;
    
        public void putBookAndAuthor(){
            Book book = new Book();
            book.setName("book1");
    
            bookRepository.save(book);
    
            Author author = new Author();
            author.setName("author1");
    
            authorRepository.save(author);
        }
    }

    Test

    @Test
    void test(){
        bookService.putBookAndAuthor();
    
        System.out.println(bookRepository.findAll());
        System.out.println(authorRepository.findAll());
    }

    위의 테스트는 Book, Author Entity에 하나의 데이터를 주입해준 다음, findAll()로 모든 데이터를 출력하고 있습니다.

    이제 @Transactional 어노테이션을 붙이게 되면 특정 시점에 어떻게 변하는지 확인해보도록 하겠습니다.

     

    @Transactional 어노테이션 추가

    @Transactional  // 추가
    public void putBookAndAuthor(){
        Book book = new Book();
        book.setName("book1");
    
        bookRepository.save(book); // save1
    
        Author author = new Author();
        author.setName("author1");
    
        authorRepository.save(author); // save2
    }

    putBookAndAuthor 메서드에 @Transactional 어노테이션을 추가한 뒤 다시 테스트를 했습니다.

    @Transactional로 묶여있는 쿼리들은 모든 쿼리들이 문제 없이 동작하는 시점에 DB 반영을 위한 커밋을 하게 됩니다. 그러므로 각각의 save가 실행되는 시점에는 값을 저장하지 않고 있다가 Transaction이 완료되는 시점에 DB에 반영하게 됩니다.

     

    @Transactional로 묶여있는 메서드는 메서드의 시작이 Transaction의 시작이 되고 메서드 종료 시점이 Transaction 종료 시점이 됩니다. 메서드 사이에 있는 모든 쿼리들은 한꺼번에 묶어서 Transaction으로 처리하게 됩니다.

     

    (@Transactional이 없는 경우 save 메서드가 @Transactional이 선언되어 있으므로 하나의 save 메서드가 실행됨과 동시에 DB에 반영되게 됩니다.)

     

     

    예시 2)

    putBookAndAuthor 메서드에서 마지막에 무조건 오류가 발생하도록 바꾼 상태로 테스트해보겠습니다..

    public void putBookAndAuthor(){
        Book book = new Book();
        book.setName("book1");
    
        bookRepository.save(book);
    
        Author author = new Author();
        author.setName("author1");
    
        authorRepository.save(author);
        
        throw new RuntimeException("오류");
    }
    
    @Test
    void test(){
        try{
            bookService.putBookAndAuthor();
        } catch (RuntimeException e) {
            System.out.println(e.getMessage());
        }
        System.out.println(bookRepository.findAll());
        System.out.println(authorRepository.findAll());
    }

    (putBookAndAuthor 실행 시 반드시 오류가 발생하지만, Transaction을 살펴보기 위해 try-catch 로 묶어주었습니다.)

    @Transactional을 선언하지 않은 상태에서 모든 save는 정상 동작하게 되고, 추가된 Book과 Author의 데이터는 모두 잘 조회되게 됩니다.

     

    @Transactional 어노테이션 추가

    @Transactional
    public void putBookAndAuthor(){
        ...

    @Transactional 어노테이션을 선언한 뒤 테스트를 실행해보면 삽입된 데이터들의 조회가 되지 않고 Book과 Author는 비어있는 상태임을 알 수 있습니다.

     

    putBookAndAuthor 메서드는 하나의 Transaction으로 동작하게 됩니다. 그렇기 때문에 마지막 오류로 인해 Book과 Author에 삽입된 데이터들은 모두 rollback이 되므로 실제 Book과 Author 테이블에는 데이터가 없게 됩니다.

     

     

    checked exception vs unchecked exception

    checked exception은 unchecked exception보다 상위의 예외입니다.

    위와 같은 경우 그냥 unchecked exception인 RuntimeException으로 오류를 발생시켰습니다. unchecked exception의 경우 Transaction 메서드 안에서 발생하면 rollback이 발생하게 됩니다. 하지만, checked Exception의 경우 Transaction 내부에서 오류가 발생하더라도 반영된 내용들은 그냥 커밋되어 버립니다. 그러므로 checked Exception으로 처리할 경우 개발자가 예외 처리에 대해 핸들링해주어야합니다.

     

    이유

    // TransactionAspectSupport.java
    @Nullable
    	protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
    			final InvocationCallback invocation) throws Throwable {
        ...
        
            try {  // 385번째 줄
                // This is an around advice: Invoke the next interceptor in the chain.
                // This will normally result in a target object being invoked.
                retVal = invocation.proceedWithInvocation();
            }
            catch (Throwable ex) {
                // target invocation exception
                completeTransactionAfterThrowing(txInfo, ex);
                throw ex;
            }
            finally {
                cleanupTransactionInfo(txInfo);
            }
            ...
        }
        
        protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
    		if (txInfo != null && txInfo.getTransactionStatus() != null) {
    			if (logger.isTraceEnabled()) {
    				logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() +
    						"] after exception: " + ex);
    			}
    			if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
    				try {
    					txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
    				}
                    ...
    
    
    // DefaultTransactionAttribute.java
    
    	@Override
    	public boolean rollbackOn(Throwable ex) {
    		return (ex instanceof RuntimeException || ex instanceof Error);
    	}

    invokeWithinTransaction 메서드 안에서 transaction 내부에서 발생하는 메서드에 대해 정의해주는 것을 알 수 있습니다.

    아래 try catch문 안에서 invocation.proceedWithInvocation()을 보면 해당 메서드를 실행해주게 되고, 예외에 대한 처리는 completeTransactionAfterThrowing() 이라는 메서드에서 처리해주고 있습니다.

    completeTransactionAfterThrowing() 내부에서 txInfo.transactionAttribute.rollbackOn 메서드를 살펴보게 되면, 예외가 RuntimeException 일 경우 true를 반환해줍니다. 그래서 if문의 조건은 true가 되고, rollback 관련 메서드가 실행되는 것을 알 수 있습니다.

    txInfo.transactionAttribute.rollbackOnchecked exception일 경우에는 false를 반환하게 되고 이 경우 rollback이 진행되지 않고 commit이 됩니다.

     

     

    rollbackFor

    checked exception의 경우 rollback을 하고 싶다면 @Transactional 어노테이션에 rollbackFor을 설정해주면 됩니다.

    @Transactional(rollbackFor = Exception.class)
    public void putBookAndAuthor(){
        ...

     

     

    @Transactional 메서드 직접 사용

    public void put(){
        this.putBookAndAuthor();
    }
    
    @Transactional
    public void putBookAndAuthor(){
        ...
        
        new RuntimeException("오류");
    }
    
    // Test
    
    @Test
    void test(){
        bookService.put();
    
        System.out.println(bookRepository.findAll());
        System.out.println(authorRepository.findAll());
    }

    위의 테스트를 실행할 경우 @Transactional 안에서 오류가 반드시 발생하기 때문에 모든 쿼리가 rollback 될 것처럼 보이나, 모든 쿼리가 정상동작하는 것을 확인할 수 있습니다.

     

    스프링 컨테이너는 빈으로 진입할 때 걸려있는 어노테이션에 대해 처리하도록 되어있습니다. 이미 put 메서드에 진입하는 순간 빈 내부로 들어왔고 put 메서드 안에서 빈 내부의 다른 메서드를 호출하면, 해당 메서드의 @Transactional 어노테이션의 효과가 없게 됩니다.

    왜나하면, 빈 외부에서 호출되는 시점에 AOP에서 어노테이션을 읽어서 처리하기 때문입니다.

     

    빈 클래스의 내부 메서드에서 다른 내부 메서드를 호출할 경우 @Transactional 어노테이션의 효과는 없습니다.

     

     

     

    isolation

    @Transactional에서 격리 수준을 정의할 수 있는 속성입니다. 동시에 발생하는 Transaction 간에 데이터 접근을 어떤식으로 정의할 것인지 정할 수 있습니다.

    • DEFAULT
    • READ_UNCOMMITTED
    • READ_COMMITTED
    • REPEATABLE_READ
    • SEARIZABLE

    아래쪽으로 내려갈수록 격리 단계가 강력해지고 데이터의 정합성을 보장해주지만, 동시 처리에 대한 성능이 떨어집니다.

     

    테스트 순서

    // Service
    
    @Transactional
    public void get(Long id){
        System.out.println(bookRepository.findById(id)); // 중단점 1
        System.out.println(bookRepository.findAll());
        
        System.out.println(bookRepository.findById(id)); // 중단점 2
        System.out.println(bookRepository.findAll());
        
        Book book = bookRepository.findById(1L).get();
        book.setName("changed name");
        bookRepository.save(book);
    }
    
    // Test
    
    @Test
    void test(){
        Book book = new Book();
        book.setName("book1");
    
        bookRepository.save(book);
    
        bookService.get(1L);
    
        System.out.println(bookRepository.findAll()); // 중단점
    }

    위의 코드로 다음과 같이 테스트를 진행해보도록 하겠습니다.

     

    1. 테스트를 디버그로 실행한 상태에서 중단점 1에 걸리게 합니다.

    2. 다른 트랜잭션 내에서 update쿼리를 이용해 1번 id의 book의 category 속성을 "changed" 바꾼 뒤 Transaction을 끝내지 않은 상태에서 디버그를 다시 실행을 해줍니다.

    3. 중단점 2에서의 조회된 데이터 값을 확인합니다.

    4. name 속성을 "changed name"으로 수정해줍니다.

    5.1 순서 2의 Transaction을 commit 시켜준 뒤 디버그를 다시 실행합니다.

    5.2 순서 2의 Transaction을 rollback 시켜준 뒤 디버그를 다시 실행합니다.

    6. Test 코드 내의 findAll()에 의해 조회되는 데이터를 확인합니다.

     

    DEFAULT

    데이터베이스의 default 격리 단계를 따라가는 것입니다. (mysql의 경우 REPEATABLE_READ)

     

    READ_UNCOMITTED

    @Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void get(Long id){
        ...

    순서 3에서 조회된 데이터에서 category의 속성이 "changed"로 수정된 값을 조회한 것을 알 수 있습니다.

    아직 commit 되지 않은 데이터가 조회되는 dirty read가 발생합니다.

     

    발생할 수 있는 문제

     

    메서드 내의 update 쿼리는 순서 2에서 commit되지 않은 Transaction에 의해 Lock이 발생하게 됩니다. 순서 5.1을 실행하면, Lock이 풀려 Test메서드의 findAll() 메서드에서 데이터를 조회하게 됩니다.

    이 때 조회된 값을 살펴보면 데이터의 name과 category가 각각 "changed name"과 "changed"로 수정된 것을 확인할 수 있습니다.

    이는 의도한대로 잘 동작한 것입니다.

     

    하지만, 순서 5.2를 실행해서 Transaction을 rollback 해도 name과 category 모두 수정이 됩니다.

    그 이유는 findById를 할 때, 커밋되지 않은 데이터를 읽어와 1번 id의 Book Entity를 저장하기 때문입니다. Book Entity의 category값은 이미 수정된 상태이므로 메서드 내부의 update쿼리 상에 name과 category가 모두 수정되게 됩니다. 

     

    이를 방지하기 위해서는 Book Entity에 @DynamicUpdate 어노테이션을 추가해주면 됩니다. (@DynamicUpdate는 필요한 정보만 update해줍니다.)

     

    위와 같은 상황처럼 dirty read를 통해 데이터 정합성을 헤칠 수 있기 때문에 실제 서비스에서 READ_UNCOMMITTED 격리 수준은 일반적으로 많이 사용하지 않는다고 합니다.

     

    READ_COMMITTED

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public void get(Long id){
        ...

    순서 3에서 category column이 수정되지 않은 값을 조회하게 됩니다.

    순서 5.1을 실행한 경우 순서 6에서 name과 category가 모두 수정된 데이터를 조회하게 됩니다.

    순서 5.2를 실행한 경우 순서 6에서 name만 수정된 데이터를 조회하게 됩니다.

     

    READ_COMMITTED의 경우 commit된 데이터만 읽기 때문에 순서 3에서 category값이 수정되지 않았으므로 어떤 경우에도 의도한대로 동작하게 됩니다.

     

    발생할 수 있는 문제

    @Transactional(isolation = ISOLATION.READ_COMMITTED
    public void get(Long id){
        System.out.println(bookRepository.findById(id)); // 중단점 1
        System.out.println(bookRepository.findAll());
        
        System.out.println(bookRepository.findById(id)); // 중단점 2
        System.out.println(bookRepository.findAll());
    }

    메서드 상의 update를 지우고 테스트 순서를 조금 수정해보도록 하겠습니다. 순서 2와 순서 3 사이에서 바로 Transaction을 commit을 해주도록 하겠습니다.

    먼저 commit을 해줄 경우 순서 3에서는 category속성이 변경된 값이 조회될 것 같지만, 조회된 값의 category는 변경되지 않았습니다.

     

    이는 영속성 컨텍스트의 Entity Cache 때문입니다. id를 통해 조회를 했기 때문에 Entity Cache 내부의 값을 조회해서 가져왔기 때문에 아직 수정되지 않은 값을 조회하게 된 것입니다.

     

    만약 중단점1과 중단점 2 사이에서 EntityManager로 cache를 clear해준다면, 중단점 1과 중단점 2에서 조회되는 값의 category 속성은 다르게 될 것입니다.

    이러한 경우를 unrepeatable read 상태라고 합니다. Transaction 내부에서 다른 조작을 하지 않았지만 조회 값이 달라지는 현상입니다. 이러한 unrepeatable read 상태를 해결하기 위해 나온 것이 REPEATABLE_READ입니다.

     

    REPEATABLE_READ

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void get(Long id){
        ...

    같은 트랜잭션 내부에서 반복해서 값을 읽더라도 항상 같은 값을 조회하는 것을 보장해줍니다. 트랜잭션 시작 시 조회한 데이터들에 대한 스냅샷을 저장해두고 트랜잭션이 끝나기 전까지는 이 스냅샷 정보를 계속해서 리턴해주게 됩니다.

     

    READ_COMMITTED의 예시처럼 Entity Cache를 clear한 뒤 중단점 1과 중단점 2에서 조회하더라도 category 속성은 동일하게 조회됩니다. 당연하게도 get메서드가 끝난 뒤 findAll에 의한 조회에서는 category 속성이 변경된 값이 조회됩니다.

     

    발생할 수 있는 문제

    트랜잭션 내에서 조회되지 않은 값에 대한 처리가 발생하는 팬텀 리드가 발생할 수 있습니다.

    이를 해결하기 위해 나온 것이 SERIALIZABLE입니다.

     

    SERIALIZABLE

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void get(Long id){
        ...

    commit이 일어나지 않은 Transaction이 존재할 경우 Lock을 통해 waiting하게 됩니다. 해당 Transaction이 commit되어야지만 로직이 진행되게 됩니다.

     

    데이터 변경, 삽입을 원할 경우 다른 쪽 Transaction이 무조건 commit되어야지만 동작하기 때문에 데이터 정합성은 100%가 됩니다. 하지만, 그만큼 waiting이 길어져 성능면에서는 떨어질 수 있습니다.

     

     

     

     

     

     

     

     

     

    오늘은 @Transaction 어노테이션에 대해 알아보았습니다. 사실 Jpa에 관련된 학습이라기 보다는 DB 관련 내용이 많았기 때문에 이해하는데 어렵지 않았습니다.

     

    반응형
    LIST

    '백엔드' 카테고리의 다른 글

    JPA Cascade, OrphanRemoval  (0) 2023.09.04
    Spring IOC, DI, AOP  (0) 2023.08.26
    영속성 컨텍스트, Entity Cache, Entity Lifecycle  (2) 2023.08.24
    Jpa 연관관계 살펴보기 (N:N)  (0) 2023.08.23
    JPA 연관관계 살펴보기 (1:1, 1:N, N:1)  (4) 2023.08.21

    댓글

Designed by Tistory.