[Spring] Transactional Rollback

2020. 8. 3. 13:01Java

프로젝트를 진행하다가 알 수 없는 예외 때문에 고생한 경험을 바탕으로 이번 포스트를 쓴다. 해결하는 데 이 글이 정말 많은 도움이 됐다. 저 글보다 더 자세하고, 전문성 있는 설명을 할 수는 없어서 조금 더 간단하고, 직관적으로 풀어보려고 한다.

이번 포스트에서 사용한 로그 레벨은 다음과 같다.

logging.level.org.springframework.jdbc.core.JdbcTemplate=debug
logging.level.org.springframework.jdbc.core.StatementCreatorUtils=trace
logging.level.org.springframework.transaction=trace

문제의 시작

`@Transactional`이 걸려 있는 메서드 내부에서 `UnexpectedRollbackException`이 터지거나 예외 없이 DB에 값이 저장되지 않는 현상이 발생했다.

Transaction rolled back because it has been marked as rollback-only
org.springframework.transaction.UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only

테스트 코드를 모두 짠 코드를 실제 서버에 배포하니 위와 같은 `UnexpectedRollbackException` 예외가 나왔다. 디버그를 해보니 로직 진행은 모두 정상적으로 된다. 로직이 모두 정상적으로 진행되지만 `@Transactional`이 붙은 메서드가 끝남과 동시에 코드가 터지는 문제가 발생해서, 도대체 어떤 부분에서 예외가 나오는지 알 방법이 없었다. 아래는 상황을 비슷하게 재현한 코드다.

// TransactionalParent.java
@AllArgsConstructor
@Service
public class TransactionParent {
    private final TransactionChild transactionChild;

    @Transactional
    public void savePersonAndCatchException() {
        try {
            transactionChild.savePersonAndThrowException();
        } catch (RuntimeException e) {
            System.out.println("부모가 예외를 처리");
        }
        System.out.println("로직이 계속 진행");
    }
}

// TranscationalChild.java
@AllArgsConstructor
@Service
public class TransactionChild {
    private final PersonRepository personRepository;

    @Transactional
    public void savePersonAndThrowException() {
        personRepository.save(new Person(null, "장효혁", 20));

        throw new RuntimeException("자식에게서 예외 발생!");
    }
}

// TransactionalParentTest.java
@SpringBootTest
class TransactionParentTest {
    @Autowired
    private PersonRepository personRepository;

    @Autowired
    private TransactionParent transactionParent;

    @DisplayName("Person 객체가 저장되는지 확인")
    @Test
    void transaction() {
        transactionParent.savePersonAndCatchException();

        assertThat(personRepository.findAll()).hasSize(1);
    }
}

테스트 코드를 실행하면 위의 예외가 발생해서 테스트를 통과하지 못한다.

이유

Rollback flow

1. 부모가 자식의 트랜잭션이 걸려있는 메서드인 `savePersonAndThrowException` 메서드를 호출한다.

2. 자식 메서드의 트랜잭션이 시작된다.

자식 메서드의 트랜잭션

3. Person 객체를 저장하는 로직이 수행된다. 여기까지는 예외 없이 실제 db에 객체가 저장이 된다.

4. 여기서 자식이 unchecked 예외인 Runtime 예외를 발생시킨다.

5. 그리고 이와 동시에 트랜잭션이 끝난다.

6. 트랜잭션이 끝날 때, 예외가 발생했으므로 Rollback에 대한 규칙을 적용한다. 따로 설정한 규칙이 없으므로 기본 규칙인 롤백을 적용한다. 여기서는 롤백을 실행하지 않고, 단순히 Rollback을 하겠다고 표시(mark)만 해둔다. 이는 전역으로 관리되기 때문에 메서드가 끝난다고 사라지지 않는다.

7. 로직이 수행된다. 코드 상으로 로직은 문제 없이 수행된다.

8. 최초로 생성된 트랜잭션이 완료된다. 이 때, commit이 발생하는데(이전 트랜잭션들이 끝날 때는 commit이 발생하지 않는다. 자세한 이유는 관련된 글에서 Propagation.REQUIRED 를 살펴보시면 된다) commit을 수행할 때 아까 마킹해뒀던 Rollback 여부를 체크해서 이 값이 true라면 Rollback이 진행된다.