자주 사용되는 애노테이션 중 하나인 @Transactional에 대해서 살펴보고자 합니다.
@Transactional은 스프링에서 제공하는 애노테이션으로, AOP를 활용해 트랜잭션 기능을 제공하는 선언적 트랜잭션입니다.
다른 방식으로 프로그래밍 방식의 트랜잭션이 존재하며, 이 경우 주로 TransactionTemplate을 사용합니다.
물리 트랜잭션이란 DB 커넥션과 실제로(물리적으로) 연결된 트랜잭션을 의미하며, 논리 트랜잭션이란 DB 커넥션과 연결되어 있지는 않지만 다른 트랜잭션과 문맥상 구분되는 트랜잭션입니다.
@Trasactional은 지정된 레벨에서 대상을 논리 트랜잭션으로 구분합니다.
문제가 발생한 논리 트랜잭션은 롤백 마크가 추가되며 이를 토대로 물리 트랜잭션의 커밋/롤백 여부가 결정되고, Connection을 통해 커밋/롤백이 수행됩니다.
전파 속성의 기본 값입니다.
모든 논리적 트랜잭션은 하나의 동일한 물리 트랜잭션에 참여하게 됩니다.
즉, 각 논리 트랜잭션은 고유한 범위(메서드)를 가지게 되지만 모든 논리 트랜잭션은 동일한 물리 트랜잭션과 매핑된다는 것입니다.
Propagation.REQUIRED에서 물리 트랜잭션과 논리 트랜잭션의 관계를 그림으로 표현하면 위 그림과 같습니다.
물리 트랜잭션에 속한 논리 트랜잭션 중 하나라도 문제가 발생한다면 물리 트랜잭션까지 전파되어 전체적으로 롤백됩니다.
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
method2();
logic1();
}
@Transactional(propagation = Propagation.REQUIRED)
public void method2() {
logic2();
}
위와 같이 Propagation.REQUIRED로 명시된 두 메서드 method1(), method2()가 존재하며 method1()에서 method2()를 호출합니다.
logic1(), logic2()는 DB의 CRUD를 수행하는 메서드이며, 해당 메서드는 예외가 발생할 수 있다고 가정합니다.
method1()에서 method2()를 호출했기 때문에, method1()과 연결된 Connection에 method2()가 참여한 것을 확인할 수 있습니다.
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
method2();
logic1();
}
@Transactional(propagation = Propagation.REQUIRED)
public void method2() {
try {
logic2(); // 예외 발생
} catch (Exception e) {
// 예외 처리
}
}
위와 같이 발생한 예외를 try-catch를 통해 처리한다고 하더라도 롤백 마킹이 부여되므로, noRollbackFor로 롤백을 방지하지 않는 이상 롤백이 진행됩니다.
항상 새로운 물리 트랜잭션을 생성하는 전파 속성입니다.
@Transactional은 논리 트랜잭션을 구분하는 역할을 수행하기 때문에, Propagation.REQUIRES_NEW가 명시된 경우 물리 트랜잭션과 논리 트랜잭션이 동시에 생성됩니다.
물리 트래잭션이 분리되어 있기 때문에, 예외 처리가 잘 되었다면 외부 트랜잭션과 내부 트랜잭션이 별도로 커밋/롤백될 수 있습니다.
내부 트랜잭션에 예외가 발생했고 외부 트랜잭션이 예외를 처리했다면, 외부 트랜잭션은 커밋되고 내부 트랜잭션을 롤백됩니다.
내부 트랜잭션은 정상 동작했고 외부 트랜잭션에서 예외가 발생했다면, 외부 트랜잭션은 롤백되고 내부 트랜잭션은 커밋됩니다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void method1() {
method2();
logic1();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void method2() {
logic2();
}
위와 같이 Propagation.REQUIRED로 명시된 두 메서드 method1(), method2()가 존재하며 method1()에서 method2()를 호출합니다.
logic1(), logic2()는 DB의 CRUD를 수행하는 메서드이며, 해당 메서드는 예외가 발생할 수 있다고 가정합니다.
확인해보면 두 개의 메서드의 DataSource는 동일하지만, Connection은 별개인 것을 확인할 수 있습니다.
두 개의 트랜잭션 모두 별도의 물리적인 Connection과 연결되어 있지만, 트랜잭션 전파가 이루어지는 상황이 애초부터 하나의 메서드에서 다른 하나의 메서드를 호출하기 때문에 예외 처리가 되지 않으면 예외가 던져지게 되어 Connection과 별도로 전체 롤백이 이루어집니다.
Propagation.REQUIRED와 유사하지만 savepoint를 활용해 외부 트랜잭션과 내부 트랜잭션을 별도로 커밋/롤백할 수 있습니다.
@Transactional(propagation = Propagation.NESTED)
public void method1() {
method2();
logic1();
}
@Transactional(propagation = Propagation.NESTED)
public void method2() {
logic2();
}
Propagation.REQUIRED와 동일하게 DataSource, Connection이 동일한 것을 확인할 수 있습니다.
외부/내부 트랜잭션에서 예외가 발생하는 경우 savepoint로 롤백되며, 예외 처리가 되었다면 다른 트랜잭션에 영향을 주지 않습니다.
다만 Propagation.NESTED의 경우 Hibernate는 savepoint를 지원하지 않으며, jdbc나 jooq에서는 savepoint를 지원합니다.
물리 트랜잭션이 반드시 필요한 전파 속성입니다.
만약 참여할 트랜잭션이 없다면 예외가 발생합니다.
Propagation.REQUIRED와 동일하게 내부 트랜잭션에 예외가 발생한 경우 예외 처리 여부와 무관하게 외부 트랜잭션도 롤백됩니다.
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
method2();
logic1();
}
@Transactional(propagation = Propagation.MANDATORY)
public void method2() {
logic2();
}
DataSource, Connection 모두 동일한 것을 확인할 수 있습니다.
참여할 물리 트랜잭션이 없는 경우 위와 같이 예외가 발생합니다.
반드시 물리 트랜잭션이 하나 이상 존재해야 한다는 특징을 제외하면 기본 동작은 Propagation.REQUIRED와 유사합니다.
트랜잭션에 참여하게 되면 예외가 발생하는 전파 속성입니다.
참여할 때 예외가 발생하므로, Propagation.NEVER 내부에 트랜잭션이 있는 경우에는 정상적으로 동작합니다.
@Transactional(propagation = Propagation.NEVER)
public void method1() {
repository.save(...);
}
Propagation.NEVER의 경우 트랜잭션에 참여하게 되는 경우 예외가 발생합니다.
단, Propagation.NEVER가 외부에 있다면 내부에는 트랜잭션이 존재할 수 있습니다.
JPA의 경우 JpaRepository.save()에 @Transactional이 존재하기 때문에 다음과 같이 동작하게 됩니다.
트랜잭션을 지원하지 않는 전파 속성입니다.
만약 트랜잭션에 참여하게 된다면, 트랜잭션을 지원하지 않도록 외부 트랜잭션을 일시 중지(suspend) 시킵니다.
일시 중지된 트랜잭션은 마지막에 동작(resume)하게 됩니다.
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
method2();
userRepository.save(new User("outer"));
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void method2() {
userRepository.save(new User("inner"));
throw new RuntimeException();
}
method2()는 method1()을 중단(suspend)시키기 때문에 method2() 시점에서는 아무런 물리 트랜잭션도 없게 됩니다.
그렇기 때문에 method2()에서 userRepository.save()를 호출하는 경우 별도의 물리 트랜잭션이 생성되며, 이 경우 즉시 insert 작업이 수행됩니다.
DataSource는 일치하지만, Connection은 다른 것을 확인할 수 있습니다.
이는 Propagation.REQUIRED인 method1()은 물리 트랜잭션 + 논리 트랜잭션이 생성되지만, Propagation.NOT_SUPPORTED는 이전 물리 트랜잭션을 일시 중지(suspend)하지만 @Transactional로 인해 논리 트랜잭션은 생성하기 때문입니다.
예제 코드 기반 동작 흐름은 위와 같습니다.
실행 시 현재 트랜잭션을 중지(suspending)하는 로그를 확인할 수 있습니다.
밑을 확인해보면 물리 트랜잭션이 없는 상태에서 JpaRepository.save()를 호출했을 때 즉시 insert가 수행되고 있음을 확인할 수 있습니다.
insert를 수행한 내부 물리 트랜잭션은 즉시 종료됩니다.
이후 method2()에서 예외가 발생했지만 method2()에서 실행한 JpaRepository.save()는 이미 커밋되었고, 물리 트랜잭션이 종료된 상태이기 때문에 롤백이 불가능합니다.
method1()은 예외 처리를 하지 않았기 때문에 롤백이 진행됩니다.
트랜잭션을 지원하는 전파 속성입니다.
다만, 참여할 트랜잭션이 존재해야 하며 존재하지 않은 경우 트랜잭션 외부에서 동작하게 됩니다.
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
method2();
userRepository.save(new User("outer"));
}
@Transactional(propagation = Propagation.SUPPORTS)
public void method2() {
userRepository.save(new User("inner"));
throw new RuntimeException();
}
method2()가 method1()의 트랜잭션에 참여하기 때문에 둘의 Connection이 동일한 것을 확인할 수 있습니다.
외부 트랜잭션에 참여하는 경우 Propagation.REQUIRED와 유사합니다.
다만, 외부 트랜잭션이 없는 경우 트랜잭션 외부에서 동작하기 때문에 내부 트랜잭션에 예외가 발생하므로 물리적으로 구분되어 있는 외부 트랜잭션에 영향을 주지 않습니다.
JPA 환경에서 어떤 식으로 트랜잭션을 생성하는지 살펴보도록 하겠습니다.
JPA 환경에서 트랜잭션을 관리하는 핵심적인 인터페이스는 PlatformTransactionManager 입니다.
사용 기술 스택에 따라서 PlatformTransactionManager의 구현체들을 통해 트랜잭션을 생성하고 관리하게 됩니다.
제 경우 JPA를 사용하기 때문에, JpaTransactionManager 위주로 트랜잭션을 생성하고 관리합니다.
전파 속성 중 Propagation.REQUIRED와 Propagation.REQUIRES_NEW만이 트랜잭션을 생성하기 때문에, 이 두 가지 경우에 대해서만 확인하도록 하겠습니다.
AbstractTransactionManager.getTransaction()에서 doGetTransaction()을 호출합니다.
이는 템플릿 메서드 패턴이 적용된 추상 메서드이며, JpaTransactionManager에서 구현한 doGetTransaction()이 호출됩니다.
JpaTransactionManager.doGetTransaction()에서 항상 JpaTransactionObject를 생성합니다.
현재 상황은 트랜잭션 영역에 처음 진입해 아무 트랜잭션 영역도 없는 상황이기 때문에, EntityManagerHolder가 null이 되므로 이후 조건문은 실행되지 않습니다.
이후 JpaTransactionObject를 반환합니다.
AbstractTransactionManager는 반환받은 Object(= TransactionObject = JpaTransactionObject)를 토대로 기존에 트랜잭션이 존재하는지 확인합니다.
오버라이딩된 JpaTransactionManager.isExistingTransaction()이 동작하게 됩니다.
이 때 호출한 JpaTransactionObject.hasTransaction()는 JpaTransactionObject.doGetTransaction()에서 처리한 EntityManagerHolder를 체크하게 되며, null이었기 때문에 false를 반환합니다.
false를 반환했기 때문에 return되지 않고 이후 로직을 쭉 실행해 startTransaction()을 호출합니다.
startTransaction() 내부적으로 doBegin()을 호출하며, 이는 템플릿 메서드를 적용한 추상 메서드이기 때문에 JpaTransactionManager.doBegin()이 동작하게 됩니다.
Object로 관리하던 TransactionObject를 JpaTransactionObject로 변환한 뒤, EntityManagerHolder를 세팅합니다.
이후 TransactionalSynchronizationManager.bindResource()를 통해 트랜잭션과 DB 관련 정보(DataSource, Connection) 등을 매핑합니다.
AbstractTransactionManager.doGetTransaction()를 호출합니다.
기존 트랜잭션이 존재하기 때문에 EntityManagerHolder는 null이 아니므로, JpaTransactionObject에 세팅됩니다.
이 때 조회되는 EntityManagerHolder는 트랜잭션이 동작중이기 때문에 transactionActive가 true인 상태입니다.
isExistingTransaction()를 호출합니다.
EntityManagerHolder가 null이 아니고, EntityManagerHolder의 transactionActive가 true이므로 hasTransaction()은 true를 반환합니다.
AbstractTransactionManager.handleExistingTransaction()이 호출됩니다.
해당 메서드에서 Propagation에 맞는 처리를 수행하고 있음을 확인할 수 있습니다.
마지막 부분에서 newTransaction을 false로 세팅합니다.
이는 커밋 시 영향을 주는 설정이며, Propagation 후처리 과정 중 TransactionSynchronizationManager.bindResource()가 호출되지 않은 것을 확인할 수 있습니다.
이후 필요한 설정을 세팅하고, 초기화를 진행합니다.
Propagation.REQUIRES_NEW에서 기존 트랜잭션이 존재하지 않는 경우 트랜잭션을 생성하는 과정은 Propagation.REQUIRED와 동일하기 때문에, 기존 트랜잭션이 존재하는 경우에 대해서만 살펴보도록 하겠습니다.
기존 트랜잭션이 존재하므로 handleExistingTransaction()가 호출됩니다.
현재 Propagation 상태는 REQUIRES_NEW이므로, 해당 조건문이 동작하게 됩니다.
startTransaction()이 호출되는 경우 항상 newTransaction이 true이기 때문에 새로운 트랜잭션으로 취급하며, 이후 TransactionSynchronizationManager.bindResource()를 통해 DB 리소스 매핑이 이루어집니다.
기본적으로 DB 격리 수준을 따라가지만, @Transactional을 통해서도 격리 수준을 설정할 수 있습니다.
주석에서 확인할 수 있듯이 새로운 물리 트랜잭션에서만 적용할 수 있기 때문에, Propagation.REQUIRED나 Propagation.REQUIRES_NEW와 함께 사용해야만 합니다.
enum으로 관리되며, DB 격리 수준과 별개로 @Transactional에서 지정해서 사용할 일은 거의 없습니다.
@Transactional은 @Target({ElementType.TYPE, ElementType.METHOD})으로 인해 클래스, 메서드, 인터페이스에 붙일 수 있습니다.
다음과 같은 우선순위를 가집니다.
@Transactional은 AOP 기반으로 동작합니다.
AOP 적용 방식을 선택할 수 있는 Mode가 존재하며, 종류로는 Proxy Mode와 Aspect Mode가 존재합니다.
기본적으로는 Proxy Mode가 사용됩니다.
// construct an appropriate transaction manager
DataSourceTransactionManager txManager = new DataSourceTransactionManager(getDataSource());
// configure the AnnotationTransactionAspect to use it; this must be done before executing any transactional methods
AnnotationTransactionAspect.aspectOf().setTransactionManager(txManager);
위와 같이 AspectJ로도 설정이 가능합니다.
Proxy Mode로 사용할 경우 스프링에서 기본적으로 사용하는 CGLIB에 의해 AOP 방식으로 @Transactional이 동작하게 됩니다.
그렇기 때문에 다음과 같은 내용을 주의해야 합니다.
AspectJ는 직접 바이트 코드를 조작하기 때문에, Non-public method에서도 사용 가능합니다.
또한, 유사한 이유로 final이 붙은 경우에도 사용 가능합니다.
그러므로 Non-public method에서 @Transactional을 사용해야만 한다면 선택지가 될 수 있습니다.
다만, 레퍼런스가 적고 일관성이 깨질 수 있으므로 권장되는 방식은 아닙니다.
@Transactional은 Unchecked Exception(+ Error)만을 감지해 롤백 마크를 등록합니다.
Checked Exception 발생 시에도 롤백 처리를 하고자 한다면, @Transactional의 옵션 중 rollbackFor를 활용하거나, try-catch를 통해 Checked Exception을 Unchecked Exception으로 변환해 처리할 수 있습니다.
@Transactional은 TransactionAspectSupport.invokeWithinTransaction()에서 위와 같이 AOP가 수행됩니다.
catch 문에서 completeTransactionAfterThrowing()을 호출하고 있음을 확인할 수 있습니다.
txInfo는 항상 null이 아니기 때문에 txInfo.transactionAttribute.rollbackOn()에 의해 해당 조건문의 동작 여부가 결정됩니다.
rollbackOn() 메서드의 경우 해당 예외가 RuntimeException 혹은 Error라면 true를 반환합니다.
즉, Unchecked Exception이면 true를 동작한다는 의미입니다.
TransactionManager.rollback()을 호출합니다.
계속 진행하다보면 AbstractPlatformTransactionManager.processRollback() 내부적으로 위와 같은 코드가 수행됩니다.
status.isNewTransaction()일 때만 수행되며, isNewTransaction()은 물리 트랜잭션(= 새롭게 생성된 트랜잭션)을 의미합니다.
doRollback은 템플릿 메서드 패턴이 적용된 추상 메서드이며, JpaTransactionManager.doRollback()에서 롤백이 수행됩니다.
AbstractPlatformTransactionManager.processRollback()에서 status.isNewTransaction()이 아닐 때 위와 같은 코드가 실행됩니다.
status.isLocalRollbackOnly()의 경우 로컬 레벨에서 롤백을 수행할지에 대한 값인데, 기본 값은 false이며 true로 변경하는 일은 거의 없습니다.
주로 isGlobalRollbackOnParticipantionFailure()에 의해 롤백 유무가 결정됩니다.
globalRollbackOnParticipationFailure의 기본값은 true이며, 이 값 또한 거의 변경하지 않기 때문에 doSetRollbackOnly()가 호출됩니다.
doSetRollbackOnly()는 템플릿 메서드가 적용된 추상 메서드이기 때문에 구체화된 TransactionManager의 메서드를 호출하게 되며, 현재 환경은 JPA를 사용하고 있기 때문에 JpaTransactionManager.doSetRollbackOnly()가 호출되게 됩니다.
JpaTransactionObject에 롤백 마크를 부여하는 것으로 해당 과정이 종료됩니다.
TransactionAspectSupport.invokeWithinTransaction()에서는 위 과정을 통해 롤백 마크를 표시한 뒤 예외를 던집니다.
이는 마지막 커밋 시점인 AbstractPlatformTransactionManager.processCommit() 내부에서 롤백 마크를 확인한 뒤, UnexpectedRollbackException을 던지게 됩니다.
UnexpectedRollbackException은 RuntimeException이기 때문에, 물리 트랜잭션이 커밋되는 시점에 던져진 UnexpectedRollbackException으로 인해 롤백이 진행됩니다.
NOT_SUPPORTED 예제에서 method호출순서를 아래처럼 변경하게 되면, 물리,논리 transaction 갯수는 어떻게 되나요?
@Transactional(propagation = Propagation.REQUIRED)
public void method1() {
userRepository.save(new User("outer"));
method2();
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void method2() {
userRepository.save(new User("inner"));
}
추가로, datasource와 connection 로깅은 어떻게 진행하셨는지 궁금합니다.