트러블 슈팅

Transaction propagation option & silent rollback

오렌지색 귤 2022. 12. 28. 23:08
반응형

일반적인 상황

 

하나의 서비스 로직 안에서 다른 서비스의 메서드를 호출하는 경우가 많지만 지금까지는 어떤 메서드일지라도 문제가 생기면 전부 롤백시켜야 하는 상황이 대부분이었다.

 

예를 들자면, 게임의 결과로 100만원이 지급되어야 하는데 유저 서비스에 문제가 생겨 재화를 지급할 수 없는 상황이라면 해당 스핀 자체를 무효화하고 오류를 발생시키는 것이 일반적이다.

 

만약 게임은 계속 진행되는데 게임 머니가 지급되지 않는다면 아마 고객센터 서버마저 터져버릴 것이다.. ㅎㅎ

 

 

문제 상황

 

그런데 주요 로직이 아닌 부차적인 로직에 대해서도 하나의 트랜잭션으로 작용해야할까?

 

예를 들어, 게임에서 막대한 상금을 받았을 때 친구에게 자랑할 수 있는 메세지를 보낼 수 있다고 치자.

 

메세지 서버에 문제가 생겼다면 게임 자체를 플레이하지 못하도록 해야할까?

 

앞서 말했던 상황과는 달리 메세지는 전송되지 않더라도 게임은 계속 할 수 있는 것이 유저에게 좋은 사용자 경험을 가져다 줄 것이다.

 

발송에 실패한 메세지는 따로 저장해뒀다가 추후에 전송하는 방식으로 결과적 일관성을 지켜낼 수도 있을 것이다.

 

 

예시 코드

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class MajorService {

  private final MinorService minorService;
  private final RepositoryImpl repositoryImpl;

  @Transactional
  public void majorMethod() {

    // 해당 메서드는 minor 서비스의 메서드가 실패하든 성공하든 커밋되었으면 좋겠다
    // minor 서비스는 추후 메세징 큐를 이용해서 결과적 일관성을 맞춰줄 수 있으니까...
    repositoryImpl.save(SomeEntity.create("aaa"));

    try {
      // minor 서비스에서 runtime exception을 던지지만 catch 했으니 문제 없이 커밋되지 않을까?
      minorService.minorMethod();
    } catch (RuntimeException e) {
      log.error("minor method는 특정 문제가 발생해서 처리가 완료되지 않았습니다.", e.getMessage());
    }
  }
}

 

MajorServicemajorMethod()SomeEntity를 저장하고 MinorServiceminorMethod()를 호출한다.

 

내가 구현하고 싶은 것은 majorMethod()가 호출된 이상, minorMethod()에서 런타임 에러가 발생하더라도 catch해서 해당 엔티티의 저장은 정상적으로 커밋되는 것이다.

 

아래는 MinorServiceRepositoryImpl, SomeEntity의 간단한 예시 코드이다..

 

@Service
@Transactional(readOnly = true)
public class MinorService {

  @Transactional
  public void minorMethod() {
    throw new RuntimeException();
  }
}

@Repository
public interface RepositoryImpl extends CrudRepository<SomeEntity, Long> {
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SomeEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String description;

  private SomeEntity(String description) {
    this.description = description;
  }

  public static SomeEntity create(String description) {
    // validation
    return new SomeEntity(description);
  }
}

 

 

MinorServiceminorMethod()는 호출되자마자 바로 RuntimeException을 던진다.. (실제로 이런 코드를 짤리는 없다)

 

 

테스트 결과

 

당연히 저장에 실패한다. (이 포스팅을 남기는 이유일테니..)

 

테스트 코드는 아래와 같다.

 

@SpringBootTest
@ContextConfiguration(classes = MarsPaymentServerApplication.class)
@ActiveProfiles("test2")
public class TransactionPropagationTest {

  @Autowired
  private MajorService majorService;

  @Test
  @Rollback(value = false)
  void call() {
    majorService.majorMethod();
  }
}

 

로그를 살펴보자.

 

 

여기까지만 보면 some_entity 테이블에 insert 쿼리가 정상적으로 나간 것을 확인할 수 있고, minorService에서 던진 에러를 정상적으로 catch하여 로그가 찍힌 것도 확인할 수 있다.

 

하지만

 

Transaction silently rolled back because it has been marked as rollback-only라는 문구와 함께 롤백 에러가 발생했다.

 

그리고 당연하게도 DB에는 엔티티가 저장되지 않았다.

 

 

내부 살펴보기

 

 

우선 MajorService의 프록시 객체가 majorMethod()를 호출하고, AOP에 의해 실제 target bean 객체의 majorMethod()를 호출하게 되는 과정이 연이어 로그로 나타난다.

 

TransactionalInterceptorinvoke() 이후에 TransactionAspectSupport 클래스의 invokeWithinTransaction() 메서드가 호출되게 되는데 해당 메서드의 코드는 아래와 같다.

 

