이번 글에서는 Spring에서 Transaction을 롤백하는 방법에 대해 실제 코드와 함께 알아보도록 하겠습니다.

이 글은 Spring Boot 3, Java 17을 기준으로 작성하였습니다. 참고 자료는 다음과 같습니다.

Spring의 트랜잭션

Spring에서 @Transactional을 사용하면 메서드를 트랜잭션으로 감쌀 수 있습니다. 이때 메서드에서 예외가 발생하면 경우에 따라 트랜잭션이 롤백되는데요. 모든 예외가 롤백되는 것은 아니고 unchecked exception과 error에 대해 롤백을 수행합니다.

If no custom rollback rules are configured in this annotation, the transaction will roll back on RuntimeException and Error but not on checked exceptions. - (Annotation Interface Transactional)

진짜로 롤백을 하나?

공식 문서대로 작동하는지 학습 테스트로 한 번 확인해보겠습니다. 우선 트랜잭션과 관련된 로그를 확인하기 위하여 로그 레벨을 수정해야합니다. application.yml에 다음과 같은 속성을 추가합니다.

logging.level:
  org.springframework.orm: debug

이후, 예외로 인해 롤백을 수행하는 메서드를 작성합니다.

@Service
public class DummyInnerService {

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

위 메서드는 아무런 동작도 하지 않은 채 unchecked exception인 RuntimeException을 던집니다. 테스트 코드를 이용하여 롤백이 진행되는 지 로그로 확인해보겠습니다.

Untitled.png

공식 문서에서 이야기한 것처럼 트랜잭션이 롤백된 것을 확인할 수 있습니다.

트랜잭션 전파

더 이야기를 하기 전에 이어서 나올 이야기를 이해하기 위해선 트랜잭션 전파(transaction propagation)에 대해 알아야합니다.

트랜잭션 전파는 트랜잭션이 여러 메서드에서 시작될 때 이를 관리하는 방식을 말합니다. 이를 이용하면 트랜잭션의 경계와 트랜잭션 사이의 상호작용 방식을 결정할 수 있습니다.

이렇게 정의를 이야기하는 것보다 실제 코드로 보는 것이 백배 빠르니 예제 코드로 살펴보겠습니다.

너 나의 동료가 되어라

Service 클래스를 하나 더 정의하여 다음과 같은 코드를 작성하였습니다.

@Service
@RequiredArgsConstructor
public class DummyService {

    private final DummyInnerService dummyInnerService;

    @Transactional
    public void doesNotCatchRuntimeException() {
        dummyInnerService.throwRuntimeException();
    }
}

RuntimeException을 메서드를 단순히 감싸는 메서드를 추가로 정의했습니다. 이때 DummyService의 메서드를 외부 메서드, DummyInnerService의 메서드를 내부 메서드라 부르겠습니다.

외부 메서드와 내부 메서드 모두 @Transactional이기 때문에 외부 메서드에서 트랜잭션을 시작하고 내부 메서드에서도 트랜잭션을 시작합니다. 그럼 트랜잭션이 2개 생성될까요?

이를 확인하기 위해 테스트 코드를 작성하고 실행하였습니다. 과연 정답은…?

Untitled.png

정답은 NO! 트랜잭션은 하나만 생성되었습니다. 왜일까요?

Transactional propagation properties

바로 @Transactional의 트랜잭션 전파 속성 때문입니다. 앞서 트랜잭션 전파는 트랜잭션이 여러 메서드에서 시작될 때 이를 관리하는 방식이라고 했습니다. Spring은 다양한 트랜잭션 전파 속성을 제공하여 트랜잭션 범위 제어, 데이터의 일관성 보장, 성능 최적화 등을 지원합니다.

Untitled.png

Spring에서 제공하는 트랜잭션 전파 속성에는 다양한 값이 있지만 default 값인 REQUIRED부터 알아보겠습니다. 트랜잭션 전파 속성이 REQUIRED일 경우 트랜잭션을 시작할 때 다음과 같이 동작합니다.

