개발놀이터

@Transactional, 왜 이게 롤백이돼..? 본문

Spring/Spring

@Transactional, 왜 이게 롤백이돼..?

마늘냄새폴폴 2024. 8. 10. 17:27

이번 포스팅에선 되게 신기한 상황을 접하게 되어서 이를 분석해보고 뜯어본 결과를 공유해보고 싶어서 글을 쓰게 되었습니다. 

 

이번 글은 물리 트랜잭션, 논리 트랜잭션, @Transactional을 사용할 때 주의사항, 롤백전략 등의 내용이 모두 선행되기 때문에 해당 내용을 모두 담을 수 없어 알고있다는 전제하에 글을 작성하려고 합니다. 

 

트랜잭션에 관한 자세한 내용은 아래의 링크에 자세히 설명이 되어 있습니다. 아래의 링크를 꼭 보고 오시길 바랍니다. 

 

https://coding-review.tistory.com/425

 

트랜잭션 전파 (feat. 논리 트랜잭션, 물리 트랜잭션)

이번 포스팅에선 트랜잭션 전파에 대해서 알아보도록 하겠습니다. 사실 트랜잭션 전파에 대해서는 어느정도 알고 있는 부분이 있었는데 제가 아는 수준은 "트랜잭션 전파를 이용하면 데이터 정

coding-review.tistory.com

 

이게 왜... 롤백?

상황은 이렇습니다. 

 

서로 다른 클래스에 존재하는 메서드 A, B, C가 있고 A -> B -> C의 순서로 호출합니다. B에서 REQUIRES_NEW를 이용해서 새로운 트랜잭션을 시작하였고 C에서 예외가 발생했습니다. 

 

여기서 A에서 만들어진 트랜잭션을 TA라고 하고 B에서 만들어진 트랜잭션을 TB라고 할 때, 우리는 TA는 커밋, TB는 롤백되기를 원합니다. 

 

때문에 B에서 예외를 잡아서 처리하였죠. 

 

이를 코드로 보겠습니다. 

 

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberServiceA {

    private final MemberRepository memberRepository;
    private final MemberServiceB memberServiceB;

    @Transactional
    public void update() {
        Member member = Member.builder()
                .name("MemberServiceA")
                .age(20).build();
        memberRepository.save(member);
        memberServiceB.update();
    }
}

 

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberServiceB {

    private final ExceptionInvoke exceptionInvoke;
    private final MemberRepository memberRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void update() {
        try {
            Member member = Member.builder()
                    .name("MemberServiceB")
                    .age(27)
                    .build();

            memberRepository.save(member);
            exceptionInvoke.invoke();
        } catch (Exception e) {
            log.error("잡았다!!!");
        }
    }
}

 

@Component
@RequiredArgsConstructor
public class ExceptionInvoke {

    private final MemberRepository memberRepository;

    @Transactional
    public void invoke() {
        memberRepository.findByName("우헤헤").orElseThrow(() -> new IllegalStateException("존재하는 사용자가 없습니다."));
    }
}

 

이때 우리가 기대하는 것은 다음과 같습니다. 

 

TA에서 TB가 분리되었고 C에서 예외가 발생한걸 B에서 잡았으니 A는 커밋되겠지? 

 

우리가 TA이 커밋되는 것을 바라는건 지극히 당연해보입니다. 하지만!!

 

 

엥? rollbackOnly 때문에 롤백됐다고? 

 

TB에서는 C에서 예외가 발생했고 이 예외 때문에 rollbackOnly가 되는게 당연해 보이지만 TA는 TB와 전혀 다른 트랜잭션으로 동작할텐데 왜 롤백이 되는걸까? 

 

 

답은 TransactionAspectSupport 에 있었다.

이 문제를 해결하기위해 디버깅을 하면서 스프링 내부 동작 코드들을 보게 되었습니다. 그런데 이게 왠걸?

 

 

트랜잭션 진행 중에 예외가 발생하면 들어오는 코드인 completeTransactionAfterThrowing 메서드입니다. 여기서 디버깅을 더 자세히 해보면?

 

 

TransactionStatus에는 현재 A의 메서드명이 담겨있고 전파단계가 REQUIRED라고 적혀있습니다. 그리고 예외가 UnexpectedRollbackException 이라고 적혀있네요. 

 

이는 이렇게 해석할 수 있습니다. 

 

"try / catch 문으로 잡을 수 없는 내부적인 Exception이 터졌다."

 

이 내부적인 Exception이 TA의 rollbackOnly태그에 다시 true로 적는 결과를 초래했고 이것이 TA가 롤백된 이유입니다. 

 

이를 해결하려면?

이를 해결하기 위해서는 A에서 예외를 잡아줘야합니다. 즉, 이런 예외까지 예상하고 잡아줘야한다는 것이죠. 

 

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberServiceA {

    private final MemberRepository memberRepository;
    private final MemberServiceB memberServiceB;

    @Transactional
    public void update() {
        try {
        	Member member = Member.builder()
                .name("MemberServiceA")
                .age(20).build();
            memberRepository.save(member);
            memberServiceB.update();
        }
        catch (UnexpectedRollbackException e) {
        	log.error("잡았죠?");
        }
    }
}

 

 

마치며

이런 상황과 비슷한 상황이 한 가지 더 있습니다. 바로 데이터베이스 내부적인 Exception상황인데요. 예를 들어서 컬럼 길이보다 더 긴 데이터를 삽입하려고 하는 상황에서 발생하는 예외인 DataAccessException이 발생하여도 위와같은 결과를 초래합니다. 

 

정말... 트랜잭션을 다루는 것은 어디로 튈지 모르는 공을 잡아야하는 것 같은 기분이 드네요. 그냥 서버 한대에서 발생하는 트랜잭션도 이렇게 다루기 힘든데 MSA로 가기 시작하면 얼마나 복잡도가 올라갈지 상상도 안됩니다. 

 

괜히 MSA의 유지보수만으로도 시간이 다간다 라고 하는게 아닌 것 같네요. 

 

여기까지 트랜잭션 전파단계와 롤백에 관해서 의아한 상황에 대한 글을 정리해봤습니다. 오늘도 즐거운 하루 되시고 공부 화이팅입니다!