백엔드/JPA

[ JPA ] 6-4. 트랜잭션 매니저 (TransactionManager)

Reference. 한 번에 끝내는 Java/Spring 웹 개발 마스터 초격차 패키지 Online

이전 글

1. 트랜잭션

  • 트랜잭션 ACID(원자성, 일관성, 고립성, 지속성)
    • 원자성 (Atomicity)
      • 작업들이 중간에 중단되어도 일관성 보장을 의미 (모두 성공 or 실패)
    • 일관성 (Consistency)
      • 데이터간에 정합성을 맞추는 의미 (데이터의 일관성 있는 상태로 유지)
    • 독립성 (Isolation)
      • 트랜잭션 수행 시 다른 트랜잭션의 연산을 못하도록 보장
      • 고립성이라고도 말하며, 성능 관련 이유가장 유연성 있는 제약조건
    • 귀속성 (Durability)
      • 성공적으로 수행된 트랜잭션은 영원히 반영 (데이터 영구 보관)
  • @Transactional
    • 2가지 라이브러리의 차이
      • javax.transaction : 스프링에 의존없이 사용 가능 (다른 컨테이너 사용 가능)
      • org.springframework.transactional.annotaion : 스프링에 많은 기능 사용 가능
    • 메소드, 클래스(포함된 모든 메소드 적용)에 사용 가능 (우선순위는 메소드)
      • 블럭을 기준으로 시작시점에 트랜잭션 시작, 종료시점에 트랜잭션 종료
      //메소드에서 실행한 경우
      @Transactional
      public void pubBookAndAuthor(){
          Book book = new Book();
          book.setName("JPA 시작하기");
      
          bookRepository.save(book);
      
          Author author = new Author();
          author.setName("martin");
      
          authorRepository.save(author);
      }

1.1. 테스트 코드 작성

BookServiceTest.java
@SpringBootTest
class BookServiceTest {
    private final BookService bookService;
    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    @Autowired
    public BookServiceTest(BookService bookService, BookRepository bookRepository, AuthorRepository authorRepository){
        this.bookService = bookService;
        this.bookRepository = bookRepository;
        this.authorRepository = authorRepository;
    }

    @Test
    void transactionTest(){
				//RuntimeException이 발생해도 조회가 가능하기 위해 임시처리
        try {
            bookService.pubBookAndAuthor();
        } catch (RuntimeException e) {
            System.out.println(">>> " + e.getMessage());
        }

        System.out.println("books : " + bookRepository.findAll());
        System.out.println("authors : " + authorRepository.findAll());
    }
}

1. 트랜잭션이 없는 경우 (@Transactional 주석처리)

  • Exception이 발생해도 메소드 블럭에 @Transactional 이 없어 데이터 반영 처리
  • JpaRepository save()안에 트랜잭션이 존재하므로save()마다 트랜잭션 처리
BookService.java
@Service
@RequiredArgsConstructor
public class BookService {
    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

//    @Transactional
    public void pubBookAndAuthor(){
        Book book = new Book();
        book.setName("JPA 시작하기");

        bookRepository.save(book);

        Author author = new Author();
        author.setName("martin");

        authorRepository.save(author);

        throw new RuntimeException("오류 발생 commit 실패");
    }
}
실행결과
...
books : [Book(super=BaseEntity(createdAt=2021-08-07T23:34:19.368267, updatedAt=2021-08-07T23:34:19.368267), id=1, name=JPA 시작하기, category=null)]
...
authors : [Author(super=BaseEntity(createdAt=2021-08-07T23:34:19.591270, updatedAt=2021-08-07T23:34:19.591270), id=1, name=martin, country=null)]

2. 트랜잭션이 있는 경우

  • @Transactional이 메소드에 선언되어 블럭에 Exception이 발생하여 rollback 처리
BookService.java (pubBookAndAuthor)
@Transactional
public void pubBookAndAuthor(){
    Book book = new Book();
    book.setName("JPA 시작하기");

    bookRepository.save(book);

    Author author = new Author();
    author.setName("martin");

    authorRepository.save(author);

    throw new RuntimeException("오류 발생 commit 실패");
}
실행결과
...
books : []
...
authors : []

