개요
테스트 수행 시 데이터를 롤백시키기 위해 @Transactional을 붙이는 경우가 있다. 하지만 이는 몇 가지 문제를 일으킬 수 있다. 이번 포스팅에서는 이러한 문제에 대해 소개해보며 테스트 코드 시 무의식적으로 @Transactional을 붙이는 것에 주의를 요하고자 한다.
1. 영속성 컨텍스트에서 새로운 데이터가 조회되지 않을 수 있다
JPA를 사용한 프로젝트에서 테스트 코드에 @Transactional을 붙이면 메서드 단위로 트랜잭션이 적용된다. 이는 테스트 대상(아래 코드에서는 서비스 계층)의 메서드가 트랜잭션을 이어받게 됨을 의미한다. 이 경우 커밋이 테스트 메서드 종료시점에 수행되므로 DAO를 통해 객체를 조회할 때 예상과 다르게 조회될 수 있다. 아래 코드를 살펴보자(Java 코드를 Kotlin으로 리팩토링하는 강의다보니 예제코드에 두 언어가 섞여 있다).
BookServiceTest
@Transactional
@SpringBootTest
open class BookServiceTest @Autowired constructor(
private val bookService: BookService,
private val bookRepository: BookRepository,
private val userRepository: UserRepository,
private val userLoanHistoryRepository: UserLoanHistoryRepository,
) {
// 생략..
@Test
fun returnBookTest() {
// given
bookRepository.save(Book("이상한 나라의 엘리스"))
val savedUser = userRepository.save(User("김", null))
// 생성한 UserLoanHistory 객체는 영속성 컨텍스트에 캐싱됨(DB에는 저장되지 않음)
userLoanHistoryRepository.save(UserLoanHistory(savedUser, "이상한 나라의 엘리스", false))
val request = BookReturnRequest("김", "이상한 나라의 엘리스")
// when
// **최종적으로 user.returnBook 호출**
bookService.returnBook(request)
// then
val results = userLoanHistoryRepository.findAll()
assertThat(results).hasSize(1)
assertThat(results[0].isReturn).isTrue
}
}
BookService.returnBook
@Transactional
public void returnBook(BookReturnRequest request) {
User user = userRepository.findByName(request.getUserName()).orElseThrow(IllegalArgumentException::new);
user.returnBook(request.getBookName()); // **호출**
}
User
@Entity
public class User {
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private final List<UserLoanHistory> userLoanHistories = new ArrayList<>();
// 생략..
public void returnBook(String bookName) {
UserLoanHistory targetHistory = this.userLoanHistories.stream()
.filter(history -> history.getBookName().equals(bookName))
.findFirst()
.orElseThrow(); // **예외 발생**
targetHistory.doReturn();
}
}
bookService.returnBook()은 userRepository.findByName()을 호출하는데, 여기서 가져온 User는 영속성 컨텍스트에서 캐싱된 객체로서 userLoanHistories는 비어있는 상태이다. 이는 userLoanHistoryRepository.save()가 상위 트랜잭션을 이어받아 수행됐음에도 불구하고 아직 커밋이 일어나지 않았기 때문이다.
결국 user.returnBook()에서 NoSuchElementException이 발생한다.
@Transactional을 제거하여 영속성 컨텍스트 내 캐싱된 데이터를 조회하는 것이 아니라 DB에서 조회하게끔 하면 테스트가 성공한다.
2. 운영 코드와 테스트 코드 간에 괴리가 발생할 수 있다
이 문제는 위 문제보다 추상적이다. 위에서 BookService.returnBook에는 트랜잭션이 걸려 있었는데, 추후 운영 코드가 수정되어 트랜잭션이 제거되었다고 가정해보자. 이 경우엔 LazyInitializationException이 발생할 수 있다
BookService.returnBook
// @Transactional - 트랜잭션 제거
fun returnBook(request: BookReturnRequest) {
val user = userRepository.findByName(request.userName()) ?: fail()
user.returnBook(request.bookName);
}
User
@Entity
class User(
// 프록시 객체 컬렉션
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true)
val userLoanHistories: MutableList<UserLoanHistory> = mutableListOf()
) {
// ...생략
fun returnBook(bookName: String) {
// **프록시 객체에 대해 Lazy Loading을 시도할 때 예외발생**
this.userLoanHistories.first { history -> history.bookName == bookName }.doReturn()
}
}
user.returnBook()이 호출될 때 트랜잭션이 걸려 있지 않아 Lazy Loading이 불가능하고 결국 LazyInitializationException이 발생하게 되는 것이다. 간단하게 말해 세션이 이미 종료된 상태인데 DB에 접근하려 하기 때문에 문제가 발생한다. 이와 관련해서는 JPA LazyInitializationException이라는 키워드로 검색해보면 더 자세한 정보를 얻을 수 있다.
문제는 이렇게 실제 운영 코드는 예외가 발생함에도 불구하고 테스트 코드는 정상적으로 작동한다는 것이다. 이 또한 1번 문제와 마찬가지로 서비스 계층의 메서드는 트랜잭션이 없지만, 서비스를 호출하는 테스트 코드에 트랜잭션이 있기 때문에 트랜잭션을 상속받기 때문이다.
이렇게 운영 코드와 테스트 코드 간에 괴리가 발생하는 상황은 1번 문제보다 더 큰 문제를 야기할 수 있다. 운영 코드에서 문제가 일어나도 테스트 코드에서 이를 감지하지 못하기 때문에 실제 운영 상황에서 예상치 못한 문제가 발생할 수 있다. 덧붙여 요구사항을 만족(여기서는 버그 제거)이라는 테스트의 최우선 가치가 훼손된다.
결론
@Transactional을 테스트 코드에 붙이면 얻는 이점도 분명있다. 하지만 앞서 설명한 문제들, 특히 2번 문제를 고려해봤을 때 무의식적으로 테스트 코드에 @Transactional을 붙이는 것은 지양해야 할 것이다.
참고
'Spring > Spring' 카테고리의 다른 글
[Spring] 스프링 트랜잭션 전파와 롤백 (0) | 2022.09.07 |
---|---|
[Spring] 트랜잭션 AOP 사용 시 주의점 (ft. Spring AOP self-invocation) (0) | 2022.09.01 |
[Spring] @Transactional과 메모리 DB를 활용한 데이터 접근 계층 테스트 (0) | 2022.08.13 |
[Spring] 트랜잭션 동기화 정리 (0) | 2022.08.13 |
[Spring] 트랜잭션 추상화 정리 (0) | 2022.08.08 |