[Spring] Transaction Manager(예외 Rollback 차이, isolation, propagation)

WOOK JONG KIM·2022년 11월 22일
0

패캠_java&Spring

목록 보기
61/103
post-thumbnail

트랜잭션

DB 명령어들의 논리적인 묶음
-> 자바에서 메서드를 통해 논리적인 작업 단위 묶듯이!

트랜잭션 ACID(원자성, 일관성, 고립성, 지속성) 속성

원자성 (Atomicity) -> All or Nothing
작업들이 중간에 중단되어도 일관성 보장을 의미 (모두 성공 or 실패)

ex) 돈을 송금하면 받는 계좌에 추가되어야 함

일관성 (Consistency)
데이터간에 정합성을 맞추는 의미 (데이터의 일관성 있는 상태로 유지)

ex) 송금을 하기 전 잔액이 0보다 많아야 하고 -> 송금 하면 계좌는 0이거나 0보다 많아야 함

독립성 (Isolation)
트랜잭션 수행 시 다른 트랜잭션의 연산을 못하도록 보장
고립성이라고도 말하며, 성능 관련 이유로 가장 유연성 있는 제약조건

ex) 친구에게 송금을 하는 도중에(송금 트랜잭션 진행중, 완료X) 그 돈을 친구가 통장에서 돈 인출(인출 트랙잭션)이 먼저 실행되는 경우 -> 문제 발생

귀속성 (Durability)
성공적으로 수행된 트랜잭션은 영원히 반영 (데이터 영구 보관)

JPA 트랜잭션 설정

@Transactional

두 군데 에서 제공

-> javax.transaction : 스프링에 의존없이 사용 가능 (다른 컨테이너 사용 가능)
org.springframework.transactional.annotaion : 스프링에 많은 기능 사용 가능

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

    bookRepository.save(book);

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

    authorRepository.save(author);
}

위 코드에 Transactional 어노테이션을 붙이지 않고 save(book)뒤에 브레이크 포인트를 두었을 때 db에 정상적으로 값이 반영됨
-> 붙이지 않을 시엔 select * from book; 해도 empty
-> 블럭 내 코드들을 하나의 트랜잭션으로 보았기에 save(author)가 끝난 후에야 DB에 반영 되는 것!

만약 BookAndAuthor() 코드 마지막에 throw new RunTimeException() 즉 Unchecked Exception 을 발생시켰다면

-> @Transactional가 붙은 경우에는 커밋이 안되는 반면
-> 붙이지 않았을 시에는 이미 save 과정에서 커밋이 되어 오류는 발생하지만 Db 에 반영될것이라 유추 가능


Exception에 따른 Rollback 차이

Checked Exception에 사용 (Checked - UnChecked에 혼용이 원인)

  • Checked Exception
    대표 Class : Exception
    명시적인 Exception 처리가 필요
    예외가 발생해도 트랜잭션이 Rollback 처리X
    개발자가 Exception catch에서 트랜잭션을 핸들링하도록 가이드
  • UnChecked Exception
    대표 Class : RuntimeException
    예외가 발생하면 Rollback 처리O
    Checked Exception과 UnChecked Exception 차이가 발생하는 이유
    UnChecked Exception 처리

즉 Check Exception을 처리하는 try-catch에서 DB에 반영되지 않게 롤백을 명시적으로 지정해줘야 원하는 결과 실행될 것

차이 발생 이유

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

unchecked 경우 getTransactionManager를 통해 rollback 치는 것을 볼 수 있다

