[Spring] 롤백 마크가 생긴 트랜잭션은 재사용이 불가능할까?

Hocaron·2023년 7월 19일
1

Spring

목록 보기
28/44

왜 조용히 롤백이 되는거야...🤫
엄청 유명한 배민의 블로그 글(응? 이게 왜 롤백되는거지?)에서 봤던 에러가 발생했다. 이 참에 공부해보자.

UnexpectedRollbackException

아래와 같은 에러 로그를 볼 수 있다

원인은 무엇일까?

참여 중인 트랜잭션이 실패하면 기본정책이 전역롤백인데, 이 에러는AbstractPlatformTransactionManager 에서 발생한 것을 알 수 있다. 구현되어있는 코드를 같이 살펴보자.

RuntimeException 을 상속받은 커스텀 에러를 던져도 롤백이 되는데, UnexpectedRollbackException 은 어떤 경우에 발생하는 걸까?

롤백이 일어날 수 있는 경우와 실행되는 메서드

우리가 발생시킨 예외의 의한 롤백

try-catch 로 감싸지 않으면 예외 발생시에 rollback 메서드가 실행이 된다.

UnexpectedRollbackException에 의한 롤백

try-catch 로 감싸면 commit 메서드가 실행되고, 그 내부에서 unexpectedRollback 플래그에 의해 롤백이 일어난다.

다양한 경우를 테스트 코드를 통해 공부해보자

@Transactional 은 아래 2가지를 지켜야 동작한다.

  • Public Method에 적용되어야 합니다.(Protected, Private Method에서 선언되지만 작동하지 않아요.)
  • self-invocation 상황(내부 호출)에서는 적용되지 않는다.(Spring AOP CGLIB이 동작하지 않음.)

우리가 발생시킨 예외의 의한 롤백(예외에 대한 try-catch 처리 하지 않음)

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

UnexpectedRollbackException에 의한 롤백

이 경우에는 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 에 관한 테스트 코드

profile
기록을 통한 성장을

0개의 댓글