  • 기존에 진행중인 트랜잭션이 존재할 경우: 기존 트랜잭션에 합류
  • 트랜잭션이 존재하지 않을 경우: 새로운 트랜잭션을 생성

바로 위 코드에선 트랜잭션 전파 속성을 별도로 지정하지 않았으므로 기본 값인 REQUIRED로 동작합니다. 따라서 내부 메서드가 트랜잭션을 시작할 때 이미 외부 메서드에서 먼저 트랜잭션을 시작했기 때문에 내부 메서드에서 시작한 트랜잭션은 기존 트랜잭션인 외부 트랜잭션에 합류합니다.

이러한 과정을 통해 실제로 생성되는 트랜잭션은 총 1개입니다. 이를 논리적 트랜잭션은 2개지만 물리적 트랜잭션은 1개이다라고 표현할 수 있습니다.

트랜잭션 전파와 롤백의 관계

뜬금없이 트랜잭션 전파에 대해 이야기한 이유는 이러한 전파 속성이 롤백과 관련이 있기 때문입니다. 트랜잭션 전파 속성에 따라 롤백의 범위가 달라지기 때문인데요. 이에 대해 학습 테스트 예제와 함께 알아보겠습니다.

예외 떠넘기기~

방금 전에 봤던 코드를 다시 보겠습니다.

@Service
@RequiredArgsConstructor
public class DummyService {

    private final DummyInnerService dummyInnerService;

    @Transactional
    public void doesNotCatchRuntimeException() {
        dummyInnerService.throwRuntimeException();
    }
}

내부 메서드가 던진 예외를 외부 메서드에서 처리하지 않기 때문에 외부 트랜잭션도 예외의 영향을 받습니다.

Untitled.png

따라서 당연히 트랜잭션은 롤백됩니다.

우리 없던 걸로 하지 않을래?

그럼 내부 메서드가 던진 예외를 외부에서 핸들링하면 롤백이 되지 않고 커밋될까요?

@Transactional
public void catchRuntimeException() {
    try {
        dummyInnerService.throwRuntimeException();
    } catch (Exception e) {
        /* do nothing */
    }
}

다음과 같이 외부에서 예외를 핸들링하는 코드를 작성하고 실행해보겠습니다. 과연…?

Untitled.png

정답은 롤백! 정확히 말하자면 외부 메서드에서 커밋을 시도하였으나 트랜잭션이 rollback-only마킹되어있어 의도치 않게 예외가 발생했다네요. rollback-only…? 마킹…? 그게 뭘까요?

Transactional에 롤백 마크 남기기

이를 이해하기 위해 트랜잭션의 AOP와 관련 깊은 TransactionAspectSupport의 코드를 보겠습니다. 이 곳에서 메서드를 트랜잭션으로 감싸는 메서드는 invokeWithinTransaction()인데요.

Untitled.png

노란색 박스 부분에서 실제 메서드를 실행하고 예외가 발생하면 catch문에서 이를 핸들링합니다. 내부 메서드에서 예외가 발생했기 때문에 completeTransactionAfterThrowing()을 호출하여 핸들링합니다.

Untitled.png

내부에서 TransactionManager의 rollback()을 호출하고 있습니다.

TransactionManager는 인터페이스입니다. 이번 코드에서는 JPA를 사용중이기에 구현체로 JpaTransactionManager를 사용합니다.

Untitled.png

rollback() 내부에서는 다시 processRollback()을 호출하고..

Untitled.png

processRollback() 내부에서는 doSetRollbackOnly()를 호출하여..

Untitled.png

예외가 발생한 내부 트랜잭션이 롤백되었다고 마킹을 합니다. 실제로 마킹 로그를 출력하는 곳도 이 곳이구요.

롤백 마크 감지

지금까지 코드를 타고가며 내부 트랜잭션이 롤백되었을 때 이를 기록하는 과정을 살펴보았습니다. 그렇다면 외부 트랜잭션에서는 이를 언제 감지할까요?

외부 메서드의 경우 예외를 핸들링했기 때문에 정상적으로 커밋을 시도합니다. 트랜잭션의 커밋은 AbstractPlatformTransactionManagerprocessCommit()에 의해 이루어지는데요.

Untitled.png

이때 트랜잭션의 rollback-only 정보를 가져온 후에 doCommit()으로 커밋을 합니다. 이후 rollback-only가 true일 경우 UnexpectedRollbackException을 던지며 롤백을 하게 됩니다.

왜 나까지 롤백하는데

그런데 외부 입장에서는 억울할 수 있습니다. 롤백되는 내부 메서드에서 던진 예외를 핸들링했음에도 본인도 롤백이 됐으니 말이죠.

이러한 일이 발생하는 근본적인 원인은 트랜잭션 전파 속성 때문입니다. 트랜잭션 전파 속성이 REQUIRED이기 때문에 내부 트랜잭션과 외부 트랜잭션은 하나의 물리적 트랜잭션을 공유합니다. 따라서 내부 트랜잭션에서 rollback-only를 마크한 순간 트랜잭션을 공유하는 외부 트랜잭션도 영향을 받습니다.

그럼 외부 트랜잭션이 내부 트랜잭션의 영향을 받게 하고 싶지 않다면 어떻게 하면 될까요?

트랜잭션을 분리한다면?

이에 대한 해결 방법은 간단합니다. 내부 트랜잭션과 외부 트랜잭션을 물리적으로 분리하는 것이죠. 이를 위해서 트랜잭션 전파 속성을 REQUIRES_NEW로 지정합니다.

Untitled.png

트랜잭션 전파 속성이 REQUIRES_NEW일 경우 트랜잭션을 시작할 때 다음과 같이 동작합니다.