1.2. 트랜잭션에 잘못된 대표 사례

  1. Checked Exception에 사용 (Checked - UnChecked에 혼용이 원인)
    • Checked Exception
      • 대표 Class : Exception
      • 명시적인 Exception 처리가 필요
      • 예외가 발생해도 트랜잭션이 Rollback 처리X
      • 개발자가 Exception catch에서 트랜잭션을 핸들링하도록 가이드
    • UnChecked Exception
      • 대표 Class : RuntimeException
      • 예외가 발생하면 Rollback 처리O
    • Checked ExceptionUnChecked Exception 차이가 발생하는 이유
      • UnChecked Exception 처리
      //TransactionAspectSupport.java
      
      @Nullable
      protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
      		final InvocationCallback invocation) throws Throwable {
      	...
      	try {
      			retVal = invocation.proceedWithInvocation(); //트랜잭션이 선언된 메소드 실행
      	}
      	catch (Throwable ex) {
      			completeTransactionAfterThrowing(txInfo, ex); //Exception 발생시 Rollback 처리
      			throw ex;
      	}
      //TransactionAspectSupport.java
      
      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)) {
      			...
      //DefaultTransactionAttribute.java
      
      @Override
      public boolean rollbackOn(Throwable ex) {
      	return (ex instanceof RuntimeException || ex instanceof Error); //RuntimeException or Error만 롤백
      }
      • Checked Exception 처리
      //TransactionAspectSupport.java
      
      protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
      		...
      		if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
      		...
      		else {
      				// We don't roll back on this exception.
      				// Will still roll back if TransactionStatus.isRollbackOnly() is true.
      				try {
      						txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
      				}
    • Checked ExceptionRollback 하는 방법 (rollbackFor 속성 사용)
      ...
      public class BookService {
      		...
      
      		@Transactional(rollbackFor = Exception.class)
      		public void pubBookAndAuthor() throws Exception {
      				...
      		}
      }
      
      //테스트 코드 실행 결과
      books : []
      authors : []
  1. 메소드에 참조하는 메소드가 @Transaction인 경우 (해당 메소드는 @Transaction X)
    • 스프링 컨테이너는 빈으로 진입할 때 메소드에 걸려있는 어노테이션에 대해서만 처리
    • put() 에는 @Transactional 이 존재하지 않으므로 처리X
    ...
    public class BookService {
    		...
    		public void put(){
    	      this.pubBookAndAuthor();
    	  }
    	
    		@Transactional
        void pubBookAndAuthor(){
    				...
    				throw new RuntimeException("오류 발생 commit 실패");
    		}
    }
    
    //테스트 실행 결과 (Rollback 실패)
    >>> 오류 발생 commit 실패
    books : [Book(super=BaseEntity(createdAt=2021-08-08T00:29:26.443095, updatedAt=2021-08-08T00:29:26.443095), id=1, name=JPA 시작하기, category=null)]
    authors : [Author(super=BaseEntity(createdAt=2021-08-08T00:29:26.598097, updatedAt=2021-08-08T00:29:26.598097), id=1, name=martin, country=null)]

1.3. 스프링 @Transactional 어노테이션 기능

1. isolation() (격리 수준)

  • 트랜잭션 격리단계를 의미, 트랜잭션간에 데이터 접근을 어떤식으로 정의할 것인지 설정
  • 격리 수준: DEFAULT, READ_UNCOMMITTED, READ_COMMITTED, REPEATABLE_READ, SERIALIZABLE
  • 일반적으로 READ_COMMITTED, REPEATABLE_READ를 많이 사용(정합성과 성능상의 이유)
  • DEFAULT: 데이터베이스에 격리 단계를 사용(MySQL default: REPATABLE_READ)
  • 레벨이 높을 수록 데이터정합성은 정확해지고 성능향상은 저하 (DEFAULT는 DB설정단계 사용)
    • READ_UNCOMMITTED: 레벨0, 다른 트랜잭션 수행에 커밋되지 않은 결과를 조회(Dirty Read)
    • READ_COMMITTED: 레벨1, 다른 트랜잭션에서 커밋된 결과를 조회 (Unrepeatable 상태)
    • REPEATABLE_READ: 레벨2
    • SERIALIZABLE: 레벨3

-isolation.READ_UNCOMMITTED 테스트 실행 및 결과

  • IDE debug모드를 사용하여 브레이크 포인트를 지정데이터베이스엔 수정 쿼리 실행
  • READ_UNCOMMITTED실행중인 트랜잭션에서 다른 트랜잭션 결과에 영향을 받음
  • 데이터 조회(find) 테스트
    1. 자바 트랜잭션을 이용한 테스트 코드 작성 (BookServiceTest.java)
      @Transactional(isolation = Isolation.READ_UNCOMMITTED)
      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());
      }
    1. 데이터베이스에 쿼리를 이용한 트랜잭션 생성
      start transaction;
      
      update book set category='none';
      
      commit;
    1. 결과 확인
      //실행중인 트랜잭션이 완료되지 않은 상태(브레이크 포인트에 걸려있는 상태)
      //데이터베이스를 이용한 쿼리가 커밋되지 않았는데 결과가 java 트랜잭션 반영
      >>> Optional[Book(super=BaseEntity(createdAt=2021-08-08T09:02:45.370835, updatedAt=2021-08-08T09:02:45.370835), id=1, name=JPA 강의, category=none)]
  • 데이터 수정(save) 테스트
    1. 자바 트랜잭션을 이용한 테스트 코드 작성 (BookServiceTest.java)
      @Transactional(isolation = Isolation.READ_UNCOMMITTED)
      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(id).get();
          book.setName("바꾼이름");
          bookRepository.save(book);
      }
    1. 데이터베이스에 쿼리를 이용한 트랜잭션 생성
      start transaction;
      
      update book set category='none';
      
      commit;
    1. 결과 확인
      // 1 브레이크 포인트에서 데이터베이스 트랜잭션 시작 후 update 실행
      // 트랜잭션 락이 발생, 데이터베이스에서 commit/rollback 실행하면 락이 해제
      // commit과 rollback이 아래와 같은 동일한 결과를 반환
      // 이유는 jpa에서 commit되지 않은 값을 가지고 있다가 save에서 모두 반영하기 때문
      >>> [Book(super=BaseEntity(createdAt=2021-08-08T09:27:29.000685, updatedAt=2021-08-08T09:27:44.300163), id=1, name=바꾼이름, category=none)]
    1. JPA에서 Dirty Readsave 처리 방법 (임시 방편, 데이터 정합성 해결X)
      • Book.java@DynamicUpdate를 추가
        • JPA에서 수정하고자 하는 컬럼(name)만 반영 됌 (book.setName("바꾼이름");)
        ...
        @DynamicUpdate
        public class Book extends BaseEntity{
        ...
        Hibernate: 
            update
                book 
            set
                updated_at=?,
                name=? 
            where
                id=?
        
        >>> [Book(super=BaseEntity(createdAt=2021-08-08T09:35:31.706323, updatedAt=2021-08-08T09:35:53.869175), id=1, name=바꾼이름, category=null)]

-isolation.READ_COMMITTED 테스트 실행 및 결과

  • isolation.READ_UNCOMMITTED 를 해결하기 위해 등장, 테스트 방법은 READ_UNCOMMITTED 와 동일
  • BookService.java@Transactional(isolation = Isolation.READ_COMMITTED) 로 수정
  • Book.java@DynamicUpdate를 제거 (커밋된 것만 조회하므로 필요 없음)
  • 실행결과
    //DynamicUpdate를 제거했기 때문에 모든 컬럼에 대해 update
    Hibernate: 
        update
            book 
        set
            updated_at=?,
            category=?,
            name=?,
            publisher_id=? 
        where
            id=?
    
    
    //Dirty Read 현상 제거
    >>> [Book(super=BaseEntity(createdAt=2021-08-08T09:43:48.675890, updatedAt=2021-08-08T09:43:57.891441), id=1, name=바꾼이름, category=null)]
  • READ_COMMITTED에 문제점
    • 1 브레이크 위치에서 다른 트랜잭션 수행에 commit이 발생하면 2 브레이크 결과가 달라짐
      • 첫번째 브레이크 category = null, 두번째 브레이크 category = none
      @Transactional(isolation = Isolation.READ_COMMITTED)
      public void get(Long id){
          System.out.println(">>> " + bookRepository.findById(id)); // 1 브레이크
          System.out.println(">>> " + bookRepository.findAll());
      
          entityManager.clear(); //JPA cache로 인해 예상결과와 달라 clear() 실행
      
          System.out.println(">>> " + bookRepository.findById(id)); // 2 브레이크
          System.out.println(">>> " + bookRepository.findAll());
      
          entityManager.clear(); //JPA cache로 인해 예상결과와 달라 clear() 실행
      }
      //실행결과
      //2 브레이크에서 데이터베이스 update 후 commit
      
      //1 브레이크 조회에서는 commit된 것이 없기 때문에 category = null
      >>> Optional[Book(super=BaseEntity(createdAt=2021-08-08T10:04:39.933340, updatedAt=2021-08-08T10:04:39.933340), id=1, name=JPA 강의, category=null)]
      
      //2 브레이크 조회에서는 update commit이 진행되어 category=none
      >>> Optional[Book(super=BaseEntity(createdAt=2021-08-08T10:04:39.933340, updatedAt=2021-08-08T10:04:39.933340), id=1, name=JPA 강의, category=none)]

-isolation.REPEATABLE_READ테스트 실행 및 결과

  • isolation.READ_COMMITTED 를 해결하기 위해 등장, 테스트 소스 및 구성은 동일
  • 조회결과를 별도의 스냅샷으로 저장 트랜잭션이 종료되기 전까지는 그 값을 리턴
  • 수행중인 트랜잭션안에서만 영향을 주고 받음
  • BookService.java@Transactional(isolation = Isolation.REPEATABLE_READ) 로 수정
  • REPEATABLE_READ 문제점
    • 팬텀 리드 (Phantom read): 트랜잭션 내에서 같은 쿼리를 실행하지만 예상결과와 다른 결과가 나오는 현상이 발생
    • 1 브레이크에서 데이터베이스르 insert문을 commit
    • 트랜잭션내 실행으로 1개의 데이터만 수정 될 것처럼 보이지만 안보이던 insert문도 수정
    //BookRepository.java
    @Repository
    public interface BookRepository extends JpaRepository<Book, Long> {
        @Modifying
        @Query(value = "update book set category='none'", nativeQuery = true)
        void update();
    }
    //BookService.java
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public void get(Long id){
    		...
    
        bookRepository.update(); // 1 브레이크
    		entityManager.clear();
    }
    //데이터베이스 쿼리
    start transaction;
    	insert into book(id, name) values(2, 'jpa 강의 2');
    commit;

-isolation.SERIALIZABLE테스트 실행 및 결과

  • isolation.REPEATABLE_READ 를 해결하기 위해 등장, 테스트 소스 및 구성은 동일
  • 다른 트랜잭션이 끝날때 까지 무조건 기다리고 처리가 완료되면 실행 (데이터 정합성 100%)
  • 웨이팅이 길어져서 성능에는 안좋은 영향 발생

2. propagation()(트랜잭션 전파)

  • 실습을 위한 공통 테스트 코드
@SpringBootTest
class BookServiceTest {
		@Autowired
    private final BookService bookService;
		@Autowired
    private final BookRepository bookRepository;
		@Autowired
    private final AuthorRepository authorRepository;
		@Autowired
    private final EntityManager entityManager;

		@Test
		void transactionTest(){
		    try {
		        bookService.pubBookAndAuthor();
		    } catch (RuntimeException e) {
		        System.out.println(">>> " + e.getMessage());
		    }
		
		    System.out.println("books : " + bookRepository.findAll());
		    System.out.println("authors : " + authorRepository.findAll());
		}
}
  • Spring @TransactionalPropagation.java의 7가지 설정 지원 (default: REQIRED)
    • REQUIRED:
      • 기존에 사용하는 트랜잭션이 있으면 그것을 사용, 없으면 새로운 트랜잭션 생성
      • JPA Repository에 save()REQUIRED전파를 사용 (코드블럭 내에선 동일 트랜잭션)
      • UnChecked Exception이 일어나면 전파된 트랜잭션 모두 rollback
      @Service
      @RequiredArgsConstructor
      public class AuthorService {
          private final AuthorRepository authorRepository;
      
          @Transactional(propagation = Propagation.REQUIRED)
          public void putAuthor() {
              Author author = new Author();
              author.setName("martin");
      
              authorRepository.save(author);
      
              throw new RuntimeException("오류가 발생"); //여기서 발생해도 롤백
          }
      }
      @Transactional(propagation = Propagation.REQUIRED)
      public void pubBookAndAuthor(){
          Book book = new Book();
          book.setName("JPA 시작하기");
      
          bookRepository.save(book);
          //오류가 전파되지 않도록 try - catch를 사용
          try{
              authorService.putAuthor();
          }catch (RuntimeException e){
          }
      
      		throw new RuntimeException("오류가 발생"); //여기서 발생해도 롤백
      }
      //실행결과
      books : []
      authors : []
    • REQUIRES_NEW:
      • 기존 트랜잭션 유무와는 상관없이 무조건 새로운 트랜잭션을 생성
      @Transactional(propagation = Propagation.REQUIRES_NEW)
      public void putAuthor() {
      		...
      		throw new RuntimeException("오류가 발생"); //여기서 롤백
      }
      @Transactional(propagation = Propagation.REQUIRED)
      public void pubBookAndAuthor(){
      		...
      }
      //실행 결과
      books : [Book(super=BaseEntity(createdAt=2021-08-08T14:05:27.011909, updatedAt=2021-08-08T14:05:27.011909), id=1, name=JPA 시작하기, category=null)]
      authors : []
    • NESTED:
      • 별도의 트랜잭션을 생성하지 않음, 하나의 트랜잭션이지만 분리되어 동작
      • save point (중간 저장) 까지는 보장
      • JPA에서는 NESTED 전파를 사용하지 못함 (의도와는 다른 결과 때문)
    • SUPPORTS:
      • 트랜잭션이 있는 경우 그 트랜잭션을 사용, 없는 경우 트랜잭션 사용X (새로 안만듬)
    • NOT_SUPPORTED:
      • 트랜잭션 없이 별개로 동작, 다른 트랜잭션이 수행이 된 후 실행
    • MANDATORY:
      • 필수적으로 트랜잭션이 반드시 존재해야 함, 트랜잭션이 없으면 오류 발생
    • NEVER:
      • 트랜잭션이 없어야 함, 트랜잭션이 있는 경우 오류 발생