Spring Data

예외 발생 시 롤백 방지하기: Spring에서 독립적인 트랜잭션 설정 방법

백엔드 유성 2024. 9. 7. 23:46

프로젝트에서 repository.save() 메서드를 사용했음에도 불구하고 데이터가 커밋되지 않는 문제를 겪은 적이 있습니다. 이 글에서는 문제의 원인과 해결 방법을 공유합니다.

 

트랜잭션 처리 방식

문제의 원인을 파악하기 위해 하나의 쓰레드에서 두 개의 메서드를 사용하며, 각각의 트랜잭션이 어떻게 작동하는지 세 가지 케이스로 살펴보겠습니다.

 

1. 첫 번째 케이스: 동일 트랜잭션

두 메서드(A 트랜잭션, B 트랜잭션)가 동일한 트랜잭션에 포함되어 있습니다. 즉, A와 B가 모두 성공해야 트랜잭션이 커밋됩니다. 만약 B에서 예외가 발생하면, A에서의 작업까지 모두 롤백됩니다.

 

|-----------(A 트랜잭션)-----------|
                               |-----------(B 트랜잭션)-----------|

 

2. 두 번째 케이스: 독립적인 트랜잭션

A 트랜잭션과 B 트랜잭션이 서로 독립적입니다. B 트랜잭션에서 예외가 발생하더라도 A 트랜잭션은 영향을 받지 않으며, A는 정상적으로 커밋됩니다.

|-----------(A 트랜잭션)-----------|
                                     |-----------(B 트랜잭션)-----------|

 

3. 세 번째 케이스: 포함된 트랜잭션

A 트랜잭션이 B 트랜잭션을 포함하지만, 둘은 하나의 트랜잭션으로 간주됩니다. 따라서 B에서 예외가 발생하면 A 트랜잭션도 롤백됩니다.

|---------------------(A 트랜잭션)---------------------|
             |-----------(B 트랜잭션)-----------|

 

 

문제 발생 코드

@Transactional
open fun transferMoney(senderId: Long, recipientId: Long, sendAmount: BigDecimal) {
    ...
    if (RequestTracker.getRequestTimes(senderAccount.id!!).size > blockAttemptCount) {
        accountService.save(senderAccount)
        throw IllegalStateException("Account ${senderAccount.id} has been blocked due to abnormal activity. (Max request count exceeded)")
    }
}

fun save(senderAccount: Account) {
    accountRepository.save(senderAccount)
}

 

위 코드에서 save() 메서드가 실행되었음에도 불구하고 데이터가 커밋되지 않는 문제가 발생했습니다. 이는 트랜잭션이 하나로 묶여 있기 때문에, 예외가 발생하면 전체 트랜잭션이 롤백되기 때문입니다.

JpaRepositorysave() 메서드가 기본적으로 @Transactional로 처리되지만, 상위 메서드(transferMoney)에서 발생한 예외로 인해 하위 메서드에서의 작업까지 롤백됩니다.

 

이 상황은 세 번째 케이스와 동일한 트랜잭션 동작 방식입니다. B 트랜잭션이 실패하면 A 트랜잭션도 롤백됩니다.

 

문제 해결 방법

문제를 해결하기 위해서는 A 트랜잭션과 B 트랜잭션을 독립적으로 처리하거나, 예외를 던지기 전 미리 커밋을 해야합니다.

 

즉, 각각의 트랜잭션이 별도로 커밋되도록 트랜잭션 전파 방식을 명시적으로 나누어 처리하거나 EntityManager를 통해 강제로 커밋하는 방식이 있습니다.

 

여기서는 트랜잭션을 분리해서 처리하겠습니다.

    # Another Service class
    @Transactional
    open fun transferMoney(senderId: Long, recipientId: Long, sendAmount: BigDecimal) {
        ...
        if (RequestTracker.getRequestTimes(senderAccount.id!!).size > blockAttemptCount) {
            ...
            // 강제 커밋이므로 주의 필요
            accountService.saveAccountWithNewTransaction(senderAccount)
        throw IllegalStateException("Account ${senderAccount.id} has been blocked due to abnormal activity. (Max request count exceeded)")
        }
    }

	# Service class
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun saveAccountWithNewTransaction(senderAccount: Account) { 
        accountRepository.save(senderAccount)
    }

 

해결된 트랜잭션 동작

위 코드에서 Propagation.REQUIRES_NEW 옵션을 사용하여 A 트랜잭션과 B 트랜잭션을 각각 독립적으로 처리하게 되었습니다. 이로써 B 트랜잭션이 실패하더라도 A 트랜잭션은 영향을 받지 않고 정상적으로 커밋됩니다.

 

|---------------------(A 트랜잭션)-------------------롤백|
                 |--------(새로운 B 트랜잭션)------커밋|

 

이 방식은 트랜잭션이 서로 독립적으로 작동하므로, 예외가 발생하더라도 특정 트랜잭션만 롤백되고 나머지는 정상적으로 처리됩니다.

 

그러나 B트랜잭션이 커밋되기 전까지 의도하지 않은 데이터가 B 트랜잭션에서 커밋이 될 수 있으니 주의해야합니다.

 

결론

트랜잭션 간 독립성을 보장하기 위해서는 Propagation.REQUIRES_NEW 옵션을 사용하여 각각의 트랜잭션이 독립적으로 커밋되도록 처리하는 것이 중요합니다. 이 방식은 예외가 발생하더라도 특정 트랜잭션만 롤백되고, 나머지는 정상적으로 커밋되도록 합니다. 이를 통해 트랜잭션 간 영향을 최소화하고, 필요한 데이터가 의도대로 커밋되도록 보장할 수 있습니다.

 

이와 같은 방식으로 트랜잭션 동작을 개선할 수 있으며, 프로젝트에서 유사한 문제를 경험할 때 Propagation 옵션을 활용하여 적절한 트랜잭션 처리를 할 수 있습니다.