트랜잭션 전파

Single Ko·2023년 6월 8일
0

Spring 강의 정리

목록 보기
28/31

스프링에서 우선순위는 항상 더 구체적이고 자세한 것이 높은 우선순위를 가진다. 이것만 기억하면
스프링에서 발생하는 대부분의 우선순위를 쉽게 기억할 수 있다. 그리고 더 구체적인 것이 더 높은
우선순위를 가지는 것은 상식적으로 자연스럽다.

예를 들어서 메서드와 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 메서드가 더 높은 우선순위를 가진다.

인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다

인터페이스에 @Transactional 적용

인터페이스에도 @Transactional 을 적용할 수 있다. 이 경우 다음 순서로 적용된다. 구체적인 것이 더 높은 우선순위를 가진다고 생각하면 바로 이해가 될 것이다.
1. 클래스의 메서드 (우선순위가 가장 높다.)
2. 클래스의 타입
3. 인터페이스의 메서드
4. 인터페이스의 타입 (우선순위가 가장 낮다.)

클래스의 메서드를 찾고, 만약 없으면 클래스의 타입을 찾고 만약 없으면 인터페이스의 메서드를 찾고 그래도 없으면 인터페이스의 타입을 찾는다.

프록시 내부 호출

@Transactional을 사용하면 스프링의 트랜잭션 AOP가 적용된다.
AOP를 사용할 때 조심해야 할 것 중 하나가 바로 프록시 내부 호출인데, @Transactional도 AOP기 때문에 이 문제를 인식하고 있어야 한다.

public void external() {
 log.info("call external");
 printTxInfo();
 internal();
}

@Transactional
public void internal() {
 log.info("call internal");
 printTxInfo();
}

문제 원인
자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다.
결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal() 이 되는데, 여기서 this 는 자기 자신을 가리키므로, 실제 대상 객체( target )의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 트랜잭션을 적용할 수 없다. 결과적으로 target 에 있는 internal() 을 직접 호출하게 된 것이다.

내부 호출 해결 방법으로는 몇가지가 있다. 자기 자신 주입을 받거나, Lazy loading을 하거나,
구조 변경을 시키는 것이다.

보통 메서드를 별도의 클래스로 분리하는 방법을 권장한다.

public 메서드만 트랜잭션 적용

스프링의 트랜잭션 AOP 기능은 public 메서드에만 트랜잭션을 적용하도록 기본 설정이 되어있다.
그래서 protected , private , package-visible 에는 트랜잭션이 적용되지 않는다. protected , package-visible 도 외부에서 호출이 가능하다. 따라서 이 부분은 앞서 설명한 프록시의 내부 호출과는 무관하고, 스프링이 막아둔 것이다

@Transactional
public class Hello {
 public method1();
 method2():
 protected method3();
 private method4();
}

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

초기화 시점

초기화 코드(예: @PostConstruct )와 @Transactional 을 함께 사용하면 트랜잭션이 적용되지 않는다.

@PostConstruct
@Transactional
public void initV1() {
 log.info("Hello init @PostConstruct");
}

왜냐하면 초기화 코드가 먼저 호출되고, 그 다음에 트랜잭션 AOP가 적용되기 때문이다. 따라서 초기화 시점에는 해당 메서드에서 트랜잭션을 획득할 수 없다.

가장 확실한 대안은 ApplicationReadyEvent 이벤트를 사용하는 것이다.

@EventListener(value = ApplicationReadyEvent.class)
@Transactional
public void init2() {
 log.info("Hello init ApplicationReadyEvent");
}

이 이벤트는 트랜잭션 AOP를 포함한 스프링이 컨테이너가 완전히 생성되고 난 다음에 이벤트가 붙은 메서드를 호출해준다. 따라서 init2() 는 트랜잭션이 적용된 것을 확인할 수 있다.

트랜잭션 옵션 소개

value, transactionManager

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

사용할 트랜잭션 매니저를 지정할 때는 value , transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다.
이 값을 생략하면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그런데 사용하는 트랜잭션 매니저가 둘 이상이라면 다음과 같이 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.

값이 하나일때는 value, transactionManger는 생략 가능.

rollbackFor

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

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

이 옵션을 사용하면 기본 정책에 추가로 어떤 예외가 발생할 때 롤백할 지 지정할 수 있다.

@Transactional(rollbackFor = Exception.class)

예를 들어서 이렇게 지정하면 체크 예외인 Exception 이 발생해도 롤백하게 된다. (하위 예외들도 대상에 포함된다.)

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

isolation

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

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

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

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

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

커밋 , 롤백

