📕 목차
1. @Transactional 내부에서 Exception 발생
2. 로그 추적
3. 코드 추적
4. 문제의 근원
5. 결론
1. @Transactional 내부에서 Exception 발생
📌 발단
@Slf4j
@Helper
@RequiredArgsConstructor
public class UserSyncHelper {
private final UserService userService;
/**
* 일반 회원가입 시 이미 가입된 회원인지 확인
*
* @param phone String : 전화번호
* @return Pair<Boolean, String> : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 ID 반환
* @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우
*/
@Transactional(readOnly = true)
public Pair<Boolean, String> isSignedUserWhenGeneral(String phone) {
User user;
try {
user = userService.readUserByPhone(phone);
} catch (GlobalErrorException e) {
log.info("User not found. phone: {}", phone);
return Pair.of(Boolean.FALSE, null);
}
if (user.getPassword() != null) {
log.warn("User already exists. phone: {}", phone);
throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP);
}
return Pair.of(Boolean.TRUE, user.getUsername());
}
}
userService 계층에선 read 메서드를 호출했다가 데이터를 찾지 못하면 error을 던지도록 처리해두었다.
하지만 해당 시나리오에서는 user가 존재하지 않으면 에러 응답이 아닌, 정상적인 시나리오로 흘러가기에 해당 예외를 잡아서 응답을 반환하도록 처리했다.
당연히 스프링 트랜잭션 내에서 에러를 던지고, 그 내부에서 다시 잡았으므로 롤백 없이 커밋이 될거라 예상했다.
다른 프로젝트에서 이미 한 번 겪었던 일인데 또 당하니까 자존심이 상해서 로그 레벨을 조정해 내부 동작을 추적하기로 했다.
2. 로그 추적
📌 트랜잭션 시작
o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [kr.co.pennyway.api.apis.auth.helper.UserSyncHelper.isSignedUserWhenGeneral]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,readOnly
o.s.orm.jpa.JpaTransactionManager : Opened new EntityManager [SessionImpl(901907673<open>)] for JPA transaction
o.h.e.t.internal.TransactionImpl : On TransactionImpl creation, JpaCompliance#isJpaTransactionComplianceEnabled == false
o.h.e.t.internal.TransactionImpl : begin
o.s.orm.jpa.JpaTransactionManager : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@51dddf44]
o.s.t.i.TransactionInterceptor : Getting transaction for [kr.co.pennyway.api.apis.auth.helper.UserSyncHelper.isSignedUserWhenGeneral]
o.s.orm.jpa.JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(901907673<open>)] for JPA transaction
o.s.orm.jpa.JpaTransactionManager : Participating in existing transaction
o.s.t.i.TransactionInterceptor : Getting transaction for [kr.co.pennyway.domain.domains.user.service.UserService.readUserByPhone]
o.s.t.i.TransactionInterceptor : No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByPhone]: This method is not transactional.
org.hibernate.orm.sql.ast.create : Created new SQL alias : u1_0
org.hibernate.orm.sql.ast.create : Registration of TableGroup [StandardTableGroup(kr.co.pennyway.domain.domains.user.domain.User(1026536948079200))] with identifierForTableGroup [kr.co.pennyway.domain.domains.user.domain.User] for NavigablePath [kr.co.pennyway.domain.domains.user.domain.User]
org.hibernate.orm.sql.ast.create : Registration of TableGroup [StandardVirtualTableGroup(kr.co.pennyway.domain.domains.user.domain.User(1026536948079200).notifySetting)] with identifierForTableGroup [kr.co.pennyway.domain.domains.user.domain.User.notifySetting] for NavigablePath [kr.co.pennyway.domain.domains.user.domain.User.notifySetting]
o.h.q.sqm.sql.BaseSqmToSqlAstConverter : Determining mapping-model type for SqmParameter : org.hibernate.query.sqm.tree.expression.SqmJpaCriteriaParameterWrapper@53954667
o.h.q.sqm.sql.BaseSqmToSqlAstConverter : Determining mapping-model type for SqmPath : SqmBasicValuedSimplePath(kr.co.pennyway.domain.domains.user.domain.User(1026536948079200).phone)
- UserSyncHelper.isSignedUserWhenGenral()의 이름을 갖는 트랜잭션이 생성된다.
- PROPAGATION_REQUIRED 속성이 기본값으로 지정되어 있으므로 다음 트랜잭션에 참가한다.
- UserService.readUserByPhone()의 트랜잭션으로 참가한다.
- data jpa 기능으로 생성된 findByPhone() 메서드는 transaction이 아니므로 생성할 필요가 없다.
- 사실 이거 처음 알았다. repository 레벨 메서드라 당연히 transaction이 실행되는 줄 알았는데 아니었다.
- Entity를 매핑하고 SQL을 생성한다.
📌 예외 발생
org.hibernate.orm.sql.exec : Skipping reading Query result cache data: cache-enabled = false, cache-mode = NORMAL
org.hibernate.orm.results : Initializer list:
kr.co.pennyway.domain.domains.user.domain.User(1026536948079200).notifySetting -> EmbeddableFetchInitializer(kr.co.pennyway.domain.domains.user.domain.User(1026536948079200).notifySetting) : `class kr.co.pennyway.domain.domains.user.domain.NotifySetting`@1665953339 (EmbeddedAttributeMapping(NavigableRole[kr.co.pennyway.domain.domains.user.domain.User.notifySetting])@1091492545)
kr.co.pennyway.domain.domains.user.domain.User(1026536948079200) -> EntityResultInitializer(kr.co.pennyway.domain.domains.user.domain.User(1026536948079200))@1293004947 (SingleTableEntityPersister(kr.co.pennyway.domain.domains.user.domain.User))
org.hibernate.SQL : select u1_0.id,u1_0.created_at,u1_0.deleted_at,u1_0.locked,u1_0.name,u1_0.account_book_notify,u1_0.chat_notify,u1_0.feed_notify,u1_0.password,u1_0.password_updated_at,u1_0.phone,u1_0.profile_image_url,u1_0.profile_visibility,u1_0.role,u1_0.updated_at,u1_0.username from user u1_0 where u1_0.phone=?
Hibernate: select u1_0.id,u1_0.created_at,u1_0.deleted_at,u1_0.locked,u1_0.name,u1_0.account_book_notify,u1_0.chat_notify,u1_0.feed_notify,u1_0.password,u1_0.password_updated_at,u1_0.phone,u1_0.profile_image_url,u1_0.profile_visibility,u1_0.role,u1_0.updated_at,u1_0.username from user u1_0 where u1_0.phone=?
o.s.t.i.TransactionInterceptor : Completing transaction for [kr.co.pennyway.domain.domains.user.service.UserService.readUserByPhone] after exception: GlobalErrorException(code=4040, message=유저를 찾을 수 없습니다.)
o.s.orm.jpa.JpaTransactionManager : Participating transaction failed - marking existing transaction as rollback-only
o.s.orm.jpa.JpaTransactionManager : Setting JPA transaction on EntityManager [SessionImpl(901907673<open>)] rollback-only
cResourceLocalTransactionCoordinatorImpl : JDBC transaction marked for rollback-only (exception provided for stack trace)
- UserService.readUserByPhone 트랜잭션이 종료되고 GlobalErrorException을 던진다.
- 여기가 문제의 근원. transaction 참가가 실패하면서 marking existing transaction as rollback-only라는 문구가 뜬다.
- 그러면서 모든 트랜잭션에 rollback-only 마킹을 시작한다.
예외를 처리하기 시작하는 트랜잭션 내부
참여한 트랜잭션을 모두 실패처리한다.
📌 트랜잭션 롤백
k.c.p.a.apis.auth.helper.UserSyncHelper : User not found. phone: 010-1234-5678
o.s.t.i.TransactionInterceptor : Completing transaction for [kr.co.pennyway.api.apis.auth.helper.UserSyncHelper.isSignedUserWhenGeneral]
o.s.orm.jpa.JpaTransactionManager : Initiating transaction commit
o.s.orm.jpa.JpaTransactionManager : Committing JPA transaction on EntityManager [SessionImpl(901907673<open>)]
o.h.e.t.internal.TransactionImpl : committing
cResourceLocalTransactionCoordinatorImpl : On commit, transaction was marked for roll-back only, rolling back
o.s.orm.jpa.JpaTransactionManager : Closing JPA EntityManager [SessionImpl(901907673<open>)] after transaction
k.c.p.a.c.r.h.GlobalExceptionHandler : class org.springframework.transaction.UnexpectedRollbackException : handleException : Transaction silently rolled back because it has been marked as rollback-only
- UserSyncHelper 메서드 내에서 해당 예외를 잡았고, 트랜잭션이 종료되었다는 문구가 뜬다.
- commit을 하는가 싶더니 transaction was marked for roll-back only 문구가 다시 나타나며 rollback 처리해버린다.
3. 코드 추적
📌 TransactionInterceptor
o.s.t.i.TransactionInterceptor : Completing transaction for [kr.co.pennyway.domain.domains.user.service.UserService.readUserByPhone] after exception: GlobalErrorException(code=4040, message=유저를 찾을 수 없습니다.)
해당 로그가 나오는 TransactionInterceptor를 들어갔지만 위와 같은 로그를 찾을 수 없었다.
그런데 TransactionAspectSupport를 구현하고 있기에 해당 클래스를 한 번 더 진입해보았다.
📌 TransactionAspectSupport
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;
}
}
else {
// We don't roll back on this exception.
// Will still roll back if TransactionStatus.isRollbackOnly() is true.
try {
txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
}
catch (TransactionSystemException ex2) {
logger.error("Application exception overridden by commit exception", ex);
ex2.initApplicationException(ex);
throw ex2;
}
catch (RuntimeException | Error ex2) {
logger.error("Application exception overridden by commit exception", ex);
throw ex2;
}
}
}
}
else 문으로 넘어가면 해당 exception을 roll back하지 않는다고 한다.
그렇다면 if 조건에서 걸렸다는 말이 되는데, txInfo.transactionAttribute.rollbackOn(ex)이 성립하는 조건을 살펴보자.
📌 DefaultTransactionAttribute
구현체가 4개나 되길래 어디로 들어갈까 하다가, 별다른 설정을 하지 않았으므로 DefaultTransactionAttribute로 넘어갔을 것이라 판단했다.
@Override
public boolean rollbackOn(Throwable ex) {
return (ex instanceof RuntimeException || ex instanceof Error);
}
내가 만든 커스텀 예외는 RuntimeException을 상속받으므로 롤백 대상으로 판단한다.
그렇다면 다시 TransactionAspectSupport의 if 문 내부에 있던 txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus()); 내부로 들어가보자.
📌 AbstractPlatformTransactionManager
@Override
public final void rollback(TransactionStatus status) throws TransactionException {
if (status.isCompleted()) {
throw new IllegalTransactionStateException(
"Transaction is already completed - do not call commit or rollback more than once per transaction");
}
DefaultTransactionStatus defStatus = (DefaultTransactionStatus) status;
processRollback(defStatus, false);
}
status.isCompleted()에 해당하는 문구가 발생하지 않았으므로 processRollback()으로 더 내려가야 한다.
// 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");
}
}
}
900까지 내려오면 드디어 내가 마주쳤던 로그를 확인할 수 있다.
status.isLocalRoolbackOnly()와 isGlobalRollbackOnParticipationFailure() 둘 중 하나라도 true인 경우(혹은 둘 다), 해당 예외가 발생한다.
🟡 isLocalRollbackOnly()
정확히 어떻게 설정하는 건지는 모르겠지만, 주석에 의하면 이렇다.
"오직 application이 TransactionStatus 객체에 대해 setRollbakOnly를 호출한 경우에만 true를 반환한다."
이건 존재조차 몰랐기에 기본값은 false였을 것이라 가정하자. (선언적 트랜잭션을 실행할 때 기본으로 true로 세팅했을 수도 있지만 모르겠다.)
public abstract class AbstractTransactionStatus implements TransactionStatus {
private boolean rollbackOnly = false;
...
}
실제로 해당 변수는 기본값을 false로 갖는다.
🟡 isGlobalRollbackOnParticipationFailure()
"참여한 트랜잭션이 실패한 후, 기존 트랜잭션을 rollback-only으로 전역적으로 marking할 지에 대해 반환한다."
public abstract class AbstractPlatformTransactionManager
implements PlatformTransactionManager, ConfigurableTransactionManager, Serializable {
...
private boolean globalRollbackOnParticipationFailure = true;
...
}
주석 설명만 봐도 문제의 원인인 게 확실하지만, 실제로 변수로 기본값이 true로 설정되어 있음을 알 수 있다.
📌 예외가 발생하는 순서
플로우는 이렇다.
private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
try {
boolean unexpectedRollback = unexpected;
boolean rollbackListenerInvoked = false;
try {
triggerBeforeCompletion(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);
}
...
}
...
}
}
...
}
...
}
- isGlobalRollbackOnParticipationFailure()가 true를 반환하므로 doSetRollbackOnly(status)가 실행된다.
- 해당 메서드가 실행되면 IllegalTransactionStateException이 발생하고, 다시 processRollback() 메서드가 해당 예외를 catch하게 된다.
- 그보다 중요한 건 현재 transaction을 rollback-only로 지정한다는 주석이다.
- 현재 트랜잭션만 rollback marker를 찍는다는 것에 유의하자. 예외가 발생하자마자 모든 트랜잭션이 종료되지 않는 이유다.
private void processRollback(DefaultTransactionStatus status, boolean unexpected) {
try {
boolean unexpectedRollback = unexpected;
boolean rollbackListenerInvoked = false;
try {
triggerBeforeCompletion(status);
if (status.hasSavepoint()) {
...
}
else if (status.isNewTransaction()) {
if (status.isDebug()) {
logger.debug("Initiating transaction rollback");
}
this.transactionExecutionListeners.forEach(listener -> listener.beforeRollback(status));
rollbackListenerInvoked = true;
doRollback(status);
}
else {
...
}
}
...
// 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");
}
}
...
}
- 앞선 transaction이 실패했으니 doRollback() → JpaTransactionManager → TransactionImpl → JdbcResourceLocalTransactionCoordinatorImple까지 내려가서 다음과 같은 로그가 찍히게 된다.
이로써 순차적으로 모든 트랜잭션이 roll back되면서 종료되는 것이다.
4. 문제의 근원
📌 globalRollbackOnParticipationFailure
결국 Exception 때문에 transaction이 roll-back되는 근원을 파악하지 못 해서 블로그를 어떻게 정리해야 하나 했는데, 우아한 형제들 기술 블로그에 이미 정리되어 있는 내용이었다..
(나 코드 추적 왜 함? ㅋㅋ......)
위에서 살펴봤던 rollback이 발생하는 두 가지 조건 중 globalRollbackOnParticipationFailure가 문제였다.
위 링크에서 해당 변수의 주석을 해석해놨는데 원문으로 읽어보면 얼추 이해가 된다.
- globalRollbackOnParticipationFailure는 디폴트로 true를 갖는다.
- PROPAGTION_REQUIRED나 PROPAGATION_SUPPORTS로 참여 중인 트랜잭션이 실패하면, 해당 트랜잭션은 전역적으로 rollback-only로 마킹된다.
- The transaction originator도 트랜잭션을 더이상 commit으로 처리할 수 없게 된다.
- 만약 이 값을 false로 바꾸면 The transaction originator가 롤백을 결정한다.
- 참여 중인 트랜잭션이 예외로 실패하더라도, 트랜잭션 내 다른 경로를 통해 진행시킬 수 있긴 하다.
- 단, 이렇게 할 거면 참여 중인 모든 자원이 데이터 접근이 안 되더라도 commit에 지장이 없음을 보장해야 한다.
- 일반적으로 Hibernate Session는 그걸 보장하지 않는다.
- 데이터 접근에 문제가 생겨 예외를 던지면, TransactionInterceptor가 rollback rule에 따라 PlatformTransactionManager.rollback()을 호출한다.
- 해당 설정이 꺼져있으면 sub transaction의 rollbackk rules와 무관하게 예외를 처리하고 rollback 여부를 결정할 수 있다.
- 그러나 TransactionStatus object에 setRollbackOnly를 호출해버리면 소용이 없다. → 위에서 무시하고 넘어갔던 isLocalRollbackOnly가 true로 변경되기 때문
- 내부 트랜잭션이 실패했을 때, 예외를 외부로 전파하여 전체를 rollback하는 것은 의도된 것이다.
- 그게 싫으면 DataSourceTransactionManager를 직접 사용해서 처리해야 한다.
- 쓸려면 잘 알아야 할 뿐더러, 그게 항상 가능하지도 않다.
5. 결론
📌 @Transactional(readOnly = true) 제거
@Slf4j
@Helper
@RequiredArgsConstructor
public class UserSyncHelper {
private final UserService userService;
/**
* 일반 회원가입 시 이미 가입된 회원인지 확인
*
* @param phone String : 전화번호
* @return Pair<Boolean, String> : 이미 가입된 회원인지 여부 (TRUE: 가입되지 않은 회원, FALSE: 가입된 회원), 가입된 회원인 경우 회원 ID 반환
* @throws UserErrorException : 이미 일반 회원가입을 한 유저인 경우
*/
public Pair<Boolean, String> isSignedUserWhenGeneral(String phone) {
User user;
try {
user = userService.readUserByPhone(phone);
} catch (GlobalErrorException e) {
log.info("User not found. phone: {}", phone);
return Pair.of(Boolean.FALSE, null);
}
if (user.getPassword() != null) {
log.warn("User already exists. phone: {}", phone);
throw new UserErrorException(UserErrorCode.ALREADY_SIGNUP);
}
return Pair.of(Boolean.TRUE, user.getUsername());
}
}
문제 해결은 너무나도 간단했는데, 그냥 @Transactional 어노테이션을 제거해버리면 된다.
애초에 해결이 안 돼서 찾아본 건 아니라 문제는 되지 않았다.
내가 작성한 메서드는 User 정보를 찾지 못했다는 에러가 발생하면, 그 자체가 답이 되기에 더 이상 트랜잭션을 보장되지 않아도 무방하다.
성공하더라도 entity 정보를 가져오는 정도의 작업만을 수행하는데, 만약 해당 응답이 끝나기 전에 동시성 문제가 발생할 수 있다면 Transaction을 보장했어야 할 수도 있다. (다행히 그런 케이스는 전혀 보이지 않았다.)
Spring이 RuntimeException과 Error를 반드시 rollback 해야 하는 대상으로 보는 이유를 알지는 못 하겠다.
짐작컨데, rollback으로 처리해버렸을 때보다 처리하지 않은 경우의 리스크가 상당히 높다고 판단했기 때문이 아닐까.
여튼 Service Layer를 논리적으로 분리해서 사용하는 사람들은 중첩 Transaction을 사용하고 있을 확률이 높으니, 이러한 점을 주의해서 코드를 작성해야 할 것 같다.
📌 추가 고찰 (`24.03.27)
해당 이슈가 다른 Use case를 처리하는데 자꾸 발목을 잡아서 고민을 하던 과정에서 Transaction이 RuntimeError와 Error 시에 모든 작업을 rollback 시키는 이유와 관심사 분리, 그리고 비검사 예외 처리에 대해 더 고민해보게 되었다.
현재 Domain Service 계층에선 사용자를 조회하고, 데이터가 없으면 RuntimeException을 던지도록 되어 있었다.
이는 상위 수준의 서비스가 모든 예외 처리를 작업하는 로직을 추가하는 것이 옳지 않다는 생각이었는데, 조금 더 생각해보니 무언가 이상했다.
RuntimeException이라는 건 원래 프로그래밍 에러에 해당하고, Domain Service가 비검사 예외를 던지는 게 과연 옳은가?
이펙티브 자바에선 이렇게 이야기한다.
- 비검사 예외는 프로그램에서 잡을 필요가 없거나 통상적으로 잡지 말아야 한다.
- 복구가 불가능하거나, 더 실행해봐야 득보다는 실이 많은 경우에 비검사 예외를 던지기 때문이다.
- Runtime 예외 대부분은 전제 조건을 만족하지 못 했을 때 발생한다.
- 복구 가능하다고 믿는다면 검사 예외를 사용하라.
즉, 선언적 Transaction이 Runtime 예외와 Error에 대해 모든 트랜잭션을 rollback하는 이유는 Runtime 예외가 애초에 복구 불가능한 예외를 의미하기 때문이다.
또한 Domain Service 계층이 이러한 비검사 예외를 던지는 것은 명백히 관심사 분리의 실패라 생각한다. 상위 서비스에서 어떤 작업을 할 줄 알고 마음대로 프로그래밍 실패 예외를 던진다는 말인가.
그래서 현재는 모든 Domain Service 계층의 예외를 던지는 코드를 제거하고, Optional 인스턴스를 그대로 반환하도록 수정하고 있다.
처음 스프링 부트를 배울 때 아무 생각없이 DAO 계층을 의존하는 Service 계층의 메서드에서 예외를 던지던 것에 아무런 의문을 갖지 않았었는데, 이 참에 호되게 당하고 알게 되었다.