//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)) {
            try {
					txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
			...
/DefaultTransactionAttribute.java

@Override
public boolean rollbackOn(Throwable ex) {
	return (ex instanceof RuntimeException || ex instanceof Error); //RuntimeException or Error만 롤백
}

checked 의 경우 commit을 친다

//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 Exception 을 Rollback 하는 방법 (rollbackFor 속성 사용)

...
public class BookService {
		...

		@Transactional(rollbackFor = Exception.class)
		public void pubBookAndAuthor() throws Exception {
				...
		}
}

메소드에 참조하는 메소드가 @Transaction인 경우 (해당 메소드는 @Transaction X)

스프링 컨테이너는 빈으로 진입할 때 메소드에 걸려있는 어노테이션에 대해서만 처리
-> put() 에는 @Transactional 이 존재하지 않으므로 처리X

빈 클래스 내부에서 내부를 호출할때는 @Transactional 효과가 없음

...
public class BookService {
		...
		public void put(){
	      this.pubBookAndAuthor();
	  }
	
		@Transactional
    	public void pubBookAndAuthor(){
				...
				throw new RuntimeException("오류 발생 commit 실패");
		}
}

//테스트 실행 결과 (Rollback 실패되고 DB에 값이 반영됨)

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

isolation

동시에 발생하는 트랜잭션 간에 데이터 접근을 어떠한 식으로 처리할것인지 나타내는것이 격리 단계(두 트랜잭션 값 경합 시)

일반적으로 READ_COMMITTED, REPEATABLE_READ를 많이 사용(정합성과 성능상의 이유)

DEFAULT: 데이터베이스에 격리 단계를 사용(MySQL default: REPATABLE_READ)

0단계 : Default

1단계 : READ_UNCOMMITTED : 다른 트랜잭션 수행에 커밋되지 않은 결과를 조회(Dirty Read)

2단계 : READ_COMMITTED : 다른 트랜잭션에서 커밋된 결과를 조회 (Unrepeatable 상태)

3단계 : REPEATABLE_READ

4단계 : SERIALIZABLE

단계가 높을수록 정합성을 보장해주는 반면 동시 처리 수행 성능이 떨어짐

낮아질수록 성능은 높지만 정합성을 보장해주지 못하는 경우가 간혹 발생


isolation 옵션별 차이 보기

1. Isolation.READ_UNCOMMITTED

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
    public void get(Long id){
    // 1st breakpoint
    	System.out.println(" >>> " + bookRepository.findById(id));
        System.out.println(" >>> " + bookRepository.findAll());
	// 2rd breakpoint
        System.out.println(" >>> " + bookRepository.findById(id));
        System.out.println(" >>> " + bookRepository.findAll());

    }

데이터 조회 테스트 코드

	@Test
    void isolationTest(){
        Book book = new Book();
        book.setName("JPA 기본 책");
        bookRepository.save(book);

        bookService.get(1L);
		
        // 3rd breakpoint
        System.out.println(">>> " + bookRepository.findAll());
    }
}

1st breakpoint 실행 이후 mysql 터미널에서 transaction 실행

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> update book set category = 'none';
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

이후 2rd breakPoint 실행 시

id=1, name=JPA 기본 책, category=none, authorId=null

update가 반영 된것처럼 실행 됨(아직 commit 되지 않은 데이터가 조회되었다는 의미의 dirty read)
-> 데이터베이스를 이용한 쿼리가 커밋되지 않았는데 결과가 jpa 트랜잭션 반영


데이터 수정 테스트 코드

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

mysql 트랜잭션 생성

start transaction;

update book set category='none';

commit;

name만 바꿀려 했는데 category = none 까지 반영 되는걸 볼 수 있다

jpa update 특징이 반영되어있다

// 테스트 로그
 update
        book 
    set
        updated_at=?,
        author_id=?,
        category=?,
        name=?,
        publisher_id=? 
    where
        id=?
// 1 브레이크 포인트에서 데이터베이스 트랜잭션 시작 후 update 실행

// 두번째 브레이크 포인트에서 세번째로 넘어갈때 트랜잭션 락이 발생
데이터베이스에서 commit/rollback 실행하면 락이 해제

// commit과 rollback이 아래와 같은 동일한 결과를 반환

// 이유는 jpa에서 commit되지 않은 값을 가지고 있다가 save에서 모두 반영하기 때문

>>> [Book(super=BaseEntity(createdAt=2022-11-21T21:31:46.314764, updatedAt=2022-11-21T21:32:36.307645), id=1, name=바뀔까?, category=none, authorId=null)]

Book 위에다가 @DynamicUpdate 선언 후 mysql에서 rollback 실행 시category = 'none'이 반영안되는 것을 볼 수 있다

