삽질인가 고찰인가

Transaction살펴보기1) 엥 왜 롤백이 되지? Transaction silently rolled back because it has been marked as rollback-only.

우당탕 오리의 개발모험 2023. 11. 16. 18:07

요약

  • 문제: 롤백 말고 그냥 흐르게 하고 싶은데 롤백이 된다.
  • 원인: 스프링에 기본적으로 RuntimeExcepion이 발생하면 롤백을 시킨단다.
  • 해결방법: 몇 가지 방법들이 있음 & 해결방법에 따라 결과가 다름. 주의!!

[해결방법]

  1. 트랜잭션 전파 전략=RequiresNew를 사용한다. 즉, 메서드를 별도 트랜잭션으로 처리한다
    • @Transactional(propagation = Propagation.REQUIRES_NEW)
    • 주의!
      Out 메서드의 로직은 모두 실행되지만, 예외가 발생한 Inner 메서드 로직은 RollBack된다.
      Out 클래스와 트랜젝션이 분리되었기 때문이다.
  2. RuntimeException 대신, Exception 로 예외를 발생시킨다.
    • throw new Exception("에랏 받아랏 Exception 예외다!");
    • 주의!
      Inner 로직, Out 로직 모두 Rollback되지 않고 Commit된다.
      단, 예외발생 이후의 로직은 실행되지 않으므로, 로직설계 시 주의가 필요하다.
      (즉, 메서드 전체 중 일부는 Commit, exception throws 이후 로직은 아예 실행되지도 않음)
    • 주의!
      checkedException의 속성을 이용한 방법이다. 따라서 잘 이해하고 사용해야한다.
  3. @Transactionl 어노테이션의 속성에 no-rollback 설정한다.
    • @Transactional(noRollbackFor = RuntimeException.class)
    • 주의!
      (2번 방법과 동일) Inner 로직, Out 로직 모두Rollback되지 않고, Commit된다.
      단, 예외발생 이후의 로직은 실행되지 않으므로 로직설계 시 주의가 필요하다.
      (즉, 메서드 전체 중 일부는 Commit, exception throws 이후 로직은 아예 실행되지도 않음)

[다 정리하고 새삼 다시 상기시킨 점]

  • 한 트랜잭션에서 롤백된다는 것은 그 트랜잭션 범위의 모든 작업이 롤백되는 것이다(메서드가 몇개 호출되든 가장 바깥 트랜잭션이 있는 메서드 안에서 실행된 모든 로직은 롤백/커밋 둘 중 하나다)
  • 만약 트랜잭션이 여러개면 각각 롤백/커밋된다.

재현해보기

  • 목표: 내부 로직에서 Exception이 발생하더라도 Out 클래스의 나머지 로직이 실행되어야 한다.
  • 시나리오:
    • Out 클래스에서 Inner 클래스의 Exception을 발생시키는 메서드를 호출한다.
    • Out 클래스에는 3개 로직이 있다.
    • 1.직접 리뷰생성 -> 2.예외있는 메서드를 호출 & try-catch에서 예외를 무시 -> 3.직접 리뷰생성
    • 결과확인: 시도한 리뷰저장 3개 중 몇개가 저장됐는지 확인한다.

[문제상황 재현] Transaction 전파. Transaction 1개 사용. + 내부 로직에서 RuntimeException 발생

결과 => Out 로직, Inner 로직 모두 RollBack! **(저장된 데이터 0건)**

// Out 클래스
@Slf4j
@RequiredArgsConstructor
@Service
public class OutService {

    private final InnerService innerService;
    private final ReviewRepository reviewRepository;

    @Transactional
    public void createReviewWithException() {
        // out 로직. 1번리뷰 저장
        reviewRepository.save(Review.builder().userId(1L).text("1번리뷰: 요래요래해서 좋았어요!!").build());

        try {
            // inner 로직 호출. 2번리뷰 저장
            innerService.createReviewWithException(2L, "2번리뷰: 요래요래해서 좋았어요!!");
        } catch (Exception ex) {
            // 의도: exception 받아서 로그만 찍기 때문에 이 메서드의 전체 로직은 정상작동(1,3번 리뷰 저장) 되어야 한다.
            log.error("리뷰 생성 중 에러 발생 - " + ex.getMessage());
        }

        // out 로직. 3번리뷰 저장
        reviewRepository.save(Review.builder().userId(3L).text("3번리뷰: 요래요래해서 좋았어요!!").build());

        log.info("완료");
    }
}