스프링은 왜 체크 예외는 커밋하고, 언체크(런타임) 예외는 롤백할까?
스프링 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임(언체크) 예외는 복구 불가능한 예외로 가정한다.

체크 예외: 비즈니스 의미가 있을 때 사용
언체크 예외: 복구 불가능한 예외

참고로 꼭 이런 정책을 따를 필요는 없다. 그때는 앞서 배운 rollbackFor 라는 옵션을 사용해서 체크 예외도 롤백하면 된다.

트랜잭션 전파

@Test
void commit() {
	log.info("트랜잭션 시작");
 	TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("트랜잭션 커밋 시작");
 	txManager.commit(status);
 	log.info("트랜잭션 커밋 완료");
 }
 
 @Test
 void rollback() {
	log.info("트랜잭션 시작");
 	TransactionStatus status = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("트랜잭션 롤백 시작");
 	txManager.rollback(status);
 	log.info("트랜잭션 롤백 완료");
 }
 
 @Test
 void double_commit() {
 	log.info("트랜잭션1 시작");
 	TransactionStatus tx1 = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("트랜잭션1 커밋");
 	txManager.commit(tx1);
 	log.info("트랜잭션2 시작");
 	TransactionStatus tx2 = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("트랜잭션2 커밋");
 	txManager.commit(tx2);
}
  • 위의 코드들은 별 다른 문제없이 트랜잭션이 잘 된다.
  • double_commit()의 경우에는 서로 다른 트랜잭션이 사용된다. 서로가 다른 트랜잭션 이라는 거다. 연관이 전혀 없다는 것이다.

하지만 트랜잭션을 각각 사용하는 것이 아니라, 트랜잭션이 이미 진행중인데, 여기에 추가로 트랜잭션을 수행하면 어떻게 될까?
기존 트랜잭션과 별도의 트랜잭션을 진행해야 할까? 아니면 기존 트랜잭션을 그대로 이어 받아서 트랜잭션을 수행해야 할까?

이런 경우 어떻게 동작할지 결정하는 것을 트랜잭션 전파(propagation)라 한다.

  • 외부 트랜잭션이 수행중이고, 아직 끝나지 않았는데, 내부 트랜잭션이 수행된다.

  • 스프링 이 경우 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 트랜잭션을 만들어준다. 내부 트랜잭션이 외부 트랜잭션에 참여하는 것이다. 이것이 기본 동작이고, 옵션을 통해 다른 동작방식도 선택할 수 있다.

  • 스프링은 이해를 돕기 위해 논리 트랜잭션과 물리 트랜잭션이라는 개념을 나눈다.
    논리 트랜잭션들은 하나의 물리 트랜잭션으로 묶인다. 물리 트랜잭션은 우리가 이해하는 실제 데이터베이스에 적용되는 트랜잭션을 뜻한다. 실제 커넥션을 통해서 트랜잭션을 시작( setAutoCommit(false)) 하고, 실제 커넥션을 통해서 커밋, 롤백하는 단위이다.

  • 논리 트랜잭션은 트랜잭션 매니저를 통해 트랜잭션을 사용하는 단위이다.
    이러한 논리 트랜잭션 개념은 트랜잭션이 진행되는 중에 내부에 추가로 트랜잭션을 사용하는 경우에 나타난다. 단순히 트랜잭션이 하나인 경우 둘을 구분하지는 않는다. (더 정확히는 REQUIRED 전파 옵션을 사용하는 경우에 나타나고, 이 옵션은 뒤에서 설명한다.)

그럼 왜 이렇게 논리 트랜잭션과 물리 트랜잭션을 나누어 설명하는 것일까?

트랜잭션이 사용중일 때 또 다른 트랜잭션이 내부에 사용되면 여러가지 복잡한 상황이 발생한다. 이때 논리 트랜잭션 개념을 도입하면 다음과 같은 단순한 원칙을 만들 수 있다.

원칙
모든 논리 트랜잭션이 커밋되어야 물리 트랜잭션이 커밋된다. 하나의 논리 트랜잭션이라도 롤백되면 물리 트랜잭션은 롤백된다. (모든 트랜잭션 매니저를 커밋해야 물리 트랜잭션이 커밋된다. 하나의 트랜잭션 매니저라도 롤백하면 물리 트랜잭션은 롤백된다)

트랜잭션 전파의 예

둘 모두 커밋