commit 시에는 category 'none' 반영
-> update 쿼리엔 실행안되지만 이후 Binding Parameter 에 나타남

Hibernate: 
    update
        book 
    set
        updated_at=?,
        name=? 
    where
        id=?
>>> [Book(super=BaseEntity(createdAt=2022-11-21T21:44:51.986046, updatedAt=2022-11-21T21:45:30.507843), id=1, name=바뀔까?, category=null, authorId=null)]

2. 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=?,
        author_id=?,
        category=?,
        name=?,
        publisher_id=? 
    where
        id=?
        
//Dirty Read 현상 제거 -> Rollback시 반영 안됨

>>> [Book(super=BaseEntity(createdAt=2022-11-21T21:54:32.964687, updatedAt=2022-11-21T21:54:50.025090), id=1, name=바뀔까?, category=null, authorId=null)]

문제점

@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() 실행
}

1브레이크에서 update 후 2브레이크에서 commit 하였음

entityManager.clear()를 하지 않고 그냥 실행 시 2브레이크에서 commit을 했음에도 불구하고 1,2브레이크에서 category = null로 조회됨

-> JPA Cache로 인해 unrepeatble read(반복적으로 조회시 값이 변경 될 수 있는 상태) 상태가 됨


3.Isolation.REPEATABLE_READ

이를 해결 하기 위한 것이 repeatable read

반복해서 값을 조회하더라도 항상 동일한 값이 리턴 되도록

다른 트랜잭션에서 commit된 값이 발생하더라도 별도의 스냅샷, 즉 커밋된 데이터를 직접 가져오는 것이 아니라 자기 트랜잭션이 시작할때 조회했던 데이터를 별도로 저장하고 있다가 이 트랜잭션이 끝나기 전까지 스냅샷을 계속해서 리턴

문제점

팬텀 리드 (Phantom read): 트랜잭션 내에서 같은 쿼리를 실행하지만 예상결과와 다른 결과가 나오는 현상이 발생

public interface BookRepository extends JpaRepository<Book, Long> {
    @Modifying
    @Query(value = "update book set category = 'none'", nativeQuery = true)
    // jpa는 entity기반 이기에 무조건 insert 후 update
    void update();
}

test 코드

		System.out.println(" >>> " + bookRepository.findById(id)); // break1
        System.out.println(" >>> " + bookRepository.findAll());

        entityManager.clear();

        System.out.println(" >>> " + bookRepository.findById(id)); // break2
        System.out.println(" >>> " + bookRepository.findAll());

        bookRepository.update();
        
        entityManager.clear();

mysql 쿼리

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into book(`id`, `name`) values(2, 'jpa lecture2');
Query OK, 1 row affected (0.00 sec)

break 1에서 위의 insert문 실행하고 3포인트로 넘어갈 때 commit 실행

id=1, name=JPA 기본 책, category=none, authorId=null), Book(super=BaseEntity(createdAt=null, updatedAt=null), id=2, name=jpa 강의 2, category=none, authorId=null)]

하지만 id2의 카테고리 값까지 none이 추가됨(예상 : 한개의 쿼리만 none으로)
-> 이런것이 Phantom Read 상태라고 함


4. Isolation.SERIALIZABLE

commit이 일어나지 않은 트랜잭션이 존재하면 Lock을 통해 웨이팅을 하게됨
-> commit이 실행 되어야만 로직 실행 됨

1포인트에서 2포인트로 넘어가는 시점부터 락이 바로 걸림

isolation.REPEATABLE_READ 를 해결하기 위해 등장, 테스트 소스 및 구성은 동일
다른 트랜잭션이 끝날때 까지 무조건 기다리고 처리가 완료되면 실행 (데이터 정합성 100%)

웨이팅이 길어져서 성능에는 안좋은 영향 발생


Propagation()

트랜잭션의 시작과 끝은 각 메서드에 시작과 끝과 같음

한 메서드(현재 트랜잭션) 내에서 다른 메서드(다른 트랜잭션)를 호출한다면
트랜잭션을 어떻게 처리하는지에 대한 교통정리를 하는 것이 Propagation()