// Inner 클래스
@RequiredArgsConstructor
@Service
public class InnerService {

    private final ReviewRepository reviewRepository;

    // 저장 + 예외
    @Transactional
    public void createReviewWithException(Long userId, String text) {
        reviewRepository.save(Review.builder().userId(userId).text(text).build());

        throw new RuntimeException("에랏 받아랏 RuntimeException 예외다!");
    }
}

[재현결과]

  • 에러 발생
    UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only
  • Out에서 저장한 거, Inner에서 저장한 거 모두 RollBack 됨

[해결방법1 재현] Inner 로직(예외포함)의 Transaction 분리. Transaction 2개 사용.

결과 => Inner 로직은 Rollback, Out 로직은 Commit 됨 *(저장된 데이터 2건: *OUT에서 저장한 데이터)

public class InnerService {
    ......

    // Inner 클래스의 예외발생 메서드 수정: REQUIRES_NEW 설정해서 별도 트랜젝션으로 실행하도록 함
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void createReviewWithException(Long userId, String text) {
        reviewRepository.save(Review.builder().userId(userId).text(text).build());

        throw new RuntimeException("에랏 받아랏 RuntimeException 예외다!");
    }
}

[재현결과]

  • 롤백됐다는 메시지가 표시되지 않는다.
  • Inner 로직은 Rollback, Out 로직은 Commit 된다.

[해결방법2 재현] RuntimeException 대신 Exception(CheckedException)으로 발생시킨다.

결과 => OUT, INNER 에서 저장한 모든 로직 Commit 됨 (저장된 데이터 3건: 저장 시도한 모든 데이터 저장)

@Slf4j
@RequiredArgsConstructor
@Service
public class InnerService {

    private final ReviewRepository reviewRepository;

    @Transactional
    public void createReviewWithException(Long userId, String text) throws Exception {
        reviewRepository.save(Review.builder().userId(userId).text(text).build());

        // throw new RuntimeException("에랏 받아랏 RuntimeException 예외다!");
        // RuntimeException 대신 Exception을 발생시킨다.
        throw new Exception("에랏 받아랏 Exception 예외다!");
    }
}

[재현결과]

  • 1,2,3번 리뷰 모두 생성된다.
  • Exception 에러는 catch 구문에서 처리되고 Out/Inner 클래스의 모든 로직이 Commit 된다.
  • 주의! 만약 예외발생 이후 로직이 있면 주의해야한다!
    예외발생 전 로직만 실행되고, 예외발생 후에 작성된 로직은 실행되지 않기 때문이다!!!

[주의사항 상세설명]

@Slf4j
@RequiredArgsConstructor
@Service
public class InnerService {

    private final ReviewRepository reviewRepository;

    @Transactional
    public void createReviewWithException(Long userId, String text) throws Exception {
        Review save = reviewRepository.save(Review.builder().userId(userId).text(text).build());

        // 만약, 이렇게 조건에 따라 Exception을 발생시켰을 때
        if (save != null) {
            throw new Exception("에랏 받아랏 Exception 예외다!");
        }
        // 아래 로직을 실행되지 않는다: 예외발생 이후의 로직은 실행되지 않는다.
        Review save2 = reviewRepository.save(Review.builder().userId(userId).text(text).build());
    }
}

[해결방법3 재현] @Transactionl 어노테이션의 속성에 rollback하지 말라고 설정한다

  • spring의 @Transactional를 쓸 때: @Transactional(noRollbackFor = RuntimeException.class)
  • javax의 @Transactional를 쓸 때: @Transactional(dontRollbackOn = RuntimeException.class)