  • 기존에 진행중인 트랜잭션이 존재할 경우: 기존 트랜잭션을 중지하고 새로운 트랜잭션을 시작
  • 트랜잭션이 존재하지 않을 경우: 새로운 트랜잭션을 시작

다시 말해 REQUIRES_NEW는 기존 트랜잭션이 있든 말든 항상 새로운 트랜잭션을 시작합니다. 따라서 물리적으로 트랜잭션이 분리되기에 내부 메서드와 외부 메서드가 서로 영향을 주지 않습니다.

정말 그렇게 동작하는지 실제 코드로 알아보겠습니다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void throwRuntimeExceptionWithRequiresNew() {
    throw new RuntimeException();
}

위와 같이 트랜잭션 전파 속성을 REQUIRES_NEW로 설정한 내부 메서드를 정의합니다.

@Transactional
public void catchRuntimeExceptionWithPropagationRequiresNew() {
    try {
        dummyInnerService.throwRuntimeExceptionWithRequiresNew();
    } catch (Exception e) {
        /* do nothing */
    }
}

다음으로 이를 사용하는 외부 메서드를 정의합니다. 이 상태에서 테스트 코드를 실행하면…?

Untitled.png

외부 메서드에서 진행중인 트랜잭션이 존재함에도 불구하고 내부 메서드는 이를 멈춘 후 별도의 트랜잭션을 시작합니다! 따라서 내부 메서드에서 예외가 발생하여 롤백되더라도 이와 물리적으로 분리되어있는 외부 트랜잭션은 정상적으로 커밋할 수 있습니다.

마무리하며

이번 글에서는 Spring에서 트랜잭션을 쉽게 사용할 수 있는 @Transactional의 롤백에 대해 알아보았습니다. unchecked exception이 발생했을 때 트랜잭션이 롤백하는 것부터 시작하여 nested transactional method 구조에서 발생하는 다양한 롤백 케이스에 대해 알아보았습니다.

이때 롤백의 범위는 트랜잭션 전파 속성에 의해 달라졌습니다. 따라서 우리는 트랜잭션 전파 속성을 잘 관리하여 원치 않는 결과가 발생하는 것을 방지해야할 것입니다.

이번 글에서 사용한 코드는 아래의 학습 테스트에서 확인하실 수 있습니다.

link_preview

카테고리:

업데이트:

댓글남기기