@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
firebaseUsersRepository;
public void createUser(CreateUserRequest request) {
Users users = firebaseUsersRepository.findUsersByFirebaseUid(request.getFirebaseUid())
.orElseThrow(() -> new BusinessException("Not Found User", HttpStatus.INTERNAL_SERVER_ERROR));
User user = User.builder()
.name(users.getDisplay_name())
.firebaseUid(request.getFirebaseUid())
.build();
userRepository.save(user);
}
}
@Component
@RequiredArgsConstructor
@Transactional(propagation = Propagation.REQUIRES_NEW)
public class BaseEntityAuditAware implements AuditorAware<User> {
private final UserRepository userRepository;
@Override
public Optional<User> getCurrentAuditor() {
try{
return userRepository.findById(ApiLogger.getRequestCallerId());
}
catch(Exception e){
return Optional.emtpy();
}
}
}
외와 같은 코드가 있다.
UserServcie::createUser의 userRespository.save(user)를 할때,
Jpa AduitorAware을 구현하여, 유저의 createdBy 컬럼을 자동으로 주입해주는 코드이다.
이때, ApiLogger.getRequestCallerId()가 null이 반환되면서
BaseEntityAuditAware::getCurretnAuditor에서
userRepository.findById(null)이 호출되어 RuntimeException이 발생되었다.
이때 UserServcie::createUser의 userRespository.save(user)은 커밋될까?
나의 상식으로는
Outer에서 Inner를 호출할때,
Inner가 REQUIRES_NEW이고, Inner에서 발생한 예외를 catch한다면
Outer에서는 롤백이 되지 않는다는것이 나의 상식이였다.
하지만 UnexpectedRollbackException과 함께 롤백이 발생되었다.!!
하루꼬박의 디버깅과 지인(고수)의 자문을 받아 이유를 알아본결과 요지는 이렇다.
A(REQUIRED) --> B(REQRUIES_NEW) --> C(REQRUIED)
A에서 B를 호출하고, B에서 C를 호출할때 전파속성이 괄호안과 같다고 해보자.
이때 C에서 Runtime Exception이 발생하면, A에서 롤백이 될까?
나는 B에서 별개의 트랜잭션이 생성되고,
C는 B에 속하니, B까지만 롤백되고, A는 커밋될 줄 알았다.
하지만 A도 같이 롤백된다.
단, A,B,C는 다른 클래스에 있어야한다.
왜냐하면 아래와같이 모든 매서드가 한 클래스에 있다면,
전파속성이 REQRUIES_NEW임에도 불구하고,
sout을 통해 찍은 현재 트랜잭션 이름을 보면 모두 같은 트랜잭션으로 나온다.
이것은 트랜잭션에 대해 조금만 공부했다면 왜 그런지 알 것이다.
@Service
@RequiredArgsConstructor
public class TestService {
private final UserRepository userRepository;
@Transactional(propagation = Propagation.REQUIRED)
public void A() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
User user = User.builder()
.name("test")
.firebaseUid("test")
.build();
this.B();
userRepository.save(user);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void B() {
try {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
this.C();
} catch (Exception e) {
}
}
@Transactional(propagation = Propagation.REQUIRED)
public void C() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
throw new RuntimeException();
}
}
/** 출력
io.sollink.sollinkHubServer.global.test.TestService.A
io.sollink.sollinkHubServer.global.test.TestService.A
io.sollink.sollinkHubServer.global.test.TestService.A
**/
그렇다면 A(), B(), C()가 각각 다른 클래스에 존재하면 어떨까?
@Service
@RequiredArgsConstructor
public class TestAService {
private final UserRepository userRepository;
private final TestBService testBService;
@Transactional(propagation = Propagation.REQUIRED)
public void A() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
User user = User.builder()
.name("test")
.firebaseUid("test")
.build();
testBService.B();
userRepository.save(user);
}
}
@Service
@RequiredArgsConstructor
public class TestBService {
private final TestCService testCService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void B() {
try {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
testCService.C();
} catch (Exception e) {
}
}
}
@Service
public class TestCService {
@Transactional(propagation = Propagation.REQUIRED)
public void C() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
throw new RuntimeException();
}
}
/** 출력
io.sollink.sollinkHubServer.global.test.TestAService.A
io.sollink.sollinkHubServer.global.test.TestBService.B
io.sollink.sollinkHubServer.global.test.TestBService.B
**/
내가 생각한대로 B에서 새로운 트랜잭션이 만들어지고, C는 B에 합류된다.
단, C에서 예외가 발생할 경우, A도 롤백이 된다.. <--- 이부분이 진짜 이해가 안간다.
그래서 아래와같이 A에서 B를 호출할때도 try-catch를 해줘야, Rollback이 안되고 정상 커밋된다.
@Service
@RequiredArgsConstructor
public class TestAService {
private final UserRepository userRepository;
private final TestBService testBService;
@Transactional(propagation = Propagation.REQUIRED)
public void A() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
User user = User.builder()
.name("test")
.firebaseUid("test")
.build();
try{
testBService.B();
}catch(Exception e){
}
userRepository.save(user);
}
}
@Service
@RequiredArgsConstructor
public class TestBService {
private final TestCService testCService;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void B() {
try {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
testCService.C();
} catch (Exception e) {
}
}
}
@Service
public class TestCService {
@Transactional(propagation = Propagation.REQUIRED)
public void C() {
System.out.println(TransactionSynchronizationManager.getCurrentTransactionName());
throw new RuntimeException();
}
}
/** 출력
io.sollink.sollinkHubServer.global.test.TestAService.A
io.sollink.sollinkHubServer.global.test.TestBService.B
io.sollink.sollinkHubServer.global.test.TestBService.B
**/
물론 A에서 try-catch를 안하고, C에서 try-catch를 해줘도 된다.
이렇게하면, B,C에서 터진 UnexpectedRollbackException을 모두 B,C에서 catch하고
A까지 rollback마킹이 전파되지 않기때문에 A는 롤백되지 않는다.
또는 C를 REQUIRES_NEW로 만들어도 된다.
B,C에서 새로운 트랜잭션이 만들어지고, C에서 runtimeException을 Throw하지만
B에서 이를 try-catch 하고있기때문에 롤백이 전파되지않아 A는 롤백되지 않는다.
근데 왜 A,C에서 try-catch를 안하면 롤백이 되는거야...ㅡㅡ;;
디버깅을 통해 결론에 도달했다.
//TransactionAspectSupport.class
@Nullable
protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
final InvocationCallback invocation) throws Throwable {
...
if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager cpptm)) {
// Standard transaction demarcation with getTransaction and commit/rollback calls.
TransactionInfo txInfo = createTransactionIfNecessary(ptm, txAttr, joinpointIdentification);
Object retVal;
try {
// 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);
}
...
}
....
commitTransactionAfterReturning(txInfo);
return retVal;
}
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());
}
catch (TransactionSystemException ex2) {
logger.error("Application exception overridden by rollback exception", ex);
ex2.initApplicationException(ex);
throw ex2;
}
catch (RuntimeException | Error ex2) {
logger.error("Application exception overridden by rollback exception", ex);
throw ex2;
}
}
....
}
}
protected void commitTransactionAfterReturning(@Nullable TransactionInfo txInfo) {
if (txInfo != null && txInfo.getTransactionStatus() != null) {
if (logger.isTraceEnabled()) {
logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "]");
}
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
}
@Transactional 어노테이션을 붙은 메소드는, TransactionManager가 관리한다.
그중 핵심코드는 invokeWithinTransaction이다.
여기서 먼저 retVal = invocation.proceedWithInvocation();을 통해 메소드가 실행되고,
그에따라 try-catch 및 commit을 한다. commitTransactionAfterReturning(txInfo)를 통해 커밋
이때 어떠한 원인으로 Throwable Exception이 발생하면 completeTransactionAfterThrowing() 함수를 호출하고
txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); <-- 이 코드를 타고 들어가다보면
AbastractPlatformTransactionManager::processRollback 함수를 실행한다.
여기서 하는일은
1. 새로운 트랜잭션이라면? --> 롤백
2. 새로운 트랜잭션이 아니라면? 즉 어딘가에 포함되어있는 논리 트랜잭션이라면? 롤백마킹을 한다.
롤백마킹은 쓰레드로컬에서 관리하는 txInfo의 TransactionStatus안에서 static변수로 관리되고있다.
그리고 해당예외를 상위로 던진다.
예외가 발생하지 않았거나, catch 했다면
commitTransactionAfterReturning()함수를 호출하여 다양한 롤백 조건을 체크하며 커밋이 되게 되는데
특수한 상황이 아니라면 AbastractPlatformTransactionManager::processCommit까지 도달한다.
//AbastractPlatformTransactionManager.class
private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
try {
boolean unexpectedRollback = unexpected;
boolean rollbackListenerInvoked = false;
try {
triggerBeforeCompletion(status);
if (status.hasSavepoint()) {
if (status.isDebug()) {
logger.debug("Rolling back transaction to savepoint");
}
this.transactionExecutionListeners.forEach(listener -> listener.beforeRollback(status));
rollbackListenerInvoked = true;
status.rollbackToHeldSavepoint();
}
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug("Initiating transaction rollback");
}
this.transactionExecutionListeners.forEach(listener -> listener.beforeRollback(status));
rollbackListenerInvoked = true;
doRollback(status);
}
else {
// Participating in larger transaction
if (status.hasTransaction()) {
if (status.isLocalRollbackOnly() || isGlobalRollbackOnParticipationFailure()) {
if (status.isDebug()) {
logger.debug("Participating transaction failed - marking existing transaction as rollback-only");
}
doSetRollbackOnly(status);
}
else {
if (status.isDebug()) {
logger.debug("Participating transaction failed - letting transaction originator decide on rollback");
}
}
}
else {
logger.debug("Should roll back transaction but cannot - no transaction available");
}
// Unexpected rollback only matters here if we're asked to fail early
if (!isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = false;
}
}
}
catch (RuntimeException | Error ex) {
triggerAfterCompletion(status, TransactionSynchronization.STATUS_UNKNOWN);
if (rollbackListenerInvoked) {
this.transactionExecutionListeners.forEach(listener -> listener.afterRollback(status, ex));
}
throw ex;
}
triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);
if (rollbackListenerInvoked) {
this.transactionExecutionListeners.forEach(listener -> listener.afterRollback(status, null));
}
// Raise UnexpectedRollbackException if we had a global rollback-only marker
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction rolled back because it has been marked as rollback-only");
}
}
finally {
cleanupAfterCompletion(status);
}
}
private void processCommit(DefaultTransactionStatus status) throws TransactionException {
try {
boolean beforeCompletionInvoked = false;
boolean commitListenerInvoked = false;
try {
boolean unexpectedRollback = false;
prepareForCommit(status);
triggerBeforeCommit(status);
triggerBeforeCompletion(status);
beforeCompletionInvoked = true;
if (status.hasSavepoint()) {
if (status.isDebug()) {
logger.debug("Releasing transaction savepoint");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
status.releaseHeldSavepoint();
}
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug("Initiating transaction commit");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
doCommit(status);
}
else if (isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = status.isGlobalRollbackOnly();
}
// Throw UnexpectedRollbackException if we have a global rollback-only
// marker but still didn't get a corresponding exception from commit.
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only");
}
}
catch (UnexpectedRollbackException ex) {
triggerAfterCompletion(status, TransactionSynchronization.STATUS_ROLLED_BACK);
this.transactionExecutionListeners.forEach(listener -> listener.afterRollback(status, null));
throw ex;
}
catch (TransactionException ex) {
if (isRollbackOnCommitFailure()) {
doRollbackOnCommitException(status, ex);
}
else {
triggerAfterCompletion(status, TransactionSynchronization.STATUS_UNKNOWN);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(listener -> listener.afterCommit(status, ex));
}
}
throw ex;
}
catch (RuntimeException | Error ex) {
if (!beforeCompletionInvoked) {
triggerBeforeCompletion(status);
}
doRollbackOnCommitException(status, ex);
throw ex;
}
// Trigger afterCommit callbacks, with an exception thrown there
// propagated to callers but the transaction still considered as committed.
try {
triggerAfterCommit(status);
}
finally {
triggerAfterCompletion(status, TransactionSynchronization.STATUS_COMMITTED);
if (commitListenerInvoked) {
this.transactionExecutionListeners.forEach(listener -> listener.afterCommit(status, null));
}
}
}
finally {
cleanupAfterCompletion(status);
}
}
여기선 먼저 boolean unexpectedRollback = false; 로 설정해놓고,
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug("Initiating transaction commit");
}
unexpectedRollback = status.isGlobalRollbackOnly();
this.transactionExecutionListeners.forEach(listener -> listener.beforeCommit(status));
commitListenerInvoked = true;
doCommit(status);
}
else if (isFailEarlyOnGlobalRollbackOnly()) {
unexpectedRollback = status.isGlobalRollbackOnly();
}
// Throw UnexpectedRollbackException if we have a global rollback-only
// marker but still didn't get a corresponding exception from commit.
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only");
}
그리고 어딘가 속한 논리 트랜잭션이 아니라,
새로 생긴 트랜잭션 즉.. 최초의 트랜잭션이라면
Commit을 시도하는데, 이때 unexpectedRollback을 검사한다.
unexpectedRollback = status.isGlobalRollbackOnly(); <-- 이부분이 바로 롤백마킹이 되어있는지 확인하는 부분이다.
그리고 롤백마킹이 되어있다면 doCommit에서 커밋을 시도하지만
아래
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only");
}
로직을타 새로운 UnexpectedRollbackException을 발생시키고, 이에 대한 처리가 되어있지않다면 결국 롤백이 된다.
내상황은 A(REQUIRED) -> B(REQUIRES_NEW) -> C(REQUIRED)를 호출하는 상황이였고,
C에서 RuntimeException이 발생하였다.
위 코드처럼 따라가다보면, C에서 롤백마킹이 되고, B에서
if (unexpectedRollback) {
throw new UnexpectedRollbackException(
"Transaction silently rolled back because it has been marked as rollback-only");
}
이부분이 실행된다!! 따라서 B에서는 C에서 발생한 Exception은 모두 try-catch 했지만,
B에서 롤백마킹이 되어있기때문에, AOP에서 새롭게 UnexpectedRollbackException이 발생한다.
이 런타임에러가 A까지 도달하여, A에서는 RuntimeException이 발생하는것으로 인지하고 B에서 커밋을 시도했지만? A에서 바로 롤백이 되는것이다.
즉 B는 C때문에 롤백이 되었고, 이때 발생한 UnexpectedRollbackException 때문에 A역시 롤백이 된것이다.
A(REQUIRED) -> B(REQUIRES_NEW) -> C(REQUIRES_NEW) 에서는 어떨까?
똑같이 C에서 롤백마킹이 되었지만
이는 B에서 모두 try-catch 하였다. 또한, C에서 롤백마킹이 되어있을뿐, B에서는 롤백마킹이 안되어있기때문에 UnexpectedRollbackException을 안터트린다.그래서 A에서는 아무런 예외가 없고 + 롤백마킹이 되어있지 않아 정상커밋된다.
일단 같은 클래스에서 내부 호출은 스프링 AOP 동작 방식이 프록시를 통해서 하기 때문에
@Transactional이 A메서드에서 시작한 트랜잭션이 계속 같이 사용됩니다
A 프록시 -> A(실제 Target) -> A에서 내부 메서드B 호출 -> 내부 메서드 B에서 C 호출
서로 다른 클래스
A 프록시 -> A(실제 TARGET) -> B 프록시(새로운 물리 트랜잭션 생성) -> B(실제 Target)
-> C 프록시(B 트랜잭션 참여) -> C(실제 target)