Spring @Transactional은 Propagation.java의 7가지 설정 지원 (default: REQIRED)

REQUIRED

기존에 사용하는 트랜잭션이 있으면 그것을 재사용, 없으면 새로운 트랜잭션 생성

JPA Repository에 save()가 REQUIRED전파를 사용 (코드블럭 내에선 동일 트랜잭션)

	@Transactional
	@Override
	public <S extends T> S save(S entity) {

		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}

앞서 설명했을 때 @Transactional이 붙은 메서드 안의 save는 메서드 내 하나의 트랜잭션에서 동작하는 반면

@Transactional이 붙지 않은 메서드 안에 save 메서드 한줄한줄이 각각 트랜잭션이 생성되고 commit 된다고 한 이유 -> REQUIRED

UnChecked Exception이 일어나면 전파된 트랜잭션 모두 rollback

테스트 코드

	@Test
    void transactionTest(){
        try{
            bookService.putBookAndAuthor();
        } catch(RuntimeException e){
            System.out.println(">>> " + e.getMessage());
        }


        System.out.println("books : " + bookRepository.findAll());
        System.out.println("authors : " + authorRepository.findAll());
    }
//BookService.class
@Transactional(propagation = Propagation.REQUIRED)
public void putBookAndAuthor() {
    Book book = new Book();
    book.setName("JPA 시작하기");
    
    bookRepository.save(book);
    
    try {
    	authorService.putAuthor();
    } catch(RuntimeException e) {
    	System.out.println(e.getMessage());
    }
    
    //throw new RuntimeException("오류가 발생하였습니다");
}

//AuthorService.class
@Transactional(propagation = Propagation.REQUIRED)
public void putAuthor() {
    Author author = new Author();
    author.setName("martin");
    
    authRepository.save(author);
    
    //throw new RuntimeException("오류가 발생하였습니다");
}

putBookAndAuthor()에 처음에 public을 안붙이니.. 롤백이 되지 않았음..
-> 이유는 차차 알아가보자.., default로 처리되어있을때 jpa 내부 처리 방식에 오류가 생긴듯

둘중 어디에 throw new RuntimeException을 해도 롤백이 진행됨

putAuthor에 RuntimeException 붙인 경우

-> putBookAndAuthor만 실행하였을 때는 예외처리를 하였기에 putAuthor(), save(book) 둘다 정상 로직으로 처리되었지만 롤백됨

-> putAuthor() 메서드에 접근전 putBookAndAuthor()에서 자체적으로 트랜잭션을 생성하였고, putAuthor()는 트랜잭션을 재활용하였는데 여기서 에러가 발생하였기에 둘다 성공,실패 되어야 한다는 ACID에 근거해 롤백

REQUIRES_NEW

트랜잭션이 있던 없던 상관없이, 새로운 트랜잭션을 만들어 독립(자체)적으로 커밋과 롤백을 진행

위에서 설명한 putAuthor에 RuntimeException 붙인 경우에서

putAuthor()에만 REQUIRES_NEW를 붙이게 되면 Author만 롤백 되는 것을 볼 수 있음

반대로 PutBookAndAuthor에만 RuntimeException을 붙인 경우에는

Book만 롤백 됨!

NESTED

별도의 트랜잭션을 생성하지 않음, 하나의 트랜잭션이지만 분리되어 동작
-> 종속적이지만 상위 코드에는 영향 X

save point (중간 저장) 까지의 성공은 보장
-> but JPA에서는 NESTED(기본 옵션 사용시) 전파를 사용하지 못함 (의도와는 다른 결과 때문)

SUPPORTS

트랜잭션이 있는 경우 그 트랜잭션을 사용, 없는 경우 트랜잭션 사용X (새로 안만듬)

NOT_SUPPORTED

트랜잭션 없이 별개로 동작, 다른 트랜잭션이 수행이 된 후 실행

MANDATORY

필수적으로 트랜잭션이 반드시 존재해야 함, 트랜잭션이 없으면 오류 발생

NEVER

트랜잭션이 없어야 함, 트랜잭션이 있는 경우 오류 발생

profile
Journey for Backend Developer

0개의 댓글