@Nullable
    protected Object invokeWithinTransaction(Method method, @Nullable Class<?> targetClass,
            final InvocationCallback invocation) throws Throwable {

    // ... 생략

    PlatformTransactionManager ptm = asPlatformTransactionManager(tm);
    final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

    if (txAttr == null || !(ptm instanceof CallbackPreferringPlatformTransactionManager)) {
        // 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);
        }

        if (retVal != null && vavrPresent && VavrDelegate.isVavrTry(retVal)) {
            // Set rollback-only in case of Vavr failure matching our rollback rules...
            TransactionStatus status = txInfo.getTransactionStatus();
            if (status != null && txAttr != null) {
                retVal = VavrDelegate.evaluateTryFailure(retVal, txAttr, status);
            }
        }

        commitTransactionAfterReturning(txInfo);
        return retVal;
    }

    // ... 생략
}

 

다음 로그에서 commitTransactionAfterReturning() 메서드가 호출되는 것을 보면 try 구문 안의 proceedWithInvocation() 에서 에러가 던지지는 않은 것으로 보인다.

 

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());
        }
    }

 

연이어 commitTransactionAfterReturning() 메서드에서는 commit() 메서드가 호출되고 연이어 processCommit()이 호출된다.

 

private void processCommit(DefaultTransactionStatus status) throws TransactionException {
        try {
            boolean beforeCompletionInvoked = 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();
                    status.releaseHeldSavepoint();
                }
                else if (status.isNewTransaction()) {
                    if (status.isDebug()) {
                        logger.debug("Initiating transaction commit");
                    }
                    unexpectedRollback = status.isGlobalRollbackOnly();
                    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");
                }
            }

            //... 생략

 

마지막의 if(unexpectedRollback) 구문으로 인해 결국 예외가 던져진 것이다.

 

unexpectedRollback이 왜 true인지를 디버깅을 통해 확인해보니 TransactionInfostatusisGloballyRollBackOnly()에 의해 true가 됨을 알 수 있었다.

 

@Override
    public boolean isGlobalRollbackOnly() {
        return ((this.transaction instanceof SmartTransactionObject) &&
                ((SmartTransactionObject) this.transaction).isRollbackOnly());
    }

 

 

원인 파악

 

  1. 최초로 MajorService의 메서드 이름으로 새로운 트랜잭션이 시작
  2. MinorService의 메서드로 진입하면서 이미 만들어진 트랜잭션에 참여
    @Transactional의 기본 propagation 속성이 PROPAGATION_REQUIRED이기 때문
  3. try/catch 없이 RuntimeException이 던져지면서 트랜잭션 완료 처리가 시작
  4. RuntimeException 때문에 트랜잭션을 롤백할지 결정하는 규칙이 없어 디폴트 규칙 사용
  5. 참여한 트랜잭션에 실패를 선언하고 rollback-only 마킹
  6. 안에서 발생한 예외를 최초 트랜잭션 메서드에서 catch 후 완료 처리 시작
  7. 최종 커밋을 하려는 순간 rollback-only 마킹으로 인해 모두 롤백

 

 

해결 방안 1 - @NoRollbackFor

 

롤백 규칙이 없어서 디폴트 규칙을 사용한거고 그 결과 rollback-only가 마킹되었으니, 롤백을 안시키는 규칙을 주면 되는거 아닌가?

실제로 silent 롤백이 일어나지 않게 된다.

 

허나 단점이 있다.

 

MinorService에서 예외를 던지기 전에 커밋이 된 작업들까지도 롤백이 되지 않는다는 것이다 😱

 

결과적 일관성을 쉽게 지키려면 내부 트랜잭션은 통으로 롤백시키고 롤백 되었다는 정보를 기록해뒀다가 해당 로직을 다시 처음부터 태우는게 훨씬 낫다.

 

만약에 내부 로직안에서도 일부는 처리되고 나머지는 처리되지 않았다면, 모든 상황에 대해서 일일이 대비를 해야할 것이다...

 

 

 

해결 방안 2 - TransactionDefinition.PROPAGATION_REQUIRES_NEW

 

그럼 더 앞서서 MinorService의 메서드로 진입할 때 이미 만들어진 트랜잭션이 아니라 아예 새로운 트랜잭션을 만들어버리면 되는거 아닌가?

 

그에 대한 설정이 바로 TransactionDefinition.PROPAGATION_REQUIRES_NEW이다.

 

그리고 내가 원했던 대로 MinorService에서 에러가 발생해도 MajorService는 정상적으로 커밋되며, MinorService의 모든 로직은 롤백된다.

 

 

 

 

참조

 

 

응? 이게 왜 롤백되는거지? | 우아한형제들 기술블로그

{{item.name}} 이 글은 얼마 전 에러로그 하나에 대한 호기심과 의문으로 시작해서 스프링의 트랜잭션 내에서 예외가 어떻게 처리되는지를 이해하기 위해 삽질을 해본 경험을 토대로 쓰여졌습니다.

techblog.woowahan.com

 

 

Transactional (Spring Framework 6.0.3 API)

Describes a transaction attribute on an individual method or on a class. When this annotation is declared at the class level, it applies as a default to all methods of the declaring class and its subclasses. Note that it does not apply to ancestor classes

docs.spring.io

 

반응형