왜 조용히 롤백이 되는거야...🤫
엄청 유명한 배민의 블로그 글(응? 이게 왜 롤백되는거지?)에서 봤던 에러가 발생했다. 이 참에 공부해보자.
참여 중인 트랜잭션이 실패하면 기본정책이 전역롤백인데, 이 에러는AbstractPlatformTransactionManager 에서 발생한 것을 알 수 있다. 구현되어있는 코드를 같이 살펴보자.
RuntimeException
을 상속받은 커스텀 에러를 던져도 롤백이 되는데, UnexpectedRollbackException
은 어떤 경우에 발생하는 걸까?
try-catch
로 감싸지 않으면 예외 발생시에 rollback 메서드가 실행이 된다.
try-catch
로 감싸면 commit
메서드가 실행되고, 그 내부에서 unexpectedRollback
플래그에 의해 롤백이 일어난다.
@Transactional
은 아래 2가지를 지켜야 동작한다.
// outer method
@Transactional
public String rollBackMethodWithInnerTransaction() {
String status = DEFAULT_VALUE;
innerMethodService.innerTransactionMethodWithThrow();
return status;
}
// inner method
@Transactional
public String innerTransactionMethodWithThrow() {
throw new RuntimeException();
}
// test code
@Test
@DisplayName("내부 트랜잭션에서 예외 발생하여 전체 롤백")
void rollBackMethodWithInnerTransaction() {
assertThatThrownBy(() -> outerMethodService.rollBackMethodWithInnerTransaction())
.isInstanceOf(RuntimeException.class);
}
이 경우에는 try-catch
로 묶어 롤백이 실행되지 않았으면 했지만, 위에서 언급한 unexpectedRollback
플래그에 의해 전역 롤백이 되어버린다.
// outer method
@Transactional
public String silentlyRollBackedMethodWithTryCatch() {
String status = DEFAULT_VALUE;
try {
status = innerMethodService.innerTransactionMethodWithThrow();
} catch (RuntimeException e) {
log.info("예외를 잡았지만, 내부 트랜재션에서 롤백마크 생성");
}
return status;
}
// inner method
@Transactional
public String innerTransactionMethodWithThrow() {
throw new RuntimeException();
}
// test code
@Test
@DisplayName("try catch 로 롤백이 안 되기를 바라지만 내부 트랜잭션 롤백마크로 인해 롤백되는 테스트")
void silentlyRollBackedMethodTest() {
outerMethodService.silentlyRollBackedMethodWithTryCatch();
assertThatThrownBy(() -> outerMethodService.silentlyRollBackedMethodWithTryCatch())
.isInstanceOf(UnexpectedRollbackException.class);
}
try-catch 감싼 경우는 보통 예외 발생시에 롤백이 아닌 추가적인 처리를 해주고 싶은 경우이다. 예를 들자면, 위에 innerTransactionMethodWithThrow
라는 메서드가 외부 의존성이 걸려있는 경우, 다른 서비스의 장애가 우리 서비스로 전파될 수 있다. 장애가 발생한 경우(예외 발생) 에는 기본값(DEFAULT_VALUE
)으로 두는 등의 정책이 있다면 try-catch 처리가 필요할 수 있다.
예외가 발생해도 전체 롤백이 실행시키고 싶지 않은 메서드에 한해 트랜잭션을 제거한 후 try-catch 처리는 그대로 두자.
실패시에 기본값을 반환하도록 하고 싶다면 아래처럼 처리할 수 있다.
// outer method
@Transactional
public String noRollBackMethod1() {
String status = DEFAULT_VALUE;
try {
status = innerMethodService.innerMethodWithThrow();
} catch (RuntimeException e) {
}
return status;
}
// inner method
public String innerMethodWithThrow() {
throw new RuntimeException();
}
// test code
@Test
@DisplayName("내부 메서드에서 트랜잭션을 제거하여 롤백이 되지 않도록 처리한 테스트")
void noRollBackMethod1Test() {
assertThat(outerMethodService.noRollBackMethod1()).isEqualTo("DEFAULT");
}
예외 발생시에 메서드 밖으로 예외를 던지지 말자. 예외를 던지지 않으니 롤백마크도 생기지 않을 것이다.
// outer method
@Transactional
public String noRollBackMethod2() {
return innerMethodService.innerTransactionMethodWithTryCatch();
}
// inner method
@Transactional
public String innerTransactionMethodWithTryCatch() {
String status = DEFAULT_VALUE;
try {
throwMethod();
} catch (Exception e) {
}
return status;
}
// test code
@Test
@DisplayName("내부 메서드에서 트랜잭션이 있지만, 외부로 에러를 던지지 않도록 처리하여 롤백이 되지 않도록 처리한 테스트")
void noRollBackMethod2Test() {
assertThat(outerMethodService.noRollBackMethod2()).isEqualTo("DEFAULT");
}
Propagation.REQUIRES_NEW 옵션으로 참여중인 트랜잭션이 아닌 새로운 트랜잭션으로 처리하자.
// outer method
@Transactional
public String noRollBackMethod3() {
String status = DEFAULT_VALUE;
try {
status = innerMethodService.innerNewTransactionMethodWithThrow();
} catch (RuntimeException e) {
}
return status;
}
// inner method
@Transactional(propagation = Propagation.REQUIRES_NEW)
public String innerNewTransactionMethodWithThrow() {
throw new RuntimeException();
}
// test code
@Test
@DisplayName("내부 메서드에서 트랜잭션이 있지만, 새로운 트랜잭션으로 처리하여 롤백이 되지 않도록 처리한 테스트")
void noRollBackMethod3Test() {
assertThat(outerMethodService.noRollBackMethod3()).isEqualTo("DEFAULT");
}
}
RollbackMarkTest 에 관한 테스트 코드