@Test
void inner_commit() {
 	log.info("외부 트랜잭션 시작");
 	TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("outer.isNewTransaction()={}",outer.isNewTransaction());
    
 	log.info("내부 트랜잭션 시작");
 	TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("inner.isNewTransaction()={}",inner.isNewTransaction());
 	log.info("내부 트랜잭션 커밋");
 	txManager.commit(inner);
    
 	log.info("외부 트랜잭션 커밋");
 	txManager.commit(outer);
}
  • 외부 트랜잭션이 수행중인데, 내부 트랜잭션을 추가로 수행했다.

  • 내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.

  • 트랜잭션 참여
    내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다.

  • 다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
    외부에서 시작된 물리적인 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.

  • 외부 트랜잭션과 내부 트랜잭션이 하나의 물리 트랜잭션으로 묶이는 것이다.
    내부 트랜잭션은 이미 진행중인 외부 트랜잭션에 참여한다. 이 경우 신규 트랜잭션이 아니다( isNewTransaction=false )

스프링은 도대체 어떻게 외부 트랜잭션과 내부 트랜잭션을 묶어서 하나의 물리 트랜잭션으로 묶어서 동작하게 하는가?

  • 그 비밀은 사실 내부 트랜잭션에서 커밋을 날려도 커밋을 하는 것이 아니다.DB 커넥션을 통해 커밋하는 로그를 전혀 확인할 수 없다
  • 정리하면 외부 트랜잭션만 물리 트랜잭션을 시작하고, 커밋한다. 만약 내부 트랜잭션이 실제 물리 트랜잭션을 커밋하면 트랜잭션이 끝나버리기 때문에, 트랜잭션을 처음
    시작한 외부 트랜잭션까지 이어갈 수 없다. 따라서 내부 트랜잭션은 DB 커넥션을 통한 물리 트랜잭션을 커밋하면 안된다.

스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 이를 통해 트랜잭션 중복 커밋 문제를 해결한다.

내부 커밋, 외부 롤백

@Test
void outer_rollback() {
	log.info("외부 트랜잭션 시작");
	TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
	log.info("내부 트랜잭션 시작");
	TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
	log.info("내부 트랜잭션 커밋");
	txManager.commit(inner);
	log.info("외부 트랜잭션 롤백");
	txManager.rollback(outer);
}

논리 트랜잭션이 하나라도 롤백되면 전체 물리 트랜잭션은 롤백된다.
따라서 이 경우 내부 트랜잭션이 커밋했어도, 내부 트랜잭션 안에서 저장한 데이터도 모두 함께 롤백된다.

내부 롤백, 외부 커밋

@Test
void inner_rollback() {
	log.info("외부 트랜잭션 시작");
 	TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("내부 트랜잭션 시작");
 	TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("내부 트랜잭션 롤백");
 	txManager.rollback(inner);
 	log.info("외부 트랜잭션 커밋");
 	assertThatThrownBy(() -> txManager.commit(outer))
 				.isInstanceOf(UnexpectedRollbackException.class);
}
  • 내부 트랜잭션 롤백
Participating transaction failed - marking existing transaction as rollbackonly

내부 트랜잭션을 롤백하면 실제 물리 트랜잭션은 롤백하지 않는다. 대신에 기존 트랜잭션을 롤백 전용으로 표시한다.

  • 외부 트랜잭션 커밋
Global transaction is marked as rollback-only

커밋을 호출했지만, 전체 트랜잭션이 롤백 전용으로 표시되어 있다. 따라서 물리 트랜잭션을 롤백한다.

정리

  1. 논리 트랜잭션이 하나라도 롤백되면 물리 트랜잭션은 롤백된다.
  2. 내부 논리 트랜잭션이 롤백되면 롤백 전용 마크를 표시한다.
  3. 외부 트랜잭션을 커밋할 때 롤백 전용 마크를 확인한다. 롤백 전용 마크가 표시되어 있으면 물리 트랜잭션을 롤백하고, UnexpectedRollbackException 예외를 던진다.

애플리케이션 개발에서 중요한 기본 원칙은 모호함을 제거하는 것이다. 개발은 명확해야 한다. 이렇게 커밋을 호출했는데, 내부에서 롤백이 발생한 경우 모호하게 두면 아주 심각한 문제가 발생한다. 이렇게 기대한 결과가 다른 경우 예외를 발생시켜서 명확하게 문제를 알려주는 것이 좋은 설계이다.

트랜잭션 전파 - REQUIRES_NEW

외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 사용하는 방법에 대해서 알아보자.
외부 트랜잭션과 내부 트랜잭션을 완전히 분리해서 각각 별도의 물리 트랜잭션을 사용하는 방법이다. 그래서 커밋과 롤백도 각각 별도로 이루어지게 된다.