결과 => OUT, INNER 에서 저장한 모든 로직 Commit 됨 (저장된 데이터 3건: 저장 시도한 모든 데이터 저장)

@Slf4j
@RequiredArgsConstructor
@Service
public class InnerService {

    private final ReviewRepository reviewRepository;

    // 아래 둘 중 하나 쓰면 됨
    @org.springframework.transaction.annotation.Transactional(noRollbackFor = RuntimeException.class)
    @javax.transaction.Transactional(dontRollbackOn = RuntimeException.class)
    public void createReviewWithException(Long userId, String text) throws Exception {
        reviewRepository.save(Review.builder().userId(userId).text(text).build());

        throw new RuntimeException("에랏 받아랏 RuntimeException 예외다!");
    }
}

[재현결과]

  • 2번 방법과 동일한 결과.
  • 1,2,3번 리뷰 모두 생성된다.
  • @Transactional에 설정한대로 RuntimeException이 발생해도 롤백하지 않는다.
  • 주의! 앞 방법과 마찬가지로 만약 예외발생 이후 로직이 있면 주의해야한다!
    예외발생 전 로직만 실행되고, 예외발생 후에 작성된 로직은 실행되지 않기 때문이다!!!

좀 더 파보기

왜 롤백되는가?

Spring Doc

Spring Doc의 Transaction 설명 중 rollback-only 관련 문구를 찾을 수 있었다.

여기서는 해결방법1에 관련된 내용을 다시 한 번 확인할 수 있다.

요약하자면,

  • 트랜잭션 전략 중 '전파'를 사용하는 경우!
    rollback-only 가 기본적으로 적용되어있으며, 내부 트랜잭션에서 발생한 롤백은 외부 트랜잭션에도 영향을 끼친다.
    즉, 내부 트랜젝션이 예외발생으로 롤백되면, 외부 트랜젝션도 롤백된다.
  • 반면 RequiresNew 방법을 사용할 때는 아예 트랜잭션이 별도로 생기므로 외부 트랜잭션에 영향을 끼치지 않는다.
    대신, 내부 트랜잭션에서는 롤백이 발생한다.

출처: 스프링 공식doc(이미지클릭)

그럼에도 질문이 생긴다
그럼 왜 rollback-only는 기본값인가?
왜 RuntimeException 일 때만 롤백하는가? 그냥 Exception일 때는 왜 롤백하지 않는가?
오히려 롤백하는게 더 헷갈리지 않나? 일부러 개발자가 try-catch를 명시해서 처리한다는데??

토비의 스프링

요 질문들에 대한 대답은 '토비의 스프링' 책에서 찾을 수 있었다.

앞서 소개한 해결방법 중 2,3번에 대한 이해를 할 수 있다.

참고로 해결방법 2,3번은 트랜잭션 전략=전파다. 즉 1개 트랜잭션 범위이다.

책 내용을 요약하자면..

  • rollback-only 기본값 true인 이유 & 해결방법1(checkedException 사용) 근거:
    기본적으로 "예외가 발생하면 롤백한다"는 전제를 깐다.
    즉, '트랜잭션이 전파'되어 1개 트랜잭션의 범위에 있는 로직이니까 당연히 내부에서 롤백되면, 외부도 롤백되어야 한다는 전제이다.
    반면에 checkedException을 발생시키면: 일종의 '비지니스 로직'으로 이식하고 커밋하는 것이다.
  • 해결방법2(@Transactional 설정) 근거:
    바로 윗 근거대로 예외원칙을 항상 따르면 좋겠지만, 사실 'checkedException/uncheckedException 예외원칙'을 따르지 않을 때가 있다.(실무에서 RuntimeException(대표적인 uncheckedException)만 사용할 때도 있다)
    즉, Exception 구분으로 처리하기 힘든 상황인 것이다.
    이럴 때 사용하라고 TransactionAttribute에서 rollbackOn() 속성을 설정할 수 있게 한 것이다.

토비의 스프링 p518

checkedException, unCheckedException 차이 조사

추가로 토비의 책에서도 있던 '예외원칙'에 대해 좀 더 알아보자.

여기 잘 정리한 블로그를 보자