트랜잭션 전파속성과 UnexpectedRollbackException
어느날, 회사에서 에러 로그를 보던 중 UnexpectedRollbackException이라는 예외가 발생한 에러로그를 발견했습니다.
로그를 읽어보았을 때는 뭔가 기대하지 않은 롤백이 발생했다는 내용같은데, 왜 롤백이 발생했는지 호기심이 생겨 관련 내용을 쭉 파헤쳐 보았고, 그 과정 속에서 알게된 내용들을 글로 정리해두려 합니다.
※ 아래의 예제 코드들은 전부 여기 에서 확인할 수 있습니다.
예외 상황 재현
먼저, 예외가 터지는 상황을 재현해보고 실습해보기 위하여 아래와 같이 코드를 구성해보겠습니다.
- ParentService
package com.devchw;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentService {
private final ChildService childService;
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional
public void parentMethod() {
testEntityJpaRepository.save(new TestEntity("parent"));
try {
childService.childMethod();
} catch (Exception e) {
log.info("자식 서비스의 모든 예외를 잡았으니 롤백 없이 커밋되겠지??");
}
}
}
- ChildService
package com.devchw;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ChildService {
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional
public void childMethod() {
testEntityJpaRepository.save(new TestEntity("child"));
throw new RuntimeException("예상치 못한 예외가 발생했습니다...");
}
}
위 코드의 흐름을 살펴보면,
1. `ParentService`의 parentMethod() 안에서 `JpaRepository`를 통해 `TestEntity`를 save()합니다.
2. `ChildService`의 childMethod()를 호출합니다.
2. childMethod()에서는 `JpaRepository`를 통해 `TestEntity`를 save() 하고 있습니다.
3. save()한 뒤, RuntimeException이 발생하여 예외가 parentMethod()로 던져집니다.
4. `ParentService` 에서는 try-catch로 모든 예외를 잡고, 로그를 출력합니다.
최초 트랜잭션 밖으로 예외가 던져지지 않았으니, 개발자는 정상적으로 커밋이 될 것이라 예상합니다.
과연 ParentService 에서 ChildService의 모든 예외를 잡아두었으니 정상적으로 커밋이 될까요?
이를 알아보기 위하여 아래와 같이 테스트코드를 작성해보겠습니다.
- 테스트 코드
package com.devchw;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
class ParentServiceTest {
@Autowired
private ParentService parentService;
@Autowired
private TestEntityJpaRepository testEntityJpaRepository;
@Test
@DisplayName("부모서비스, 자식서비스의 save()가 둘 다 커밋이 완료되어 2개가 조회되어야 한다.")
void commit_success(){
// when
parentService.parentMethod();;
List<TestEntity> result = testEntityJpaRepository.findAll();
// then
assertThat(result)
.hasSize(2);
}
}
과연 검증 결과는?
이런! 처음 소개부분에서 말씀드린 `UnexpectedRollbackException`이 발생했다고 합니다.
왜 예외가 발생할까?
정확히는 @Transactional의 내부 구현 코드를 봐야 하겠지만, 우아한형제들 기술블로그에 구인본님이 작성하신
응? 이게 왜 롤백되는거지? 에 잘 정리되어있으므로 자세한 내용은 링크로 대체하고, 저는 간략하게 요약하여 설명드리겠습니다.
@Transactional에 별 다른 지정을 하지 않으면 기본 전파속성은 `Propagation.REQUIRED` 입니다. 해당 전파속성은 최초 트랜잭션 시작 시점부터 쭉 하나의 트랜잭션을 재사용하는 속성 입니다.
따라서 최초 트랜잭션 시작 시점인 parentMethod() 부터 다시 차근차근 살펴보며 설명드리겠습니다.
1. JpaRepository의 save()를 호출합니다. JpaRepository는 기본적으로 @Transactional이 걸려있기에, parentMethod의 트랜잭션을 재사용합니다.
2. 문제의 childMethod()를 호출합니다. 그런데 try-catch 없이 예외를 던져서, 해당 메서드가 종료될 때 childMethod()는 참여한 트랜잭션 실패를 선언하고 rollback-only 마킹을 합니다.
3. 최초 트랜잭션 시작 메서드인 parentMethod() 에서는 try-catch로 예외를 잡았고, 정상적으로 메서드를 종료하고 커밋하려 합니다. 그런데 정작 커밋하려는 순간 roll-back only가 마킹되어 있다고 롤백을 해버리고, UnexpectedRollbackException을 발생시킵니다.
좀 더 간략하게 설명드리면, 자식 트랜잭션에서 발생한 예외를 부모 트랜잭션에서 잡아봤자 롤백되어 버린다는 것입니다.
의도대로 예외처리 해주기
그렇다면, 정상적으로 둘 다 커밋이 되게 하려면 어떻게 하면 될까요?
부모 메서드에서 예외를 처리하는 것이 아닌, 자식 메서드에서 예외 처리를 해주면 둘 다 정상적으로 커밋이 됩니다.
정말 커밋이 되는지 확인을 위하여 아래처럼 코드를 수정해보겠습니다.
- ParentService
package com.devchw;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentService {
private final ChildService childService;
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional
public void parentMethod() {
testEntityJpaRepository.save(new TestEntity("parent"));
childService.childMethod();
}
}
- ChildService
package com.devchw;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChildService {
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional
public void childMethod() {
try {
testEntityJpaRepository.save(new TestEntity("child"));
throw new RuntimeException("예상치 못한 예외가 발생했습니다...");
} catch (Exception e) {
log.info("예외를 잡았으니 롤백 없이 커밋되겠지??");
}
}
}
이렇게 try-catch를 자식 메서드 쪽으로 옮기고, 아까의 테스트 코드를 다시 수행해보면?
이렇게 테스트가 성공하는 것을 확인할 수 있습니다.
전파속성 변경하기
자, 그런데 과연 항상 부모 메서드와 자식 메서드 둘 다 같은 트랜잭션을 사용해야 할까요? 실제로 복잡한 실무 비즈니스 로직을 개발하다보면, 자식 메서드에서는 예외 발생 시 롤백을 시키고, 부모 메서드에서는 자식 메서드의 예외를 처리하여 부모 메서드만 커밋을 완료하고 싶은 경우도 있지 않을까요?
이럴 땐 자식 메서드에서 @Transactional의 전파속성을 REQUIRES_NEW로 지정해주면 됩니다.
다시 아래처럼 코드를 수정해보겠습니다.
- ParentService
package com.devchw;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentService {
private final ChildService childService;
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional
public void parentMethod() {
testEntityJpaRepository.save(new TestEntity("parent"));
try {
childService.childMethod();
} catch (Exception e) {
log.info("예외를 잡았으니 롤백 없이 커밋되겠지??");
}
}
}
- ChildService
package com.devchw;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChildService {
private final TestEntityJpaRepository testEntityJpaRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void childMethod() {
testEntityJpaRepository.save(new TestEntity("child"));
throw new RuntimeException("예상치 못한 예외가 발생했습니다...");
}
}
try-catch를 부모 메서드 쪽으로 옮기고, 자식 메서드에서는 예외를 던지게 했습니다.
그러나 이전과는 다르게 자식 메서드에서 @Transactional의 전파속성을 `REQUIRES_NEW`로 지정해주었습니다.
이렇게 코드를 수정한 의도는 자식 메서드의 save()는 롤백이 되고, 부모 메서드의 save()는 정상적으로 커밋이 되게 하기 위함입니다.
의도대로 동작한다는 가정하에, 부모 메서드가 완료되고 전체 TestEntity를 조회해보면 부모 메서드의 save()만 커밋이 되어 1개가 조회되어야 할 것입니다.
자 그럼 테스트 코드를 통해 다시 검증을 해보겠습니다.
- 테스트 코드
package com.devchw;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.UnexpectedRollbackException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringBootTest
class ParentServiceTest {
@Autowired
private ParentService parentService;
@Autowired
private TestEntityJpaRepository testEntityJpaRepository;
@Test
@DisplayName("부모서비스만 커밋이 완료되어서 TesEntity는 1개가 조회되어야 한다.")
void commit_success(){
// when
parentService.parentMethod();;
List<TestEntity> result = testEntityJpaRepository.findAll();
// then
assertThat(result)
.hasSize(1);
}
}
테스트 코드가 통과한 것으로 보아, 의도한대로 잘 동작했음을 알 수 있습니다.
여기까지 실습은 마치고, 위의 내용을 정리해보겠습니다.
- 스프링의 @Transactional에서 별 다른 전파속성을 지정하지 않는다면, 기본 전파 속성은 REQUIRED이다.
- REQUIRED는 최초 트랜잭션을 재사용하며 하나의 트랜잭션 안에서 처리된다.
- 그런데, 부모 트랜잭션이 아닌 자식 트랜잭션에서 예외가 터지면, 롤백 마크를 하여 부모 트랜잭션에서 예외 처리를 해봤자 전역적으로 롤백이 된다.
- 이러한 사실을 잘 알고, 의도대로 동작시키기 위해서는 전파속성을 변경해주거나, 자식 트랜잭션 내부에서 예외를 처리해주는 방법을 고려해보자. 어쩌면 비동기적으로 처리하는 것도 하나의 방법일 수 있다.
비하인드 스토리..
평소에 저와 개발 이야기를 자주 나누는 주니어 개발자 친구와 이번 주제에 대해 토론하면서, 헷갈렸던 부분들이 많이 정리되었습니다. 친구도 호기심을 가지고 열심히 토론에 참여했는데, 실무에서 스프링으로 개발하다 보면 한 번쯤 마주치게 될 문제라고 생각한 것 같네요.
아래는 친구와 카톡으로 나눈 열띤 토론 내용 중 일부 발췌 😄