이 방법은 내부 트랜잭션에 문제가 발생해서 롤백해도, 외부 트랜잭션에는 영향을 주지 않는다. 반대로 외부 트랜잭션에 문제가 발생해도 내부 트랜잭션에 영향을 주지 않는다.

@Test
void inner_rollback_requires_new() {
 	log.info("외부 트랜잭션 시작");
 	TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
 	log.info("outer.isNewTransaction()={}",outer.isNewTransaction());
    
 	log.info("내부 트랜잭션 시작");
 	DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
	definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
	TransactionStatus inner = txManager.getTransaction(definition);
 	log.info("inner.isNewTransaction()={}",inner.isNewTransaction());
 	log.info("내부 트랜잭션 롤백");
 	txManager.rollback(inner); //롤백
    
 	log.info("외부 트랜잭션 커밋");
 	txManager.commit(outer); //커밋
}
  • 외부의 트랜잭션 커넥트와는 다른 완전 새로운 트랜잭션이 생겨서 내부 트랜잭션을 실행한다.

  • REQUIRES_NEW 옵션을 사용하면 물리 트랜잭션이 명확하게 분리된다.

  • REQUIRES_NEW 를 사용하면 데이터베이스 커넥션이 동시에 2개 사용된다는 점을 주의해야 한다.

다양한 전파 옵션

스프링은 다양한 트랜잭션 전파 옵션을 제공한다. 전파 옵션에 별도의 설정을 하지 않으면 REQUIRED 가 기본으로 사용된다.
참고로 실무에서는 대부분 REQUIRED 옵션을 사용한다. 그리고 아주 가끔 REQUIRES_NEW 을 사용하고, 나머지는 거의 사용하지 않는다. 그래서 나머지 옵션은 이런 것이 있다는 정도로만 알아두고 필요할 때 찾아보자.

  1. REQUIRED

    가장 많이 사용하는 기본 설정이다. 기존 트랜잭션이 없으면 생성하고, 있으면 참여한다.
    트랜잭션이 필수라는 의미로 이해하면 된다. (필수이기 때문에 없으면 만들고, 있으면 참여한다.)
    기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
    기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

  2. REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다.
기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
기존 트랜잭션 있음: 새로운 트랜잭션을 생성한다.

  1. SUPPORT
    트랜잭션을 지원한다는 뜻이다. 기존 트랜잭션이 없으면, 없는대로 진행하고, 있으면 참여한다.
    기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
    기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

  2. NOT_SUPPORT
    트랜잭션을 지원하지 않는다는 의미이다.
    기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
    기존 트랜잭션 있음: 트랜잭션 없이 진행한다. (기존 트랜잭션은 보류한다)

  3. MANDATORY
    의무사항이다. 트랜잭션이 반드시 있어야 한다. 기존 트랜잭션이 없으면 예외가 발생한다.
    기존 트랜잭션 없음: IllegalTransactionStateException 예외 발생
    기존 트랜잭션 있음: 기존 트랜잭션에 참여한다.

  4. NEVER
    트랜잭션을 사용하지 않는다는 의미이다. 기존 트랜잭션이 있으면 예외가 발생한다. 기존 트랜잭션도 허용하지 않는 강한 부정의 의미로 이해하면 된다.
    기존 트랜잭션 없음: 트랜잭션 없이 진행한다.
    기존 트랜잭션 있음: IllegalTransactionStateException 예외 발생

  5. NESTED
    기존 트랜잭션 없음: 새로운 트랜잭션을 생성한다.
    기존 트랜잭션 있음: 중첩 트랜잭션을 만든다.
    중첩 트랜잭션은 외부 트랜잭션의 영향을 받지만, 중첩 트랜잭션은 외부에 영향을 주지 않는다.
    중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있다.
    외부 트랜잭션이 롤백 되면 중첩 트랜잭션도 함께 롤백된다.
    참고
    JDBC savepoint 기능을 사용한다. DB 드라이버에서 해당 기능을 지원하는지 확인이 필요하다.
    중첩 트랜잭션은 JPA에서는 사용할 수 없다.

트랜잭션 전파와 옵션
isolation , timeout , readOnly 는 트랜잭션이 처음 시작될 때만 적용된다. 트랜잭션에 참여하는 경우에는 적용되지 않는다. 예를 들어서 REQUIRED 를 통한 트랜잭션 시작, REQUIRES_NEW 를 통한 트랜잭션 시작 시점에만 적용된다.

참조 : 본 글은 김영한님의 스프링 강의를 공부를 위해 정리한 글이다.

profile
공부 정리 블로그

0개의 댓글